hatch-surf/cmd/hatch/cli.go
Riley Zhang ee24ab8aeb feat: implement Phase 3 CLI Polish - Error handling, output formatting, completions
Implements comprehensive CLI improvements for Hatch:

Error Handling:
- Add structured CLIError type with exit codes
- Add consistent exit codes (OK, GeneralError, UsageError, NetworkError, ServerError, ConfigError)
- Add colored error messages with details
- Add proper connection refused error handling

Output Formatting:
- Add ANSI color support with terminal detection
- Add colored success/error/warning messages
- Add compact output format option
- Add --output flag for json/table/compact formats

Shell Completions:
- Add bash completions with full command/flag support
- Add zsh completions with descriptions and subcommand handling
- Add fish completions with helper functions
- Add PowerShell completions with native argument completer
- Add hatch completions <shell> command

Configuration Support:
- Add config file support (XDG_CONFIG_HOME or ~/.config/hatch/config.json)
- Add environment variable overrides (HATCH_URL, HATCH_FORMAT, NO_COLOR)
- Add hatch config show/set/get/init commands
- Add configuration keys: server_url, format, no_color, timeout

Additional Improvements:
- Add version command with build-time version injection
- Add help command with colored usage output
- Add --output flag to all commands for format control
- Add request timeout support
- Improve error messages with actionable suggestions
- Add comprehensive test coverage

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-24 05:17:58 +02:00

1482 lines
42 KiB
Go

