From 052cd1af9b65e587413605082a4eda01d821ccfc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 3 Apr 2026 01:04:35 -0400 Subject: [PATCH 1/3] Refine root and commands UX --- SURFACE.txt | 18 +++ internal/commands/commands.go | 179 +++++++++++++++++++++++++++-- internal/commands/commands_test.go | 34 +++++- internal/commands/format_test.go | 33 ++++++ internal/commands/help.go | 40 +++---- internal/commands/help_test.go | 2 +- internal/commands/quickstart.go | 98 ++++++++++++++++ internal/commands/root.go | 1 + 8 files changed, 365 insertions(+), 40 deletions(-) create mode 100644 internal/commands/quickstart.go diff --git a/SURFACE.txt b/SURFACE.txt index f832adc..16f4d8a 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -4,7 +4,9 @@ ARG fizzy board help 00 [command] ARG fizzy card attachments download 00 [ATTACHMENT_INDEX] ARG fizzy card attachments help 00 [command] ARG fizzy card help 00 [command] +ARG fizzy cmds 00 [filter] ARG fizzy column help 00 [command] +ARG fizzy commands 00 [filter] ARG fizzy comment attachments download 00 [ATTACHMENT_INDEX] ARG fizzy comment attachments help 00 [command] ARG fizzy comment help 00 [command] @@ -94,6 +96,7 @@ CMD fizzy card unwatch CMD fizzy card update CMD fizzy card view CMD fizzy card watch +CMD fizzy cmds CMD fizzy column CMD fizzy column create CMD fizzy column delete @@ -1272,6 +1275,20 @@ FLAG fizzy card watch --quiet type=bool FLAG fizzy card watch --styled type=bool FLAG fizzy card watch --token type=string FLAG fizzy card watch --verbose type=bool +FLAG fizzy cmds --agent type=bool +FLAG fizzy cmds --api-url type=string +FLAG fizzy cmds --count type=bool +FLAG fizzy cmds --help type=bool +FLAG fizzy cmds --ids-only type=bool +FLAG fizzy cmds --jq type=string +FLAG fizzy cmds --json type=bool +FLAG fizzy cmds --limit type=int +FLAG fizzy cmds --markdown type=bool +FLAG fizzy cmds --profile type=string +FLAG fizzy cmds --quiet type=bool +FLAG fizzy cmds --styled type=bool +FLAG fizzy cmds --token type=string +FLAG fizzy cmds --verbose type=bool FLAG fizzy column --agent type=bool FLAG fizzy column --api-url type=string FLAG fizzy column --count type=bool @@ -3011,6 +3028,7 @@ SUB fizzy card unwatch SUB fizzy card update SUB fizzy card view SUB fizzy card watch +SUB fizzy cmds SUB fizzy column SUB fizzy column create SUB fizzy column delete diff --git a/internal/commands/commands.go b/internal/commands/commands.go index e9ee20c..2fd594c 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -3,7 +3,11 @@ package commands import ( "encoding/json" "fmt" + "io" + "sort" + "strings" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -11,11 +15,44 @@ import ( // commandInfo describes a command for structured output. type commandInfo struct { Name string `json:"name"` + Category string `json:"category,omitempty"` Description string `json:"description"` Flags []flagInfo `json:"flags,omitempty"` Subcommands []commandInfo `json:"subcommands,omitempty"` } +var commandCatalogOrder = []string{"core", "collaboration", "admin", "utilities"} + +var commandCatalogTitles = map[string]string{ + "core": "CORE COMMANDS", + "collaboration": "COLLABORATION", + "admin": "ACCOUNT & ADMIN", + "utilities": "SETUP & TOOLS", +} + +var commandCatalogGroups = map[string][]string{ + "core": {"board", "card", "column", "comment", "search", "step"}, + "collaboration": {"notification", "pin", "reaction", "tag", "user"}, + "admin": {"auth", "account", "identity", "webhook", "upload", "migrate"}, + "utilities": {"setup", "signup", "completion", "skill", "commands", "version"}, +} + +var commandCatalogCategory = func() map[string]string { + m := make(map[string]string) + for category, names := range commandCatalogGroups { + for _, name := range names { + m[name] = category + } + } + return m +}() + +type commandCatalogEntry struct { + Name string + Description string + Actions []string +} + type flagInfo struct { Name string `json:"name"` Shorthand string `json:"shorthand,omitempty"` @@ -26,11 +63,24 @@ type flagInfo struct { // commandsCmd emits a catalog of all commands with their flags. var commandsCmd = &cobra.Command{ - Use: "commands", - Short: "List all available commands", - Long: "Lists all available commands. Use --json for a structured command catalog.", + Use: "commands [filter]", + Aliases: []string{"cmds"}, + Short: "List all available commands", + Long: "Lists all available commands. Use --json for a structured command catalog.", + Args: cobra.MaximumNArgs(1), + Example: "$ fizzy commands\n$ fizzy commands auth\n$ fizzy commands --json", RunE: func(cmd *cobra.Command, args []string) error { - catalog := walkCommands(rootCmd, "fizzy") + filter := "" + if len(args) == 1 { + filter = args[0] + } + + catalog := walkCommands(rootCmd, "fizzy", filter) + if isHumanOutput() { + renderCommandsCatalog(outWriter, filter) + captureResponse() + return nil + } printSuccess(catalog) return nil }, @@ -41,24 +91,29 @@ func init() { } // walkCommands recursively builds a command catalog. -func walkCommands(cmd *cobra.Command, prefix string) []commandInfo { +func walkCommands(cmd *cobra.Command, prefix, filter string) []commandInfo { var result []commandInfo for _, sub := range cmd.Commands() { - if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { + if sub.Hidden || sub.Name() == "help" { continue } fullName := prefix + " " + sub.Name() + children := walkCommands(sub, fullName, filter) + if !matchesCommandFilter(sub, fullName, filter) && len(children) == 0 { + continue + } info := commandInfo{ Name: fullName, + Category: commandCatalogCategory[sub.Name()], Description: sub.Short, Flags: collectFlags(sub), } - children := walkCommands(sub, fullName) if len(children) > 0 { info.Subcommands = children } result = append(result, info) } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } @@ -77,9 +132,113 @@ func collectFlags(cmd *cobra.Command) []flagInfo { Description: f.Usage, }) }) + sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) return flags } +func matchesCommandFilter(cmd *cobra.Command, fullName, filter string) bool { + if strings.TrimSpace(filter) == "" { + return true + } + needle := strings.ToLower(strings.TrimSpace(filter)) + fields := []string{cmd.Name(), fullName, cmd.Short, cmd.Long} + for _, field := range fields { + if strings.Contains(strings.ToLower(field), needle) { + return true + } + } + return false +} + +func commandActions(cmd *cobra.Command) []string { + var actions []string + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == "help" { + continue + } + actions = append(actions, sub.Name()) + } + sort.Strings(actions) + return actions +} + +func renderCommandsCatalog(w io.Writer, filter string) { + if w == nil { + w = outWriter + } + if w == nil { + return + } + + registered := make(map[string]*cobra.Command) + for _, sub := range rootCmd.Commands() { + if sub.Hidden || sub.Name() == "help" { + continue + } + registered[sub.Name()] = sub + } + + grouped := make(map[string][]commandCatalogEntry) + maxName := 0 + maxDesc := 0 + for _, category := range commandCatalogOrder { + for _, name := range commandCatalogGroups[category] { + cmd := registered[name] + if cmd == nil { + continue + } + if !matchesCommandFilter(cmd, rootCmd.CommandPath()+" "+name, filter) { + continue + } + entry := commandCatalogEntry{Name: name, Description: cmd.Short, Actions: commandActions(cmd)} + grouped[category] = append(grouped[category], entry) + if len(entry.Name) > maxName { + maxName = len(entry.Name) + } + if len(entry.Description) > maxDesc { + maxDesc = len(entry.Description) + } + } + } + + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + showActions := strings.TrimSpace(filter) != "" + printedAny := false + for _, category := range commandCatalogOrder { + entries := grouped[category] + if len(entries) == 0 { + continue + } + if printedAny { + fmt.Fprintln(w) + } + printedAny = true + fmt.Fprintln(w, commandCatalogTitles[category]) + for _, entry := range entries { + line := fmt.Sprintf(" %-*s %-*s", maxName, entry.Name, maxDesc, entry.Description) + if showActions && len(entry.Actions) > 0 { + line += " " + muted.Render(strings.Join(entry.Actions, ", ")) + } + fmt.Fprintln(w, line) + } + } + + if !printedAny { + fmt.Fprintf(w, "No commands match %q\n", filter) + return + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "LEARN MORE") + fmt.Fprintln(w, " fizzy --help Help for a specific command") + fmt.Fprintln(w, " fizzy commands --json Structured command catalog") + if strings.TrimSpace(filter) == "" { + fmt.Fprintln(w, " fizzy commands auth Filter commands by name or description") + } else { + fmt.Fprintln(w, " fizzy commands Full command catalog") + } +} + // agentHelp outputs command help as structured JSON. func agentHelp(cmd *cobra.Command, _ []string) { info := commandInfo{ @@ -102,7 +261,7 @@ func agentHelp(cmd *cobra.Command, _ []string) { }) }) - children := walkCommands(cmd, cmd.CommandPath()) + children := walkCommands(cmd, cmd.CommandPath(), "") if len(children) > 0 { info.Subcommands = children } @@ -118,10 +277,6 @@ func installAgentHelp() { agentHelp(cmd, args) return } - // Banner on root help only - if cmd == rootCmd { - printBanner() - } // Fall back to Cobra's default help cmd.Root().SetHelpFunc(nil) _ = cmd.Help() diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index ca5019e..90291c3 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -18,12 +18,37 @@ func TestCommandsStyledOutputRendersHumanCatalog(t *testing.T) { } raw := TestOutput() - if !strings.Contains(raw, "Name") { - t.Fatalf("expected styled catalog header, got:\n%s", raw) + if !strings.Contains(raw, "CORE COMMANDS") { + t.Fatalf("expected styled catalog heading, got:\n%s", raw) } - if !strings.Contains(raw, "fizzy auth") { + if !strings.Contains(raw, "auth") || !strings.Contains(raw, "board") { t.Fatalf("expected styled catalog to include commands, got:\n%s", raw) } + if strings.Contains(raw, "list, show") { + t.Fatalf("expected unfiltered styled catalog to omit action lists, got:\n%s", raw) + } +} + +func TestCommandsFilterRendersMatchingHumanCatalog(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestFormat(output.FormatStyled) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{"auth"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := TestOutput() + if !strings.Contains(raw, "auth") { + t.Fatalf("expected filtered catalog to include auth, got:\n%s", raw) + } + if strings.Contains(raw, "board") { + t.Fatalf("expected filtered catalog to omit non-matching board command, got:\n%s", raw) + } + if !strings.Contains(raw, "list, login, logout, status, switch") { + t.Fatalf("expected filtered catalog to include action list, got:\n%s", raw) + } } func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { @@ -54,6 +79,9 @@ func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { continue } if entry["name"] == "fizzy commands" { + if entry["category"] != "utilities" { + t.Fatalf("expected fizzy commands category utilities, got %#v", entry["category"]) + } found = true break } diff --git a/internal/commands/format_test.go b/internal/commands/format_test.go index 0f61b1c..8a2bcb4 100644 --- a/internal/commands/format_test.go +++ b/internal/commands/format_test.go @@ -345,6 +345,39 @@ func runCobraWithArgs(args ...string) (string, error) { return lastRawOutput, err } +func TestBareRootNonTTYReturnsQuickStartJSON(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + raw, err := runCobraWithArgs() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatalf("expected JSON object, got parse error: %v\noutput: %s", err, raw) + } + if resp["ok"] != true { + t.Fatalf("expected ok response, got %#v", resp) + } + if _, ok := resp["summary"].(string); !ok { + t.Fatalf("expected summary string, got %#v", resp["summary"]) + } + data, ok := resp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data object, got %#v", resp["data"]) + } + if data["version"] == nil { + t.Fatalf("expected version in quick start response, got %#v", data) + } + if _, ok := data["commands"].(map[string]any); !ok { + t.Fatalf("expected commands block, got %#v", data["commands"]) + } +} + func TestCobraFormatCount(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ diff --git a/internal/commands/help.go b/internal/commands/help.go index 1f5fa0b..b2f8408 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -66,9 +66,6 @@ func installHumanHelp() { agentHelp(cmd, args) return } - if cmd == rootCmd { - printBanner() - } renderHelp(cmd, outWriter) }) } @@ -104,20 +101,13 @@ func renderRootHelp(cmd *cobra.Command, w io.Writer) { printCommandList(w, group.Commands) } + fmt.Fprintln(w) + fmt.Fprintln(w, "FLAGS") + printNamedFlags(w, cmd.PersistentFlags(), []string{"json", "jq", "quiet", "profile", "verbose"}) if flags := rootLocalFlags(cmd); len(flags) > 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, "FLAGS") printFlags(w, flags) } - fmt.Fprintln(w) - fmt.Fprintln(w, "GLOBAL OUTPUT FLAGS") - printNamedFlags(w, cmd.PersistentFlags(), []string{"json", "quiet", "styled", "markdown", "ids-only", "count", "limit"}) - - fmt.Fprintln(w) - fmt.Fprintln(w, "GLOBAL CONFIG FLAGS") - printNamedFlags(w, cmd.PersistentFlags(), []string{"profile", "token", "api-url", "verbose", "agent"}) - if cmd.Example != "" { fmt.Fprintln(w) fmt.Fprintln(w, "EXAMPLES") @@ -126,6 +116,7 @@ func renderRootHelp(cmd *cobra.Command, w io.Writer) { fmt.Fprintln(w) fmt.Fprintln(w, "LEARN MORE") + fmt.Fprintln(w, " Use `fizzy commands` to see the full command catalog.") fmt.Fprintln(w, " Use `fizzy --help` for more information about a command.") fmt.Fprintln(w, " Use `fizzy commands --json` for a structured command catalog.") } @@ -296,8 +287,9 @@ func printNamedFlags(w io.Writer, flags *pflag.FlagSet, names []string) { func rootLocalFlags(cmd *cobra.Command) []*pflag.Flag { excluded := map[string]bool{ "agent": true, "api-url": true, "count": true, "ids-only": true, - "json": true, "limit": true, "markdown": true, "profile": true, - "quiet": true, "styled": true, "token": true, "verbose": true, + "jq": true, "json": true, "limit": true, "markdown": true, + "profile": true, "quiet": true, "styled": true, "token": true, + "verbose": true, } flags := visibleFlags(cmd.Flags()) @@ -376,20 +368,20 @@ func printExampleBlock(w io.Writer, example string) { } } -var rootCommandGroupsOrder = []string{"core", "collaboration", "admin", "utilities"} +var rootCommandGroupsOrder = []string{"core", "collaboration", "getting-started", "discover"} var rootCommandGroupTitles = map[string]string{ - "core": "CORE COMMANDS", - "collaboration": "COLLABORATION COMMANDS", - "admin": "ADMINISTRATION COMMANDS", - "utilities": "UTILITIES", + "core": "CORE COMMANDS", + "collaboration": "COLLABORATION", + "getting-started": "GETTING STARTED", + "discover": "DISCOVER", } var rootCommandGroups = map[string][]string{ - "core": {"auth", "board", "card", "comment", "search", "step", "column"}, - "collaboration": {"notification", "pin", "reaction", "tag", "user"}, - "admin": {"account", "identity", "migrate", "upload", "webhook"}, - "utilities": {"commands", "completion", "setup", "signup", "skill", "version"}, + "core": {"auth", "board", "card", "search"}, + "collaboration": {"comment", "notification"}, + "getting-started": {"setup", "signup"}, + "discover": {"commands", "version"}, } var commandExamples = map[string]string{ diff --git a/internal/commands/help_test.go b/internal/commands/help_test.go index 9feaabb..cf4d147 100644 --- a/internal/commands/help_test.go +++ b/internal/commands/help_test.go @@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) { renderHelp(rootCmd, &buf) out := buf.String() - for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE", "implies --json"} { + for _, want := range []string{"CORE COMMANDS", "GETTING STARTED", "DISCOVER", "FLAGS", "--profile", "LEARN MORE", "Use `fizzy commands` to see the full command catalog.", "implies --json"} { if !strings.Contains(out, want) { t.Fatalf("expected root help to contain %q, got:\n%s", want, out) } diff --git a/internal/commands/quickstart.go b/internal/commands/quickstart.go new file mode 100644 index 0000000..0ec1881 --- /dev/null +++ b/internal/commands/quickstart.go @@ -0,0 +1,98 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type quickStartResponse struct { + Version string `json:"version"` + Auth quickStartAuthInfo `json:"auth"` + Context quickStartContextInfo `json:"context"` + Commands quickStartCommandsInfo `json:"commands"` +} + +type quickStartAuthInfo struct { + Status string `json:"status"` + Profile string `json:"profile,omitempty"` + Account string `json:"account,omitempty"` +} + +type quickStartContextInfo struct { + Board string `json:"board,omitempty"` +} + +type quickStartCommandsInfo struct { + QuickStart []string `json:"quick_start"` + Common []string `json:"common"` +} + +func runRootDefault(cmd *cobra.Command, args []string) error { + if isHumanOutput() { + return cmd.Help() + } + + auth := quickStartAuthInfo{Status: "unauthenticated"} + if cfgProfile != "" { + auth.Profile = cfgProfile + } + if cfg != nil { + if auth.Profile == "" { + auth.Profile = cfgProfile + } + if cfg.Account != "" { + auth.Account = cfg.Account + } + if cfg.Token != "" { + auth.Status = "authenticated" + } + } + + context := quickStartContextInfo{} + if cfg != nil && cfg.Board != "" { + context.Board = cfg.Board + } + + resp := quickStartResponse{ + Version: currentVersion(), + Auth: auth, + Context: context, + Commands: quickStartCommandsInfo{ + QuickStart: []string{"fizzy board list", "fizzy card list", `fizzy search "query"`}, + Common: []string{"fizzy auth status", "fizzy board list", "fizzy card show 42"}, + }, + } + + summary := fmt.Sprintf("fizzy %s - not logged in", currentVersion()) + if auth.Status == "authenticated" { + summary = fmt.Sprintf("fizzy %s - logged in", currentVersion()) + if auth.Account != "" { + summary += " @ " + auth.Account + } + } + if auth.Profile != "" { + summary += fmt.Sprintf(" (profile: %s)", auth.Profile) + } + + breadcrumbs := []Breadcrumb{ + breadcrumb("list_boards", "fizzy board list", "List boards"), + breadcrumb("list_cards", "fizzy card list", "List cards"), + breadcrumb("search_cards", `fizzy search "query"`, "Search cards"), + } + if auth.Status == "unauthenticated" { + breadcrumbs = append(breadcrumbs, breadcrumb("authenticate", authLoginHint(), "Authenticate")) + } + + printSuccessWithBreadcrumbs(resp, summary, breadcrumbs) + return nil +} + +func authLoginHint() string { + parts := []string{"fizzy", "auth", "login", ""} + if strings.TrimSpace(cfgProfile) != "" { + parts = append(parts, "--profile", cfgProfile) + } + return strings.Join(parts, " ") +} diff --git a/internal/commands/root.go b/internal/commands/root.go index accd565..ff88854 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -75,6 +75,7 @@ var rootCmd = &cobra.Command{ Short: "Fizzy CLI - Command-line interface for the Fizzy API", Long: `Command-line interface for Fizzy`, Version: "dev", + RunE: runRootDefault, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { errOutputWrite = nil // Early jq validation: check flag conflicts first (actionable message), From 1546b59a36b00d9e6bb1faa738752d549b114492 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 3 Apr 2026 01:06:41 -0400 Subject: [PATCH 2/3] Remove unused root banner helper --- internal/commands/banner.go | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 internal/commands/banner.go diff --git a/internal/commands/banner.go b/internal/commands/banner.go deleted file mode 100644 index dbcbbd4..0000000 --- a/internal/commands/banner.go +++ /dev/null @@ -1,20 +0,0 @@ -package commands - -import ( - "os" - - "github.com/basecamp/fizzy-cli/internal/tui" - "github.com/mattn/go-isatty" -) - -// printBanner prints the Fizzy braille art banner to stderr. -// Suppressed when machine format flags are set or stderr is not a TTY. -func printBanner() { - if cfgAgent || cfgJSON || cfgQuiet || cfgIDsOnly || cfgCount { - return - } - if !isatty.IsTerminal(os.Stderr.Fd()) && !isatty.IsCygwinTerminal(os.Stderr.Fd()) { - return - } - tui.AnimateBanner(os.Stderr) -} From acc39750ce9bfeb8e69b132c916699ea18cd9181 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 3 Apr 2026 01:09:35 -0400 Subject: [PATCH 3/3] Remove redundant quick-start profile assignment --- internal/commands/quickstart.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/commands/quickstart.go b/internal/commands/quickstart.go index 0ec1881..d47a5bc 100644 --- a/internal/commands/quickstart.go +++ b/internal/commands/quickstart.go @@ -39,9 +39,6 @@ func runRootDefault(cmd *cobra.Command, args []string) error { auth.Profile = cfgProfile } if cfg != nil { - if auth.Profile == "" { - auth.Profile = cfgProfile - } if cfg.Account != "" { auth.Account = cfg.Account }