From 13932ac1ca36c3a14dae3dce78b9cd3a3ac5d11b Mon Sep 17 00:00:00 2001 From: John McBride Date: Wed, 4 Mar 2026 16:00:29 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Adds=20"mb=20mixtape=20?= =?UTF-8?q?list"=20and=20"mb=20mixtape=20list=20[name]"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables users to see what mixtapes are available. Signed-off-by: John McBride --- cmd/mixtapes/mixtapes.go | 68 +++++++++++++++++++---- pkg/mixtapes/pull.go | 15 +++-- pkg/mixtapes/registry.go | 117 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 pkg/mixtapes/registry.go diff --git a/cmd/mixtapes/mixtapes.go b/cmd/mixtapes/mixtapes.go index 358507d..d893be3 100644 --- a/cmd/mixtapes/mixtapes.go +++ b/cmd/mixtapes/mixtapes.go @@ -1,5 +1,5 @@ // Package mixtapescmder provides the mixtapes command group for managing -// locally available StereOS mixtape images. +// StereOS mixtape images, both locally and in the OCI registry. package mixtapescmder import ( @@ -12,19 +12,22 @@ import ( "github.com/papercomputeco/masterblaster/pkg/ui" ) -const mixtapesLongDesc string = `Manage locally available StereOS mixtapes (bootable VM images). +const mixtapesLongDesc string = `Manage StereOS mixtapes (bootable VM images). Mixtapes are pre-configured StereOS images bundled with agent harnesses -and workflows. Use "mb mixtapes ls" to see what's available locally and +and workflows. Use "mb mixtapes list" to see what's available in the +registry, "mb mixtapes local" to see what's downloaded, and "mb mixtapes pull " to download new ones. Examples: - mb mixtapes ls + mb mixtapes list # List mixtapes in the registry + mb mixtapes list opencode-mixtape # List tags for a mixtape + mb mixtapes local # List locally downloaded mixtapes mb mixtapes pull opencode-mixtape` const mixtapesShortDesc string = "Manage StereOS mixtapes" -// NewMixtapesCmd creates the mixtapes command group with ls and pull subcommands. +// NewMixtapesCmd creates the mixtapes command group with list, local, pull subcommands. func NewMixtapesCmd(configDirFn func() string) *cobra.Command { cmd := &cobra.Command{ Use: "mixtapes", @@ -32,19 +35,43 @@ func NewMixtapesCmd(configDirFn func() string) *cobra.Command { Long: mixtapesLongDesc, } - cmd.AddCommand(newMixtapesLsCmd(configDirFn)) + cmd.AddCommand(newMixtapesListCmd()) + cmd.AddCommand(newMixtapesLocalCmd(configDirFn)) cmd.AddCommand(newMixtapesPullCmd(configDirFn)) return cmd } -func newMixtapesLsCmd(configDirFn func() string) *cobra.Command { +func newMixtapesListCmd() *cobra.Command { return &cobra.Command{ - Use: "ls", - Short: "List locally available mixtapes", + Use: "list [name]", + Short: "List mixtapes or tags in the remote registry", + Long: `Query the OCI registry for available mixtapes. + +With no arguments, lists known mixtape repositories. + +With a mixtape name, lists all available tags for that mixtape. + +Examples: + mb mixtapes list # List available mixtapes + mb mixtapes list coder-arm64 # List tags for coder-arm64`, + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return runMixtapesCatalog() + } + return runMixtapesTags(args[0]) + }, + } +} + +func newMixtapesLocalCmd(configDirFn func() string) *cobra.Command { + return &cobra.Command{ + Use: "local", + Short: "List locally downloaded mixtapes", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { - return runMixtapesLs(configDirFn()) + return runMixtapesLocal(configDirFn()) }, } } @@ -64,7 +91,26 @@ This is an alias for "mb pull ".`, } } -func runMixtapesLs(baseDir string) error { +func runMixtapesCatalog() error { + entries, err := mixtapes.ListCatalog() + if err != nil { + return fmt.Errorf("listing mixtapes: %w", err) + } + mixtapes.PrintCatalog(entries) + return nil +} + +func runMixtapesTags(name string) error { + ui.Status("Listing tags for %q...", name) + entries, err := mixtapes.ListTags(name) + if err != nil { + return fmt.Errorf("listing tags for %q: %w", name, err) + } + mixtapes.PrintTags(entries) + return nil +} + +func runMixtapesLocal(baseDir string) error { list, err := mixtapes.List(baseDir) if err != nil { return err diff --git a/pkg/mixtapes/pull.go b/pkg/mixtapes/pull.go index a6cc808..01e3ebc 100644 --- a/pkg/mixtapes/pull.go +++ b/pkg/mixtapes/pull.go @@ -78,12 +78,9 @@ func ParseReference(rawRef string) (name.Reference, error) { // Zstd-compressed disk layers are decompressed during extraction. All // files are written flat into the tag directory. func PullOCI(baseDir, mixtapeName, tag string, ref name.Reference) error { - mixtapeDir := filepath.Join(baseDir, "mixtapes", mixtapeName, tag) - if err := os.MkdirAll(mixtapeDir, 0755); err != nil { - return fmt.Errorf("creating mixtape directory: %w", err) - } - - // Fetch the remote descriptor. This may be an index or a single manifest. + // Fetch the remote descriptor first, before creating any directories. + // This avoids leaving empty directories on the filesystem if the + // registry returns an error (e.g. manifest unknown). desc, err := remote.Get(ref) if err != nil { return fmt.Errorf("fetching manifest for %s: %w", ref.String(), err) @@ -103,6 +100,12 @@ func PullOCI(baseDir, mixtapeName, tag string, ref name.Reference) error { return fmt.Errorf("manifest for %s contains no layers", ref.String()) } + // Only create the directory once we know the manifest is valid. + mixtapeDir := filepath.Join(baseDir, "mixtapes", mixtapeName, tag) + if err := os.MkdirAll(mixtapeDir, 0755); err != nil { + return fmt.Errorf("creating mixtape directory: %w", err) + } + for i, layer := range layers { if err := extractLayer(mixtapeDir, layer, i); err != nil { // Clean up on failure -- don't leave partial downloads. diff --git a/pkg/mixtapes/registry.go b/pkg/mixtapes/registry.go new file mode 100644 index 0000000..36170a8 --- /dev/null +++ b/pkg/mixtapes/registry.go @@ -0,0 +1,117 @@ +package mixtapes + +import ( + "fmt" + "os" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + + "github.com/papercomputeco/masterblaster/pkg/ui" +) + +// knownMixtapes is the list of mixtape names published in the default registry. +// Update this list when new mixtapes are added to download.stereos.ai. +var knownMixtapes = []string{ + "coder-arm64", + "coder-x86", +} + +// CatalogEntry describes a mixtape repository in the remote registry. +type CatalogEntry struct { + Name string // Short name (e.g. "coder-arm64") + Repo string // Full repository path (e.g. "download.stereos.ai/mixtapes/coder-arm64") +} + +// TagEntry describes a tag for a mixtape in the remote registry. +type TagEntry struct { + Mixtape string // Short mixtape name + Tag string // Tag string (e.g. "latest", "0.1.0") +} + +// ListCatalog returns the known mixtape repositories in the default registry. +func ListCatalog() ([]CatalogEntry, error) { + var entries []CatalogEntry + for _, m := range knownMixtapes { + entries = append(entries, CatalogEntry{ + Name: m, + Repo: DefaultDownloadRegistry + "/" + defaultRepoPrefix + "/" + m, + }) + } + return entries, nil +} + +// ListTags queries the OCI registry for all tags of a given mixtape. +// The mixtapeName can be a short name (e.g. "coder-arm64") or a full +// OCI repository reference (e.g. "myregistry.io/my/repo"). +func ListTags(mixtapeName string) ([]TagEntry, error) { + repoStr := mixtapeName + if !strings.Contains(repoStr, "/") { + repoStr = DefaultDownloadRegistry + "/" + defaultRepoPrefix + "/" + repoStr + } + + repo, err := name.NewRepository(repoStr) + if err != nil { + return nil, fmt.Errorf("parsing repository %q: %w", repoStr, err) + } + + tags, err := remote.List(repo) + if err != nil { + return nil, fmt.Errorf("listing tags for %s: %w", repo.String(), err) + } + + // Derive a short name for display. + shortName := mixtapeName + if strings.Contains(shortName, "/") { + parts := strings.Split(shortName, "/") + shortName = parts[len(parts)-1] + } + + var entries []TagEntry + for _, tag := range tags { + entries = append(entries, TagEntry{ + Mixtape: shortName, + Tag: tag, + }) + } + + return entries, nil +} + +// PrintCatalog writes a styled table of registry mixtape repositories to stdout. +func PrintCatalog(entries []CatalogEntry) { + if len(entries) == 0 { + fmt.Println("No mixtapes found.") + return + } + + tbl := &ui.Table{ + Headers: []string{"NAME", "REPOSITORY"}, + StateCol: -1, + } + for _, e := range entries { + tbl.Rows = append(tbl.Rows, []string{e.Name, e.Repo}) + } + tbl.Render(os.Stdout) + fmt.Printf("\nList tags with: mb mixtapes list \n") +} + +// PrintTags writes a styled table of tags for a mixtape to stdout. +func PrintTags(entries []TagEntry) { + if len(entries) == 0 { + fmt.Println("No tags found.") + return + } + + mixtapeName := entries[0].Mixtape + tbl := &ui.Table{ + Headers: []string{"MIXTAPE", "TAG"}, + StateCol: -1, + } + for _, e := range entries { + tbl.Rows = append(tbl.Rows, []string{e.Mixtape, e.Tag}) + } + tbl.Render(os.Stdout) + fmt.Printf("\nPull with: mb pull %s:\n", mixtapeName) +} From 122355f5e47f725e83796ec79ecff94fae0185d6 Mon Sep 17 00:00:00 2001 From: John McBride Date: Thu, 5 Mar 2026 08:19:36 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Adds=20"mixtapes=20rm"?= =?UTF-8?q?=20to=20remove=20local=20mixtapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John McBride --- cmd/mixtapes/mixtapes.go | 50 ++++++++++++++++++++++++++++++++++++- go.mod | 16 ++++++++++-- go.sum | 53 +++++++++++++++++++++++++++++++++++----- jcard.toml | 6 ++--- pkg/mixtapes/mixtapes.go | 49 +++++++++++++++++++++++++++++++++++++ pkg/ui/output.go | 21 ++++++++++++++++ 6 files changed, 182 insertions(+), 13 deletions(-) diff --git a/cmd/mixtapes/mixtapes.go b/cmd/mixtapes/mixtapes.go index d893be3..7b85428 100644 --- a/cmd/mixtapes/mixtapes.go +++ b/cmd/mixtapes/mixtapes.go @@ -27,7 +27,7 @@ Examples: const mixtapesShortDesc string = "Manage StereOS mixtapes" -// NewMixtapesCmd creates the mixtapes command group with list, local, pull subcommands. +// NewMixtapesCmd creates the mixtapes command group with list, local, pull, rm subcommands. func NewMixtapesCmd(configDirFn func() string) *cobra.Command { cmd := &cobra.Command{ Use: "mixtapes", @@ -38,6 +38,7 @@ func NewMixtapesCmd(configDirFn func() string) *cobra.Command { cmd.AddCommand(newMixtapesListCmd()) cmd.AddCommand(newMixtapesLocalCmd(configDirFn)) cmd.AddCommand(newMixtapesPullCmd(configDirFn)) + cmd.AddCommand(newMixtapesRmCmd(configDirFn)) return cmd } @@ -124,3 +125,50 @@ func runMixtapesPull(baseDir, name string) error { return mixtapes.Pull(baseDir, name) }) } + +func newMixtapesRmCmd(configDirFn func() string) *cobra.Command { + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove a locally downloaded mixtape", + Long: `Remove a mixtape from the local disk. + +With just a name, removes the mixtape and all of its tags. +With name:tag, removes only that specific tag. + +Examples: + mb mixtapes rm coder-arm64 # Remove all tags + mb mixtapes rm coder-arm64:latest # Remove only the "latest" tag`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + return runMixtapesRm(configDirFn(), args[0], force) + }, + } + + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + + return cmd +} + +func runMixtapesRm(baseDir, ref string, force bool) error { + name, tag := mixtapes.ParseNameTag(ref) + + display := name + if tag != "" { + display = name + ":" + tag + } + + if !force { + if !ui.Confirm("Remove mixtape %q?", display) { + ui.Info("Aborted.") + return nil + } + } + + if err := mixtapes.Remove(baseDir, name, tag); err != nil { + return err + } + + ui.Success("Removed %s", display) + return nil +} diff --git a/go.mod b/go.mod index 3db0634..7d9a716 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( github.com/BurntSushi/toml v1.6.0 github.com/Code-Hex/vz/v3 v3.7.1 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/google/go-containerregistry v0.21.0 @@ -20,14 +21,21 @@ require ( require ( github.com/Code-Hex/go-infinity-channel v1.0.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/docker/cli v29.2.1+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -37,8 +45,12 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect diff --git a/go.sum b/go.sum index f3ca316..3a339cd 100644 --- a/go.sum +++ b/go.sum @@ -4,23 +4,51 @@ github.com/Code-Hex/go-infinity-channel v1.0.0 h1:M8BWlfDOxq9or9yvF9+YkceoTkDI1p github.com/Code-Hex/go-infinity-channel v1.0.0/go.mod h1:5yUVg/Fqao9dAjcpzoQ33WwfdMWmISOrQloDRn3bsvY= github.com/Code-Hex/vz/v3 v3.7.1 h1:EN1yNiyrbPq+dl388nne2NySo8I94EnPppvqypA65XM= github.com/Code-Hex/vz/v3 v3.7.1/go.mod h1:1LsW0jqW0r0cQ+IeR4hHbjdqOtSidNCVMWhStMHGho8= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -30,6 +58,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -70,12 +102,20 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= @@ -137,14 +177,15 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= diff --git a/jcard.toml b/jcard.toml index 6146ad2..3473d71 100644 --- a/jcard.toml +++ b/jcard.toml @@ -5,8 +5,6 @@ cpus = 2 memory = "8GiB" [[agents]] -type = "native" harness = "opencode" -prompt = "Use cowsay to print Hello world!" -extra_packages = [ "cowsay" ] -replicas = 10 +prompt = "Hello world" +replicas = 3 diff --git a/pkg/mixtapes/mixtapes.go b/pkg/mixtapes/mixtapes.go index 1330385..7c5b003 100644 --- a/pkg/mixtapes/mixtapes.go +++ b/pkg/mixtapes/mixtapes.go @@ -149,6 +149,55 @@ func PrintList(mixtapes []MixtapeInfo) { tbl.Render(os.Stdout) } +// Remove deletes a locally downloaded mixtape. If tag is empty, the entire +// mixtape directory (all tags) is removed. If tag is specified, only that +// tag directory is removed -- and if it was the last tag, the parent name +// directory is cleaned up too. +func Remove(baseDir, mixtapeName, tag string) error { + mixtapesRoot := filepath.Join(baseDir, "mixtapes") + nameDir := filepath.Join(mixtapesRoot, mixtapeName) + + // Verify the mixtape exists. + if _, err := os.Stat(nameDir); os.IsNotExist(err) { + return fmt.Errorf("mixtape %q not found locally", mixtapeName) + } + + if tag == "" { + // Remove the entire mixtape (all tags). + if err := os.RemoveAll(nameDir); err != nil { + return fmt.Errorf("removing mixtape %q: %w", mixtapeName, err) + } + return nil + } + + // Remove a specific tag. + tagDir := filepath.Join(nameDir, tag) + if _, err := os.Stat(tagDir); os.IsNotExist(err) { + return fmt.Errorf("mixtape %q tag %q not found locally", mixtapeName, tag) + } + + if err := os.RemoveAll(tagDir); err != nil { + return fmt.Errorf("removing %s:%s: %w", mixtapeName, tag, err) + } + + // Clean up the parent name directory if no tags remain. + remaining, err := os.ReadDir(nameDir) + if err == nil && len(remaining) == 0 { + _ = os.Remove(nameDir) + } + + return nil +} + +// ParseNameTag splits a "name[:tag]" string into name and tag components. +// If no tag is present, tag is returned as empty. +func ParseNameTag(ref string) (name, tag string) { + if idx := strings.Index(ref, ":"); idx != -1 { + return ref[:idx], ref[idx+1:] + } + return ref, "" +} + func formatSize(bytes int64) string { const ( _ = iota diff --git a/pkg/ui/output.go b/pkg/ui/output.go index 57533ac..392c68d 100644 --- a/pkg/ui/output.go +++ b/pkg/ui/output.go @@ -7,6 +7,7 @@ import ( "io" "os" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) @@ -92,3 +93,23 @@ func Label(label, value string) string { value, ) } + +// Confirm prompts the user with an interactive yes/no confirmation using +// charmbracelet/huh. Returns true if the user confirms, false otherwise. +// Defaults to "no" if the user presses enter without selecting. +func Confirm(format string, args ...interface{}) bool { + msg := fmt.Sprintf(format, args...) + + var confirmed bool + err := huh.NewConfirm(). + Title(msg). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed). + Run() + if err != nil { + return false + } + + return confirmed +}