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 +}