From 03d32aba262be747ec228f9b9646b0db74630db0 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sun, 7 Jun 2026 15:31:36 +0800 Subject: [PATCH] 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 --- cmd/ninja/cmd/plugin.go | 1 + cmd/ninja/cmd/plugin_tags.go | 179 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 cmd/ninja/cmd/plugin_tags.go diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index 9598784..048da1a 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -34,6 +34,7 @@ func newPluginCmd() *cobra.Command { newPluginDeleteVersionCmd(), newPluginBumpCmd(), newPluginVersionCmd(), + newPluginTagsCmd(), ) return c } diff --git a/cmd/ninja/cmd/plugin_tags.go b/cmd/ninja/cmd/plugin_tags.go new file mode 100644 index 0000000..bc84f08 --- /dev/null +++ b/cmd/ninja/cmd/plugin_tags.go @@ -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 ...", + 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 ...", + 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 ...", + 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 +}