Standardize CLI Error Handling (#318)

* Standardize CLI error handling and deprecate cli.Fatal

- Updated `pkg/cli/output.go` to send error and warning output to `os.Stderr`.
- Added `ErrorWrap`, `ErrorWrapVerb`, and `ErrorWrapAction` helpers to `pkg/cli/output.go`.
- Deprecated `cli.Fatal` family of functions in `pkg/cli/errors.go`.
- Introduced `cli.ExitError` and `cli.Exit` helper to allow commands to return specific exit codes.
- Updated `pkg/cli/app.go` to silence Cobra errors and handle error printing and process exit in `Main`.
- Refactored multiple commands (QA, SDK, CI, Updater) to return errors instead of exiting abruptly.
- Replaced direct `os.Stderr` writes with standardized CLI or log helpers across the codebase.
- Updated tests to accommodate changes in output destination.

* Fix CI failure: remove unused fmt import in pkg/mcp/transport_tcp.go

- Removed unused "fmt" import in `pkg/mcp/transport_tcp.go` that was causing CI failure.
- Verified build and relevant tests pass.

* Standardize CLI error handling and fix formatting issues

- Updated `pkg/cli/output.go` to send error and warning output to `os.Stderr`.
- Added `ErrorWrap`, `ErrorWrapVerb`, and `ErrorWrapAction` helpers to `pkg/cli/output.go`.
- Deprecated `cli.Fatal` family of functions in `pkg/cli/errors.go`.
- Introduced `cli.ExitError` and `cli.Exit` helper to allow commands to return specific exit codes.
- Updated `pkg/cli/app.go` to silence Cobra errors and handle error printing and process exit in `Main`.
- Refactored multiple commands (QA, SDK, CI, Updater) to return errors instead of exiting abruptly.
- Replaced direct `os.Stderr` writes with standardized CLI or log helpers across the codebase.
- Updated tests to accommodate changes in output destination.
- Fixed formatting in `pkg/io/local/client.go`.
- Removed unused `fmt` import in `pkg/mcp/transport_tcp.go`.

* Standardize CLI error handling and fix CI issues

- Updated `pkg/cli/output.go` to send error and warning output to `os.Stderr`.
- Added `ErrorWrap`, `ErrorWrapVerb`, and `ErrorWrapAction` helpers to `pkg/cli/output.go`.
- Deprecated `cli.Fatal` family of functions in `pkg/cli/errors.go`.
- Introduced `cli.ExitError` and `cli.Exit` helper to allow commands to return specific exit codes.
- Updated `pkg/cli/app.go` to silence Cobra errors and handle error printing and process exit in `Main`.
- Refactored multiple commands (QA, SDK, CI, Updater) to return errors instead of exiting abruptly.
- Replaced direct `os.Stderr` writes with standardized CLI or log helpers across the codebase.
- Updated tests to accommodate changes in output destination.
- Fixed formatting in `pkg/io/local/client.go`.
- Removed unused `fmt` import in `pkg/mcp/transport_tcp.go`.
- Fixed potential `gh` context issue in `.github/workflows/auto-merge.yml` by providing `GH_REPO`.

---------

Co-authored-by: Claude <developers@lethean.io>
This commit is contained in:
Snider 2026-02-05 06:55:49 +00:00 committed by GitHub
parent 1d73209e89
commit 10ea31e586
12 changed files with 107 additions and 39 deletions

View file

@ -308,7 +308,7 @@ func runGoQA(cmd *cli.Command, args []string) error {
} }
if failed > 0 { if failed > 0 {
os.Exit(1) return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
} }
return nil return nil
} }

View file

@ -189,7 +189,7 @@ func runPHPCI() error {
return err return err
} }
if !result.Passed { if !result.Passed {
os.Exit(result.ExitCode) return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed"))
} }
return nil return nil
} }

View file

@ -167,7 +167,7 @@ func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) {
}, parser.ParseComments) }, parser.ParseComments)
if err != nil { if err != nil {
// Log parse errors but continue to check other directories // Log parse errors but continue to check other directories
fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v\n", dir, err) cli.Warnf("failed to parse %s: %v", dir, err)
continue continue
} }

View file

