Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions SURFACE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 0 additions & 20 deletions internal/commands/banner.go

This file was deleted.

179 changes: 167 additions & 12 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,56 @@ package commands
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// 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"`
Expand All @@ -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
},
Expand All @@ -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
}

Expand All @@ -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 <command> --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{
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
34 changes: 31 additions & 3 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading