Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<resource>/<verb>.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/<resource>/<resource>.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/<resource>/<resource>.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

Expand Down
25 changes: 15 additions & 10 deletions cmd/alias/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,19 @@ 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() {
activityCmd.Flags().IntVar(&activityPage, "page", 1, "Page number (1-indexed)")
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 {
Expand Down Expand Up @@ -76,7 +78,7 @@ func runActivity(cmd *cobra.Command, args []string) error {
return output.PrintJSON(data)
}

printActivityTable(activities)
printActivityTable(activities, activityFields)
return nil
}

Expand All @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions cmd/alias/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions cmd/alias/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cmd/alias/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cmd/alias/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions cmd/alias/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
26 changes: 20 additions & 6 deletions cmd/alias/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ var (
listAll bool
listJSON bool
listJQ string
listFields string
)

func init() {
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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),
Expand All @@ -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()
}
1 change: 1 addition & 0 deletions cmd/alias/toggle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cmd/contact/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cmd/contact/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias-id>")
fmt.Println(id)
return nil
}
25 changes: 15 additions & 10 deletions cmd/contact/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,19 @@ 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() {
listCmd.Flags().IntVar(&listPage, "page", 1, "Page number (1-indexed)")
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 {
Expand Down Expand Up @@ -76,7 +78,7 @@ func runList(cmd *cobra.Command, args []string) error {
return output.PrintJSON(data)
}

printContactTable(contacts)
printContactTable(contacts, listFields)
return nil
}

Expand All @@ -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()
}
4 changes: 4 additions & 0 deletions cmd/domain/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions cmd/domain/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions cmd/domain/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading