diff --git a/SURFACE.txt b/SURFACE.txt index 8dd2fb7..8021422 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -163,6 +163,7 @@ FLAG fizzy --quiet type=bool FLAG fizzy --styled type=bool FLAG fizzy --token type=string FLAG fizzy --verbose type=bool +FLAG fizzy --version type=bool FLAG fizzy account --agent type=bool FLAG fizzy account --api-url type=string FLAG fizzy account --count type=bool diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 71418be..f9e416f 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -237,7 +237,7 @@ var authListCmd = &cobra.Command{ breadcrumb("login", "fizzy auth login ", "Log in"), breadcrumb("signup", "fizzy signup", "Sign up"), } - printSuccessWithBreadcrumbs([]any{}, "No profiles configured", breadcrumbs) + printList([]any{}, authProfileColumns, "No profiles configured", breadcrumbs) return nil } @@ -247,7 +247,7 @@ var authListCmd = &cobra.Command{ breadcrumb("login", "fizzy auth login ", "Log in"), breadcrumb("signup", "fizzy signup", "Sign up"), } - printSuccessWithBreadcrumbs([]any{}, "No profiles configured", breadcrumbs) + printList([]any{}, authProfileColumns, "No profiles configured", breadcrumbs) return nil } @@ -282,7 +282,7 @@ var authListCmd = &cobra.Command{ breadcrumb("switch", "fizzy auth switch ", "Switch profile"), } - printSuccessWithBreadcrumbs(entries, fmt.Sprintf("%d profile(s)", len(entries)), breadcrumbs) + printList(entries, authProfileColumns, fmt.Sprintf("%d profile(s)", len(entries)), breadcrumbs) return nil }, } diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go index 2ab6d6d..ad1aa9c 100644 --- a/internal/commands/auth_test.go +++ b/internal/commands/auth_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/basecamp/cli/credstore" + "github.com/basecamp/cli/output" "github.com/basecamp/cli/profile" "github.com/basecamp/fizzy-cli/internal/config" "gopkg.in/yaml.v3" @@ -507,6 +509,41 @@ func TestAuthList(t *testing.T) { t.Errorf("expected 0 profiles, got %d", len(profiles)) } }) + + t.Run("renders styled output with next steps", func(t *testing.T) { + credDir := t.TempDir() + profileDir := t.TempDir() + + os.Setenv("FIZZY_LIST_STYLED_NO_KR", "1") + defer os.Unsetenv("FIZZY_LIST_STYLED_NO_KR") + store := credstore.NewStore(credstore.StoreOptions{ + ServiceName: "fizzy-list-styled-test", + DisableEnvVar: "FIZZY_LIST_STYLED_NO_KR", + FallbackDir: credDir, + }) + profileStore := profile.NewStore(filepath.Join(profileDir, "config.json")) + profileStore.Create(&profile.Profile{Name: "acme", BaseURL: "https://app.fizzy.do"}) + t1, _ := json.Marshal("token1") + store.Save("profile:acme", t1) + + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestCreds(store) + SetTestProfiles(profileStore) + SetTestFormat(output.FormatStyled) + defer resetTest() + + err := authListCmd.RunE(authListCmd, []string{}) + assertExitCode(t, err, 0) + + raw := TestOutput() + if !strings.Contains(raw, "Profile") { + t.Fatalf("expected styled table output, got:\n%s", raw) + } + if !strings.Contains(raw, "Next steps:") { + t.Fatalf("expected next steps section, got:\n%s", raw) + } + }) } func TestAuthSwitch(t *testing.T) { diff --git a/internal/commands/column.go b/internal/commands/column.go index 8bbbc89..b387d04 100644 --- a/internal/commands/column.go +++ b/internal/commands/column.go @@ -41,7 +41,7 @@ var columnListCmd = &cobra.Command{ dataSlice := toSliceAny(items) if dataSlice == nil { - printSuccess(items) + printDetail(items, "", nil) return nil } diff --git a/internal/commands/columns.go b/internal/commands/columns.go index 3ffdef3..99a5b1b 100644 --- a/internal/commands/columns.go +++ b/internal/commands/columns.go @@ -39,6 +39,13 @@ var ( {Header: "Name", Field: "name"}, } + authProfileColumns = render.Columns{ + {Header: "Profile", Field: "profile"}, + {Header: "Active", Field: "active"}, + {Header: "Board", Field: "board"}, + {Header: "Base URL", Field: "base_url"}, + } + notificationColumns = render.Columns{ {Header: "ID", Field: "id"}, {Header: "Message", Field: "message"}, diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 1364857..0c6ada3 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -24,11 +24,11 @@ type flagInfo struct { Description string `json:"description"` } -// commandsCmd emits a flat catalog of all commands with their flags. +// 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 with their flags in JSON format.", + Long: "Lists all available commands. Use --json for a structured command catalog.", RunE: func(cmd *cobra.Command, args []string) error { catalog := walkCommands(rootCmd, "fizzy") printSuccess(catalog) @@ -110,20 +110,3 @@ func agentHelp(cmd *cobra.Command, _ []string) { data, _ := json.MarshalIndent(info, "", " ") fmt.Fprintln(outWriter, string(data)) } - -// installAgentHelp sets the custom help function when --agent is active. -func installAgentHelp() { - rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cfgAgent { - 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 new file mode 100644 index 0000000..ca5019e --- /dev/null +++ b/internal/commands/commands_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/basecamp/cli/output" +) + +func TestCommandsStyledOutputRendersHumanCatalog(t *testing.T) { + mock := NewMockClient() + SetTestModeWithSDK(mock) + SetTestFormat(output.FormatStyled) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + raw := TestOutput() + if !strings.Contains(raw, "Name") { + t.Fatalf("expected styled catalog header, got:\n%s", raw) + } + if !strings.Contains(raw, "fizzy auth") { + t.Fatalf("expected styled catalog to include commands, got:\n%s", raw) + } +} + +func TestCommandsJSONOutputReturnsStructuredCatalog(t *testing.T) { + mock := NewMockClient() + result := SetTestModeWithSDK(mock) + defer resetTest() + + if err := commandsCmd.RunE(commandsCmd, []string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Response == nil || !result.Response.OK { + t.Fatalf("expected OK JSON response, got %#v", result.Response) + } + + items, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected command catalog slice, got %#v", result.Response.Data) + } + if len(items) == 0 { + t.Fatal("expected command catalog entries") + } + + found := false + for _, item := range items { + entry, ok := item.(map[string]any) + if !ok { + continue + } + if entry["name"] == "fizzy commands" { + found = true + break + } + } + if !found { + t.Fatalf("expected command catalog to include fizzy commands, got %#v", items) + } +} diff --git a/internal/commands/help.go b/internal/commands/help.go new file mode 100644 index 0000000..1f5fa0b --- /dev/null +++ b/internal/commands/help.go @@ -0,0 +1,444 @@ +package commands + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const helpGroupAnnotation = "fizzy.help.group" + +var cliUXConfigured bool + +type helpLink struct { + Command string + Description string +} + +type commandGroup struct { + Title string + Commands []*cobra.Command +} + +func configureCLIUX() { + if cliUXConfigured { + return + } + cliUXConfigured = true + + applyHelpMetadata(rootCmd) + installHumanHelp() +} + +func applyHelpMetadata(root *cobra.Command) { + setRootHelpMetadata(root) + walkCommandTree(root, func(cmd *cobra.Command) { + applyGenericAliases(cmd) + if cmd.Example == "" { + if ex := commandExamples[cmd.CommandPath()]; ex != "" { + cmd.Example = ex + } + } + }) + + for group, names := range rootCommandGroups { + for _, name := range names { + if cmd := findSubcommand(root, name); cmd != nil { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[helpGroupAnnotation] = group + } + } + } +} + +func installHumanHelp() { + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cfgAgent { + agentHelp(cmd, args) + return + } + if cmd == rootCmd { + printBanner() + } + renderHelp(cmd, outWriter) + }) +} + +func renderHelp(cmd *cobra.Command, w io.Writer) { + if w == nil { + w = outWriter + } + if w == nil { + w = io.Discard + } + + if cmd == rootCmd { + renderRootHelp(cmd, w) + return + } + renderCommandHelp(cmd, w) +} + +func renderRootHelp(cmd *cobra.Command, w io.Writer) { + cmd.InitDefaultHelpFlag() + cmd.InitDefaultVersionFlag() + + fmt.Fprintln(w, strings.TrimSpace(cmd.Long)) + fmt.Fprintln(w) + fmt.Fprintln(w, "USAGE") + fmt.Fprintf(w, " %s [flags]\n", cmd.Name()) + + groups := groupedRootCommands(cmd) + for _, group := range groups { + fmt.Fprintln(w) + fmt.Fprintln(w, group.Title) + printCommandList(w, group.Commands) + } + + 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") + printExampleBlock(w, cmd.Example) + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "LEARN MORE") + fmt.Fprintln(w, " Use `fizzy --help` for more information about a command.") + fmt.Fprintln(w, " Use `fizzy commands --json` for a structured command catalog.") +} + +func renderCommandHelp(cmd *cobra.Command, w io.Writer) { + cmd.InitDefaultHelpFlag() + + desc := strings.TrimSpace(cmd.Long) + if desc == "" { + desc = strings.TrimSpace(cmd.Short) + } + if desc != "" { + fmt.Fprintln(w, desc) + fmt.Fprintln(w) + } + + subs := visibleSubcommands(cmd) + usageLine := cmd.UseLine() + if len(subs) > 0 && !cmd.Runnable() { + usageLine = cmd.CommandPath() + " [flags]" + } + + fmt.Fprintln(w, "USAGE") + fmt.Fprintf(w, " %s\n", usageLine) + + if len(cmd.Aliases) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "ALIASES") + fmt.Fprintf(w, " %s\n", strings.Join(cmd.Aliases, ", ")) + } + + if len(subs) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "AVAILABLE COMMANDS") + printCommandList(w, subs) + } + + if flags := visibleFlags(cmd.LocalFlags()); len(flags) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "FLAGS") + printFlags(w, flags) + } + + if cmd.HasInheritedFlags() { + fmt.Fprintln(w) + fmt.Fprintln(w, "GLOBAL OUTPUT FLAGS") + printNamedFlags(w, cmd.InheritedFlags(), []string{"json", "quiet", "styled", "markdown", "ids-only", "count", "limit"}) + + fmt.Fprintln(w) + fmt.Fprintln(w, "GLOBAL CONFIG FLAGS") + printNamedFlags(w, cmd.InheritedFlags(), []string{"profile", "token", "api-url", "verbose", "agent"}) + } + + if cmd.Example != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, "EXAMPLES") + printExampleBlock(w, cmd.Example) + } + + if links := relatedCommands[cmd.CommandPath()]; len(links) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "SEE ALSO") + for _, link := range links { + fmt.Fprintf(w, " %-34s %s\n", link.Command, link.Description) + } + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "LEARN MORE") + fmt.Fprintf(w, " Use `fizzy %s --help` for more information about this command.\n", strings.TrimPrefix(cmd.CommandPath(), "fizzy ")) +} + +func setRootHelpMetadata(cmd *cobra.Command) { + cmd.Example = strings.Join([]string{ + "$ fizzy auth status", + "$ fizzy board list", + "$ fizzy card show 42", + "$ fizzy search \"billing bug\"", + }, "\n") +} + +func applyGenericAliases(cmd *cobra.Command) { + switch cmd.Name() { + case "list": + appendAlias(cmd, "ls") + case "show": + appendAlias(cmd, "view") + case "delete": + appendAlias(cmd, "rm") + } +} + +func appendAlias(cmd *cobra.Command, alias string) { + for _, existing := range cmd.Aliases { + if existing == alias { + return + } + } + cmd.Aliases = append(cmd.Aliases, alias) +} + +func walkCommandTree(cmd *cobra.Command, fn func(*cobra.Command)) { + fn(cmd) + for _, sub := range cmd.Commands() { + if sub.Hidden { + continue + } + walkCommandTree(sub, fn) + } +} + +func findSubcommand(cmd *cobra.Command, name string) *cobra.Command { + for _, sub := range cmd.Commands() { + if sub.Name() == name { + return sub + } + } + return nil +} + +func groupedRootCommands(cmd *cobra.Command) []commandGroup { + groups := make([]commandGroup, 0, len(rootCommandGroupsOrder)) + for _, key := range rootCommandGroupsOrder { + title := rootCommandGroupTitles[key] + var commands []*cobra.Command + for _, sub := range visibleSubcommands(cmd) { + if sub.Annotations[helpGroupAnnotation] == key { + commands = append(commands, sub) + } + } + if len(commands) == 0 { + continue + } + sort.Slice(commands, func(i, j int) bool { return commands[i].Name() < commands[j].Name() }) + groups = append(groups, commandGroup{Title: title, Commands: commands}) + } + return groups +} + +func visibleSubcommands(cmd *cobra.Command) []*cobra.Command { + var subs []*cobra.Command + for _, sub := range cmd.Commands() { + if sub.Hidden || sub.Name() == "help" { + continue + } + subs = append(subs, sub) + } + sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() }) + return subs +} + +func printCommandList(w io.Writer, commands []*cobra.Command) { + for _, sub := range commands { + fmt.Fprintf(w, " %-14s %s\n", sub.Name(), sub.Short) + } +} + +func printNamedFlags(w io.Writer, flags *pflag.FlagSet, names []string) { + selected := make([]*pflag.Flag, 0, len(names)) + for _, name := range names { + if f := flags.Lookup(name); f != nil && !f.Hidden { + selected = append(selected, f) + } + } + printFlags(w, selected) +} + +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, + } + + flags := visibleFlags(cmd.Flags()) + result := make([]*pflag.Flag, 0, len(flags)) + for _, f := range flags { + if excluded[f.Name] { + continue + } + result = append(result, f) + } + return result +} + +func visibleFlags(flags *pflag.FlagSet) []*pflag.Flag { + var result []*pflag.Flag + flags.VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + result = append(result, f) + }) + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func printFlags(w io.Writer, flags []*pflag.Flag) { + for _, f := range flags { + fmt.Fprintf(w, " %-26s %s\n", flagDisplayName(f), flagUsage(f)) + } +} + +func flagDisplayName(f *pflag.Flag) string { + parts := make([]string, 0, 2) + if f.Shorthand != "" { + parts = append(parts, "-"+f.Shorthand) + } + name := "--" + f.Name + if f.Value.Type() != "bool" { + name += " <" + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + ">" + } + parts = append(parts, name) + return strings.Join(parts, ", ") +} + +func flagUsage(f *pflag.Flag) string { + usage := f.Usage + if shouldShowDefault(f) { + usage += fmt.Sprintf(" (default: %s)", f.DefValue) + } + return usage +} + +func shouldShowDefault(f *pflag.Flag) bool { + if f.DefValue == "" { + return false + } + switch f.Value.Type() { + case "bool": + return f.DefValue == "true" + case "int": + return f.DefValue != "0" + default: + return true + } +} + +func printExampleBlock(w io.Writer, example string) { + style := lipgloss.NewStyle() + if f, ok := w.(*os.File); ok { + if isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) { + style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + } + } + for _, line := range strings.Split(strings.TrimSpace(example), "\n") { + fmt.Fprintf(w, "%s\n", style.Render(" "+line)) + } +} + +var rootCommandGroupsOrder = []string{"core", "collaboration", "admin", "utilities"} + +var rootCommandGroupTitles = map[string]string{ + "core": "CORE COMMANDS", + "collaboration": "COLLABORATION COMMANDS", + "admin": "ADMINISTRATION COMMANDS", + "utilities": "UTILITIES", +} + +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"}, +} + +var commandExamples = map[string]string{ + "fizzy auth": "$ fizzy auth status\n$ fizzy auth login TOKEN --profile acme", + "fizzy auth status": "$ fizzy auth status", + "fizzy auth list": "$ fizzy auth list\n$ fizzy auth switch acme", + "fizzy board": "$ fizzy board list\n$ fizzy board show ", + "fizzy board list": "$ fizzy board list\n$ fizzy board list --page 2", + "fizzy board show": "$ fizzy board show ", + "fizzy card": "$ fizzy card list --board \n$ fizzy card show ", + "fizzy card list": "$ fizzy card list --board \n$ fizzy card list --board --all", + "fizzy card show": "$ fizzy card show ", + "fizzy card create": "$ fizzy card create --board --title \"Fix billing bug\"", + "fizzy comment list": "$ fizzy comment list --card ", + "fizzy comment create": "$ fizzy comment create --card --body \"Looks good\"", + "fizzy commands": "$ fizzy commands\n$ fizzy commands --json", + "fizzy search": "$ fizzy search \"billing bug\"\n$ fizzy search \"billing bug\" --board ", + "fizzy notification": "$ fizzy notification tray\n$ fizzy notification list", + "fizzy notification tray": "$ fizzy notification tray", + "fizzy user": "$ fizzy user list\n$ fizzy user show ", + "fizzy user list": "$ fizzy user list\n$ fizzy user list --all", + "fizzy setup": "$ fizzy setup", + "fizzy signup": "$ fizzy signup start --email you@example.com", + "fizzy version": "$ fizzy version", +} + +var relatedCommands = map[string][]helpLink{ + "fizzy auth": { + {Command: "fizzy identity show", Description: "View your identity and accessible accounts"}, + {Command: "fizzy board list", Description: "List boards after logging in"}, + }, + "fizzy auth status": { + {Command: "fizzy identity show", Description: "View your identity"}, + {Command: "fizzy auth list", Description: "List saved profiles"}, + }, + "fizzy board": { + {Command: "fizzy card list --board ", Description: "List cards on a board"}, + {Command: "fizzy column list --board ", Description: "List columns on a board"}, + }, + "fizzy board show": { + {Command: "fizzy card list --board ", Description: "List cards on this board"}, + {Command: "fizzy column list --board ", Description: "List columns on this board"}, + }, + "fizzy card": { + {Command: "fizzy search \"query\"", Description: "Search cards by text"}, + {Command: "fizzy comment list --card ", Description: "List comments on a card"}, + }, + "fizzy search": { + {Command: "fizzy card list --board ", Description: "Browse cards on a board"}, + {Command: "fizzy card show ", Description: "Show a matching card"}, + }, +} diff --git a/internal/commands/help_test.go b/internal/commands/help_test.go new file mode 100644 index 0000000..d435515 --- /dev/null +++ b/internal/commands/help_test.go @@ -0,0 +1,91 @@ +package commands + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRenderRootHelp(t *testing.T) { + configureCLIUX() + + var buf bytes.Buffer + renderHelp(rootCmd, &buf) + out := buf.String() + + for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE"} { + if !strings.Contains(out, want) { + t.Fatalf("expected root help to contain %q, got:\n%s", want, out) + } + } +} + +func TestRenderSubcommandHelpIncludesAliasesAndExamples(t *testing.T) { + configureCLIUX() + + var buf bytes.Buffer + renderHelp(authListCmd, &buf) + out := buf.String() + + if !strings.Contains(out, "ALIASES") || !strings.Contains(out, "ls") { + t.Fatalf("expected alias section in help, got:\n%s", out) + } + if !strings.Contains(out, "EXAMPLES") { + t.Fatalf("expected examples section in help, got:\n%s", out) + } +} + +func TestRenderRootHelpOmitsCommonWorkflows(t *testing.T) { + configureCLIUX() + + var buf bytes.Buffer + renderHelp(rootCmd, &buf) + out := buf.String() + + if strings.Contains(out, "COMMON WORKFLOWS") { + t.Fatalf("expected root help to omit common workflows, got:\n%s", out) + } +} + +func TestRenderRunnableParentCommandHelpPreservesDirectUsage(t *testing.T) { + configureCLIUX() + + tests := []struct { + name string + cmd *cobra.Command + want string + }{ + {name: "signup", cmd: signupCmd, want: " fizzy signup [flags]"}, + {name: "setup", cmd: setupCmd, want: " fizzy setup [flags]"}, + {name: "skill", cmd: skillCmd, want: " fizzy skill [flags]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + renderHelp(tt.cmd, &buf) + out := buf.String() + + if !strings.Contains(out, tt.want) { + t.Fatalf("expected help to contain %q, got:\n%s", tt.want, out) + } + }) + } +} + +func TestRenderCommandsHelpMentionsJSONCatalog(t *testing.T) { + configureCLIUX() + + var buf bytes.Buffer + renderHelp(commandsCmd, &buf) + out := buf.String() + + if !strings.Contains(out, "Use --json for a structured command catalog.") { + t.Fatalf("expected commands help to mention --json catalog, got:\n%s", out) + } + if !strings.Contains(out, "EXAMPLES") || !strings.Contains(out, "$ fizzy commands --json") { + t.Fatalf("expected commands help examples to include --json, got:\n%s", out) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index e4725b6..5095c96 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "github.com/basecamp/cli/credstore" @@ -66,11 +67,9 @@ var ( // rootCmd represents the base command. var rootCmd = &cobra.Command{ - Use: "fizzy", - Short: "Fizzy CLI - Command-line interface for the Fizzy API", - Long: `A command-line interface for the Fizzy API. - -Use fizzy to manage boards, cards, comments, and more from your terminal.`, + Use: "fizzy", + Short: "Fizzy CLI - Command-line interface for the Fizzy API", + Long: `Command-line interface for Fizzy`, Version: "dev", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Resolve output format from parsed flags (must happen post-parse). @@ -155,16 +154,27 @@ func SetVersion(v string) { // Execute runs the root command. func Execute() { + configureCLIUX() + // Default to Auto — PersistentPreRunE will re-resolve from parsed flags. outWriter = os.Stdout out = output.New(output.Options{Format: output.FormatAuto, Writer: os.Stdout}) - if err := rootCmd.Execute(); err != nil { + cmd, err := rootCmd.ExecuteC() + if err != nil { + if format, formatErr := resolveFormat(); formatErr == nil { + out = output.New(output.Options{Format: format, Writer: os.Stdout}) + } + var e *output.Error if !stderrors.As(err, &e) { // Cobra-level errors (arg count, unknown flag) → usage e = &output.Error{Code: output.CodeUsage, Message: err.Error()} } - _ = out.Err(e) + if isHumanOutput() { + printHumanError(cmd, e) + } else { + _ = out.Err(e) + } os.Exit(e.ExitCode()) } } @@ -240,6 +250,55 @@ func IsMachineOutput() bool { return false } +func isHumanOutput() bool { + if cfgStyled || cfgMarkdown || requestedHumanOutput() { + return true + } + if out != nil { + switch out.EffectiveFormat() { + case output.FormatStyled, output.FormatMarkdown: + return true + default: + return false + } + } + return !IsMachineOutput() +} + +func requestedHumanOutput() bool { + for _, arg := range os.Args[1:] { + if arg == "--styled" || arg == "--markdown" { + return true + } + } + return false +} + +func printHumanError(cmd *cobra.Command, err error) { + e := output.AsError(err) + msg := strings.TrimSpace(e.Message) + if msg != "" { + fmt.Fprintln(os.Stderr, msg) + } + if e.Hint != "" && !strings.Contains(msg, e.Hint) { + fmt.Fprintf(os.Stderr, "\nHint: %s\n", e.Hint) + } + if e.Code == output.CodeUsage && !strings.Contains(msg, "--help") { + fmt.Fprintf(os.Stderr, "\nRun `%s` for usage.\n", usageHelpCommand(cmd)) + } +} + +func usageHelpCommand(cmd *cobra.Command) string { + if cmd == nil { + return rootCmd.CommandPath() + " --help" + } + path := strings.TrimSpace(cmd.CommandPath()) + if path == "" { + path = rootCmd.CommandPath() + } + return path + " --help" +} + func init() { rootCmd.PersistentFlags().StringVar(&cfgToken, "token", "", "API access token") rootCmd.PersistentFlags().StringVar(&cfgProfile, "profile", "", "Named profile to use") @@ -253,8 +312,6 @@ func init() { rootCmd.PersistentFlags().BoolVar(&cfgStyled, "styled", false, "Styled terminal output with colors") rootCmd.PersistentFlags().BoolVar(&cfgMarkdown, "markdown", false, "Markdown formatted output") rootCmd.PersistentFlags().IntVar(&cfgLimit, "limit", 0, "Maximum number of results to display") - - installAgentHelp() } // getClient returns an API client configured from global settings. @@ -493,13 +550,31 @@ func captureResponse() { // printSuccess prints a success response. func printSuccess(data any) { - _ = out.OK(data) - captureResponse() + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, renderHumanData(data, "", false)) + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, renderHumanData(data, "", true)) + captureResponse() + default: + _ = out.OK(data) + captureResponse() + } } func printSuccessWithLocation(location string) { - _ = out.OK(nil, output.WithContext("location", location)) - captureResponse() + switch out.EffectiveFormat() { + case output.FormatStyled: + fmt.Fprint(outWriter, renderHumanData(nil, location, false)) + captureResponse() + case output.FormatMarkdown: + fmt.Fprint(outWriter, renderHumanData(nil, location, true)) + captureResponse() + default: + _ = out.OK(nil, output.WithContext("location", location)) + captureResponse() + } } // breadcrumb creates a single breadcrumb. @@ -585,16 +660,12 @@ func printList(data any, cols render.Columns, summary string, breadcrumbs []Brea switch out.EffectiveFormat() { case output.FormatStyled: - fmt.Fprint(outWriter, render.StyledList(toMaps(data), cols, summary)) - if notice != "" { - fmt.Fprintf(outWriter, "\n%s\n", notice) - } + body := render.StyledList(toMaps(data), cols, summary) + fmt.Fprint(outWriter, appendHumanSections(body, notice, "", breadcrumbs, false)) captureResponse() case output.FormatMarkdown: - fmt.Fprint(outWriter, render.MarkdownList(toMaps(data), cols, summary)) - if notice != "" { - fmt.Fprintf(outWriter, "\n%s\n", notice) - } + body := render.MarkdownList(toMaps(data), cols, summary) + fmt.Fprint(outWriter, appendHumanSections(body, notice, "", breadcrumbs, true)) captureResponse() default: opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} @@ -617,16 +688,12 @@ func printListPaginated(data any, cols render.Columns, hasNext bool, nextURL str switch out.EffectiveFormat() { case output.FormatStyled: - fmt.Fprint(outWriter, render.StyledList(toMaps(data), cols, summary)) - if notice != "" { - fmt.Fprintf(outWriter, "\n%s\n", notice) - } + body := render.StyledList(toMaps(data), cols, summary) + fmt.Fprint(outWriter, appendHumanSections(body, notice, "", breadcrumbs, false)) captureResponse() case output.FormatMarkdown: - fmt.Fprint(outWriter, render.MarkdownList(toMaps(data), cols, summary)) - if notice != "" { - fmt.Fprintf(outWriter, "\n%s\n", notice) - } + body := render.MarkdownList(toMaps(data), cols, summary) + fmt.Fprint(outWriter, appendHumanSections(body, notice, "", breadcrumbs, true)) captureResponse() default: opts := []output.ResponseOption{output.WithBreadcrumbs(breadcrumbs...)} @@ -651,10 +718,12 @@ func printListPaginated(data any, cols render.Columns, hasNext bool, nextURL str func printDetail(data any, summary string, breadcrumbs []Breadcrumb) { switch out.EffectiveFormat() { case output.FormatStyled: - fmt.Fprint(outWriter, render.StyledDetail(toMap(data), summary)) + body := render.StyledDetail(toMap(data), summary) + fmt.Fprint(outWriter, appendHumanSections(body, "", "", breadcrumbs, false)) captureResponse() case output.FormatMarkdown: - fmt.Fprint(outWriter, render.MarkdownDetail(toMap(data), summary)) + body := render.MarkdownDetail(toMap(data), summary) + fmt.Fprint(outWriter, appendHumanSections(body, "", "", breadcrumbs, true)) captureResponse() default: printSuccessWithBreadcrumbs(data, summary, breadcrumbs) @@ -665,10 +734,12 @@ func printDetail(data any, summary string, breadcrumbs []Breadcrumb) { func printMutationWithLocation(data any, location string, breadcrumbs []Breadcrumb) { switch out.EffectiveFormat() { case output.FormatStyled: - fmt.Fprint(outWriter, render.StyledDetail(toMap(data), "")) + body := render.StyledDetail(toMap(data), "") + fmt.Fprint(outWriter, appendHumanSections(body, "", location, breadcrumbs, false)) captureResponse() case output.FormatMarkdown: - fmt.Fprint(outWriter, render.MarkdownDetail(toMap(data), "")) + body := render.MarkdownDetail(toMap(data), "") + fmt.Fprint(outWriter, appendHumanSections(body, "", location, breadcrumbs, true)) captureResponse() default: printSuccessWithLocationAndBreadcrumbs(data, location, breadcrumbs) @@ -680,16 +751,176 @@ func printMutationWithLocation(data any, location string, breadcrumbs []Breadcru func printMutation(data any, summary string, breadcrumbs []Breadcrumb) { switch out.EffectiveFormat() { case output.FormatStyled: - fmt.Fprint(outWriter, render.StyledSummary(toMap(data), summary)) + body := render.StyledSummary(toMap(data), summary) + fmt.Fprint(outWriter, appendHumanSections(body, "", "", breadcrumbs, false)) captureResponse() case output.FormatMarkdown: - fmt.Fprint(outWriter, render.MarkdownSummary(toMap(data), summary)) + body := render.MarkdownSummary(toMap(data), summary) + fmt.Fprint(outWriter, appendHumanSections(body, "", "", breadcrumbs, true)) captureResponse() default: printSuccessWithBreadcrumbs(data, summary, breadcrumbs) } } +func renderHumanData(data any, location string, markdown bool) string { + switch v := data.(type) { + case nil: + if markdown { + return appendHumanSections(render.MarkdownSummary(nil, ""), "", location, nil, true) + } + return appendHumanSections(render.StyledSummary(nil, ""), "", location, nil, false) + case map[string]any: + if markdown { + return appendHumanSections(render.MarkdownDetail(v, ""), "", location, nil, true) + } + return appendHumanSections(render.StyledDetail(v, ""), "", location, nil, false) + } + + if maps := toMaps(data); maps != nil { + cols := inferColumns(maps) + if markdown { + return appendHumanSections(render.MarkdownList(maps, cols, ""), "", location, nil, true) + } + return appendHumanSections(render.StyledList(maps, cols, ""), "", location, nil, false) + } + + if m := toMap(data); m != nil { + if markdown { + return appendHumanSections(render.MarkdownDetail(m, ""), "", location, nil, true) + } + return appendHumanSections(render.StyledDetail(m, ""), "", location, nil, false) + } + + value := fmt.Sprintf("%v\n", data) + return appendHumanSections(value, "", location, nil, markdown) +} + +func appendHumanSections(body, notice, location string, breadcrumbs []Breadcrumb, markdown bool) string { + body = strings.TrimRight(body, "\n") + var sb strings.Builder + if body != "" { + sb.WriteString(body) + sb.WriteString("\n") + } + if notice != "" { + sb.WriteString("\n") + if markdown { + sb.WriteString("> ") + } + sb.WriteString(notice) + sb.WriteString("\n") + } + if location != "" { + sb.WriteString("\n") + if markdown { + sb.WriteString("**Location:** `") + sb.WriteString(location) + sb.WriteString("`\n") + } else { + sb.WriteString("Location: ") + sb.WriteString(location) + sb.WriteString("\n") + } + } + if len(breadcrumbs) > 0 { + sb.WriteString("\n") + if markdown { + sb.WriteString("### Next steps\n") + for _, crumb := range breadcrumbs { + sb.WriteString("- `") + sb.WriteString(crumb.Cmd) + sb.WriteString("`") + if crumb.Description != "" { + sb.WriteString(" — ") + sb.WriteString(crumb.Description) + } + sb.WriteString("\n") + } + } else { + sb.WriteString("Next steps:\n") + for _, crumb := range breadcrumbs { + sb.WriteString(" ") + sb.WriteString(crumb.Cmd) + if crumb.Description != "" { + sb.WriteString(" # ") + sb.WriteString(crumb.Description) + } + sb.WriteString("\n") + } + } + } + return sb.String() +} + +func inferColumns(data []map[string]any) render.Columns { + if len(data) == 0 { + return render.Columns{{Header: "Value", Field: "id"}} + } + + priority := map[string]int{ + "number": 0, + "id": 1, + "profile": 2, + "name": 3, + "title": 4, + "description": 5, + "active": 6, + "board": 7, + "base_url": 8, + } + + keys := make([]string, 0, len(data[0])) + for key := range data[0] { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + pi, okI := priority[keys[i]] + pj, okJ := priority[keys[j]] + if okI && okJ { + return pi < pj + } + if okI { + return true + } + if okJ { + return false + } + return keys[i] < keys[j] + }) + + if len(keys) > 4 { + keys = keys[:4] + } + + cols := make(render.Columns, 0, len(keys)) + for _, key := range keys { + cols = append(cols, render.Column{Header: humanizeFieldName(key), Field: key}) + } + return cols +} + +func humanizeFieldName(name string) string { + parts := strings.Split(name, "_") + for i, part := range parts { + if part == "id" { + parts[i] = "ID" + continue + } + parts[i] = titleWord(part) + } + return strings.Join(parts, " ") +} + +func titleWord(s string) string { + if s == "" { + return s + } + runes := []rune(strings.ToLower(s)) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + return string(runes) +} + // toMaps converts any (expected []any of map[string]any) to []map[string]any. // Falls back to JSON round-trip for typed slices (e.g., []Attachment). func toMaps(data any) []map[string]any { diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go new file mode 100644 index 0000000..93b4fef --- /dev/null +++ b/internal/commands/root_test.go @@ -0,0 +1,73 @@ +package commands + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/basecamp/cli/output" + "github.com/spf13/cobra" +) + +func TestUsageHelpCommand(t *testing.T) { + tests := []struct { + name string + cmd string + want string + }{ + {name: "nil falls back to root", cmd: "", want: "fizzy --help"}, + {name: "subcommand uses command path", cmd: "auth login", want: "fizzy auth login --help"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cmd *cobra.Command + if tt.cmd != "" { + parts := strings.Split(tt.cmd, " ") + found, _, err := rootCmd.Find(parts) + if err != nil { + t.Fatalf("find command: %v", err) + } + cmd = found + } + + if got := usageHelpCommand(cmd); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestPrintHumanErrorUsesCommandSpecificHelp(t *testing.T) { + oldStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + defer func() { + os.Stderr = oldStderr + }() + + cmd, _, err := rootCmd.Find([]string{"auth", "login"}) + if err != nil { + t.Fatalf("find command: %v", err) + } + + printHumanError(cmd, &output.Error{Code: output.CodeUsage, Message: "accepts 1 arg(s), received 0"}) + _ = w.Close() + + body, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read stderr: %v", err) + } + + out := string(body) + if !strings.Contains(out, "Run `fizzy auth login --help` for usage.") { + t.Fatalf("expected command-specific usage hint, got:\n%s", out) + } + if strings.Contains(out, "Run `fizzy --help` for usage.") { + t.Fatalf("expected root usage hint to be omitted, got:\n%s", out) + } +} diff --git a/internal/commands/setup_agents.go b/internal/commands/setup_agents.go index f9ec01b..5848616 100644 --- a/internal/commands/setup_agents.go +++ b/internal/commands/setup_agents.go @@ -5,7 +5,6 @@ import ( "os/exec" "strings" - "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/harness" "github.com/charmbracelet/huh" "github.com/spf13/cobra" @@ -316,8 +315,7 @@ func newSetupAgentCmds() []*cobra.Command { } if out != nil { - _ = out.OK(result, output.WithSummary(summary)) - captureResponse() + printMutation(result, summary, nil) } return nil }, diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 036b00e..05db5ba 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -400,7 +400,7 @@ func runSignupStart(cmd *cobra.Command, args []string) error { data["code"] = code } - printSuccessWithBreadcrumbs(data, "Magic link sent. Check your email for a 6-digit code.", breadcrumbs) + printMutation(signupHumanData(data, "code"), "Magic link sent. Check your email for a 6-digit code.", breadcrumbs) return nil } @@ -445,7 +445,7 @@ func runSignupVerify(cmd *cobra.Command, args []string) error { } } - printSuccess(result) + printDetail(signupHumanData(result, "requires_signup_completion", "accounts"), "", nil) return nil } @@ -551,10 +551,26 @@ func runSignupComplete(cmd *cobra.Command, args []string) error { breadcrumb("setup", "fizzy setup", "Full interactive setup"), } - printSuccessWithBreadcrumbs(result, summary, breadcrumbs) + printMutation(signupHumanData(result, "account", "is_new_user"), summary, breadcrumbs) return nil } +// signupHumanData removes machine-only secrets from human-facing output while +// preserving the full payload for JSON/quiet/agent workflows. +func signupHumanData(data map[string]any, fields ...string) map[string]any { + if !isHumanOutput() { + return data + } + + result := make(map[string]any, len(fields)) + for _, field := range fields { + if value, ok := data[field]; ok { + result[field] = value + } + } + return result +} + // signupAPIURL returns the effective API URL for signup commands, normalized without trailing slash. func signupAPIURL() string { if cfg != nil && cfg.APIURL != "" { diff --git a/internal/commands/signup_test.go b/internal/commands/signup_test.go index e58ef3a..0ac6b89 100644 --- a/internal/commands/signup_test.go +++ b/internal/commands/signup_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/basecamp/cli/output" "github.com/basecamp/fizzy-cli/internal/config" "gopkg.in/yaml.v3" ) @@ -754,6 +755,98 @@ func TestSignupCompleteRejectsEmptyToken(t *testing.T) { }) } +func TestSignupHumanOutputRedactsSecrets(t *testing.T) { + t.Run("signup start hides pending token in styled output", func(t *testing.T) { + resetSignupFlags() + server := newTestSignupServer(t, testSignupServerOpts{accessToken: "fizzy_test"}) + defer server.Close() + + mock := NewMockClient() + SetTestMode(mock) + SetTestFormat(output.FormatStyled) + SetTestConfig("", "", server.URL) + defer ResetTestMode() + + signupStartCmd.Flags().Set("email", "test@example.com") + err := signupStartCmd.RunE(signupStartCmd, []string{}) + assertExitCode(t, err, 0) + + raw := TestOutput() + if strings.Contains(raw, "signed-pending-token-value") { + t.Fatalf("expected styled output to hide pending token, got:\n%s", raw) + } + if !strings.Contains(raw, "Magic link sent. Check your email for a 6-digit code.") { + t.Fatalf("expected styled output to include summary, got:\n%s", raw) + } + if !strings.Contains(raw, "TEST01") { + t.Fatalf("expected styled output to include non-secret dev code, got:\n%s", raw) + } + if !strings.Contains(raw, "Next steps:") { + t.Fatalf("expected styled output to include breadcrumbs, got:\n%s", raw) + } + }) + + t.Run("signup verify hides session token in styled output", func(t *testing.T) { + resetSignupFlags() + server := newTestSignupServer(t, testSignupServerOpts{ + requiresCompletion: false, + accessToken: "fizzy_test", + }) + defer server.Close() + + mock := NewMockClient() + SetTestMode(mock) + SetTestFormat(output.FormatStyled) + SetTestConfig("", "", server.URL) + defer ResetTestMode() + + signupVerifyCmd.Flags().Set("code", "VALID1") + signupVerifyCmd.Flags().Set("pending-token", "signed-pending-token-value") + err := signupVerifyCmd.RunE(signupVerifyCmd, []string{}) + assertExitCode(t, err, 0) + + raw := TestOutput() + if strings.Contains(raw, "signed-session-token-value") { + t.Fatalf("expected styled output to hide session token, got:\n%s", raw) + } + if !strings.Contains(raw, "requires_signup_completion") { + t.Fatalf("expected styled output to retain non-secret fields, got:\n%s", raw) + } + }) + + t.Run("signup complete hides access token in styled output", func(t *testing.T) { + resetSignupFlags() + server := newTestSignupServer(t, testSignupServerOpts{ + accessToken: "fizzy_generated_token", + }) + defer server.Close() + + config.SetTestConfigDir(t.TempDir()) + defer config.ResetTestConfigDir() + + mock := NewMockClient() + SetTestMode(mock) + SetTestFormat(output.FormatStyled) + SetTestConfig("", "", server.URL) + defer ResetTestMode() + + restoreStdin := pipeSessionToken("signed-session-token-value") + defer restoreStdin() + + signupCompleteCmd.Flags().Set("account", "123456") + err := signupCompleteCmd.RunE(signupCompleteCmd, []string{}) + assertExitCode(t, err, 0) + + raw := TestOutput() + if strings.Contains(raw, "fizzy_generated_token") { + t.Fatalf("expected styled output to hide generated token, got:\n%s", raw) + } + if !strings.Contains(raw, "123456") { + t.Fatalf("expected styled output to retain account, got:\n%s", raw) + } + }) +} + func TestReadSessionTokenFromStdin(t *testing.T) { t.Run("reads token from piped stdin", func(t *testing.T) { restoreStdin := pipeSessionToken("eyJ-session-token-value") diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 49adaa5..9244f6d 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -83,8 +83,7 @@ func newSkillInstallCmd() *cobra.Command { summary := "Fizzy skill installed" if out != nil { - _ = out.OK(result, output.WithSummary(summary)) - captureResponse() + printMutation(result, summary, nil) return nil } fmt.Fprintf(cmd.OutOrStdout(), "Installed skill to %s\n", skillPath) diff --git a/internal/commands/surface_test.go b/internal/commands/surface_test.go index 58ee906..a48209f 100644 --- a/internal/commands/surface_test.go +++ b/internal/commands/surface_test.go @@ -58,6 +58,7 @@ func TestSurfaceSnapshot(t *testing.T) { func initAllHelpFlags(cmd *cobra.Command) { cmd.InitDefaultHelpCmd() cmd.InitDefaultHelpFlag() + cmd.InitDefaultVersionFlag() for _, sub := range cmd.Commands() { initAllHelpFlags(sub) } diff --git a/internal/commands/version.go b/internal/commands/version.go index 505f15d..bb842b3 100644 --- a/internal/commands/version.go +++ b/internal/commands/version.go @@ -1,14 +1,26 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" + + "github.com/basecamp/cli/output" + "github.com/spf13/cobra" +) var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", + Use: "version", + Short: "Print version information", + Example: "$ fizzy version", RunE: func(cmd *cobra.Command, args []string) error { - printSuccess(map[string]any{ - "version": rootCmd.Version, - }) + switch out.EffectiveFormat() { + case output.FormatStyled, output.FormatMarkdown: + fmt.Fprintf(outWriter, "fizzy version %s\n", rootCmd.Version) + captureResponse() + default: + printSuccess(map[string]any{ + "version": rootCmd.Version, + }) + } return nil }, } diff --git a/internal/render/markdown.go b/internal/render/markdown.go index adedea2..28a42fc 100644 --- a/internal/render/markdown.go +++ b/internal/render/markdown.go @@ -68,12 +68,16 @@ func MarkdownDetail(data map[string]any, summary string) string { return sb.String() } -// MarkdownSummary renders a single-line summary message for mutations. +// MarkdownSummary renders a summary message for mutations. +// If structured data is present, include it below the summary. func MarkdownSummary(data map[string]any, summary string) string { if summary != "" { - return fmt.Sprintf("> %s\n", summary) + if len(data) == 0 { + return fmt.Sprintf("> %s\n", summary) + } + return fmt.Sprintf("> %s\n\n%s", summary, MarkdownDetail(data, "")) } - if data == nil { + if len(data) == 0 { return "> Done\n" } return MarkdownDetail(data, "") diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go index 61b7d9b..db50b9c 100644 --- a/internal/render/markdown_test.go +++ b/internal/render/markdown_test.go @@ -70,3 +70,14 @@ func TestMarkdownDetailRendersKV(t *testing.T) { t.Errorf("expected bold key, got:\n%s", result) } } + +func TestMarkdownSummaryIncludesDataWhenPresent(t *testing.T) { + data := map[string]any{"id": "42", "name": "Test"} + result := MarkdownSummary(data, "Saved") + if !strings.Contains(result, "> Saved") { + t.Errorf("expected summary line, got:\n%s", result) + } + if !strings.Contains(result, "**name:** Test") { + t.Errorf("expected detail output, got:\n%s", result) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 97796f1..b8d9dc9 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -152,12 +152,17 @@ func formatValue(v any) string { } } -// StyledSummary renders a single-line summary message for mutations. +// StyledSummary renders a summary message for mutations. +// If structured data is present, include it below the summary for human readability. func StyledSummary(data map[string]any, summary string) string { if summary != "" { - return lipgloss.NewStyle().Bold(true).Render("✓ "+summary) + "\n" + line := lipgloss.NewStyle().Bold(true).Render("✓ " + summary) + if len(data) == 0 { + return line + "\n" + } + return line + "\n\n" + StyledDetail(data, "") } - if data == nil { + if len(data) == 0 { return lipgloss.NewStyle().Bold(true).Render("✓ Done") + "\n" } return StyledDetail(data, "") diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 8817949..8fa9611 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -69,6 +69,17 @@ func TestStyledDetailRendersKV(t *testing.T) { } } +func TestStyledSummaryIncludesDataWhenPresent(t *testing.T) { + data := map[string]any{"id": "42", "name": "Test Board"} + result := StyledSummary(data, "Saved") + if !strings.Contains(result, "✓ Saved") { + t.Errorf("expected summary line, got %q", result) + } + if !strings.Contains(result, "Test Board") { + t.Errorf("expected detail output, got %q", result) + } +} + func TestFormatValue(t *testing.T) { tests := []struct { input any