@ -96,8 +96,7 @@ func runSDKDiff(basePath, specPath string) error {
result, err := Diff(basePath, specPath) result, err := Diff(basePath, specPath)
if err != nil { if err != nil {
fmt.Printf("%s %v\n", sdkErrorStyle.Render(i18n.Label("error")), err) return cli.Exit(2, cli.Wrap(err, i18n.Label("error")))
os.Exit(2)
} }
if result.Breaking { if result.Breaking {
@ -105,7 +104,7 @@ func runSDKDiff(basePath, specPath string) error {
for _, change := range result.Changes { for _, change := range result.Changes {
fmt.Printf(" - %s\n", change) fmt.Printf(" - %s\n", change)
} }
os.Exit(1) return cli.Exit(1, cli.Err("%s", result.Summary))
} }
fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), result.Summary) fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), result.Summary)

View file

@ -3,7 +3,6 @@ package updater
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"runtime" "runtime"
"github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/cli"
@ -133,8 +132,6 @@ func runUpdate(cmd *cobra.Command, args []string) error {
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName) cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
// Exit so the watcher can restart us
os.Exit(0)
return nil return nil
} }
@ -179,7 +176,6 @@ func handleDevUpdate(currentVersion string) error {
cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName) cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName)
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
os.Exit(0)
return nil return nil
} }
@ -216,6 +212,5 @@ func handleDevTagUpdate(currentVersion string) error {
cli.Print("%s Updated to latest dev build\n", cli.SuccessStyle.Render(cli.Glyph(":check:"))) cli.Print("%s Updated to latest dev build\n", cli.SuccessStyle.Render(cli.Glyph(":check:")))
cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→"))
os.Exit(0)
return nil return nil
} }

View file

@ -120,7 +120,7 @@ func (e *Executor) runPlay(ctx context.Context, play *Play) error {
if err := e.gatherFacts(ctx, host, play); err != nil { if err := e.gatherFacts(ctx, host, play); err != nil {
// Non-fatal // Non-fatal
if e.Verbose > 0 { if e.Verbose > 0 {
fmt.Fprintf(os.Stderr, "Warning: gather facts failed for %s: %v\n", host, err) log.Warn("gather facts failed", "host", host, "err", err)
} }
} }
} }

View file

@ -33,14 +33,23 @@ func Main() {
})), })),
}, },
}); err != nil { }); err != nil {
Fatal(err) Error(err.Error())
os.Exit(1)
} }
defer Shutdown() defer Shutdown()
// Add completion command to the CLI's root // Add completion command to the CLI's root
RootCmd().AddCommand(completionCmd) RootCmd().AddCommand(completionCmd)
Fatal(Execute()) if err := Execute(); err != nil {
code := 1
var exitErr *ExitError
if As(err, &exitErr) {
code = exitErr.Code
}
Error(err.Error())
os.Exit(code)
}
} }
// completionCmd generates shell completion scripts. // completionCmd generates shell completion scripts.

View file

@ -77,41 +77,75 @@ func Join(errs ...error) error {
return errors.Join(errs...) return errors.Join(errs...)
} }
// ExitError represents an error that should cause the CLI to exit with a specific code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
if e.Err == nil {
return ""
}
return e.Err.Error()
}
func (e *ExitError) Unwrap() error {
return e.Err
}
// Exit creates a new ExitError with the given code and error.
// Use this to return an error from a command with a specific exit code.
func Exit(code int, err error) error {
if err == nil {
return nil
}
return &ExitError{Code: code, Err: err}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Fatal Functions (print and exit) // Fatal Functions (Deprecated - return error from command instead)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Fatal prints an error message and exits with code 1. // Fatal prints an error message to stderr and exits with code 1.
//
// Deprecated: return an error from the command instead.
func Fatal(err error) { func Fatal(err error) {
if err != nil { if err != nil {
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + err.Error())) fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1) os.Exit(1)
} }
} }
// Fatalf prints a formatted error message and exits with code 1. // Fatalf prints a formatted error message to stderr and exits with code 1.
//
// Deprecated: return an error from the command instead.
func Fatalf(format string, args ...any) { func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg)) fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1) os.Exit(1)
} }
// FatalWrap prints a wrapped error message and exits with code 1. // FatalWrap prints a wrapped error message to stderr and exits with code 1.
// Does nothing if err is nil. // Does nothing if err is nil.
// //
// Deprecated: return an error from the command instead.
//
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits // cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
func FatalWrap(err error, msg string) { func FatalWrap(err error, msg string) {
if err == nil { if err == nil {
return return
} }
fullMsg := fmt.Sprintf("%s: %v", msg, err) fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + fullMsg)) fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1) os.Exit(1)
} }
// FatalWrapVerb prints a wrapped error using i18n grammar and exits with code 1. // FatalWrapVerb prints a wrapped error using i18n grammar to stderr and exits with code 1.
// Does nothing if err is nil. // Does nothing if err is nil.
// //
// Deprecated: return an error from the command instead.
//
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits // cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
func FatalWrapVerb(err error, verb, subject string) { func FatalWrapVerb(err error, verb, subject string) {
if err == nil { if err == nil {
@ -119,6 +153,6 @@ func FatalWrapVerb(err error, verb, subject string) {
} }
msg := i18n.ActionFailed(verb, subject) msg := i18n.ActionFailed(verb, subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err) fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + fullMsg)) fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1) os.Exit(1)
} }

