From cca04bc11ccb47d624d2a3820010d05ef0c405ab Mon Sep 17 00:00:00 2001 From: teernisse Date: Sun, 22 Feb 2026 21:12:19 -0500 Subject: [PATCH] Add CLI commands with structured errors and robot-mode behavior Three cobra commands forming the CLI surface: - root: fetch and filter weekly deals (--store/--zip with BOGO, category, department, query, and limit filters) - stores: list nearby Publix locations by ZIP code - categories: show available deal categories with counts Structured error system with typed error codes (INVALID_ARGS, NOT_FOUND, UPSTREAM_ERROR, INTERNAL_ERROR) and semantic exit codes (0-4). Errors render as human-readable text or JSON depending on output mode. Robot-mode features: auto-JSON when stdout is not a TTY, compact quick-start help when invoked with no args, and JSON error payloads for programmatic consumers. --- cmd/categories.go | 51 +++++++ cmd/robot_mode.go | 310 +++++++++++++++++++++++++++++++++++++++++ cmd/robot_mode_test.go | 52 +++++++ cmd/root.go | 190 +++++++++++++++++++++++++ cmd/stores.go | 50 +++++++ 5 files changed, 653 insertions(+) create mode 100644 cmd/categories.go create mode 100644 cmd/robot_mode.go create mode 100644 cmd/robot_mode_test.go create mode 100644 cmd/root.go create mode 100644 cmd/stores.go diff --git a/cmd/categories.go b/cmd/categories.go new file mode 100644 index 0000000..53eba62 --- /dev/null +++ b/cmd/categories.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/display" + "github.com/tayloree/publix-deals/internal/filter" +) + +var categoriesCmd = &cobra.Command{ + Use: "categories", + Short: "List available categories for the current week", + Example: ` pubcli categories --store 1425 + pubcli categories -z 33101 --json`, + RunE: runCategories, +} + +func init() { + rootCmd.AddCommand(categoriesCmd) +} + +func runCategories(cmd *cobra.Command, _ []string) error { + client := api.NewClient() + + storeNumber, err := resolveStore(cmd, client) + if err != nil { + return err + } + + data, err := client.FetchSavings(cmd.Context(), storeNumber) + if err != nil { + return upstreamError("fetching deals", err) + } + + if len(data.Savings) == 0 { + return notFoundError( + fmt.Sprintf("no deals found for store #%s", storeNumber), + "Try another store with --store.", + ) + } + + cats := filter.Categories(data.Savings) + + if flagJSON { + return display.PrintCategoriesJSON(cmd.OutOrStdout(), cats) + } + display.PrintCategories(cmd.OutOrStdout(), cats, storeNumber) + return nil +} diff --git a/cmd/robot_mode.go b/cmd/robot_mode.go new file mode 100644 index 0000000..1548a39 --- /dev/null +++ b/cmd/robot_mode.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" +) + +const ( + // ExitSuccess is returned when the command succeeds. + ExitSuccess = 0 + // ExitNotFound is returned when the requested stores/deals are not available. + ExitNotFound = 1 + // ExitInvalidArgs is returned when the command input is invalid. + ExitInvalidArgs = 2 + // ExitUpstream is returned when an external dependency fails. + ExitUpstream = 3 + // ExitInternal is returned for unexpected internal failures. + ExitInternal = 4 +) + +type cliError struct { + Code string + Message string + Suggestions []string + ExitCode int +} + +func (e *cliError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func invalidArgsError(message string, suggestions ...string) error { + return &cliError{ + Code: "INVALID_ARGS", + Message: message, + Suggestions: suggestions, + ExitCode: ExitInvalidArgs, + } +} + +func notFoundError(message string, suggestions ...string) error { + return &cliError{ + Code: "NOT_FOUND", + Message: message, + Suggestions: suggestions, + ExitCode: ExitNotFound, + } +} + +func upstreamError(action string, err error) error { + return &cliError{ + Code: "UPSTREAM_ERROR", + Message: fmt.Sprintf("%s: %v", action, err), + Suggestions: []string{"Retry in a moment."}, + ExitCode: ExitUpstream, + } +} + +type jsonErrorPayload struct { + Error jsonErrorBody `json:"error"` +} + +type jsonErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Suggestions []string `json:"suggestions,omitempty"` + ExitCode int `json:"exitCode"` +} + +func printCLIErrorJSON(w io.Writer, err *cliError) error { + if err == nil { + return nil + } + payload := jsonErrorPayload{ + Error: jsonErrorBody{ + Code: err.Code, + Message: err.Message, + Suggestions: err.Suggestions, + ExitCode: err.ExitCode, + }, + } + return json.NewEncoder(w).Encode(payload) +} + +func formatCLIErrorText(err *cliError) string { + if err == nil { + return "" + } + + lines := []string{ + fmt.Sprintf("error[%s]: %s", strings.ToLower(err.Code), err.Message), + } + if len(err.Suggestions) > 0 { + lines = append(lines, "suggestions:") + for _, suggestion := range err.Suggestions { + lines = append(lines, " "+suggestion) + } + } + return strings.Join(lines, "\n") +} + +func classifyCLIError(err error) *cliError { + if err == nil { + return nil + } + + var typed *cliError + if errors.As(err, &typed) { + return typed + } + + msg := strings.TrimSpace(err.Error()) + lowerMsg := strings.ToLower(msg) + + switch { + case strings.Contains(msg, "unknown command"): + suggestions := []string{ + "pubcli stores --zip 33101", + "pubcli categories --zip 33101", + } + if bad := extractUnknownValue(msg, "unknown command"); bad != "" { + if suggestion, ok := closestMatch(strings.ToLower(bad), knownCommands, 2); ok { + suggestions = append([]string{fmt.Sprintf("Did you mean `%s`?", suggestion)}, suggestions...) + } + } + return &cliError{ + Code: "INVALID_ARGS", + Message: msg, + Suggestions: suggestions, + ExitCode: ExitInvalidArgs, + } + case strings.Contains(msg, "unknown flag"): + suggestions := []string{ + "pubcli --zip 33101", + "pubcli --store 1425 --bogo", + } + if bad := extractUnknownValue(msg, "unknown flag"); bad != "" { + trimmed := strings.TrimLeft(bad, "-") + if suggestion, ok := resolveFlagName(trimmed); ok { + suggestions = append([]string{fmt.Sprintf("Try `--%s`.", suggestion)}, suggestions...) + } + } + return &cliError{ + Code: "INVALID_ARGS", + Message: msg, + Suggestions: suggestions, + ExitCode: ExitInvalidArgs, + } + case strings.Contains(msg, "requires an argument for flag"), + strings.Contains(msg, "flag needs an argument"), + strings.Contains(msg, "required flag(s)"): + return &cliError{ + Code: "INVALID_ARGS", + Message: msg, + Suggestions: []string{"pubcli --zip 33101", "pubcli --store 1425"}, + ExitCode: ExitInvalidArgs, + } + case strings.Contains(lowerMsg, "no publix stores found"), + strings.Contains(lowerMsg, "no stores found near"), + strings.Contains(lowerMsg, "no deals found"), + strings.Contains(lowerMsg, "no deals match"): + return &cliError{ + Code: "NOT_FOUND", + Message: msg, + ExitCode: ExitNotFound, + } + case strings.Contains(lowerMsg, "unexpected status"), + strings.Contains(lowerMsg, "executing request"), + strings.Contains(lowerMsg, "reading response"), + strings.Contains(lowerMsg, "decoding savings"), + strings.Contains(lowerMsg, "decoding stores"), + strings.Contains(lowerMsg, "fetching deals"), + strings.Contains(lowerMsg, "fetching stores"), + strings.Contains(lowerMsg, "finding stores"): + return &cliError{ + Code: "UPSTREAM_ERROR", + Message: msg, + Suggestions: []string{"Retry in a moment."}, + ExitCode: ExitUpstream, + } + default: + return &cliError{ + Code: "INTERNAL_ERROR", + Message: msg, + Suggestions: []string{"Run `pubcli --help` for usage details."}, + ExitCode: ExitInternal, + } + } +} + +func isTTY(w io.Writer) bool { + file, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(int(file.Fd())) +} + +func hasJSONPreference(args []string) bool { + for _, arg := range args { + if arg == "--json" || strings.HasPrefix(arg, "--json=") { + return true + } + } + return false +} + +func hasHelpRequest(args []string) bool { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return true + } + } + return false +} + +func shouldAutoJSON(args []string, stdoutIsTTY bool) bool { + if stdoutIsTTY || len(args) == 0 { + return false + } + if hasJSONPreference(args) || hasHelpRequest(args) { + return false + } + switch firstCommand(args) { + case "completion", "help": + return false + default: + return true + } +} + +// knownShorthands maps single-character shorthands to whether they require a value. +var knownShorthands = map[byte]bool{ + 's': true, // --store + 'z': true, // --zip + 'c': true, // --category + 'd': true, // --department + 'q': true, // --query + 'n': true, // --limit +} + +func firstCommand(args []string) string { + expectingValue := false + for _, arg := range args { + if expectingValue { + expectingValue = false + continue + } + if arg == "--" { + break + } + if !strings.HasPrefix(arg, "-") { + return arg + } + if strings.HasPrefix(arg, "--") { + name, rest := splitFlag(strings.TrimPrefix(arg, "--")) + if spec, ok := knownFlags[name]; ok && spec.requiresValue && rest == "" { + expectingValue = true + } + } else if len(arg) == 2 && arg[0] == '-' { + // Single-char shorthand like -z, -s, -n + if needsVal, ok := knownShorthands[arg[1]]; ok && needsVal { + expectingValue = true + } + } + } + return "" +} + +type quickStartJSON struct { + Name string `json:"name"` + Usage string `json:"usage"` + Examples []string `json:"examples"` +} + +func printQuickStart(w io.Writer, asJSON bool) error { + help := quickStartJSON{ + Name: "pubcli", + Usage: "pubcli [flags] | [stores|categories] [flags]", + Examples: []string{ + "pubcli --zip 33101 --limit 10", + "pubcli stores --zip 33101", + "pubcli categories --store 1425", + }, + } + + if asJSON { + return json.NewEncoder(w).Encode(help) + } + + _, err := fmt.Fprintf( + w, + "%s\nusage: %s\nexamples:\n %s\n %s\n %s\nflags: --zip --store --json --bogo --category --department --query --limit\n", + help.Name, + help.Usage, + help.Examples[0], + help.Examples[1], + help.Examples[2], + ) + return err +} diff --git a/cmd/robot_mode_test.go b/cmd/robot_mode_test.go new file mode 100644 index 0000000..6429909 --- /dev/null +++ b/cmd/robot_mode_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldAutoJSON(t *testing.T) { + assert.True(t, shouldAutoJSON([]string{"stores", "--zip", "33101"}, false)) + assert.False(t, shouldAutoJSON([]string{"stores", "--zip", "33101", "--json"}, false)) + assert.False(t, shouldAutoJSON([]string{"completion", "zsh"}, false)) + assert.False(t, shouldAutoJSON([]string{"--help"}, false)) + assert.False(t, shouldAutoJSON([]string{"stores", "--zip", "33101"}, true)) +} + +func TestFirstCommand_SkipsFlagValues(t *testing.T) { + cmd := firstCommand([]string{"--zip", "33101", "stores"}) + assert.Equal(t, "stores", cmd) +} + +func TestPrintQuickStart_JSON(t *testing.T) { + var buf bytes.Buffer + err := printQuickStart(&buf, true) + require.NoError(t, err) + + var payload quickStartJSON + err = json.Unmarshal(buf.Bytes(), &payload) + require.NoError(t, err) + + assert.Equal(t, "pubcli", payload.Name) + assert.NotEmpty(t, payload.Usage) + assert.Len(t, payload.Examples, 3) +} + +func TestPrintCLIErrorJSON(t *testing.T) { + var buf bytes.Buffer + err := printCLIErrorJSON(&buf, classifyCLIError(invalidArgsError("bad flag", "pubcli --zip 33101"))) + require.NoError(t, err) + + var payload map[string]any + err = json.Unmarshal(buf.Bytes(), &payload) + require.NoError(t, err) + + errorObject, ok := payload["error"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "INVALID_ARGS", errorObject["code"]) + assert.Equal(t, "bad flag", errorObject["message"]) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..264ed72 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/display" + "github.com/tayloree/publix-deals/internal/filter" +) + +var ( + flagStore string + flagZip string + flagCategory string + flagDepartment string + flagBogo bool + flagQuery string + flagLimit int + flagJSON bool +) + +var rootCmd = &cobra.Command{ + Use: "pubcli", + Short: "Fetch current Publix weekly ad deals", + Long: "CLI tool that fetches the current week's sale items from the Publix API.\n" + + "Requires a store number or zip code to find deals for your local store.\n\n" + + "Agent-friendly mode: minor syntax issues are auto-corrected when intent is clear " + + "(for example: -zip 33101, zip=33101, --ziip 33101).", + Example: ` pubcli --zip 33101 + pubcli --store 1425 --bogo + pubcli categories --zip 33101 + pubcli stores --zip 33101 --json`, + RunE: runDeals, +} + +func init() { + rootCmd.SilenceErrors = true + rootCmd.SilenceUsage = true + + pf := rootCmd.PersistentFlags() + pf.StringVarP(&flagStore, "store", "s", "", "Publix store number (e.g., 1425)") + pf.StringVarP(&flagZip, "zip", "z", "", "Zip code to find nearby stores") + pf.BoolVar(&flagJSON, "json", false, "Output as JSON") + + f := rootCmd.Flags() + f.StringVarP(&flagCategory, "category", "c", "", "Filter by category (e.g., bogo, meat, produce)") + f.StringVarP(&flagDepartment, "department", "d", "", "Filter by department (e.g., Meat, Deli)") + f.BoolVar(&flagBogo, "bogo", false, "Show only BOGO deals") + f.StringVarP(&flagQuery, "query", "q", "", "Search deals by keyword in title/description") + f.IntVarP(&flagLimit, "limit", "n", 0, "Limit number of results (0 = all)") +} + +// Execute runs the root command. +func Execute() { + os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr)) +} + +func runCLI(args []string, stdout, stderr io.Writer) int { + resetCLIState() + + normalizedArgs, notes := normalizeCLIArgs(args) + for _, note := range notes { + fmt.Fprintf(stderr, "note: %s\n", note) + } + + if len(normalizedArgs) == 0 { + if err := printQuickStart(stdout, !isTTY(stdout)); err != nil { + cliErr := classifyCLIError(err) + fmt.Fprintln(stderr, formatCLIErrorText(cliErr)) + return cliErr.ExitCode + } + return ExitSuccess + } + + if shouldAutoJSON(normalizedArgs, isTTY(stdout)) { + normalizedArgs = append(normalizedArgs, "--json") + } + + setCommandIO(rootCmd, stdout, stderr) + rootCmd.SetArgs(normalizedArgs) + + if err := rootCmd.Execute(); err != nil { + cliErr := classifyCLIError(err) + if hasJSONPreference(normalizedArgs) { + if jerr := printCLIErrorJSON(stderr, cliErr); jerr != nil { + fmt.Fprintln(stderr, formatCLIErrorText(classifyCLIError(jerr))) + return ExitInternal + } + } else { + fmt.Fprintln(stderr, formatCLIErrorText(cliErr)) + } + return cliErr.ExitCode + } + return ExitSuccess +} + +func setCommandIO(cmd *cobra.Command, stdout, stderr io.Writer) { + cmd.SetOut(stdout) + cmd.SetErr(stderr) + for _, child := range cmd.Commands() { + setCommandIO(child, stdout, stderr) + } +} + +func resetCLIState() { + flagStore = "" + flagZip = "" + flagCategory = "" + flagDepartment = "" + flagBogo = false + flagQuery = "" + flagLimit = 0 + flagJSON = false +} + +func resolveStore(cmd *cobra.Command, client *api.Client) (string, error) { + if flagStore != "" { + return flagStore, nil + } + if flagZip == "" { + return "", invalidArgsError( + "please provide --store NUMBER or --zip ZIPCODE", + "pubcli --zip 33101", + "pubcli --store 1425", + ) + } + + stores, err := client.FetchStores(cmd.Context(), flagZip, 1) + if err != nil { + return "", upstreamError("finding stores", err) + } + if len(stores) == 0 { + return "", notFoundError( + fmt.Sprintf("no Publix stores found near %s", flagZip), + "Try a nearby ZIP code.", + ) + } + + num := api.StoreNumber(stores[0].Key) + if !flagJSON { + display.PrintStoreContext(cmd.OutOrStdout(), stores[0]) + } + return num, nil +} + +func runDeals(cmd *cobra.Command, _ []string) error { + client := api.NewClient() + + storeNumber, err := resolveStore(cmd, client) + if err != nil { + return err + } + + data, err := client.FetchSavings(cmd.Context(), storeNumber) + if err != nil { + return upstreamError("fetching deals", err) + } + + items := data.Savings + if len(items) == 0 { + return notFoundError( + fmt.Sprintf("no deals found for store #%s", storeNumber), + "Try another store with --store.", + ) + } + + items = filter.Apply(items, filter.Options{ + BOGO: flagBogo, + Category: flagCategory, + Department: flagDepartment, + Query: flagQuery, + Limit: flagLimit, + }) + + if len(items) == 0 { + return notFoundError( + "no deals match your filters", + "Relax filters like --category/--department/--query.", + ) + } + + if flagJSON { + return display.PrintDealsJSON(cmd.OutOrStdout(), items) + } + display.PrintDeals(cmd.OutOrStdout(), items) + return nil +} diff --git a/cmd/stores.go b/cmd/stores.go new file mode 100644 index 0000000..2e9784d --- /dev/null +++ b/cmd/stores.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/tayloree/publix-deals/internal/api" + "github.com/tayloree/publix-deals/internal/display" +) + +var storesCmd = &cobra.Command{ + Use: "stores", + Short: "List nearby Publix stores", + Long: "Find Publix stores near a zip code. Use this to discover store numbers for fetching deals.", + Example: ` pubcli stores --zip 33101 + pubcli stores -z 32801 --json`, + RunE: runStores, +} + +func init() { + rootCmd.AddCommand(storesCmd) +} + +func runStores(cmd *cobra.Command, _ []string) error { + if flagZip == "" { + return invalidArgsError( + "--zip is required for store lookup", + "pubcli stores --zip 33101", + "pubcli stores -z 33101 --json", + ) + } + + client := api.NewClient() + stores, err := client.FetchStores(cmd.Context(), flagZip, 5) + if err != nil { + return upstreamError("fetching stores", err) + } + if len(stores) == 0 { + return notFoundError( + fmt.Sprintf("no stores found near %s", flagZip), + "Try a nearby ZIP code.", + ) + } + + if flagJSON { + return display.PrintStoresJSON(cmd.OutOrStdout(), stores) + } + display.PrintStores(cmd.OutOrStdout(), stores, flagZip) + return nil +}