diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index a82a482..6cac8a6 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -54,24 +54,48 @@ func newPluginInitCmd() *cobra.Command { ctx := context.Background() scanner := bufio.NewScanner(os.Stdin) - if scope != "" { + // Read existing plugin.mod (if any) so prompts can default to its + // values. Missing or unparseable file is fine — we just start from + // empty defaults. + existing := &core.ModFile{} + if data, err := os.ReadFile("plugin.mod"); err == nil { + if m, err := core.ParseModFull(data); err == nil && m != nil { + existing = m + } + } + + // Scope precedence: --scope flag > plugin.mod scope > interactive prompt. + switch { + case scope != "": scope, err = parseScope(scope) if err != nil { return err } - } else { + case existing.Plugin.Scope != "": + scope = "@" + strings.TrimPrefix(existing.Plugin.Scope, "@") + default: scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner) if err != nil { return err } } + // Name precedence: --name flag > plugin.mod name > prompt. if name == "" { - fmt.Print("Plugin name: ") + if existing.Plugin.Name != "" { + fmt.Printf("Plugin name [%s]: ", existing.Plugin.Name) + } else { + fmt.Print("Plugin name: ") + } if !scanner.Scan() { return fmt.Errorf("cancelled") } - name = strings.TrimSpace(scanner.Text()) + input := strings.TrimSpace(scanner.Text()) + if input != "" { + name = input + } else { + name = existing.Plugin.Name + } if name == "" { return fmt.Errorf("plugin name is required") } @@ -79,28 +103,37 @@ func newPluginInitCmd() *cobra.Command { rawName := name name = strings.ToLower(name) - fmt.Printf("Display name [%s]: ", rawName) - displayName := "" - if scanner.Scan() { - displayName = strings.TrimSpace(scanner.Text()) + displayDefault := existing.Plugin.DisplayName + if displayDefault == "" { + displayDefault = rawName } - if displayName == "" { - displayName = rawName + fmt.Printf("Display name [%s]: ", displayDefault) + displayName := displayDefault + if scanner.Scan() { + if v := strings.TrimSpace(scanner.Text()); v != "" { + displayName = v + } } - fmt.Print("Description (one-line summary, optional): ") - description := "" + descLabel := "Description (one-line summary, optional)" + if existing.Plugin.Description != "" { + descLabel = fmt.Sprintf("Description [%s]", truncate(existing.Plugin.Description, 60)) + } + fmt.Printf("%s: ", descLabel) + description := existing.Plugin.Description if scanner.Scan() { - description = strings.TrimSpace(scanner.Text()) + if v := strings.TrimSpace(scanner.Text()); v != "" { + description = v + } } - kind, err := promptKind(scanner) + kind, err := promptKindWithDefault(scanner, existing.Plugin.Kind) if err != nil { return err } var cats []string if kind == "plugin" { - cats, err = promptCategories(ctx, cli, scanner) + cats, err = promptCategoriesWithDefault(ctx, cli, scanner, existing.Plugin.Categories) if err != nil { return err } @@ -233,15 +266,27 @@ func createScopeInline(ctx context.Context, cli *orchclient.Client, scanner *buf return slug, nil } -func promptKind(scanner *bufio.Scanner) (string, error) { +// promptKindWithDefault prompts for plugin kind. When current is "plugin" or +// "theme", that becomes the empty-input default (and the [n] hint reflects it). +func promptKindWithDefault(scanner *bufio.Scanner, current string) (string, error) { + defaultIdx := "1" + switch current { + case "theme": + defaultIdx = "2" + case "plugin", "": + defaultIdx = "1" + } fmt.Println("Kind: 1) plugin 2) theme") - fmt.Print("Select [1]: ") + fmt.Printf("Select [%s]: ", defaultIdx) if !scanner.Scan() { return "", fmt.Errorf("cancelled") } v := strings.TrimSpace(scanner.Text()) + if v == "" { + v = defaultIdx + } switch v { - case "", "1", "plugin": + case "1", "plugin": return "plugin", nil case "2", "theme": return "theme", nil @@ -250,7 +295,20 @@ func promptKind(scanner *bufio.Scanner) (string, error) { } } -func promptCategories(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) ([]string, error) { +// truncate clips a string to at most max runes, appending "…" if it was cut. +// Used to keep prompt labels readable when the existing description is long. +func truncate(s string, max int) string { + if len([]rune(s)) <= max { + return s + } + r := []rune(s) + return string(r[:max-1]) + "…" +} + +// promptCategoriesWithDefault lists the canonical categories and lets the user +// pick by number. When current is non-empty, those slugs are pre-selected: +// the prompt shows their numbers as the default, and empty input keeps them. +func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, current []string) ([]string, error) { resp, err := cli.Reg.ListCategories(ctx, connect.NewRequest(&v1.ListCategoriesRequest{})) if err != nil { return nil, fmt.Errorf("list categories: %w", err) @@ -259,17 +317,41 @@ func promptCategories(ctx context.Context, cli *orchclient.Client, scanner *bufi if len(cats) == 0 { return nil, nil } - fmt.Println("Categories (comma-separated numbers, or blank to skip):") + // Build the default indices string from `current` so the prompt shows e.g. + // "Select [9]: " when current is ["templates"] and templates is the 9th + // entry. + slugToIdx := make(map[string]int, len(cats)) for i, c := range cats { - fmt.Printf(" %d. %s — %s\n", i+1, c.Slug, c.DisplayName) + slugToIdx[c.Slug] = i + 1 + } + var defaultNums []string + for _, slug := range current { + if i, ok := slugToIdx[slug]; ok { + defaultNums = append(defaultNums, strconv.Itoa(i)) + } + } + defaultLabel := strings.Join(defaultNums, ",") + + fmt.Println("Categories (comma-separated numbers, or blank to keep current):") + for i, c := range cats { + marker := " " + if _, picked := pickedSet(current)[c.Slug]; picked { + marker = "*" + } + fmt.Printf(" %d.%s%s — %s\n", i+1, marker, c.Slug, c.DisplayName) + } + if defaultLabel != "" { + fmt.Printf("Select [%s]: ", defaultLabel) + } else { + fmt.Print("Select: ") } - fmt.Print("Select: ") if !scanner.Scan() { return nil, fmt.Errorf("cancelled") } raw := strings.TrimSpace(scanner.Text()) if raw == "" { - return nil, nil + // Empty input → keep current (or none, if there's no current). + return current, nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) @@ -283,6 +365,15 @@ func promptCategories(ctx context.Context, cli *orchclient.Client, scanner *bufi return out, nil } +// 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)) + for _, x := range slugs { + s[x] = struct{}{} + } + return s +} + func newPluginPublishCmd() *cobra.Command { var channel string var allowDirty bool