View file

@ -2,6 +2,7 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/i18n"
@ -45,22 +46,48 @@ func Successf(format string, args ...any) {
Success(fmt.Sprintf(format, args...)) Success(fmt.Sprintf(format, args...))
} }
// Error prints an error message with cross (red). // Error prints an error message with cross (red) to stderr.
func Error(msg string) { func Error(msg string) {
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg)) fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
} }
// Errorf prints a formatted error message. // Errorf prints a formatted error message to stderr.
func Errorf(format string, args ...any) { func Errorf(format string, args ...any) {
Error(fmt.Sprintf(format, args...)) Error(fmt.Sprintf(format, args...))
} }
// Warn prints a warning message with warning symbol (amber). // ErrorWrap prints a wrapped error message to stderr.
func Warn(msg string) { func ErrorWrap(err error, msg string) {
fmt.Println(WarningStyle.Render(Glyph(":warn:") + " " + msg)) if err == nil {
return
}
Error(fmt.Sprintf("%s: %v", msg, err))
} }
// Warnf prints a formatted warning message. // ErrorWrapVerb prints a wrapped error using i18n grammar to stderr.
func ErrorWrapVerb(err error, verb, subject string) {
if err == nil {
return
}
msg := i18n.ActionFailed(verb, subject)
Error(fmt.Sprintf("%s: %v", msg, err))
}
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr.
func ErrorWrapAction(err error, verb string) {
if err == nil {
return
}
msg := i18n.ActionFailed(verb, "")
Error(fmt.Sprintf("%s: %v", msg, err))
}
// Warn prints a warning message with warning symbol (amber) to stderr.
func Warn(msg string) {
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
}
// Warnf prints a formatted warning message to stderr.
func Warnf(format string, args ...any) { func Warnf(format string, args ...any) {
Warn(fmt.Sprintf(format, args...)) Warn(fmt.Sprintf(format, args...))
} }

View file

@ -8,14 +8,17 @@ import (
) )
func captureOutput(f func()) string { func captureOutput(f func()) string {
old := os.Stdout oldOut := os.Stdout
oldErr := os.Stderr
r, w, _ := os.Pipe() r, w, _ := os.Pipe()
os.Stdout = w os.Stdout = w
os.Stderr = w
f() f()
_ = w.Close() _ = w.Close()
os.Stdout = old os.Stdout = oldOut
os.Stderr = oldErr
var buf bytes.Buffer var buf bytes.Buffer
_, _ = io.Copy(&buf, r) _, _ = io.Copy(&buf, r)

View file

@ -58,8 +58,10 @@ func Init(opts Options) error {
// Create root command // Create root command
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: opts.AppName, Use: opts.AppName,
Version: opts.Version, Version: opts.Version,
SilenceErrors: true,
SilenceUsage: true,
} }
// Attach all registered commands // Attach all registered commands

View file

@ -3,11 +3,10 @@ package mcp
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
"io" "io"
"net" "net"
"os"
"github.com/host-uk/core/pkg/log"
"github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -49,7 +48,7 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error {
if addr == "" { if addr == "" {
addr = t.listener.Addr().String() addr = t.listener.Addr().String()
} }
fmt.Fprintf(os.Stderr, "MCP TCP server listening on %s\n", addr) log.Info("MCP TCP server listening", "addr", addr)
for { for {
conn, err := t.listener.Accept() conn, err := t.listener.Accept()
@ -58,7 +57,7 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
default: default:
fmt.Fprintf(os.Stderr, "Accept error: %v\n", err) log.Error("Accept error", "err", err)
continue continue
} }
} }
@ -84,7 +83,7 @@ func (s *Service) handleConnection(ctx context.Context, conn net.Conn) {
// Run server (blocks until connection closed) // Run server (blocks until connection closed)
// Server.Run calls Connect, then Read loop. // Server.Run calls Connect, then Read loop.
if err := server.Run(ctx, transport); err != nil { if err := server.Run(ctx, transport); err != nil {
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err) log.Error("Connection error", "err", err)
} }
} }