From 0bc9c9b9bbf8d0354bffbd6b747dc661aee4f493 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Tue, 28 Jul 2026 20:55:00 -0400 Subject: [PATCH] cli: add colorful error display --- internal/cli/error.go | 20 ++++++++++++++++++++ internal/cli/issue_comment.go | 2 +- internal/cli/issue_state.go | 2 +- internal/cli/pr_comment.go | 2 +- internal/cli/pr_state.go | 2 +- internal/cli/repo_delete.go | 2 +- internal/cli/repo_edit.go | 2 +- internal/cli/root.go | 13 +++++++++---- internal/cli/root_test.go | 27 +++++++++++++++++++++++++++ internal/cli/string_create.go | 2 +- 10 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 internal/cli/error.go diff --git a/internal/cli/error.go b/internal/cli/error.go new file mode 100644 index 0000000..527f34c --- /dev/null +++ b/internal/cli/error.go @@ -0,0 +1,20 @@ +package cli + +import ( + "fmt" + "io" + + "charm.land/lipgloss/v2" +) + +// renderError writes a concise error heading followed by the actionable error. +// Piped output stays free of ANSI escape sequences. +func renderError(writer io.Writer, err error) { + if !isTerminal(writer) { + fmt.Fprintf(writer, "Error: %s\n", err) + return + } + + heading := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Red).Render("✗ Error:") + fmt.Fprintf(writer, "%s %s\n", heading, err) +} diff --git a/internal/cli/issue_comment.go b/internal/cli/issue_comment.go index b937af8..43bc568 100644 --- a/internal/cli/issue_comment.go +++ b/internal/cli/issue_comment.go @@ -20,7 +20,7 @@ func newIssueCommentCommand(service *app.Service) *cobra.Command { return err } if body == "" { - return fmt.Errorf("set --body or --body-file") + return fmt.Errorf("provide --body or --body-file") } ctx := cmd.Context() target, err := resolveTargetFlag(ctx, repository, service) diff --git a/internal/cli/issue_state.go b/internal/cli/issue_state.go index 9929add..28bcfa8 100644 --- a/internal/cli/issue_state.go +++ b/internal/cli/issue_state.go @@ -31,7 +31,7 @@ func newIssueEditCommand(service *app.Service) *cobra.Command { body = &bodyText } if title == nil && body == nil { - return fmt.Errorf("set --title or --body") + return fmt.Errorf("provide --title or --body") } return service.EditIssue(cmd.Context(), args[0], title, body) }, diff --git a/internal/cli/pr_comment.go b/internal/cli/pr_comment.go index 3304aba..604d92d 100644 --- a/internal/cli/pr_comment.go +++ b/internal/cli/pr_comment.go @@ -20,7 +20,7 @@ func newPRCommentCommand(service *app.Service) *cobra.Command { return err } if body == "" { - return fmt.Errorf("set --body or --body-file") + return fmt.Errorf("provide --body or --body-file") } ctx := cmd.Context() target, err := resolveTargetFlag(ctx, repository, service) diff --git a/internal/cli/pr_state.go b/internal/cli/pr_state.go index 70b733f..8371882 100644 --- a/internal/cli/pr_state.go +++ b/internal/cli/pr_state.go @@ -31,7 +31,7 @@ func newPREditCommand(service *app.Service) *cobra.Command { body = &bodyText } if title == nil && body == nil { - return fmt.Errorf("set --title or --body") + return fmt.Errorf("provide --title or --body") } return service.EditPull(cmd.Context(), args[0], title, body) }, diff --git a/internal/cli/repo_delete.go b/internal/cli/repo_delete.go index 8819744..5b90710 100644 --- a/internal/cli/repo_delete.go +++ b/internal/cli/repo_delete.go @@ -16,7 +16,7 @@ func newRepoDeleteCommand(service *app.Service) *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !confirm { - return fmt.Errorf("refusing to delete without --yes") + return fmt.Errorf("pass --yes to confirm deletion") } ctx := cmd.Context() target, err := resolveTarget(ctx, args, service) diff --git a/internal/cli/repo_edit.go b/internal/cli/repo_edit.go index 859c2a6..a8de49b 100644 --- a/internal/cli/repo_edit.go +++ b/internal/cli/repo_edit.go @@ -17,7 +17,7 @@ func newRepoEditCommand(service *app.Service) *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !cmd.Flags().Changed("description") && !cmd.Flags().Changed("website") && !cmd.Flags().Changed("spindle") && len(addLabels) == 0 && len(removeLabels) == 0 { - return fmt.Errorf("set a repository field to update") + return fmt.Errorf("provide a repository field to update") } ctx := cmd.Context() target, err := resolveTarget(ctx, args, service) diff --git a/internal/cli/root.go b/internal/cli/root.go index f10abad..817028d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,9 +21,10 @@ const ( func NewRoot(service *app.Service) *cobra.Command { rootCmd := &cobra.Command{ - Use: "tg", - Short: "A CLI for Tangled", - SilenceUsage: true, + Use: "tg", + Short: "A CLI for Tangled", + SilenceErrors: true, + SilenceUsage: true, } configureRoot(rootCmd) @@ -61,6 +62,7 @@ func Execute() error { func ExecuteWith(arguments []string, input io.Reader, output, errorOutput io.Writer) error { flags, err := parseFlagSettings(arguments) if err != nil { + renderError(errorOutput, err) return err } settings := loadConfig(flags, errorOutput) @@ -75,7 +77,10 @@ func ExecuteWith(arguments []string, input io.Reader, output, errorOutput io.Wri // A not-authenticated error from any service method is surfaced as the // familiar login hint, so individual commands don't each have to. if errors.Is(err, app.ErrNotAuthenticated) { - return fmt.Errorf("not logged in; run \"tg auth login\" first") + err = fmt.Errorf("not logged in; run \"tg auth login\" first") + } + if err != nil { + renderError(errorOutput, err) } return err } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1e42588..4473efb 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,6 +1,8 @@ package cli import ( + "bytes" + "strings" "testing" "github.com/alyraffauf/tg/internal/app" @@ -30,6 +32,31 @@ func TestNewRootCreatesIndependentCommandState(t *testing.T) { } } +func TestExecuteWithRendersErrors(t *testing.T) { + var errorOutput bytes.Buffer + err := ExecuteWith([]string{"issue", "edit", "abc123"}, nil, &bytes.Buffer{}, &errorOutput) + if err == nil { + t.Fatal("ExecuteWith() returned nil error") + } + + const want = "Error: provide --title or --body\n" + if got := errorOutput.String(); got != want { + t.Errorf("error output = %q, want %q", got, want) + } +} + +func TestExecuteWithRendersPreCommandErrors(t *testing.T) { + var errorOutput bytes.Buffer + err := ExecuteWith([]string{"--appview"}, nil, &bytes.Buffer{}, &errorOutput) + if err == nil { + t.Fatal("ExecuteWith() returned nil error") + } + + if got := errorOutput.String(); !strings.Contains(got, "Error: flag --appview requires a value\n") { + t.Errorf("error output = %q", got) + } +} + func TestRepoCreateSSHPortHelp(t *testing.T) { create, _, err := NewRoot(&app.Service{}).Find([]string{"repo", "create"}) if err != nil { diff --git a/internal/cli/string_create.go b/internal/cli/string_create.go index c5643f8..2a6b438 100644 --- a/internal/cli/string_create.go +++ b/internal/cli/string_create.go @@ -63,7 +63,7 @@ Requires authentication (run "tg auth login" first).`, func stringContents(stdin io.Reader, args []string, filenameFlag string) (contents, filename string, err error) { if len(args) == 0 || args[0] == "-" { if filenameFlag == "" { - return "", "", fmt.Errorf("--filename is required when reading from standard input") + return "", "", fmt.Errorf("provide --filename when reading from standard input") } data, err := io.ReadAll(stdin) if err != nil { -- 2.51.2