feat(ninja): add 'plugin tags' subcommand

Adds ninja plugin tags (show), add, rm, set, and clear subcommands that
read and mutate the local plugin.mod tag list via NormalizeTags. The bare
tags command stubs ListTags with a TODO(tags): marker for Task 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-07 15:31:36 +08:00
parent 533632a3bb
commit 03d32aba26
2 changed files with 180 additions and 0 deletions

View File

@ -34,6 +34,7 @@ func newPluginCmd() *cobra.Command {
newPluginDeleteVersionCmd(),
newPluginBumpCmd(),
newPluginVersionCmd(),
newPluginTagsCmd(),
)
return c
}

View File

@ -0,0 +1,179 @@
package cmd
import (
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
)
func newPluginTagsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tags",
Short: "Show current tags and popular tags from the registry",
RunE: func(c *cobra.Command, _ []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
if len(mod.Plugin.Tags) == 0 {
fmt.Println("Current tags: (none)")
} else {
fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", "))
}
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil // not signed in is fine — silent best-effort
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil
}
cli := orchclient.New(resolvedHost, hc.Token)
_ = cli
// TODO(tags): re-enable once core regenerates proto bindings to expose v1.ListTagsRequest (plan Task 12).
// resp, err := cli.Reg.ListTags(context.Background(), connect.NewRequest(&v1.ListTagsRequest{Kind: mod.Plugin.Kind, Limit: 20}))
// if err != nil {
// fmt.Println("(could not fetch popular tags — orchestrator unreachable)")
// return nil
// }
// if len(resp.Msg.Tags) == 0 {
// fmt.Println("Popular tags: (none yet)")
// return nil
// }
// parts := make([]string, len(resp.Msg.Tags))
// for i, t := range resp.Msg.Tags {
// parts[i] = fmt.Sprintf("%s (%d)", t.Tag, t.Count)
// }
// fmt.Printf("Popular: %s\n", strings.Join(parts, ", "))
fmt.Println("(popular tags unavailable — orchestrator bindings not yet regenerated)")
return nil
},
}
cmd.AddCommand(&cobra.Command{
Use: "add <tag>...",
Short: "Add tags to the local plugin.mod (union with current)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "rm <tag>...",
Short: "Remove tags from the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "set <tag>...",
Short: "Replace all tags in the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all tags from the local plugin.mod",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) },
})
return cmd
}
// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes
// it back. Prints the before→after diff and a reminder to publish.
func mutateTags(op string, args []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
before := append([]string(nil), mod.Plugin.Tags...)
var next []string
switch op {
case "add":
next = append(append([]string(nil), before...), args...)
case "rm":
drop := map[string]struct{}{}
for _, a := range args {
drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{}
}
for _, t := range before {
if _, gone := drop[t]; !gone {
next = append(next, t)
}
}
case "set":
next = append([]string(nil), args...)
case "clear":
next = nil
default:
return fmt.Errorf("unknown tag op: %s", op)
}
normalised, err := core.NormalizeTags(next)
if err != nil {
return err
}
if err := writeLocalModTags(mod, normalised); err != nil {
return err
}
sortedBefore := append([]string(nil), before...)
sortedAfter := append([]string(nil), normalised...)
sort.Strings(sortedBefore)
sort.Strings(sortedAfter)
fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", "))
if !equalStringSlices(sortedBefore, sortedAfter) {
fmt.Println("Run 'ninja plugin publish' to push to the registry.")
}
return nil
}
func readLocalMod() (*core.ModFile, error) {
b, err := os.ReadFile("plugin.mod")
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(b)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
return mod, nil
}
// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all
// other fields by reusing upsertPluginMod.
func writeLocalModTags(mod *core.ModFile, tags []string) error {
return upsertPluginMod(
mod.Plugin.Scope,
mod.Plugin.Name,
mod.Plugin.DisplayName,
mod.Plugin.Description,
mod.Plugin.Kind,
mod.Plugin.Categories,
tags,
mod.Plugin.Private,
)
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}