diff --git a/AGENTS.md b/AGENTS.md index c30411d..8493517 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,16 +106,36 @@ if createJSON || createJQ != "" { **Self-documenting via `--help`.** Every command must have `Short`, `Long`, and `Example` fields. Delete commands must support `--yes` to skip confirmation and `--dry-run` to preview. +## AXI Compliance + +This CLI follows the [AXI (Agent eXperience Interface)](https://axi.md/) standard for agent-friendly CLIs. Two skills from the [`cli-dev` plugin](https://github.com/mexcool/claude-code-toolkit) are available for auditing and improving CLI design: + +- **`/cli-dev:axi`** — Deep AXI spec audit: token efficiency, `--fields`, truncation hints, content-first, contextual disclosure, structured errors, empty states +- **`/cli-dev:cursor-cli-dev`** — Practical checklist: non-interactive flags, `--dry-run`/`--yes`, idempotency, actionable errors, predictable structure, stdin/pipeline support + +Use `/cli-dev:axi` for spec-level audits and `/cli-dev:cursor-cli-dev` when designing new commands or reviewing CLI ergonomics. Key AXI patterns already implemented: + +- **Content first:** `sl alias`, `sl mailbox`, `sl domain` show live data (not help) when invoked with no subcommand +- **`--fields` flag:** All list commands accept `--fields id,email,...` for table column projection (uses `output.SelectColumns`/`FilterRow`) +- **Truncation with size hints:** `output.Truncate()` appends `... [N]` showing total original length +- **Contextual disclosure:** Mutation commands print a `Hint:` with a logical next-step command via `output.PrintHint()`; empty lists suggest creation commands; paginated lists hint at `--page N` or `--all` +- **Definitive empty states:** Every list command prints an explicit warning when results are empty +- **Structured JSON errors:** When `--json` is active and a command fails, a `{"error":"...","code":N}` envelope is emitted to stdout (handled in `main.go` via `cmd.ExecutedCmd()`) + +When adding or modifying commands, maintain these patterns. Run `/cli-dev:axi` periodically to check for regressions. + ## Adding a New Command 1. Create `cmd//.go` — define `var xxxCmd` with `Use`, `Short`, `Long`, `Example`, `Args`, `RunE` 2. Define flag vars at package level, register them in `init()` 3. In `RunE`: call `auth.GetAPIKey()`, then `api.NewClient(key, auth.GetAPIBase())` 4. Add `--json` (bool) and `--jq` (string) flags. Branch output on them. -5. If the API method doesn't exist yet, add it to `internal/api/client.go` returning `(typed, rawJSON, error)` -6. Register the command in `cmd//.go` via `Cmd.AddCommand(xxxCmd)` -7. Add an `Aliases` field if a short alias makes sense (`ls`, `rm`, `info`) -8. Run `go build ./...`, `go test ./...`, `go vet ./...` +5. For list commands: add `--fields` flag, use `output.SelectColumns`/`FilterRow` for table rendering, handle empty state with `output.PrintWarning` +6. For mutation commands: add `output.PrintHint(...)` after success with a contextual next-step command +7. If the API method doesn't exist yet, add it to `internal/api/client.go` returning `(typed, rawJSON, error)` +8. Register the command in `cmd//.go` via `Cmd.AddCommand(xxxCmd)` +9. Add an `Aliases` field if a short alias makes sense (`ls`, `rm`, `info`) +10. Run `go build ./...`, `go test ./...`, `go vet ./...` ## Non-Obvious Things diff --git a/cmd/alias/activity.go b/cmd/alias/activity.go index ee011ba..96e8cbf 100644 --- a/cmd/alias/activity.go +++ b/cmd/alias/activity.go @@ -37,10 +37,11 @@ Accepts either a numeric alias ID or the full alias email address.`, } var ( - activityPage int - activityAll bool - activityJSON bool - activityJQ string + activityPage int + activityAll bool + activityJSON bool + activityJQ string + activityFields string ) func init() { @@ -48,6 +49,7 @@ func init() { activityCmd.Flags().BoolVar(&activityAll, "all", false, "Fetch all pages") activityCmd.Flags().BoolVar(&activityJSON, "json", false, "Output as JSON") activityCmd.Flags().StringVar(&activityJQ, "jq", "", "Apply jq expression to JSON output") + activityCmd.Flags().StringVar(&activityFields, "fields", "", "Comma-separated columns to show (e.g. action,from,to)") } func runActivity(cmd *cobra.Command, args []string) error { @@ -76,7 +78,7 @@ func runActivity(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } - printActivityTable(activities) + printActivityTable(activities, activityFields) return nil } @@ -92,25 +94,28 @@ func runActivity(cmd *cobra.Command, args []string) error { return output.PrintJSON(rawJSON) } - printActivityTable(activities) + printActivityTable(activities, activityFields) return nil } -func printActivityTable(activities []api.Activity) { +func printActivityTable(activities []api.Activity, fields string) { if len(activities) == 0 { output.PrintWarning("No activities found") return } - table := output.NewTable(os.Stdout, []string{"Action", "From", "To", "Time"}) + headers := []string{"Action", "From", "To", "Time"} + indices := output.SelectColumns(headers, fields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, a := range activities { ts := time.Unix(a.Timestamp, 0).Format("2006-01-02 15:04") - table.Append([]string{ + row := []string{ a.Action, output.Truncate(a.From, 40), output.Truncate(a.To, 40), ts, - }) + } + table.Append(output.FilterRow(row, indices)) } table.Render() fmt.Fprintf(os.Stderr, "\n%d activities shown\n", len(activities)) diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go index d604e98..3f55685 100644 --- a/cmd/alias/alias.go +++ b/cmd/alias/alias.go @@ -5,6 +5,7 @@ import ( ) // Cmd is the alias parent command. +// Running "sl alias" with no subcommand lists aliases (content-first). var Cmd = &cobra.Command{ Use: "alias", Short: "Manage email aliases", @@ -17,6 +18,9 @@ prefix and domain. Most commands accept either an alias ID (integer) or the full alias email address as an identifier.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd, args) + }, } func init() { diff --git a/cmd/alias/create.go b/cmd/alias/create.go index f412aa3..eff652b 100644 --- a/cmd/alias/create.go +++ b/cmd/alias/create.go @@ -109,6 +109,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Created alias: %s", alias.Email) + output.PrintHint("sl alias view %d", alias.ID) fmt.Println(alias.Email) return nil } @@ -200,6 +201,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Created alias: %s", alias.Email) + output.PrintHint("sl alias view %d", alias.ID) fmt.Println(alias.Email) return nil } diff --git a/cmd/alias/delete.go b/cmd/alias/delete.go index 2a064fb..1fa1968 100644 --- a/cmd/alias/delete.go +++ b/cmd/alias/delete.go @@ -107,6 +107,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } output.PrintSuccess("Alias deleted") + output.PrintHint("sl alias list") fmt.Println(id) return nil } diff --git a/cmd/alias/edit.go b/cmd/alias/edit.go index f47d241..adc8b29 100644 --- a/cmd/alias/edit.go +++ b/cmd/alias/edit.go @@ -102,6 +102,7 @@ func runEdit(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Alias updated") + output.PrintHint("sl alias view %s", args[0]) if editJQ != "" || editJSON { _, rawJSON, err := client.GetAlias(id) diff --git a/cmd/alias/enable.go b/cmd/alias/enable.go index c226912..89ad4cb 100644 --- a/cmd/alias/enable.go +++ b/cmd/alias/enable.go @@ -77,6 +77,7 @@ func setAliasState(idOrEmail string, wantEnabled bool, jsonFlag bool, jqExpr str return output.PrintJSON(rawJSON) } output.PrintSuccess("Alias %s is already %s", idOrEmail, verb) + output.PrintHint("sl alias view %s", idOrEmail) fmt.Printf("enabled=%v\n", alias.Enabled) return nil } @@ -99,6 +100,7 @@ func setAliasState(idOrEmail string, wantEnabled bool, jsonFlag bool, jqExpr str } else { output.PrintWarning("Alias %s is now disabled", idOrEmail) } + output.PrintHint("sl alias view %s", idOrEmail) fmt.Printf("enabled=%v\n", enabled) return nil } diff --git a/cmd/alias/list.go b/cmd/alias/list.go index 7a607d0..f8521e1 100644 --- a/cmd/alias/list.go +++ b/cmd/alias/list.go @@ -50,6 +50,7 @@ var ( listAll bool listJSON bool listJQ string + listFields string ) func init() { @@ -61,6 +62,7 @@ func init() { listCmd.Flags().BoolVar(&listAll, "all", false, "Fetch all pages") listCmd.Flags().BoolVar(&listJSON, "json", false, "Output as JSON") listCmd.Flags().StringVar(&listJQ, "jq", "", "Apply jq expression to JSON output") + listCmd.Flags().StringVar(&listFields, "fields", "", "Comma-separated columns to show (e.g. id,email,status)") } func runList(cmd *cobra.Command, args []string) error { @@ -85,7 +87,7 @@ func runList(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } - printAliasTable(aliases) + printAliasTable(aliases, listFields) output.PrintSuccess("\nTotal: %d aliases", len(aliases)) return nil } @@ -102,17 +104,28 @@ func runList(cmd *cobra.Command, args []string) error { return output.PrintJSON(rawJSON) } - printAliasTable(aliases) + printAliasTable(aliases, listFields) output.PrintSuccess("\nPage %d, %d aliases shown", listPage, len(aliases)) + if len(aliases) == 20 { + output.PrintHint("sl alias list --page %d or sl alias list --all", listPage+1) + } return nil } -func printAliasTable(aliases []api.Alias) { - table := output.NewTable(os.Stdout, []string{"ID", "Email", "Status", "Fwd", "Block", "Reply", "Pinned", "Note"}) +func printAliasTable(aliases []api.Alias, fields string) { + if len(aliases) == 0 { + output.PrintWarning("No aliases found") + output.PrintHint("sl alias create --random") + return + } + + headers := []string{"ID", "Email", "Status", "Fwd", "Block", "Reply", "Pinned", "Note"} + indices := output.SelectColumns(headers, fields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, a := range aliases { note := output.StringOrEmpty(a.Note) note = output.Truncate(note, 30) - table.Append([]string{ + row := []string{ strconv.Itoa(a.ID), a.Email, output.EnabledStatus(a.Enabled), @@ -121,7 +134,8 @@ func printAliasTable(aliases []api.Alias) { fmt.Sprintf("%d", a.NbReply), output.BoolToStatus(a.Pinned), note, - }) + } + table.Append(output.FilterRow(row, indices)) } table.Render() } diff --git a/cmd/alias/toggle.go b/cmd/alias/toggle.go index 5b433b3..01d4810 100644 --- a/cmd/alias/toggle.go +++ b/cmd/alias/toggle.go @@ -70,6 +70,7 @@ func runToggle(cmd *cobra.Command, args []string) error { } else { output.PrintWarning("Alias %s is now disabled", args[0]) } + output.PrintHint("sl alias view %s", args[0]) fmt.Printf("enabled=%v\n", enabled) return nil } diff --git a/cmd/contact/add.go b/cmd/contact/add.go index 20eabc7..9eb3e42 100644 --- a/cmd/contact/add.go +++ b/cmd/contact/add.go @@ -75,6 +75,7 @@ func runAdd(cmd *cobra.Command, args []string) error { } else { output.PrintSuccess("Contact added") } + output.PrintHint("sl contact list %s", args[0]) fmt.Printf("Reverse alias: %s\n", contact.ReverseAliasAddress) return nil } diff --git a/cmd/contact/delete.go b/cmd/contact/delete.go index 5b94b0f..5ffca94 100644 --- a/cmd/contact/delete.go +++ b/cmd/contact/delete.go @@ -100,6 +100,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } output.PrintSuccess("Contact deleted") + output.PrintHint("sl contact list ") fmt.Println(id) return nil } diff --git a/cmd/contact/list.go b/cmd/contact/list.go index 035e2af..5b5a234 100644 --- a/cmd/contact/list.go +++ b/cmd/contact/list.go @@ -37,10 +37,11 @@ Accepts either a numeric alias ID or the full alias email address.`, } var ( - listPage int - listAll bool - listJSON bool - listJQ string + listPage int + listAll bool + listJSON bool + listJQ string + listFields string ) func init() { @@ -48,6 +49,7 @@ func init() { listCmd.Flags().BoolVar(&listAll, "all", false, "Fetch all pages") listCmd.Flags().BoolVar(&listJSON, "json", false, "Output as JSON") listCmd.Flags().StringVar(&listJQ, "jq", "", "Apply jq expression to JSON output") + listCmd.Flags().StringVar(&listFields, "fields", "", "Comma-separated columns to show (e.g. id,contact,blocked)") } func runList(cmd *cobra.Command, args []string) error { @@ -76,7 +78,7 @@ func runList(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } - printContactTable(contacts) + printContactTable(contacts, listFields) return nil } @@ -92,25 +94,28 @@ func runList(cmd *cobra.Command, args []string) error { return output.PrintJSON(rawJSON) } - printContactTable(contacts) + printContactTable(contacts, listFields) return nil } -func printContactTable(contacts []api.Contact) { +func printContactTable(contacts []api.Contact, fields string) { if len(contacts) == 0 { output.PrintWarning("No contacts found") return } - table := output.NewTable(os.Stdout, []string{"ID", "Contact", "Reverse Alias", "Blocked", "Created"}) + headers := []string{"ID", "Contact", "Reverse Alias", "Blocked", "Created"} + indices := output.SelectColumns(headers, fields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, c := range contacts { - table.Append([]string{ + row := []string{ fmt.Sprintf("%d", c.ID), c.Contact, output.Truncate(c.ReverseAliasAddress, 50), output.BoolToStatus(c.BlockForward), c.CreationDate, - }) + } + table.Append(output.FilterRow(row, indices)) } table.Render() } diff --git a/cmd/domain/domain.go b/cmd/domain/domain.go index 0f03ef3..4b3c68d 100644 --- a/cmd/domain/domain.go +++ b/cmd/domain/domain.go @@ -5,6 +5,7 @@ import ( ) // Cmd is the domain parent command. +// Running "sl domain" with no subcommand lists domains (content-first). var Cmd = &cobra.Command{ Use: "domain", Short: "Manage custom domains", @@ -15,6 +16,9 @@ You can configure catch-all, random prefix generation, and manage which mailboxes receive emails for the domain. Custom domains require a premium SimpleLogin subscription.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd, args) + }, } func init() { diff --git a/cmd/domain/edit.go b/cmd/domain/edit.go index d96f0fa..95282c8 100644 --- a/cmd/domain/edit.go +++ b/cmd/domain/edit.go @@ -118,6 +118,7 @@ func runEdit(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Domain updated") + output.PrintHint("sl domain view %s", args[0]) if editJQ != "" || editJSON { _, rawJSON, err := client.GetCustomDomain(id) diff --git a/cmd/domain/list.go b/cmd/domain/list.go index c103143..f40e2e9 100644 --- a/cmd/domain/list.go +++ b/cmd/domain/list.go @@ -30,13 +30,15 @@ and random prefix generation setting.`, } var ( - listJSON bool - listJQ string + listJSON bool + listJQ string + listFields string ) func init() { listCmd.Flags().BoolVar(&listJSON, "json", false, "Output as JSON") listCmd.Flags().StringVar(&listJQ, "jq", "", "Apply jq expression to JSON output") + listCmd.Flags().StringVar(&listFields, "fields", "", "Comma-separated columns to show (e.g. id,domain,verified)") } func runList(cmd *cobra.Command, args []string) error { @@ -63,16 +65,19 @@ func runList(cmd *cobra.Command, args []string) error { return nil } - table := output.NewTable(os.Stdout, []string{"ID", "Domain", "Verified", "Catch-All", "Aliases", "Random Prefix"}) + headers := []string{"ID", "Domain", "Verified", "Catch-All", "Aliases", "Random Prefix"} + indices := output.SelectColumns(headers, listFields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, d := range domains { - table.Append([]string{ + row := []string{ fmt.Sprintf("%d", d.ID), d.DomainName, output.BoolToStatus(d.Verified), output.BoolToStatus(d.CatchAll), fmt.Sprintf("%d", d.NbAlias), output.BoolToStatus(d.RandomPrefixGeneration), - }) + } + table.Append(output.FilterRow(row, indices)) } table.Render() return nil diff --git a/cmd/domain/trash.go b/cmd/domain/trash.go index 3c60085..0d176b0 100644 --- a/cmd/domain/trash.go +++ b/cmd/domain/trash.go @@ -29,13 +29,15 @@ be recovered by contacting SimpleLogin support.`, } var ( - trashJSON bool - trashJQ string + trashJSON bool + trashJQ string + trashFields string ) func init() { trashCmd.Flags().BoolVar(&trashJSON, "json", false, "Output as JSON") trashCmd.Flags().StringVar(&trashJQ, "jq", "", "Apply jq expression to JSON output") + trashCmd.Flags().StringVar(&trashFields, "fields", "", "Comma-separated columns to show (e.g. alias,deleted)") } func runTrash(cmd *cobra.Command, args []string) error { @@ -67,9 +69,12 @@ func runTrash(cmd *cobra.Command, args []string) error { return nil } - table := output.NewTable(os.Stdout, []string{"Alias", "Deleted"}) + headers := []string{"Alias", "Deleted"} + indices := output.SelectColumns(headers, trashFields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, a := range aliases { - table.Append([]string{a.Alias, a.DeletionDate}) + row := []string{a.Alias, a.DeletionDate} + table.Append(output.FilterRow(row, indices)) } table.Render() return nil diff --git a/cmd/mailbox/add.go b/cmd/mailbox/add.go index 6f3fda7..3b96228 100644 --- a/cmd/mailbox/add.go +++ b/cmd/mailbox/add.go @@ -59,6 +59,7 @@ func runAdd(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Mailbox added. Check %s for a verification email.", args[0]) + output.PrintHint("sl mailbox list") fmt.Println(mailbox.ID) return nil } diff --git a/cmd/mailbox/delete.go b/cmd/mailbox/delete.go index 6aba454..de83361 100644 --- a/cmd/mailbox/delete.go +++ b/cmd/mailbox/delete.go @@ -130,6 +130,7 @@ func runDelete(cmd *cobra.Command, args []string) error { return output.PrintJSON(data) } output.PrintSuccess("Mailbox deleted") + output.PrintHint("sl mailbox list") fmt.Println(id) return nil } diff --git a/cmd/mailbox/edit.go b/cmd/mailbox/edit.go index fd0d234..f516905 100644 --- a/cmd/mailbox/edit.go +++ b/cmd/mailbox/edit.go @@ -92,6 +92,7 @@ func runEdit(cmd *cobra.Command, args []string) error { } output.PrintSuccess("Mailbox updated") + output.PrintHint("sl mailbox list") if editJQ != "" || editJSON { mailboxes, _, err := client.ListMailboxes() diff --git a/cmd/mailbox/list.go b/cmd/mailbox/list.go index ff11741..8a66b24 100644 --- a/cmd/mailbox/list.go +++ b/cmd/mailbox/list.go @@ -30,13 +30,15 @@ and the number of aliases assigned to each mailbox.`, } var ( - listJSON bool - listJQ string + listJSON bool + listJQ string + listFields string ) func init() { listCmd.Flags().BoolVar(&listJSON, "json", false, "Output as JSON") listCmd.Flags().StringVar(&listJQ, "jq", "", "Apply jq expression to JSON output") + listCmd.Flags().StringVar(&listFields, "fields", "", "Comma-separated columns to show (e.g. id,email,verified)") } func runList(cmd *cobra.Command, args []string) error { @@ -58,15 +60,24 @@ func runList(cmd *cobra.Command, args []string) error { return output.PrintJSON(rawJSON) } - table := output.NewTable(os.Stdout, []string{"ID", "Email", "Verified", "Default", "Aliases"}) + if len(mailboxes) == 0 { + output.PrintWarning("No mailboxes found") + output.PrintHint("sl mailbox add ") + return nil + } + + headers := []string{"ID", "Email", "Verified", "Default", "Aliases"} + indices := output.SelectColumns(headers, listFields) + table := output.NewTable(os.Stdout, output.FilterRow(headers, indices)) for _, m := range mailboxes { - table.Append([]string{ + row := []string{ fmt.Sprintf("%d", m.ID), m.Email, output.BoolToStatus(m.Verified), output.BoolToStatus(m.Default), fmt.Sprintf("%d", m.NbAlias), - }) + } + table.Append(output.FilterRow(row, indices)) } table.Render() return nil diff --git a/cmd/mailbox/mailbox.go b/cmd/mailbox/mailbox.go index 02fa5fe..c6516bc 100644 --- a/cmd/mailbox/mailbox.go +++ b/cmd/mailbox/mailbox.go @@ -5,6 +5,7 @@ import ( ) // Cmd is the mailbox parent command. +// Running "sl mailbox" with no subcommand lists mailboxes (content-first). var Cmd = &cobra.Command{ Use: "mailbox", Short: "Manage mailboxes", @@ -17,6 +18,9 @@ aliases to different mailboxes. Each mailbox must be verified before it can receive forwarded emails. One mailbox is marked as the default, which is used for new aliases when no mailbox is explicitly specified.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd, args) + }, } func init() { diff --git a/cmd/root.go b/cmd/root.go index 28f19d8..2e5f05d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,10 @@ import ( var verbose bool +// executedCmd captures the command that ran, for post-error inspection (e.g., +// checking whether --json was active when an error occurred). +var executedCmd *cobra.Command + var rootCmd = &cobra.Command{ Use: "sl", Short: "SimpleLogin CLI - manage email aliases from the terminal", @@ -38,14 +42,22 @@ Authentication: Output: By default, commands display colored table output. Use --json for machine-readable JSON output, or --jq to filter JSON with jq expressions.`, - SilenceUsage: true, + SilenceUsage: true, + SilenceErrors: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { + executedCmd = cmd if verbose || os.Getenv("SL_VERBOSE") == "1" || os.Getenv("SL_DEBUG") == "1" { api.Verbose = true } }, } +// ExecutedCmd returns the command that was actually executed, or nil if +// execution failed before PersistentPreRun (e.g., flag/arg validation). +func ExecutedCmd() *cobra.Command { + return executedCmd +} + // SetVersionInfo sets the version string shown by --version, including // optional build metadata (commit hash and build date). func SetVersionInfo(v, commit, date string) { diff --git a/cmd/sl/main.go b/cmd/sl/main.go index 7812699..04d3145 100644 --- a/cmd/sl/main.go +++ b/cmd/sl/main.go @@ -1,11 +1,14 @@ package main import ( + "encoding/json" + "fmt" "os" "runtime/debug" "strings" "github.com/mexcool/simplelogin-cli/cmd" + "github.com/mexcool/simplelogin-cli/internal/output" ) // These variables are set at build time via ldflags. @@ -53,6 +56,22 @@ func main() { strings.Contains(errMsg, "invalid argument") { exitCode = 2 } + + // If the executed command had --json active, emit a structured + // JSON error envelope to stdout so agents can parse it. + if executed := cmd.ExecutedCmd(); executed != nil { + if f := executed.Flags().Lookup("json"); f != nil && f.Value.String() == "true" { + envelope, _ := json.Marshal(map[string]interface{}{ + "error": errMsg, + "code": exitCode, + }) + fmt.Fprintln(os.Stdout, string(envelope)) + os.Exit(exitCode) + } + } + + // SilenceErrors is on, so we print the error ourselves. + output.PrintError("%s", errMsg) os.Exit(exitCode) } } diff --git a/internal/output/output.go b/internal/output/output.go index cdfee43..5287a86 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -18,6 +18,7 @@ var ( Yellow = color.New(color.FgYellow) Bold = color.New(color.Bold) Cyan = color.New(color.FgCyan) + Dim = color.New(color.Faint) ) // PrintError prints an error message to stderr in red. @@ -35,6 +36,11 @@ func PrintWarning(format string, a ...interface{}) { Yellow.Fprintf(os.Stderr, format+"\n", a...) } +// PrintHint prints a contextual next-step hint to stderr in dim text. +func PrintHint(format string, a ...interface{}) { + Dim.Fprintf(os.Stderr, "Hint: "+format+"\n", a...) +} + // PrintJSON pretty-prints JSON data to stdout. func PrintJSON(data []byte) error { var obj interface{} @@ -199,7 +205,9 @@ func StringOrEmpty(s *string) string { return *s } -// Truncate truncates a string to maxLen characters. +// Truncate truncates a string to maxLen characters, appending a size hint +// showing the original total length (e.g., "prefix... [42]"). +// Falls back to plain "..." when maxLen is too small for the hint. func Truncate(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -207,9 +215,82 @@ func Truncate(s string, maxLen int) string { if maxLen < 4 { return s[:maxLen] } + // Try to fit "... [total]" suffix + suffix := fmt.Sprintf("... [%d]", len(s)) + if len(suffix)+1 <= maxLen { + return s[:maxLen-len(suffix)] + suffix + } + // maxLen too small for size hint — plain truncation return s[:maxLen-3] + "..." } +// SelectColumns returns the column indices matching a comma-separated fields +// string. If fields is empty, all indices are returned. Matching is +// case-insensitive and normalizes spaces to hyphens (e.g., "Reverse Alias" +// matches "reverse-alias"). Unrecognized field names are reported via +// PrintWarning and skipped. +func SelectColumns(headers []string, fields string) []int { + if fields == "" { + indices := make([]int, len(headers)) + for i := range headers { + indices[i] = i + } + return indices + } + + normalize := func(s string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), " ", "-")) + } + + headerMap := make(map[string]int, len(headers)) + for i, h := range headers { + headerMap[normalize(h)] = i + } + + var indices []int + var unknown []string + for _, f := range strings.Split(fields, ",") { + f = normalize(f) + if f == "" { + continue + } + if idx, ok := headerMap[f]; ok { + indices = append(indices, idx) + } else { + unknown = append(unknown, f) + } + } + + if len(unknown) > 0 { + available := make([]string, len(headers)) + for i, h := range headers { + available[i] = normalize(h) + } + PrintWarning("Unknown fields: %s (available: %s)", strings.Join(unknown, ", "), strings.Join(available, ", ")) + } + + if len(indices) == 0 { + // All fields invalid — show all columns as fallback + indices = make([]int, len(headers)) + for i := range headers { + indices[i] = i + } + } + + return indices +} + +// FilterRow returns only the elements at the given column indices. +func FilterRow(row []string, indices []int) []string { + out := make([]string, len(indices)) + for i, idx := range indices { + if idx < len(row) { + out[i] = row[idx] + } + } + return out +} + // IsInteractive reports whether stdin is an interactive terminal. func IsInteractive() bool { return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) diff --git a/internal/output/output_test.go b/internal/output/output_test.go index b2913fd..bf1156e 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -68,13 +68,17 @@ func TestTruncate(t *testing.T) { }{ {"short", 10, "short"}, {"exact", 5, "exact"}, - {"hello world, this is long", 10, "hello w..."}, + // Size hint fits: "... [25]" = 8 chars, leaves 2 chars of prefix + {"hello world, this is long", 10, "he... [25]"}, {"abc", 3, "abc"}, + // maxLen=5, suffix "... [6]" = 7 chars > 5, falls back to plain "..." {"abcdef", 5, "ab..."}, {"hello", 0, ""}, {"hello", 1, "h"}, {"hello", 2, "he"}, {"hello", 3, "hel"}, + // Size hint with larger maxLen + {"this is a much longer note that should be truncated", 30, "this is a much longer ... [51]"}, } for _, tt := range tests { got := Truncate(tt.input, tt.maxLen) @@ -306,6 +310,63 @@ func TestPrintJQ_VerifyOutput_Number(t *testing.T) { } } +// --------------------------------------------------------------------------- +// SelectColumns / FilterRow +// --------------------------------------------------------------------------- + +func TestSelectColumns_Empty(t *testing.T) { + headers := []string{"ID", "Email", "Status"} + got := SelectColumns(headers, "") + want := []int{0, 1, 2} + if len(got) != len(want) { + t.Fatalf("SelectColumns(empty) = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("SelectColumns(empty)[%d] = %d, want %d", i, got[i], want[i]) + } + } +} + +func TestSelectColumns_Subset(t *testing.T) { + headers := []string{"ID", "Email", "Status", "Note"} + got := SelectColumns(headers, "id,note") + want := []int{0, 3} + if len(got) != len(want) { + t.Fatalf("SelectColumns = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("SelectColumns[%d] = %d, want %d", i, got[i], want[i]) + } + } +} + +func TestSelectColumns_CaseInsensitive(t *testing.T) { + headers := []string{"Reverse Alias", "Blocked"} + got := SelectColumns(headers, "reverse-alias") + if len(got) != 1 || got[0] != 0 { + t.Errorf("SelectColumns(reverse-alias) = %v, want [0]", got) + } +} + +func TestSelectColumns_AllInvalid(t *testing.T) { + headers := []string{"ID", "Email"} + got := SelectColumns(headers, "bogus,nope") + // Should fall back to all columns + if len(got) != 2 { + t.Errorf("SelectColumns(all invalid) returned %d indices, want 2 (fallback)", len(got)) + } +} + +func TestFilterRow(t *testing.T) { + row := []string{"1", "test@example.com", "enabled", "my note"} + got := FilterRow(row, []int{0, 3}) + if len(got) != 2 || got[0] != "1" || got[1] != "my note" { + t.Errorf("FilterRow = %v, want [1, my note]", got) + } +} + // --------------------------------------------------------------------------- // Truncate - edge cases // --------------------------------------------------------------------------- @@ -316,27 +377,19 @@ func TestTruncate_EdgeCases(t *testing.T) { input string maxLen int want string - panics bool }{ - {"maxLen=0", "hello", 0, "", false}, - {"maxLen=1", "hello", 1, "h", false}, - {"maxLen=2", "hello", 2, "he", false}, - {"maxLen=3", "hello", 3, "hel", false}, - {"maxLen=5_exact_length", "hello", 5, "hello", false}, - {"maxLen=10_longer_than_string", "hello", 10, "hello", false}, - {"empty_string_maxLen=5", "", 5, "", false}, + {"maxLen=0", "hello", 0, ""}, + {"maxLen=1", "hello", 1, "h"}, + {"maxLen=2", "hello", 2, "he"}, + {"maxLen=3", "hello", 3, "hel"}, + {"maxLen=5_exact_length", "hello", 5, "hello"}, + {"maxLen=10_longer_than_string", "hello", 10, "hello"}, + {"empty_string_maxLen=5", "", 5, ""}, + {"size_hint_fits", "abcdefghijklmnopqrstuvwxyz", 15, "abcdefg... [26]"}, + {"size_hint_boundary", "abcdefghij", 9, "a... [10]"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.panics { - // Truncate may currently panic for maxLen < 3 (issue #31). - // Guard with recover so the test suite stays green. - defer func() { - if r := recover(); r != nil { - t.Skipf("Truncate(%q, %d) panics (expected until #31 is fixed): %v", tt.input, tt.maxLen, r) - } - }() - } got := Truncate(tt.input, tt.maxLen) if got != tt.want { t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)