package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// Version is set at build time via ldflags.
var Version = "dev"
// Exit codes for consistent error handling.
const (
ExitOK = 0
ExitGeneralError = 1
ExitUsageError = 2
ExitNetworkError = 3
ExitServerError = 4
ExitConfigError = 5
)
// ANSI color codes for terminal output.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorCyan = "\033[36m"
colorGray = "\033[90m"
)
// isTerminal checks if stdout is a terminal for color support.
func isTerminal() bool {
f := os.Stdout
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// colorize applies color only if terminal supports it.
func colorize(color, text string) string {
if isTerminal() && os.Getenv("NO_COLOR") == "" {
return color + text + colorReset
}
return text
}
// CLIError represents a structured CLI error with exit code.
type CLIError struct {
Code int
Message string
Detail string
}
func (e *CLIError) Error() string {
if e.Detail != "" {
return fmt.Sprintf("%s: %s", e.Message, e.Detail)
}
return e.Message
}
// Config holds CLI configuration from file and environment.
type Config struct {
ServerURL string `json:"server_url"`
Format string `json:"format"` // json, table, compact
NoColor bool `json:"no_color"`
Timeout int `json:"timeout_seconds"`
}
// LoadConfig reads configuration from file and environment variables.
// Priority: env vars > config file > defaults.
func LoadConfig() (*Config, error) {
cfg := &Config{
ServerURL: "http://localhost:8080",
Format: "json",
NoColor: false,
Timeout: 30,
}
// Try to load config file.
configPath := getConfigPath()
if configPath != "" {
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, &CLIError{Code: ExitConfigError, Message: "invalid config file", Detail: err.Error()}
}
}
}
// Environment variables override config file.
if v := os.Getenv("HATCH_URL"); v != "" {
cfg.ServerURL = strings.TrimRight(v, "/")
}
if v := os.Getenv("HATCH_FORMAT"); v != "" {
cfg.Format = v
}
if v := os.Getenv("NO_COLOR"); v != "" {
cfg.NoColor = true
}
return cfg, nil
}
// getConfigPath returns the path to the config file.
func getConfigPath() string {
// Check XDG_CONFIG_HOME first.
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "hatch", "config.json")
}
// Check home directory.
home, err := os.UserHomeDir()
if err != nil {
return ""
}
// macOS: ~/Library/Application Support/hatch/config.json
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support", "hatch", "config.json")
}
// Linux/Unix: ~/.config/hatch/config.json
return filepath.Join(home, ".config", "hatch", "config.json")
}
// cliMain parses os.Args and dispatches to the appropriate CLI command.
// Returns true if a CLI command was handled, false if the caller should
// start the server (no subcommand or "serve").
func cliMain() bool {
if len(os.Args) < 2 {
return false // no subcommand → start server
}
cmd := os.Args[1]
switch cmd {
case "serve":
return false // explicit serve → start server
case "capture":
cmdCapture(os.Args[2:])
case "inspect":
cmdInspect(os.Args[2:])
case "search":
cmdSearch(os.Args[2:])
case "replay":
cmdReplay(os.Args[2:])
case "mock":
cmdMock(os.Args[2:])
case "doc":
cmdDoc(os.Args[2:])
case "config":
cmdConfig(os.Args[2:])
case "completions":
cmdCompletions(os.Args[2:])
case "help", "-h", "--help":
printUsage()
case "version", "--version":
fmt.Printf("hatch %s\n", Version)
default:
fmt.Fprintf(os.Stderr, "%s Unknown command: %s\n", colorize(colorRed, "error:"), cmd)
fmt.Fprintln(os.Stderr)
printUsage()
os.Exit(ExitUsageError)
}
return true
}
func printUsage() {
fmt.Fprintf(os.Stderr, `%s - Webhook capture and replay tool
%s hatch <command> [options]
%s
serve Start the server (default)
capture <url> Send a request to an endpoint and store it
inspect <endpoint> Fetch requests as JSON
search <endpoint> Search captured traffic
replay <request-id> Replay a request
mock set <endpoint> Configure mock response
doc generate <endpoint> Output OpenAPI spec
config Manage configuration
completions Generate shell completions
version Print version
Run 'hatch <command> -h' for command-specific help.
Run 'hatch completions' to set up shell completions.
`,
colorize(colorCyan, "hatch"),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Commands:"),
)
}
// serverURL returns the Hatch server URL from config or environment.
func serverURL() string {
cfg, err := LoadConfig()
if err != nil {
// Fallback to env var or default.
if u := os.Getenv("HATCH_URL"); u != "" {
return strings.TrimRight(u, "/")
}
return "http://localhost:8080"
}
return cfg.ServerURL
}
// apiRequest is a helper to make API requests.
func apiRequest(method, path string, body interface{}) ([]byte, int, error) {
url := serverURL() + path
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, 0, &CLIError{Code: ExitGeneralError, Message: "failed to marshal request body", Detail: err.Error()}
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, 0, &CLIError{Code: ExitGeneralError, Message: "failed to create request", Detail: err.Error()}
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
if strings.Contains(err.Error(), "connection refused") {
return nil, 0, &CLIError{
Code: ExitNetworkError,
Message: "cannot connect to Hatch server",
Detail: fmt.Sprintf("is the server running at %s?", serverURL()),
}
}
return nil, 0, &CLIError{Code: ExitNetworkError, Message: "request failed", Detail: err.Error()}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, &CLIError{Code: ExitGeneralError, Message: "failed to read response", Detail: err.Error()}
}
return data, resp.StatusCode, nil
}
// printJSON pretty-prints JSON data.
func printJSON(data []byte) {
var buf bytes.Buffer
if err := json.Indent(&buf, data, "", " "); err != nil {
// Not valid JSON, print as-is
fmt.Println(string(data))
return
}
fmt.Println(buf.String())
}
// printSuccess prints a success message with green checkmark.
func printSuccess(msg string) {
fmt.Fprintf(os.Stderr, "%s %s\n", colorize(colorGreen, "✓"), msg)
}
// printError prints an error message with red X.
func printError(msg string) {
fmt.Fprintf(os.Stderr, "%s %s\n", colorize(colorRed, "✗"), msg)
}
// printWarning prints a warning message with yellow !.
func printWarning(msg string) {
fmt.Fprintf(os.Stderr, "%s %s\n", colorize(colorYellow, "!"), msg)
}
// handleError processes CLI errors and exits with appropriate code.
func handleError(err error) {
if cliErr, ok := err.(*CLIError); ok {
fmt.Fprintf(os.Stderr, "%s %s\n", colorize(colorRed, "error:"), cliErr.Message)
if cliErr.Detail != "" {
fmt.Fprintf(os.Stderr, " %s\n", colorize(colorGray, cliErr.Detail))
}
os.Exit(cliErr.Code)
}
printError(err.Error())
os.Exit(ExitGeneralError)
}
// cmdCapture handles: hatch capture <url> [-method METHOD] [-body BODY] [-header KEY:VALUE]
func cmdCapture(args []string) {
fs := flag.NewFlagSet("capture", flag.ContinueOnError)
method := fs.String("method", "POST", "HTTP method")
body := fs.String("body", "", "Request body (JSON string)")
output := fs.String("output", "", "Output format: json, table, compact")
var headers multiFlag
fs.Var(&headers, "header", "Header in KEY:VALUE format (repeatable)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Send a request to an endpoint and store it.
%s hatch capture <url> [options]
%s
-method string HTTP method (default "POST")
-body string Request body (JSON string)
-header string Header in KEY:VALUE format (repeatable)
-output string Output format: json, table, compact (default json)
%s
hatch capture https://api.example.com/webhook
hatch capture /my-endpoint -method POST -body '{"event":"test"}'
hatch capture /ep -header 'Content-Type:application/json' -header 'X-Custom:test'
`,
colorize(colorCyan, "Capture a webhook request."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("URL is required")
fs.Usage()
os.Exit(ExitUsageError)
}
url := fs.Arg(0)
// Parse headers into map.
headerMap := make(map[string]string)
for _, h := range headers {
parts := strings.SplitN(h, ":", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "%s invalid header format %q (expected KEY:VALUE)\n",
colorize(colorRed, "error:"), h)
os.Exit(ExitUsageError)
}
headerMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
// Build the API request body.
apiBody := map[string]interface{}{
"method": *method,
"path": "/",
}
if *body != "" {
apiBody["body"] = *body
}
if len(headerMap) > 0 {
apiBody["headers"] = headerMap
}
// Determine endpoint ID from URL.
endpointID := extractEndpointID(url)
path := fmt.Sprintf("/v1/endpoints/%s/requests", endpointID)
data, status, err := apiRequest("POST", path, apiBody)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
if len(data) > 0 {
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(data, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(os.Stderr, " %s\n", errResp.Error)
}
}
os.Exit(ExitServerError)
}
printSuccess("Request captured successfully")
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdInspect handles: hatch inspect <endpoint> [-limit N] [-output FORMAT]
func cmdInspect(args []string) {
fs := flag.NewFlagSet("inspect", flag.ContinueOnError)
limit := fs.Int("limit", 100, "Maximum number of requests to return")
output := fs.String("output", "", "Output format: json, table, compact")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Fetch captured requests for an endpoint.
%s hatch inspect <endpoint> [options]
%s
-limit int Maximum number of requests to return (default 100)
-output string Output format: json, table, compact (default json)
%s
hatch inspect my-webhook
hatch inspect my-webhook -limit 10
hatch inspect my-webhook -output table
`,
colorize(colorCyan, "Inspect captured requests."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("endpoint ID is required")
fs.Usage()
os.Exit(ExitUsageError)
}
endpointID := fs.Arg(0)
path := fmt.Sprintf("/v1/endpoints/%s/requests?limit=%d", endpointID, *limit)
data, status, err := apiRequest("GET", path, nil)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
os.Exit(ExitServerError)
}
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdSearch handles: hatch search <endpoint> -query <query> [-limit N]
func cmdSearch(args []string) {
fs := flag.NewFlagSet("search", flag.ContinueOnError)
query := fs.String("query", "", "Search query")
limit := fs.Int("limit", 100, "Maximum number of results")
output := fs.String("output", "", "Output format: json, table, compact")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Search captured traffic.
%s hatch search <endpoint> [options]
%s
-query string Search query (required)
-limit int Maximum number of results (default 100)
-output string Output format: json, table, compact (default json)
%s
hatch search my-webhook -query 'status:500'
hatch search my-webhook -query 'POST' -limit 5
`,
colorize(colorCyan, "Search captured traffic."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("endpoint ID is required")
fs.Usage()
os.Exit(ExitUsageError)
}
if *query == "" {
printError("-query is required")
fs.Usage()
os.Exit(ExitUsageError)
}
endpointID := fs.Arg(0)
path := fmt.Sprintf("/v1/endpoints/%s/requests?q=%s&limit=%d", endpointID, *query, *limit)
data, status, err := apiRequest("GET", path, nil)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
os.Exit(ExitServerError)
}
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdReplay handles: hatch replay <request-id> -endpoint <endpoint> -target <url>
func cmdReplay(args []string) {
fs := flag.NewFlagSet("replay", flag.ContinueOnError)
endpoint := fs.String("endpoint", "", "Endpoint ID")
target := fs.String("target", "", "Target URL to replay to")
output := fs.String("output", "", "Output format: json, table, compact")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Replay a captured request.
%s hatch replay <request-id> [options]
%s
-endpoint string Endpoint ID (required)
-target string Target URL to replay to (required)
-output string Output format: json, table, compact (default json)
%s
hatch replay abc123 -endpoint my-webhook -target https://httpbin.org/post
`,
colorize(colorCyan, "Replay a captured request."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("request ID is required")
fs.Usage()
os.Exit(ExitUsageError)
}
if *endpoint == "" {
printError("-endpoint is required")
fs.Usage()
os.Exit(ExitUsageError)
}
if *target == "" {
printError("-target is required")
fs.Usage()
os.Exit(ExitUsageError)
}
requestID := fs.Arg(0)
apiBody := map[string]string{
"target_url": *target,
}
path := fmt.Sprintf("/v1/endpoints/%s/requests/%s/replay", *endpoint, requestID)
data, status, err := apiRequest("POST", path, apiBody)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
os.Exit(ExitServerError)
}
printSuccess("Replay completed")
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdMock handles: hatch mock set <endpoint> -status <code> [-body BODY] [-header KEY:VALUE]
func cmdMock(args []string) {
if len(args) < 1 || args[0] != "set" {
fmt.Fprintf(os.Stderr, `%s Configure mock responses.
%s hatch mock set <endpoint> [options]
%s
set Set mock configuration
`,
colorize(colorCyan, "Mock configuration."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Subcommands:"),
)
os.Exit(ExitUsageError)
}
fs := flag.NewFlagSet("mock set", flag.ContinueOnError)
statusCode := fs.Int("status", 200, "HTTP status code")
body := fs.String("body", "", "Response body (string or base64)")
output := fs.String("output", "", "Output format: json, table, compact")
var headers multiFlag
fs.Var(&headers, "header", "Response header in KEY:VALUE format (repeatable)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Configure a mock response for an endpoint.
%s hatch mock set <endpoint> [options]
%s
-status int HTTP status code (default 200)
-body string Response body (string or base64)
-header string Response header in KEY:VALUE format (repeatable)
-output string Output format: json, table, compact (default json)
%s
hatch mock set my-webhook -status 200 -body '{"ok":true}'
hatch mock set my-webhook -status 418 -header 'X-Teapot:true'
`,
colorize(colorCyan, "Set mock response."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args[1:]); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("endpoint ID is required")
fs.Usage()
os.Exit(ExitUsageError)
}
endpointID := fs.Arg(0)
// Parse headers into map.
headerMap := make(map[string]string)
for _, h := range headers {
parts := strings.SplitN(h, ":", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "%s invalid header format %q (expected KEY:VALUE)\n",
colorize(colorRed, "error:"), h)
os.Exit(ExitUsageError)
}
headerMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
// Build mock config body.
mockBody := map[string]interface{}{
"status": *statusCode,
}
if *body != "" {
mockBody["body"] = *body
}
if len(headerMap) > 0 {
mockBody["headers"] = headerMap
}
path := fmt.Sprintf("/v1/endpoints/%s/mock", endpointID)
data, status, err := apiRequest("PUT", path, mockBody)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
os.Exit(ExitServerError)
}
printSuccess("Mock configured successfully")
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdDoc handles: hatch doc generate <endpoint>
func cmdDoc(args []string) {
if len(args) < 1 || args[0] != "generate" {
fmt.Fprintf(os.Stderr, `%s Generate API documentation.
%s hatch doc generate <endpoint>
%s
generate Generate OpenAPI spec for an endpoint
`,
colorize(colorCyan, "Documentation generation."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Subcommands:"),
)
os.Exit(ExitUsageError)
}
fs := flag.NewFlagSet("doc generate", flag.ContinueOnError)
output := fs.String("output", "", "Output format: json, compact")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `%s Generate OpenAPI 3.1 spec for an endpoint.
%s hatch doc generate <endpoint>
%s
-output string Output format: json, compact (default json)
%s
hatch doc generate my-webhook
hatch doc generate my-webhook > openapi.json
`,
colorize(colorCyan, "Generate OpenAPI spec."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Options:"),
colorize(colorGreen, "Examples:"),
)
}
if err := fs.Parse(args[1:]); err != nil {
os.Exit(ExitUsageError)
}
if fs.NArg() < 1 {
printError("endpoint ID is required")
fs.Usage()
os.Exit(ExitUsageError)
}
endpointID := fs.Arg(0)
path := fmt.Sprintf("/v1/endpoints/%s/openapi.json", endpointID)
data, status, err := apiRequest("GET", path, nil)
if err != nil {
handleError(err)
}
if status >= 400 {
fmt.Fprintf(os.Stderr, "%s HTTP %d\n", colorize(colorRed, "error:"), status)
os.Exit(ExitServerError)
}
if *output == "compact" {
fmt.Println(string(data))
} else {
printJSON(data)
}
}
// cmdConfig handles: hatch config [show|set|get|init]
func cmdConfig(args []string) {
if len(args) < 1 {
printConfigUsage()
os.Exit(ExitUsageError)
}
subcmd := args[0]
switch subcmd {
case "show":
cmdConfigShow()
case "set":
cmdConfigSet(args[1:])
case "get":
cmdConfigGet(args[1:])
case "init":
cmdConfigInit()
case "help", "-h", "--help":
printConfigUsage()
default:
fmt.Fprintf(os.Stderr, "%s Unknown config subcommand: %s\n", colorize(colorRed, "error:"), subcmd)
printConfigUsage()
os.Exit(ExitUsageError)
}
}
func printConfigUsage() {
fmt.Fprintf(os.Stderr, `%s Manage hatch configuration.
%s hatch config <command>
%s
show Show current configuration
set <key> <value> Set a configuration value
get <key> Get a configuration value
init Create default config file
%s
server_url Hatch server URL (default: http://localhost:8080)
format Output format: json, table, compact (default: json)
no_color Disable colored output (default: false)
%s
hatch config show
hatch config set server_url http://hatch.example.com:9000
hatch config set format table
hatch config get server_url
hatch config init
`,
colorize(colorCyan, "Configuration management."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Subcommands:"),
colorize(colorGreen, "Keys:"),
colorize(colorGreen, "Examples:"),
)
}
func cmdConfigShow() {
cfg, err := LoadConfig()
if err != nil {
handleError(err)
}
fmt.Fprintf(os.Stderr, "%s Current configuration:\n\n", colorize(colorCyan, "Config:"))
fmt.Fprintf(os.Stderr, " %s %s\n", colorize(colorGray, "server_url:"), cfg.ServerURL)
fmt.Fprintf(os.Stderr, " %s %s\n", colorize(colorGray, "format:"), cfg.Format)
fmt.Fprintf(os.Stderr, " %s %v\n", colorize(colorGray, "no_color:"), cfg.NoColor)
fmt.Fprintf(os.Stderr, " %s %ds\n", colorize(colorGray, "timeout:"), cfg.Timeout)
fmt.Fprintf(os.Stderr, "\n %s %s\n", colorize(colorGray, "config_file:"), getConfigPath())
}
func cmdConfigSet(args []string) {
if len(args) < 2 {
printError("key and value are required")
fmt.Fprintf(os.Stderr, "Usage: hatch config set <key> <value>\n")
os.Exit(ExitUsageError)
}
key, value := args[0], args[1]
cfg, err := LoadConfig()
if err != nil {
handleError(err)
}
switch key {
case "server_url":
cfg.ServerURL = strings.TrimRight(value, "/")
case "format":
if value != "json" && value != "table" && value != "compact" {
printError("invalid format (must be: json, table, compact)")
os.Exit(ExitUsageError)
}
cfg.Format = value
case "no_color":
cfg.NoColor = value == "true" || value == "1"
case "timeout":
var timeout int
if _, err := fmt.Sscanf(value, "%d", &timeout); err != nil || timeout < 1 {
printError("invalid timeout (must be a positive integer)")
os.Exit(ExitUsageError)
}
cfg.Timeout = timeout
default:
fmt.Fprintf(os.Stderr, "%s Unknown key: %s\n", colorize(colorRed, "error:"), key)
fmt.Fprintf(os.Stderr, "Valid keys: server_url, format, no_color, timeout\n")
os.Exit(ExitUsageError)
}
// Save config.
configPath := getConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to create config directory", Detail: err.Error()})
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to marshal config", Detail: err.Error()})
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to write config file", Detail: err.Error()})
}
printSuccess(fmt.Sprintf("Set %s = %s", key, value))
}
func cmdConfigGet(args []string) {
if len(args) < 1 {
printError("key is required")
fmt.Fprintf(os.Stderr, "Usage: hatch config get <key>\n")
os.Exit(ExitUsageError)
}
key := args[0]
cfg, err := LoadConfig()
if err != nil {
handleError(err)
}
switch key {
case "server_url":
fmt.Println(cfg.ServerURL)
case "format":
fmt.Println(cfg.Format)
case "no_color":
fmt.Println(cfg.NoColor)
case "timeout":
fmt.Println(cfg.Timeout)
default:
fmt.Fprintf(os.Stderr, "%s Unknown key: %s\n", colorize(colorRed, "error:"), key)
os.Exit(ExitUsageError)
}
}
func cmdConfigInit() {
configPath := getConfigPath()
if _, err := os.Stat(configPath); err == nil {
printWarning("Config file already exists")
fmt.Fprintf(os.Stderr, " %s\n", configPath)
return
}
cfg := &Config{
ServerURL: "http://localhost:8080",
Format: "json",
NoColor: false,
Timeout: 30,
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to create config", Detail: err.Error()})
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to create config directory", Detail: err.Error()})
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
handleError(&CLIError{Code: ExitConfigError, Message: "failed to write config file", Detail: err.Error()})
}
printSuccess(fmt.Sprintf("Created config file: %s", configPath))
}
// cmdCompletions handles shell completion generation.
func cmdCompletions(args []string) {
shell := "bash"
if len(args) > 0 {
shell = args[0]
}
switch shell {
case "bash":
printBashCompletions()
case "zsh":
printZshCompletions()
case "fish":
printFishCompletions()
case "powershell":
printPowershellCompletions()
case "help", "-h", "--help":
printCompletionsUsage()
default:
fmt.Fprintf(os.Stderr, "%s Unknown shell: %s\n", colorize(colorRed, "error:"), shell)
printCompletionsUsage()
os.Exit(ExitUsageError)
}
}
func printCompletionsUsage() {
fmt.Fprintf(os.Stderr, `%s Generate shell completions for hatch.
%s hatch completions [shell]
%s
bash Bash completions (default)
zsh Zsh completions
fish Fish completions
powershell PowerShell completions
%s
# Bash: add to ~/.bashrc
eval "$(hatch completions bash)"
# Zsh: add to ~/.zshrc
eval "$(hatch completions zsh)"
# Fish: save to completions directory
hatch completions fish > ~/.config/fish/completions/hatch.fish
# PowerShell: add to profile
hatch completions powershell | Out-String | Invoke-Expression
`,
colorize(colorCyan, "Shell completions."),
colorize(colorYellow, "Usage:"),
colorize(colorGreen, "Shells:"),
colorize(colorGreen, "Setup:"),
)
}
func printBashCompletions() {
fmt.Print(`# Bash completions for hatch
# Usage: eval "$(hatch completions bash)"
_hatch_completions() {
local cur prev words cword
_init_completion || return
# Commands
local commands="serve capture inspect search replay mock doc config completions help version"
# Global flags
local global_flags="--help --version -h"
if [[ ${cword} -eq 1 ]]; then
COMPREPLY=($(compgen -W "${commands} ${global_flags}" -- "${cur}"))
return
fi
local command="${words[1]}"
case "${command}" in
capture)
local flags="--method --body --header --output -h"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
;;
inspect)
local flags="--limit --output -h"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
;;
search)
local flags="--query --limit --output -h"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
;;
replay)
local flags="--endpoint --target --output -h"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
;;
mock)
if [[ ${cword} -eq 2 ]]; then
COMPREPLY=($(compgen -W "set help" -- "${cur}"))
elif [[ "${words[2]}" == "set" ]]; then
local flags="--status --body --header --output -h"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
fi
;;
doc)
if [[ ${cword} -eq 2 ]]; then
COMPREPLY=($(compgen -W "generate help" -- "${cur}"))
fi
;;
config)
if [[ ${cword} -eq 2 ]]; then
local subcommands="show set get init help"
COMPREPLY=($(compgen -W "${subcommands}" -- "${cur}"))
elif [[ "${words[2]}" == "set" ]]; then
local keys="server_url format no_color timeout"
if [[ ${cword} -eq 4 ]]; then
case "${words[3]}" in
format)
COMPREPLY=($(compgen -W "json table compact" -- "${cur}"))
;;
no_color)
COMPREPLY=($(compgen -W "true false" -- "${cur}"))
;;
esac
elif [[ ${cword} -eq 3 ]]; then
COMPREPLY=($(compgen -W "${keys}" -- "${cur}"))
fi
elif [[ "${words[2]}" == "get" ]]; then
local keys="server_url format no_color timeout"
COMPREPLY=($(compgen -W "${keys}" -- "${cur}"))
fi
;;
completions)
local shells="bash zsh fish powershell"
COMPREPLY=($(compgen -W "${shells}" -- "${cur}"))
;;
esac
}
complete -F _hatch_completions hatch
`)
}
func printZshCompletions() {
fmt.Print(`# Zsh completions for hatch
# Usage: eval "$(hatch completions zsh)"
#compdef hatch
_hatch() {
local -a commands
commands=(
'serve:Start the server (default)'
'capture:Send a request to an endpoint and store it'
'inspect:Fetch captured requests for an endpoint'
'search:Search captured traffic'
'replay:Replay a captured request'
'mock:Configure mock responses'
'doc:Generate API documentation'
'config:Manage configuration'
'completions:Generate shell completions'
'help:Show help'
'version:Print version'
)
local -a capture_flags
capture_flags=(
'--method[HTTP method]:method:(GET POST PUT PATCH DELETE)'
'--body[Request body]:body:'
'--header[Header in KEY:VALUE format]:header:'
'--output[Output format]:format:(json table compact)'
)
local -a inspect_flags
inspect_flags=(
'--limit[Maximum number of requests]:limit:'
'--output[Output format]:format:(json table compact)'
)
local -a search_flags
search_flags=(
'--query[Search query]:query:'
'--limit[Maximum number of results]:limit:'
'--output[Output format]:format:(json table compact)'
)
local -a replay_flags
replay_flags=(
'--endpoint[Endpoint ID]:endpoint:'
'--target[Target URL to replay to]:target:'
'--output[Output format]:format:(json table compact)'
)
local -a mock_flags
mock_flags=(
'--status[HTTP status code]:status:'
'--body[Response body]:body:'
'--header[Response header in KEY:VALUE format]:header:'
'--output[Output format]:format:(json table compact)'
)
local -a config_subcommands
config_subcommands=(
'show:Show current configuration'
'set:Set a configuration value'
'get:Get a configuration value'
'init:Create default config file'
'help:Show config help'
)
local -a config_set_keys
config_set_keys=(
'server_url:Hatch server URL'
'format:Output format'
'no_color:Disable colored output'
'timeout:Request timeout in seconds'
)
local -a completions_shells
completions_shells=(
'bash:Bash completions'
'zsh:Zsh completions'
'fish:Fish completions'
'powershell:PowerShell completions'
)
_arguments -C \
'1:command:->command' \
'*::arg:->args'
case $state in
command)
_describe 'command' commands
;;
args)
case ${words[1]} in
capture)
_arguments $capture_flags
;;
inspect)
_arguments $inspect_flags
;;
search)
_arguments $search_flags
;;
replay)
_arguments $replay_flags
;;
mock)
_arguments '1:subcommand:(set)' $mock_flags
;;
config)
_arguments '1:subcommand:->config_subcmd' '*::arg:->config_args'
;;
completions)
_describe 'shell' completions_shells
;;
esac
;;
config_subcmd)
_describe 'subcommand' config_subcommands
;;
config_args)
case ${words[1]} in
set)
_arguments '1:key:->config_key' '2:value:'
;;
get)
_arguments '1:key:->config_key'
;;
esac
;;
config_key)
_describe 'key' config_set_keys
;;
esac
}
_hatch "$@"
`)
}
func printFishCompletions() {
fmt.Print(`# Fish completions for hatch
# Usage: hatch completions fish > ~/.config/fish/completions/hatch.fish
# Helper function
function __hatch_needs_command
set cmd (commandline -opc)
if test (count $cmd) -eq 1
return 0
end
return 1
end
function __hatch_using_command
set cmd (commandline -opc)
if test (count $cmd) -gt 1
if test $argv[1] = $cmd[2]
return 0
end
end
return 1
end
function __hatch_complete_with_subcommands
set cmd (commandline -opc)
if test (count $cmd) -eq 2
switch $cmd[2]
case mock
echo -e "set\tConfigure mock response"
case doc
echo -e "generate\tGenerate OpenAPI spec"
case config
echo -e "show\tShow current configuration"
echo -e "set\tSet a configuration value"
echo -e "get\tGet a configuration value"
echo -e "init\tCreate default config file"
case completions
echo -e "bash\tBash completions"
echo -e "zsh\tZsh completions"
echo -e "fish\tFish completions"
echo -e "powershell\tPowerShell completions"
end
end
end
# Main command completions
complete -c hatch -n __hatch_needs_command -a serve -d 'Start the server (default)'
complete -c hatch -n __hatch_needs_command -a capture -d 'Send a request to an endpoint and store it'
complete -c hatch -n __hatch_needs_command -a inspect -d 'Fetch captured requests for an endpoint'
complete -c hatch -n __hatch_needs_command -a search -d 'Search captured traffic'
complete -c hatch -n __hatch_needs_command -a replay -d 'Replay a captured request'
complete -c hatch -n __hatch_needs_command -a mock -d 'Configure mock responses'
complete -c hatch -n __hatch_needs_command -a doc -d 'Generate API documentation'
complete -c hatch -n __hatch_needs_command -a config -d 'Manage configuration'
complete -c hatch -n __hatch_needs_command -a completions -d 'Generate shell completions'
complete -c hatch -n __hatch_needs_command -a help -d 'Show help'
complete -c hatch -n __hatch_needs_command -a version -d 'Print version'
# Subcommand completions
complete -c hatch -n __hatch_complete_with_subcommands
# capture flags
complete -c hatch -n __hatch_using_command capture -l method -d 'HTTP method' -r
complete -c hatch -n __hatch_using_command capture -l body -d 'Request body (JSON string)' -r
complete -c hatch -n __hatch_using_command capture -l header -d 'Header in KEY:VALUE format (repeatable)' -r
complete -c hatch -n __hatch_using_command capture -l output -d 'Output format: json, table, compact' -r -a 'json table compact'
# inspect flags
complete -c hatch -n __hatch_using_command inspect -l limit -d 'Maximum number of requests to return' -r
complete -c hatch -n __hatch_using_command inspect -l output -d 'Output format: json, table, compact' -r -a 'json table compact'
# search flags
complete -c hatch -n __hatch_using_command search -l query -d 'Search query' -r
complete -c hatch -n __hatch_using_command search -l limit -d 'Maximum number of results' -r
complete -c hatch -n __hatch_using_command search -l output -d 'Output format: json, table, compact' -r -a 'json table compact'
# replay flags
complete -c hatch -n __hatch_using_command replay -l endpoint -d 'Endpoint ID' -r
complete -c hatch -n __hatch_using_command replay -l target -d 'Target URL to replay to' -r
complete -c hatch -n __hatch_using_command replay -l output -d 'Output format: json, table, compact' -r -a 'json table compact'
# mock set flags
complete -c hatch -n __hatch_using_command mock -l status -d 'HTTP status code' -r
complete -c hatch -n __hatch_using_command mock -l body -d 'Response body (string or base64)' -r
complete -c hatch -n __hatch_using_command mock -l header -d 'Response header in KEY:VALUE format (repeatable)' -r
complete -c hatch -n __hatch_using_command mock -l output -d 'Output format: json, table, compact' -r -a 'json table compact'
# config set keys
complete -c hatch -n __hatch_using_command config -l server_url -d 'Hatch server URL' -r
complete -c hatch -n __hatch_using_command config -l format -d 'Output format' -r -a 'json table compact'
complete -c hatch -n __hatch_using_command config -l no_color -d 'Disable colored output' -r -a 'true false'
complete -c hatch -n __hatch_using_command config -l timeout -d 'Request timeout in seconds' -r
`)
}
func printPowershellCompletions() {
fmt.Print(`# PowerShell completions for hatch
# Usage: hatch completions powershell | Out-String | Invoke-Expression
Register-ArgumentCompleter -Native -CommandName 'hatch' -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$command = @($commandElements)[0].CommandElement.Text
# Commands
$commands = @(
'serve'
'capture'
'inspect'
'search'
'replay'
'mock'
'doc'
'config'
'completions'
'help'
'version'
)
# If we're typing the command itself
if ($commandElements.Count -eq 1) {
$commands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
return
}
# Handle subcommands
$subCommand = $commandElements[1].CommandElement.Text
switch ($subCommand) {
'capture' {
$flags = @('--method', '--body', '--header', '--output')
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'inspect' {
$flags = @('--limit', '--output')
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'search' {
$flags = @('--query', '--limit', '--output')
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'replay' {
$flags = @('--endpoint', '--target', '--output')
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'mock' {
if ($commandElements.Count -eq 2) {
@('set') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} else {
$flags = @('--status', '--body', '--header', '--output')
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
'doc' {
if ($commandElements.Count -eq 2) {
@('generate') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
'config' {
if ($commandElements.Count -eq 2) {
@('show', 'set', 'get', 'init') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($commandElements.Count -eq 3 -and $commandElements[2].CommandElement.Text -eq 'set') {
@('server_url', 'format', 'no_color', 'timeout') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
'completions' {
@('bash', 'zsh', 'fish', 'powershell') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
`)
}
// extractEndpointID extracts the endpoint ID from a URL or path.
func extractEndpointID(url string) string {
endpointID := strings.TrimLeft(url, "/")
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
// Extract path from URL.
if idx := strings.Index(url[8:], "/"); idx != -1 {
endpointID = url[8+idx+1:]
} else {
endpointID = "default"
}
}
endpointID = strings.TrimRight(endpointID, "/")
if endpointID == "" {
endpointID = "default"
}
return endpointID
}
// multiFlag allows repeated -header flags.
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ", ") }
func (m *multiFlag) Set(v string) error {
*m = append(*m, v)
return nil
}