feat(ninja): prompt for tags in plugin init/edit flow

Add promptTagsWithDefault helper (mirrors promptCategoriesWithDefault) and
wire it into the init flow so upsertPluginMod receives real user-entered tags
instead of nil. ListTags RPC stub left with 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:27:30 +08:00
parent 6bc0f98979
commit 533632a3bb

View File

@ -360,6 +360,10 @@ func newPluginInitCmd() *cobra.Command {
return err
}
}
tags, err := promptTagsWithDefault(ctx, cli, scanner, kind, existing.Plugin.Tags)
if err != nil {
return err
}
createReq := &v1.CreatePluginRequest{
ScopeSlug: scopeAPISlug(scope),
@ -385,7 +389,7 @@ func newPluginInitCmd() *cobra.Command {
if private {
modScope = ""
}
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, nil, private); err != nil {
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, tags, private); err != nil {
return err
}
fmt.Println("plugin.mod updated")
@ -600,6 +604,57 @@ func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, sc
return out, nil
}
// promptTagsWithDefault prompts the user for free-form tags. Best-effort fetches
// the top-20 most-used tags via ListTags(kind) to surface popular suggestions;
// if that call fails (offline, etc.) it falls back to a plain prompt with a
// one-line warning. Empty input keeps `current`. Validates with NormalizeTags;
// on error, prints the issue and reprompts (one re-try, then bails).
func promptTagsWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, kind string, current []string) ([]string, error) {
// Best-effort popular-tags lookup.
var popular string
// TODO(tags): re-enable once core regenerates proto bindings to expose v1.ListTagsRequest (plan Task 12).
// if resp, err := cli.Reg.ListTags(ctx, connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20})); err == nil {
// parts := make([]string, 0, len(resp.Msg.Tags))
// for _, t := range resp.Msg.Tags {
// parts = append(parts, fmt.Sprintf("%s (%d)", t.Tag, t.Count))
// }
// popular = strings.Join(parts, ", ")
// }
_ = ctx
_ = cli
for attempt := 0; attempt < 2; attempt++ {
if len(current) > 0 {
fmt.Printf("Tags (current: %s)\n", strings.Join(current, ", "))
} else {
fmt.Println("Tags (current: none)")
}
if popular != "" {
fmt.Printf("Popular: %s\n", popular)
} else if attempt == 0 {
fmt.Println("(could not fetch popular tags — offline or unauthenticated)")
}
fmt.Print("Enter comma-separated tags (blank to keep current): ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
return current, nil
}
parts := strings.Split(raw, ",")
out, err := core.NormalizeTags(parts)
if err == nil {
return out, nil
}
fmt.Printf(" %s\n", err.Error())
if attempt == 0 {
fmt.Println(" Try again:")
}
}
return nil, fmt.Errorf("tag input failed validation after 2 attempts")
}
// pickedSet returns a set of the given slugs for O(1) membership checks.
func pickedSet(slugs []string) map[string]struct{} {
s := make(map[string]struct{}, len(slugs))