From 4ba7f008dc5bc61728eafa8227c283db55f8d971 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 09:47:40 -0400 Subject: [PATCH 1/2] Unify resource commands under singular nouns --- .surface | 27 ++ AGENTS.md | 4 +- API-COVERAGE.md | 24 +- README.md | 62 +++-- docs/omarchy.md | 6 +- internal/cmd/box.go | 57 ++-- internal/cmd/boxes.go | 38 ++- internal/cmd/bulk_reply.go | 2 +- internal/cmd/canonical_commands_test.go | 338 ++++++++++++++++++++++++ internal/cmd/clip.go | 37 ++- internal/cmd/collection.go | 102 ++++--- internal/cmd/commands.go | 38 ++- internal/cmd/help.go | 8 +- internal/cmd/help_test.go | 20 +- internal/cmd/ignore.go | 2 +- internal/cmd/label.go | 102 ++++--- internal/cmd/move.go | 2 +- internal/cmd/postings_listing.go | 2 +- internal/cmd/root.go | 11 + internal/cmd/seen.go | 4 +- internal/cmd/setup.go | 6 +- internal/cmd/setup_test.go | 2 +- internal/cmd/share.go | 4 +- internal/cmd/snippet.go | 37 ++- internal/cmd/spam.go | 2 +- internal/cmd/stop_ignoring.go | 2 +- internal/cmd/trash.go | 2 +- internal/cmd/watch.go | 2 +- internal/cmd/workflow.go | 106 +++++--- skills/hey/SKILL.md | 75 +++--- tests/smoke/canonical_commands_test.go | 82 ++++++ 31 files changed, 938 insertions(+), 268 deletions(-) create mode 100644 internal/cmd/canonical_commands_test.go create mode 100644 tests/smoke/canonical_commands_test.go diff --git a/.surface b/.surface index 44464090..09cd7001 100644 --- a/.surface +++ b/.surface @@ -35,6 +35,13 @@ hey box hey box --all hey box --limit hey box --page +hey box list +hey box list --all +hey box list --limit +hey box view +hey box view --all +hey box view --limit +hey box view --page hey boxes hey boxes --all hey boxes --limit @@ -49,6 +56,7 @@ hey clip hey clip create hey clip create --content hey clip delete +hey clip list hey clips hey collection hey collection --all @@ -58,11 +66,18 @@ hey collection add hey collection add --to hey collection create hey collection create --summary +hey collection list +hey collection list --all +hey collection list --limit hey collection remove hey collection remove --from hey collection update hey collection update --name hey collection update --summary +hey collection view +hey collection view --all +hey collection view --limit +hey collection view --page hey collections hey collections --all hey collections --limit @@ -145,8 +160,15 @@ hey label --page hey label add hey label add --to hey label create +hey label list +hey label list --all +hey label list --limit hey label remove hey label remove --from +hey label view +hey label view --all +hey label view --limit +hey label view --page hey labels hey labels --all hey labels --limit @@ -210,6 +232,7 @@ hey snippet create hey snippet create --content hey snippet create --name hey snippet delete +hey snippet list hey snippet update hey snippet update --content hey snippet update --name @@ -263,6 +286,9 @@ hey workflow add --stage hey workflow add --to hey workflow create hey workflow delete +hey workflow list +hey workflow list --all +hey workflow list --limit hey workflow move hey workflow move --to hey workflow move --workflow @@ -275,6 +301,7 @@ hey workflow stage update hey workflow stage update --name hey workflow update hey workflow update --name +hey workflow view hey workflows hey workflows --all hey workflows --limit diff --git a/AGENTS.md b/AGENTS.md index c6d5c7c2..b47eb261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -371,14 +371,14 @@ repeat or vanish. `readBox` therefore dispatches on `BoxKind` to `GetImbox`, `Ge and the rest, and only an unfamiliar kind falls through to `Boxes().Get`. **`Page.Cursor` is opaque and per-kind.** A label and a collection carry a geared_pagination -cursor; a box carries HEY's own `next_history_url`, which is what `hey box --json` reports +cursor; a box carries HEY's own `next_history_url`, which is what `hey box view --json` reports and is the only cursor the named routes hand out. The URL is never fetched: `historyPageCursor` takes the `page` parameter out of it and gives that to the typed operation, which is why there is no same-origin check here to get wrong. A foreign URL is refused for carrying no cursor rather than declined for pointing elsewhere. **`mail.Posting` is a row, not the JSON.** `Page.Postings` stays `[]generated.Posting` -because `hey box --json` publishes those fields verbatim; `mail.Postings` describes them as +because `hey box view --json` publishes those fields verbatim; `mail.Postings` describes them as the rows a reader acts on, and everything the TUI never reads — `bundled`, `entry_kind`, `kind`, `updated_at` — is left in the SDK type where the CLI can still reach it. `TopicID` is resolved once, out of `app_url`: HEY's `_posting.jbuilder` serves neither `topic` nor diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 77c21fc1..abd68baf 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -20,21 +20,21 @@ since `Topics().Get` already carries the entry list. | Endpoint | Method | Client | CLI Command | Status | |----------|--------|--------|-------------|--------| -| `/boxes.json` | GET | SDK `Boxes().List` | `hey boxes` | covered | -| `/boxes/{id}.json` | GET | SDK `Boxes().Get`, `Boxes().GetPage` | `hey box `, TUI mail list growth | covered | -| `/imbox.json` | GET | SDK `Boxes().GetImbox` | `hey box imbox` | covered | -| `/feedbox.json` | GET | SDK `Boxes().GetFeedbox` | `hey box feedbox` | covered | -| `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box trailbox` | covered | -| `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box asidebox` | covered | -| `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered | -| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered | -| `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey labels`, Mail TUI navigation | covered | -| `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label `, Mail TUI labels | covered | +| `/boxes.json` | GET | SDK `Boxes().List` | `hey box list` | covered | +| `/boxes/{id}.json` | GET | SDK `Boxes().Get`, `Boxes().GetPage` | `hey box view `, TUI mail list growth | covered | +| `/imbox.json` | GET | SDK `Boxes().GetImbox` | `hey box view imbox` | covered | +| `/feedbox.json` | GET | SDK `Boxes().GetFeedbox` | `hey box view feedbox` | covered | +| `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box view trailbox` | covered | +| `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box view asidebox` | covered | +| `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box view laterbox` | covered | +| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox` | covered | +| `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey label list`, Mail TUI navigation | covered | +| `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label view `, Mail TUI labels | covered | | `/postings/filings.json` | POST | SDK `Postings().File` | `hey label add`, TUI `b/B` | covered | | `/postings/filings.json` | DELETE | SDK `Postings().Unfile` | `hey label remove`, TUI `b/B` | covered | | `/postings/folders.json` | POST | SDK `Postings().CreateFolder` | `hey label create`, TUI `b/B` | covered | -| `/collections.json` | GET | SDK `Collections().List` | `hey collections`, Mail TUI navigation | covered | -| `/collections/{id}.json` | GET | SDK `Collections().GetPage` | `hey collection `, Mail TUI collections | covered | +| `/collections.json` | GET | SDK `Collections().List` | `hey collection list`, Mail TUI navigation | covered | +| `/collections/{id}.json` | GET | SDK `Collections().GetPage` | `hey collection view `, Mail TUI collections | covered | | `/collections` | POST | SDK `Collections().Create` | `hey collection create` | covered | | `/collections/{id}.json` | PATCH | SDK `Collections().Update` | `hey collection update` | covered | | `/topics/{id}/collecting` | POST | SDK `Collections().AddTopic` | `hey collection add`, TUI `n/N` | covered | diff --git a/README.md b/README.md index 8e5e188e..b1872622 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ connect your coding agents (Claude Code, Codex). After that, `hey tui` opens the bare `hey` prints the help. `hey setup` reruns the wizard any time; `hey login` and `hey logout` are shortcuts for `hey auth login` and `hey auth logout`. -Logged-out data commands at a terminal (`hey boxes`, say) offer to sign you in on the spot. +Logged-out data commands at a terminal (`hey box list`, say) offer to sign you in on the spot. Piped or `--json` runs never prompt: they fail with `Not logged in` (exit 3) so scripts and agents can handle it. @@ -274,18 +274,18 @@ Use `--base-url` to override the server URL and `--account ` to select a mail account. ```bash -hey boxes --jq '.data[] | {id, name}' -hey boxes --quiet --jq '.[].id' +hey box list --jq '.data[] | {id, name}' +hey box list --quiet --jq '.[].id' ``` Listing commands also answer `--markdown` for a table, `--styled` to force the human rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a -bare number. `--ids-only` and `--count` need list data, so they work on `hey boxes`, -`hey box`, `hey labels`, `hey label`, `hey collections`, `hey collection`, `hey workflows`, -`hey workflow`, `hey clips`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, +bare number. `--ids-only` and `--count` need list data, so they work on `hey box list`, +`hey box view`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, +`hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay -pipeable. `hey clips --ids-only` and `--count` cover the newest page only because the +pipeable. `hey clip list --ids-only` and `--count` cover the newest page only because the released SDK does not expose HEY's cursor for older clip pages. `--html` writes the original HTML, for the commands that hold some: `hey threads`, @@ -313,23 +313,31 @@ is what gets pasted into something else. ### Email +Resource commands use one noun-first family: `box`, `label`, `collection`, `workflow`, +`clip`, and `snippet`, with actions such as `list` and `view` underneath. The earlier +plural listing forms and direct detail forms remain supported for compatibility; `hey +commands --json` identifies each plural form with `compatibility_for` while the primary +help and examples show the canonical family. `list` and `view` are reserved action names +under `box`; a box with either display name is addressed explicitly (`hey box view list`) +or through the direct-form escape (`hey box -- list`). + ```bash -hey boxes # list mailboxes -hey box imbox # list email threads in a box (by name or ID) -hey labels # list labels and their IDs -hey label 789 --all # list all email threads with a label -hey label add 12345 --to 789 # add a label to a thread +hey box list # list mailboxes +hey box view imbox # list email threads in a box (by name or ID) +hey label list # list labels and their IDs +hey label view 789 --all # list all email threads with a label +hey label add 12345 --to 789 # add a label to a thread hey label create "Travel receipts" 12345 # create and add a label -hey label remove 12345 --from 789 # remove one label -hey label remove 12345 --from all # remove every label -hey collections # list collections and their IDs -hey collection 321 --all # list every thread in a collection +hey label remove 12345 --from 789 # remove one label +hey label remove 12345 --from all # remove every label +hey collection list # list collections and their IDs +hey collection view 321 --all # list every thread in a collection hey collection create "Kitchen remodel" --summary "Plans and decisions" hey collection update 321 --name "Kitchen renovation" hey collection add 987 --to 321 # add a topic ID to a collection hey collection remove 987 --from 321 # remove a topic ID from a collection -hey workflows # list workflows, account IDs, and workflow IDs -hey workflow 654 # list a workflow's stages and stage IDs +hey workflow list # list workflows, account IDs, and workflow IDs +hey workflow view 654 # list a workflow's stages and stage IDs hey workflow create "Hiring" --account 12345 hey workflow update 654 --name "Recruiting" hey workflow stage create 654 # add an Untitled stage @@ -337,10 +345,10 @@ hey workflow stage update 654 321 --name "Interviewing" hey workflow add 987 --to 654 --stage 321 # add a topic ID to a stage hey workflow move 987 --workflow 654 --to 322 # move it to another stage hey workflow remove 987 --from 654 # remove it from the workflow -hey clips # newest page of saved passages and source context +hey clip list # newest page of saved passages and source context hey clip create 456 --content "The launch moves to Wednesday." hey clip delete 44 -hey snippets # list reusable email snippets +hey snippet list # list reusable email snippets hey snippet create --name "Scheduling reply" --content "Tuesday works for me." hey snippet update 44 --content "Wednesday works for me." hey snippet delete 44 @@ -409,17 +417,17 @@ The Screener is where first-time senders wait. `hey screener list` returns clear `--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachments ` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. -Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box --json`, `hey label --json`, `hey collection --json` and `hey search --json` all carry alongside `id`. `hey box` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. +Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. -Collection IDs come from `hey collections`. `hey collection` returns both each posting `id` and its `topic_id`, plus `next_page` and `total_count`. Collection membership commands take `topic_id`; posting organization commands continue to take `id`. Creating a collection returns a confirmed mutation, and `hey collections` provides its ID for subsequent commands. Collection updates accept a non-empty name, summary, or both. +Collection IDs come from `hey collection list`. `hey collection view` returns both each posting `id` and its `topic_id`, plus `next_page` and `total_count`. Collection membership commands take `topic_id`; posting organization commands continue to take `id`. Creating a collection returns a confirmed mutation, and `hey collection list` provides its ID for subsequent commands. Collection updates accept a non-empty name, summary, or both. -Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. +Workflow IDs come from `hey workflow list`, which includes the linked account ID for each workflow. `hey workflow view ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow view `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. -Clips are passages saved from existing email entries. `hey clips` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. HEY assigns a created clip to its source entry's account and resolves deletion by identity-owned clip ID across linked accounts; `--account` selects list presentation. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. +Clips are passages saved from existing email entries. `hey clip list` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. HEY assigns a created clip to its source entry's account and resolves deletion by identity-owned clip ID across linked accounts; `--account` selects list presentation. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. -Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. +Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippet list` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. -`hey box `, `hey label ` and `hey collection ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. +`hey box view `, `hey label view ` and `hey collection view ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. @@ -556,7 +564,7 @@ hey setup omarchy --remove # take it all out again The bar gets the [HEY plugin](https://github.com/basecamp/omarchy-hey-plugin): the HEY logo lights when the Imbox has unseen mail and opens a panel of recent threads. hey-cli is -its engine — the plugin reads the Imbox with `hey box imbox` and runs `hey watch` so the +its engine — the plugin reads the Imbox with `hey box view imbox` and runs `hey watch` so the bar is live: a thread you archive in the TUI, on your phone or in the web app leaves the panel within a second, and after a disconnect the watch catches up from where it left off. diff --git a/docs/omarchy.md b/docs/omarchy.md index c4be4974..9e3b57a6 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -90,7 +90,7 @@ the current defaults, the layout goes too and the user is back to inheriting the face: the HEY logo in the bar (tinted when there is unseen mail), the panel with its account switcher, `New for you` / `Previously seen` tabs, the Screener count, mark-as-seen, and the setup flow that installs `hey-cli` from the AUR and signs you in. hey-cli is the -engine, and the plugin composes its generic commands: `hey box imbox --json` for the Imbox, +engine, and the plugin composes its generic commands: `hey box view imbox --json` for the Imbox, `hey watch` to know when to read it again, `hey screener list --count` for the Screener, `hey seen` for marking, `hey accounts list` and `hey auth status` for the rest. The division was settled when both sides turned out to have built a bar indicator without @@ -100,7 +100,7 @@ and the answer was: **the CLI is the engine, the plugin is the face.** The plugin is an Omarchy `service` plugin as well as a bar widget. The shell instantiates the service once, so one `hey watch` runs per shell however many monitors carry the bar, and every bar widget reads the shared service. A watch event is a wake-up, not a delta — -any line on the watch's stdout re-reads `hey box imbox`, debounced so a burst of changes +any line on the watch's stdout re-reads `hey box view imbox`, debounced so a burst of changes costs one read (plus one follow-up when changes land while a read is in flight, since that read may predate them). `hey watch` says `ready` once every box is caught up and its subscription is live, and again after every reconnect's catch-up; the plugin's read on that @@ -201,7 +201,7 @@ new --run-async 'notify-send -a HEY "New mail in HEY"'` is the one-liner. is the cautionary precedent. - **Complement the shipped web app, never replace it.** Distinct desktop name, printed keybinding, the mailto handler left alone. -- **No HTML scraping to feed widgets.** The plugin reads with `hey box imbox`, the same +- **No HTML scraping to feed widgets.** The plugin reads with `hey box view imbox`, the same typed SDK read as everyone else. - **CLI is the engine, plugin is the face.** The plugin owns rendering and settings — the toast included; the CLI owns what needs HEY's semantics — the changes cursor and catching diff --git a/internal/cmd/box.go b/internal/cmd/box.go index bf096717..5511d130 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -46,27 +46,52 @@ var boxListing = postingsListing{ } func newBoxCommand() *boxCommand { - boxCommand := &boxCommand{} - boxCommand.cmd = &cobra.Command{ - Use: "box ", - Short: "List email threads in a box", - Long: "List email threads in a HEY box. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", + command := newBoxReaderCommand( + "box ", + "List HEY boxes and their email threads", + "List HEY boxes or list email threads in one box.", + ` hey box list + hey box view imbox + hey box view imbox --limit 10 + hey box view 123 --json`, + ) + command.cmd.Annotations[compatibilityUsageAnnotation] = "box " + command.cmd.AddCommand(newBoxListCommand().cmd) + command.cmd.AddCommand(newBoxViewCommand().cmd) + return command +} + +func newBoxViewCommand() *boxCommand { + return newBoxReaderCommand( + "view ", + "List email threads in a box", + "List email threads in a HEY box. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", + ` hey box view imbox + hey box view imbox --limit 10 + hey box view imbox --page next-cursor + hey box view 123 --json`, + ) +} + +func newBoxReaderCommand(use, short, long, example string) *boxCommand { + command := &boxCommand{} + command.cmd = &cobra.Command{ + Use: use, + Short: short, + Long: long, Annotations: map[string]string{ "agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey threads, reply, and forward; use id with seen, unseen, and move. --page continues from the next_page cursor of an earlier listing of the same box.", }, - Example: ` hey box imbox - hey box imbox --limit 10 - hey box imbox --page next-cursor - hey box 123 --json`, - RunE: boxCommand.run, - Args: validateBoxArgs, + Example: example, + RunE: command.run, + Args: validateBoxArgs, } - boxCommand.cmd.Flags().IntVar(&boxCommand.limit, "limit", 0, "Maximum number of threads to show") - boxCommand.cmd.Flags().BoolVar(&boxCommand.all, "all", false, "Fetch all results (override --limit)") - boxCommand.cmd.Flags().StringVar(&boxCommand.page, "page", "", "Continue from a next_page cursor") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor") - return boxCommand + return command } func validateBoxArgs(cmd *cobra.Command, args []string) error { @@ -74,7 +99,7 @@ func validateBoxArgs(cmd *cobra.Command, args []string) error { case 1: return nil case 0: - return usageErrorf("%s (example: hey box imbox)", cmd.CommandPath()) + return usageErrorf("%s (example: hey box view imbox)", cmd.CommandPath()) default: return fmt.Errorf("expected 1 mailbox argument, got %d", len(args)) } diff --git a/internal/cmd/boxes.go b/internal/cmd/boxes.go index 99dbd4f5..5514137c 100644 --- a/internal/cmd/boxes.go +++ b/internal/cmd/boxes.go @@ -18,23 +18,37 @@ type boxesCommand struct { } func newBoxesCommand() *boxesCommand { - boxesCommand := &boxesCommand{} - boxesCommand.cmd = &cobra.Command{ - Use: "boxes", + command := newBoxesListingCommand("boxes", ` hey boxes + hey boxes --limit 5 + hey boxes --json`) + command.cmd.Annotations[compatibilityForAnnotation] = "box list" + return command +} + +func newBoxListCommand() *boxesCommand { + command := newBoxesListingCommand("list", ` hey box list + hey box list --limit 5 + hey box list --json`) + command.cmd.Args = cobra.NoArgs + return command +} + +func newBoxesListingCommand(use, example string) *boxesCommand { + command := &boxesCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List your HEY boxes", Annotations: map[string]string{ - "agent_notes": "Returns all mailbox types. Use --ids-only to pipe IDs to hey box.", + "agent_notes": "Returns all mailbox types. Use --ids-only to pipe IDs to hey box view.", }, - Example: ` hey boxes - hey boxes --limit 5 - hey boxes --json`, - RunE: boxesCommand.run, + Example: example, + RunE: command.run, } - boxesCommand.cmd.Flags().IntVar(&boxesCommand.limit, "limit", 0, "Maximum number of boxes to show") - boxesCommand.cmd.Flags().BoolVar(&boxesCommand.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of boxes to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") - return boxesCommand + return command } func (c *boxesCommand) run(cmd *cobra.Command, args []string) error { @@ -76,7 +90,7 @@ func (c *boxesCommand) run(cmd *cobra.Command, args []string) error { output.WithNotice(notice), output.WithBreadcrumbs(output.Breadcrumb{ Action: "view", - Command: "hey box ", + Command: "hey box view ", Description: "View email threads in a box", }), ) diff --git a/internal/cmd/bulk_reply.go b/internal/cmd/bulk_reply.go index 133062d9..7a1568ae 100644 --- a/internal/cmd/bulk_reply.go +++ b/internal/cmd/bulk_reply.go @@ -81,7 +81,7 @@ func newBulkReplyPreviewCommand() *bulkReplyPreviewCommand { Use: "preview ...", Short: "Preview threads and recipients without sending", Annotations: map[string]string{ - "agent_notes": "Read-only. Posting IDs come from `hey box` or `hey search`. The result contains the latest replyable entry and exact To, CC, and BCC recipients for each thread.", + "agent_notes": "Read-only. Posting IDs come from `hey box view` or `hey search`. The result contains the latest replyable entry and exact To, CC, and BCC recipients for each thread.", }, Example: ` hey bulk-reply preview 12345 67890 hey bulk-reply preview 12345 67890 --json`, diff --git a/internal/cmd/canonical_commands_test.go b/internal/cmd/canonical_commands_test.go new file mode 100644 index 00000000..c8b6ed8e --- /dev/null +++ b/internal/cmd/canonical_commands_test.go @@ -0,0 +1,338 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "reflect" + "testing" +) + +func TestCanonicalListCommandsMatchCompatibilityForms(t *testing.T) { + tests := []struct { + name string + handler func(*testing.T) http.Handler + compatibility []string + canonical []string + }{ + { + name: "boxes", + handler: func(*testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/boxes.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `[{"id":1,"kind":"imbox","name":"Imbox"}]`) + }) + }, + compatibility: []string{"boxes", "--limit", "1"}, + canonical: []string{"box", "list", "--limit", "1"}, + }, + { + name: "labels", + handler: func(*testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/my/navigation.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `{"items":[{"title":"Labels","menu_items":[{"title":"Receipts","app_url":"/folders/12"}]}]}`) + }) + }, + compatibility: []string{"labels", "--limit", "1"}, + canonical: []string{"label", "list", "--limit", "1"}, + }, + { + name: "collections", + handler: func(*testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/collections.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `[{"id":12,"name":"Kitchen remodel"}]`) + }) + }, + compatibility: []string{"collections", "--limit", "1"}, + canonical: []string{"collection", "list", "--limit", "1"}, + }, + { + name: "workflows", + handler: func(t *testing.T) http.Handler { + return workflowHandler(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/autocompletable/accounts/1/workflows" { + _, _ = io.WriteString(w, `[["101","Home projects","Personal"]]`) + return + } + if r.URL.Path == "/autocompletable/accounts/2/workflows" { + _, _ = io.WriteString(w, `[]`) + return + } + http.NotFound(w, r) + }) + }, + compatibility: []string{"workflows", "--limit", "1"}, + canonical: []string{"workflow", "list", "--limit", "1"}, + }, + { + name: "clips", + handler: func(*testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/clips.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, clipsJSON) + }) + }, + compatibility: []string{"clips"}, + canonical: []string{"clip", "list"}, + }, + { + name: "snippets", + handler: func(*testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/snippets.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, snippetsJSON) + }) + }, + compatibility: []string{"snippets"}, + canonical: []string{"snippet", "list"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := withJSONContentType(tt.handler(t)) + compatibility, err := runJSONCommand(t, handler, tt.compatibility...) + if err != nil { + t.Fatalf("compatibility form: %v", err) + } + canonical, err := runJSONCommand(t, handler, tt.canonical...) + if err != nil { + t.Fatalf("canonical form: %v", err) + } + if !reflect.DeepEqual(canonical, compatibility) { + t.Errorf("canonical response = %#v, compatibility response = %#v", canonical, compatibility) + } + }) + } +} + +func TestCanonicalViewCommandsMatchCompatibilityForms(t *testing.T) { + tests := []struct { + name string + handler http.Handler + compatibility []string + canonical []string + }{ + { + name: "box", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/imbox.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `{"id":1,"kind":"imbox","name":"Imbox","postings":[]}`) + }), + compatibility: []string{"box", "imbox", "--limit", "1"}, + canonical: []string{"box", "view", "imbox", "--limit", "1"}, + }, + { + name: "label", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/folders/12.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[]}`) + }), + compatibility: []string{"label", "12", "--limit", "1"}, + canonical: []string{"label", "view", "12", "--limit", "1"}, + }, + { + name: "collection", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/collections/12.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `{"id":12,"name":"Kitchen remodel","postings":[]}`) + }), + compatibility: []string{"collection", "12", "--limit", "1"}, + canonical: []string{"collection", "view", "12", "--limit", "1"}, + }, + { + name: "workflow", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/workflows/8801.json" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `{"id":8801,"name":"Hiring","stages":[]}`) + }), + compatibility: []string{"workflow", "8801"}, + canonical: []string{"workflow", "view", "8801"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := withJSONContentType(tt.handler) + compatibility, err := runJSONCommand(t, handler, tt.compatibility...) + if err != nil { + t.Fatalf("compatibility form: %v", err) + } + canonical, err := runJSONCommand(t, handler, tt.canonical...) + if err != nil { + t.Fatalf("canonical form: %v", err) + } + if !reflect.DeepEqual(canonical, compatibility) { + t.Errorf("canonical response = %#v, compatibility response = %#v", canonical, compatibility) + } + }) + } +} + +func TestBoxListIsReservedAndViewStillOpensABoxNamedList(t *testing.T) { + listHandler := withJSONContentType(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/boxes.json" { + t.Errorf("box list requested %s, want /boxes.json", r.URL.Path) + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, `[{"id":17,"kind":"custom","name":"list"}]`) + })) + listed, err := runJSONCommand(t, listHandler, "box", "list") + if err != nil { + t.Fatal(err) + } + if listed.Summary != "1 mailboxes" { + t.Errorf("box list summary = %q", listed.Summary) + } + + viewHandler := withJSONContentType(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/boxes.json": + _, _ = io.WriteString(w, `[{"id":17,"kind":"custom","name":"list"}]`) + case "/boxes/17.json": + _, _ = io.WriteString(w, `{"id":17,"kind":"custom","name":"list","postings":[]}`) + default: + http.NotFound(w, r) + } + })) + viewed, err := runJSONCommand(t, viewHandler, "box", "view", "list") + if err != nil { + t.Fatal(err) + } + if viewed.Summary != "0 threads in list" { + t.Errorf("box view list summary = %q", viewed.Summary) + } + + compatibility, err := runJSONCommand(t, viewHandler, "box", "--", "list") + if err != nil { + t.Fatal(err) + } + if compatibility.Summary != viewed.Summary { + t.Errorf("box -- list summary = %q, want %q", compatibility.Summary, viewed.Summary) + } +} + +func withJSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +func TestAgentHelpPrefersCanonicalFamilies(t *testing.T) { + root := newRootCmd() + var output bytes.Buffer + root.SetOut(&output) + printAgentHelp(root) + + var info struct { + Subcommands []struct { + Name string `json:"name"` + } `json:"subcommands"` + } + if err := json.Unmarshal(output.Bytes(), &info); err != nil { + t.Fatal(err) + } + names := make(map[string]bool) + for _, command := range info.Subcommands { + names[command.Name] = true + } + for _, canonical := range []string{"box", "label", "collection", "workflow", "clip", "snippet"} { + if !names[canonical] { + t.Errorf("agent help is missing canonical family %s", canonical) + } + } + for _, compatibility := range []string{"boxes", "labels", "collections", "workflows", "clips", "snippets"} { + if names[compatibility] { + t.Errorf("agent help promotes compatibility command %s", compatibility) + } + } +} + +func TestCompatibilityCommandsNameTheirCanonicalForms(t *testing.T) { + wantCommands := map[string]string{ + "boxes": "box list", + "labels": "label list", + "collections": "collection list", + "workflows": "workflow list", + "clips": "clip list", + "snippets": "snippet list", + } + wantUsages := map[string]string{ + "box": "box ", + "label": "label ", + "collection": "collection ", + "workflow": "workflow ", + } + catalog := walkCommands(newRootCmd(), "") + for _, entry := range catalog { + path, _ := entry["path"].(string) + canonical, marked := entry["compatibility_for"].(string) + if expected, ok := wantCommands[path]; ok { + if !marked || canonical != expected { + t.Errorf("%s compatibility_for = %q, want %q", path, canonical, expected) + } + delete(wantCommands, path) + } else if marked { + t.Errorf("unexpected compatibility command %s -> %s", path, canonical) + } + + if expected, ok := wantUsages[path]; ok { + if usage, _ := entry["compatibility_usage"].(string); usage != expected { + t.Errorf("%s compatibility_usage = %q, want %q", path, usage, expected) + } + delete(wantUsages, path) + } + } + for path := range wantCommands { + t.Errorf("command catalog is missing compatibility command %s", path) + } + for path := range wantUsages { + t.Errorf("command catalog is missing compatibility usage for %s", path) + } + + flattened := flattenCommandCatalog(catalog) + for _, want := range []string{"box list", "box view", "label list", "label view", "collection list", "collection view", "workflow list", "workflow view", "clip list", "snippet list"} { + found := false + for _, entry := range flattened { + if entry["path"] == want { + found = true + break + } + } + if !found { + t.Errorf("flattened command catalog is missing %s", want) + } + } +} diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go index 93fb1c3d..f63925fe 100644 --- a/internal/cmd/clip.go +++ b/internal/cmd/clip.go @@ -20,20 +20,32 @@ type clipsCommand struct { } func newClipsCommand() *clipsCommand { - clipsCommand := &clipsCommand{} - clipsCommand.cmd = &cobra.Command{ - Use: "clips", + command := newClipsListingCommand("clips", ` hey clips + hey clips --json + hey clips --ids-only`) + command.cmd.Annotations[compatibilityForAnnotation] = "clip list" + return command +} + +func newClipListCommand() *clipsCommand { + return newClipsListingCommand("list", ` hey clip list + hey clip list --json + hey clip list --ids-only`) +} + +func newClipsListingCommand(use, example string) *clipsCommand { + command := &clipsCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List the newest page of passages clipped from email", Annotations: map[string]string{ "agent_notes": "Returns the newest page of clip IDs, content, source entry IDs, and source thread context. The SDK does not expose the cursor for older pages. Use an ID with hey clip delete.", }, - Example: ` hey clips - hey clips --json - hey clips --ids-only`, - RunE: clipsCommand.run, - Args: cobra.NoArgs, + Example: example, + RunE: command.run, + Args: cobra.NoArgs, } - return clipsCommand + return command } func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { @@ -138,11 +150,12 @@ func newClipCommand() *clipCommand { clipCommand := &clipCommand{} clipCommand.cmd = &cobra.Command{ Use: "clip", - Short: "Save and manage passages from email", + Short: "List and manage passages saved from email", Annotations: map[string]string{ - "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. HEY assigns a created clip to the source entry's account and resolves deletion by identity-owned clip ID across linked accounts; --account selects list presentation. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clips.", + "agent_notes": "List clips, create a clip from text carried by an email entry, or delete a clip. HEY assigns a created clip to the source entry's account and resolves deletion by identity-owned clip ID across linked accounts; --account selects list presentation. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clip list.", }, } + clipCommand.cmd.AddCommand(newClipListCommand().cmd) clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) clipCommand.cmd.AddCommand(newClipDeleteCommand().cmd) return clipCommand @@ -207,7 +220,7 @@ func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { return apierr.FromSDK(err) } return writeMutation(cmd, fmt.Sprintf("Clip from entry %d created", entryID), map[string]any{"entry_id": entryID}, - output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey clips", Description: "Find the new clip ID"}), + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey clip list", Description: "Find the new clip ID"}), ) } diff --git a/internal/cmd/collection.go b/internal/cmd/collection.go index 79e83556..28ed27fe 100644 --- a/internal/cmd/collection.go +++ b/internal/cmd/collection.go @@ -23,23 +23,37 @@ type collectionsCommand struct { } func newCollectionsCommand() *collectionsCommand { - collectionsCommand := &collectionsCommand{} - collectionsCommand.cmd = &cobra.Command{ - Use: "collections", + command := newCollectionsListingCommand("collections", ` hey collections + hey collections --limit 10 + hey collections --json`) + command.cmd.Annotations[compatibilityForAnnotation] = "collection list" + return command +} + +func newCollectionListCommand() *collectionsCommand { + command := newCollectionsListingCommand("list", ` hey collection list + hey collection list --limit 10 + hey collection list --json`) + command.cmd.Args = cobra.NoArgs + return command +} + +func newCollectionsListingCommand(use, example string) *collectionsCommand { + command := &collectionsCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List your email collections", Annotations: map[string]string{ - "agent_notes": "Returns collection IDs and names. Use an ID with hey collection and hey collection add/remove/update.", + "agent_notes": "Returns collection IDs and names. Use an ID with hey collection view and hey collection add/remove/update.", }, - Example: ` hey collections - hey collections --limit 10 - hey collections --json`, - RunE: collectionsCommand.run, + Example: example, + RunE: command.run, } - collectionsCommand.cmd.Flags().IntVar(&collectionsCommand.limit, "limit", 0, "Maximum number of collections to show") - collectionsCommand.cmd.Flags().BoolVar(&collectionsCommand.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of collections to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") - return collectionsCommand + return command } func (c *collectionsCommand) run(cmd *cobra.Command, args []string) error { @@ -83,7 +97,7 @@ func (c *collectionsCommand) run(cmd *cobra.Command, args []string) error { output.WithNotice(notice), output.WithBreadcrumbs(output.Breadcrumb{ Action: "view", - Command: "hey collection ", + Command: "hey collection view ", Description: "View email threads in a collection", }), ) @@ -112,30 +126,52 @@ var collectionListing = postingsListing{ } func newCollectionCommand() *collectionCommand { - collectionCommand := &collectionCommand{} - collectionCommand.cmd = &cobra.Command{ - Use: "collection ", - Short: "View and manage an email collection", + command := newCollectionReaderCommand( + "collection ", + "List and manage email collections", + ` hey collection list + hey collection view 123 + hey collection view 123 --all + hey collection view 123 --json`, + ) + command.cmd.Annotations[compatibilityUsageAnnotation] = "collection " + command.cmd.AddCommand(newCollectionListCommand().cmd) + command.cmd.AddCommand(newCollectionViewCommand().cmd) + command.cmd.AddCommand(newCollectionAddCommand().cmd) + command.cmd.AddCommand(newCollectionCreateCommand().cmd) + command.cmd.AddCommand(newCollectionRemoveCommand().cmd) + command.cmd.AddCommand(newCollectionUpdateCommand().cmd) + return command +} + +func newCollectionViewCommand() *collectionCommand { + return newCollectionReaderCommand( + "view ", + "List email threads in a collection", + ` hey collection view 123 + hey collection view 123 --page next-cursor + hey collection view 123 --all + hey collection view 123 --json`, + ) +} + +func newCollectionReaderCommand(use, short, example string) *collectionCommand { + command := &collectionCommand{} + command.cmd = &cobra.Command{ + Use: use, + Short: short, Annotations: map[string]string{ - "agent_notes": "The ID comes from hey collections. Detail returns posting IDs for organization actions and topic_id for reading threads, and answers --json, --styled, --markdown, --ids-only and --count.", + "agent_notes": "The ID comes from hey collection list. Detail returns posting IDs for organization actions and topic_id for reading threads, and answers --json, --styled, --markdown, --ids-only and --count.", }, - Example: ` hey collection 123 - hey collection 123 --page next-cursor - hey collection 123 --all - hey collection 123 --json`, - RunE: collectionCommand.run, - Args: usageExactOneArg(), + Example: example, + RunE: command.run, + Args: usageExactOneArg(), } - collectionCommand.cmd.Flags().IntVar(&collectionCommand.limit, "limit", 0, "Maximum number of threads to show") - collectionCommand.cmd.Flags().BoolVar(&collectionCommand.all, "all", false, "Fetch all results (override --limit)") - collectionCommand.cmd.Flags().StringVar(&collectionCommand.page, "page", "", "Continue from a next_page cursor") - collectionCommand.cmd.AddCommand(newCollectionAddCommand().cmd) - collectionCommand.cmd.AddCommand(newCollectionCreateCommand().cmd) - collectionCommand.cmd.AddCommand(newCollectionRemoveCommand().cmd) - collectionCommand.cmd.AddCommand(newCollectionUpdateCommand().cmd) - - return collectionCommand + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor") + return command } func (c *collectionCommand) run(cmd *cobra.Command, args []string) error { @@ -204,7 +240,7 @@ func (c *collectionCreateCommand) run(cmd *cobra.Command, args []string) error { return writeCollectionMutation(cmd, fmt.Sprintf("Collection %q created", name), output.Breadcrumb{ Action: "list", - Command: "hey collections", + Command: "hey collection list", Description: "Find the new collection ID", }) } diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index cbfc9cdf..3b651e4a 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -7,6 +7,11 @@ import ( "github.com/basecamp/hey-cli/internal/output" ) +const ( + compatibilityForAnnotation = "compatibility_for" + compatibilityUsageAnnotation = "compatibility_usage" +) + func newCommandsCommand() *cobra.Command { return &cobra.Command{ Use: "commands", @@ -17,10 +22,9 @@ func newCommandsCommand() *cobra.Command { if writer.IsStyled() { table := newTable(cmd.OutOrStdout()) table.addRow([]string{"Command", "Description"}) - for _, entry := range catalog { + for _, entry := range flattenCommandCatalog(catalog) { path, _ := entry["path"].(string) - short, _ := entry["short"].(string) - table.addRow([]string{path, short}) + table.addRow([]string{path, commandCatalogDescription(entry)}) } table.print() return nil @@ -52,6 +56,12 @@ func walkCommands(cmd *cobra.Command, prefix string) []map[string]any { if notes, ok := child.Annotations["agent_notes"]; ok { entry["agent_notes"] = notes } + if canonical, ok := child.Annotations[compatibilityForAnnotation]; ok { + entry["compatibility_for"] = canonical + } + if usage, ok := child.Annotations[compatibilityUsageAnnotation]; ok { + entry["compatibility_usage"] = usage + } var flags []map[string]string child.NonInheritedFlags().VisitAll(func(f *pflag.Flag) { @@ -76,3 +86,25 @@ func walkCommands(cmd *cobra.Command, prefix string) []map[string]any { return result } + +func flattenCommandCatalog(entries []map[string]any) []map[string]any { + var flattened []map[string]any + for _, entry := range entries { + flattened = append(flattened, entry) + if children, ok := entry["subcommands"].([]map[string]any); ok { + flattened = append(flattened, flattenCommandCatalog(children)...) + } + } + return flattened +} + +func commandCatalogDescription(entry map[string]any) string { + description, _ := entry["short"].(string) + if canonical, ok := entry["compatibility_for"].(string); ok { + return description + " (compatibility for hey " + canonical + ")" + } + if usage, ok := entry["compatibility_usage"].(string); ok { + return description + " (also accepts compatibility form hey " + usage + ")" + } + return description +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 5cac62d2..e3957d58 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,7 @@ var curatedCategories = []struct { }{ { heading: "CORE COMMANDS", - names: []string{"tui", "box", "threads", "reply", "compose", "search", "contacts", "boxes", "calendars", "todo", "journal"}, + names: []string{"tui", "box", "threads", "reply", "compose", "search", "contacts", "calendars", "todo", "journal"}, }, { heading: "MAIL", @@ -29,11 +29,11 @@ var curatedCategories = []struct { }, { heading: "SAVED CONTENT", - names: []string{"clips", "clip", "snippets", "snippet"}, + names: []string{"clip", "snippet"}, }, { heading: "ORGANIZE", - names: []string{"labels", "label", "collections", "collection", "workflows", "workflow", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, + names: []string{"label", "collection", "workflow", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, }, { heading: "CALENDAR & TASKS", @@ -179,7 +179,7 @@ func renderRootHelp(w io.Writer, cmd *cobra.Command) { b.WriteString(bold.format("EXAMPLES") + "\n") examples := []string{ "$ hey tui", - "$ hey box imbox", + "$ hey box view imbox", `$ hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?"`, "$ hey todo list", "$ hey threads 123 --json", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index f0eb03c9..942916be 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -97,13 +97,12 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI - box List email threads in a box + box List HEY boxes and their email threads threads Read a thread reply Reply to a thread compose Write and send a new email search Search email threads and messages contacts Manage contacts - boxes List your HEY boxes calendars List calendars todo Create and manage to-dos journal Read and write journal entries @@ -121,18 +120,13 @@ WRITE & SHARE unshare Turn off an email thread's sharing link SAVED CONTENT - clips List the newest page of passages clipped from email - clip Save and manage passages from email - snippets List reusable email snippets - snippet Create and manage reusable email snippets + clip List and manage passages saved from email + snippet List and manage reusable email snippets ORGANIZE - labels List your email labels - label View and manage an email label - collections List your email collections - collection View and manage an email collection - workflows List your email workflows - workflow View and manage an email workflow + label List and manage email labels + collection List and manage email collections + workflow List and manage email workflows seen Mark email threads as seen unseen Mark email threads as unseen move Move email threads to another box @@ -179,7 +173,7 @@ FLAGS EXAMPLES $ hey tui - $ hey box imbox + $ hey box view imbox $ hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?" $ hey todo list $ hey threads 123 --json diff --git a/internal/cmd/ignore.go b/internal/cmd/ignore.go index 83dd84b7..1a9d66e1 100644 --- a/internal/cmd/ignore.go +++ b/internal/cmd/ignore.go @@ -21,7 +21,7 @@ func newIgnoreCommand() *ignoreCommand { Example: ` hey ignore 12345 hey ignore 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Ignored threads remain in their box and can be restored with hey stop-ignoring.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Ignored threads remain in their box and can be restored with hey stop-ignoring.", }, RunE: ignoreCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/label.go b/internal/cmd/label.go index 7d0fbcf5..d1c01c6d 100644 --- a/internal/cmd/label.go +++ b/internal/cmd/label.go @@ -23,23 +23,37 @@ type labelsCommand struct { } func newLabelsCommand() *labelsCommand { - labelsCommand := &labelsCommand{} - labelsCommand.cmd = &cobra.Command{ - Use: "labels", + command := newLabelsListingCommand("labels", ` hey labels + hey labels --limit 10 + hey labels --json`) + command.cmd.Annotations[compatibilityForAnnotation] = "label list" + return command +} + +func newLabelListCommand() *labelsCommand { + command := newLabelsListingCommand("list", ` hey label list + hey label list --limit 10 + hey label list --json`) + command.cmd.Args = cobra.NoArgs + return command +} + +func newLabelsListingCommand(use, example string) *labelsCommand { + command := &labelsCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List your email labels", Annotations: map[string]string{ - "agent_notes": "Returns label IDs and names. Use an ID with hey label and hey label add/remove.", + "agent_notes": "Returns label IDs and names. Use an ID with hey label view and hey label add/remove.", }, - Example: ` hey labels - hey labels --limit 10 - hey labels --json`, - RunE: labelsCommand.run, + Example: example, + RunE: command.run, } - labelsCommand.cmd.Flags().IntVar(&labelsCommand.limit, "limit", 0, "Maximum number of labels to show") - labelsCommand.cmd.Flags().BoolVar(&labelsCommand.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of labels to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") - return labelsCommand + return command } func (c *labelsCommand) run(cmd *cobra.Command, args []string) error { @@ -76,7 +90,7 @@ func (c *labelsCommand) run(cmd *cobra.Command, args []string) error { output.WithNotice(notice), output.WithBreadcrumbs(output.Breadcrumb{ Action: "view", - Command: "hey label ", + Command: "hey label view ", Description: "View email threads with a label", }), ) @@ -105,29 +119,51 @@ var labelListing = postingsListing{ } func newLabelCommand() *labelCommand { - labelCommand := &labelCommand{} - labelCommand.cmd = &cobra.Command{ - Use: "label ", - Short: "View and manage an email label", + command := newLabelReaderCommand( + "label ", + "List and manage email labels", + ` hey label list + hey label view 123 + hey label view 123 --all + hey label view 123 --json`, + ) + command.cmd.Annotations[compatibilityUsageAnnotation] = "label " + command.cmd.AddCommand(newLabelListCommand().cmd) + command.cmd.AddCommand(newLabelViewCommand().cmd) + command.cmd.AddCommand(newLabelAddCommand().cmd) + command.cmd.AddCommand(newLabelCreateCommand().cmd) + command.cmd.AddCommand(newLabelRemoveCommand().cmd) + return command +} + +func newLabelViewCommand() *labelCommand { + return newLabelReaderCommand( + "view ", + "List email threads with a label", + ` hey label view 123 + hey label view 123 --page next-cursor + hey label view 123 --all + hey label view 123 --json`, + ) +} + +func newLabelReaderCommand(use, short, example string) *labelCommand { + command := &labelCommand{} + command.cmd = &cobra.Command{ + Use: use, + Short: short, Annotations: map[string]string{ - "agent_notes": "The ID comes from hey labels. Returns labeled email threads with topic_id for reading them, and answers --json, --styled, --markdown, --ids-only and --count. Subcommands add, create, and remove labels.", + "agent_notes": "The ID comes from hey label list. Returns labeled email threads with topic_id for reading them, and answers --json, --styled, --markdown, --ids-only and --count.", }, - Example: ` hey label 123 - hey label 123 --page next-cursor - hey label 123 --all - hey label 123 --json`, - RunE: labelCommand.run, - Args: usageExactOneArg(), - } - - labelCommand.cmd.Flags().IntVar(&labelCommand.limit, "limit", 0, "Maximum number of threads to show") - labelCommand.cmd.Flags().BoolVar(&labelCommand.all, "all", false, "Fetch all results (override --limit)") - labelCommand.cmd.Flags().StringVar(&labelCommand.page, "page", "", "Continue from a next_page cursor") - labelCommand.cmd.AddCommand(newLabelAddCommand().cmd) - labelCommand.cmd.AddCommand(newLabelCreateCommand().cmd) - labelCommand.cmd.AddCommand(newLabelRemoveCommand().cmd) - - return labelCommand + Example: example, + RunE: command.run, + Args: usageExactOneArg(), + } + + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor") + return command } func (c *labelCommand) run(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/move.go b/internal/cmd/move.go index 7f9539f7..bd64f563 100644 --- a/internal/cmd/move.go +++ b/internal/cmd/move.go @@ -30,7 +30,7 @@ func newMoveCommand() *moveCommand { hey move 12345 67890 --to "paper trail" hey move 12345 --to 987`, Annotations: map[string]string{ - "agent_notes": "Accepts box item IDs from hey box output. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", + "agent_notes": "Accepts box item IDs from hey box view output. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", }, RunE: moveCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/postings_listing.go b/internal/cmd/postings_listing.go index 0fe8668f..0ab517d2 100644 --- a/internal/cmd/postings_listing.go +++ b/internal/cmd/postings_listing.go @@ -43,7 +43,7 @@ type sourcePostingRow struct { Date string `json:"date,omitempty"` } -// postingsListing is what `hey box`, `hey label` and `hey collection` call the source they +// postingsListing is what `hey box view`, `hey label view` and `hey collection view` call the source they // list. Everything else about the three — the pagination, the notices and the five output // formats — is the same listing. // diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 717d26ba..fae2a3a2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -546,6 +546,12 @@ func printAgentHelp(cmd *cobra.Command) { if notes, ok := cmd.Annotations["agent_notes"]; ok { info["agent_notes"] = notes } + if canonical, ok := cmd.Annotations[compatibilityForAnnotation]; ok { + info["compatibility_for"] = canonical + } + if usage, ok := cmd.Annotations[compatibilityUsageAnnotation]; ok { + info["compatibility_usage"] = usage + } var flags []map[string]string addFlag := func(f *pflag.Flag) { @@ -567,6 +573,11 @@ func printAgentHelp(cmd *cobra.Command) { if sub.Hidden || !sub.IsAvailableCommand() { continue } + if cmd == cmd.Root() { + if _, compatibility := sub.Annotations[compatibilityForAnnotation]; compatibility { + continue + } + } subs = append(subs, map[string]string{ "name": sub.Name(), "short": sub.Short, diff --git a/internal/cmd/seen.go b/internal/cmd/seen.go index 3bf32435..0853dd16 100644 --- a/internal/cmd/seen.go +++ b/internal/cmd/seen.go @@ -21,7 +21,7 @@ func newSeenCommand() *seenCommand { Example: ` hey seen 12345 hey seen 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Marks each email thread as seen/read.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Marks each email thread as seen/read.", }, RunE: seenCommand.run, Args: usageMinOneArg(), @@ -61,7 +61,7 @@ func newUnseenCommand() *unseenCommand { Example: ` hey unseen 12345 hey unseen 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Marks each email thread as unseen/unread.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Marks each email thread as unseen/unread.", }, RunE: unseenCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index a45a7d9e..37c4517d 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -511,8 +511,8 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco fmt.Fprintln(w) examples := []struct{ cmd, desc string }{ {"hey tui", "Open the app"}, - {"hey boxes", "List your boxes"}, - {"hey box imbox", "Read your Imbox"}, + {"hey box list", "List your boxes"}, + {"hey box view imbox", "Read your Imbox"}, {`hey search "quarterly planning"`, "Search your mail"}, } width := 0 @@ -571,7 +571,7 @@ func wizardBreadcrumbs(result wizardResult) []output.Breadcrumb { } crumbs := []output.Breadcrumb{ {Action: "open", Command: "hey tui", Description: "Open the app"}, - {Action: "boxes", Command: "hey boxes", Description: "List your boxes"}, + {Action: "boxes", Command: "hey box list", Description: "List your boxes"}, } if result.Status == "incomplete" { crumbs = append(crumbs, output.Breadcrumb{Action: "doctor", Command: "hey doctor", Description: "Check CLI health"}) diff --git a/internal/cmd/setup_test.go b/internal/cmd/setup_test.go index fad8a393..c64dfc60 100644 --- a/internal/cmd/setup_test.go +++ b/internal/cmd/setup_test.go @@ -307,7 +307,7 @@ func TestShowWizardSuccessText(t *testing.T) { Checks: []agentCheck{{Agent: "Claude Code", Name: "Claude Code Plugin", Status: "pass"}}, }) text := out.String() - for _, want := range []string{"Setup complete!", "✓ Signed in", "✓ Claude Code Plugin", "Try these commands:", "hey boxes", `hey search "quarterly planning"`} { + for _, want := range []string{"Setup complete!", "✓ Signed in", "✓ Claude Code Plugin", "Try these commands:", "hey box list", `hey search "quarterly planning"`} { if !strings.Contains(text, want) { t.Errorf("complete summary missing %q:\n%s", want, text) } diff --git a/internal/cmd/share.go b/internal/cmd/share.go index d0931ce1..959ab9f6 100644 --- a/internal/cmd/share.go +++ b/internal/cmd/share.go @@ -22,7 +22,7 @@ func newShareCommand() *shareCommand { Example: ` hey share 12345 hey share 12345 --json`, Annotations: map[string]string{ - "agent_notes": "Accepts the topic_id from hey box, hey label, or hey search output. Returns the sharing link in the url field.", + "agent_notes": "Accepts the topic_id from hey box view, hey label view, or hey search output. Returns the sharing link in the url field.", }, RunE: shareCommand.run, Args: usageExactOneArg(), @@ -67,7 +67,7 @@ func newUnshareCommand() *unshareCommand { Long: "Turn off the sharing link for an email thread.", Example: ` hey unshare 12345`, Annotations: map[string]string{ - "agent_notes": "Accepts the topic_id from hey box, hey label, or hey search output. The thread remains in HEY.", + "agent_notes": "Accepts the topic_id from hey box view, hey label view, or hey search output. The thread remains in HEY.", }, RunE: unshareCommand.run, Args: usageExactOneArg(), diff --git a/internal/cmd/snippet.go b/internal/cmd/snippet.go index a2d92193..5c3950a0 100644 --- a/internal/cmd/snippet.go +++ b/internal/cmd/snippet.go @@ -18,20 +18,32 @@ type snippetsCommand struct { } func newSnippetsCommand() *snippetsCommand { - snippetsCommand := &snippetsCommand{} - snippetsCommand.cmd = &cobra.Command{ - Use: "snippets", + command := newSnippetsListingCommand("snippets", ` hey snippets + hey snippets --json + hey snippets --ids-only`) + command.cmd.Annotations[compatibilityForAnnotation] = "snippet list" + return command +} + +func newSnippetListCommand() *snippetsCommand { + return newSnippetsListingCommand("list", ` hey snippet list + hey snippet list --json + hey snippet list --ids-only`) +} + +func newSnippetsListingCommand(use, example string) *snippetsCommand { + command := &snippetsCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List reusable email snippets", Annotations: map[string]string{ "agent_notes": "Returns snippet IDs, names, plain text, and rich-text HTML. Use an ID with hey snippet update or delete.", }, - Example: ` hey snippets - hey snippets --json - hey snippets --ids-only`, - RunE: snippetsCommand.run, - Args: cobra.NoArgs, + Example: example, + RunE: command.run, + Args: cobra.NoArgs, } - return snippetsCommand + return command } func (c *snippetsCommand) run(cmd *cobra.Command, _ []string) error { @@ -110,11 +122,12 @@ func newSnippetCommand() *snippetCommand { snippetCommand := &snippetCommand{} snippetCommand.cmd = &cobra.Command{ Use: "snippet", - Short: "Create and manage reusable email snippets", + Short: "List and manage reusable email snippets", Annotations: map[string]string{ - "agent_notes": "Create, update, or delete snippets. Find snippet IDs with hey snippets.", + "agent_notes": "List, create, update, or delete snippets. Find snippet IDs with hey snippet list.", }, } + snippetCommand.cmd.AddCommand(newSnippetListCommand().cmd) snippetCommand.cmd.AddCommand(newSnippetCreateCommand().cmd) snippetCommand.cmd.AddCommand(newSnippetUpdateCommand().cmd) snippetCommand.cmd.AddCommand(newSnippetDeleteCommand().cmd) @@ -158,7 +171,7 @@ func (c *snippetCreateCommand) run(cmd *cobra.Command, _ []string) error { return apierr.FromSDK(err) } return writeMutation(cmd, fmt.Sprintf("Snippet %q created", name), map[string]any{"name": name}, - output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey snippets", Description: "Find the new snippet ID"}), + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey snippet list", Description: "Find the new snippet ID"}), ) } diff --git a/internal/cmd/spam.go b/internal/cmd/spam.go index d6ceb190..df132326 100644 --- a/internal/cmd/spam.go +++ b/internal/cmd/spam.go @@ -21,7 +21,7 @@ func newSpamCommand() *spamCommand { Example: ` hey spam 12345 hey spam 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Marks each thread as spam and removes it from the current box.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Marks each thread as spam and removes it from the current box.", }, RunE: spamCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/stop_ignoring.go b/internal/cmd/stop_ignoring.go index 394cdd11..c2ba0556 100644 --- a/internal/cmd/stop_ignoring.go +++ b/internal/cmd/stop_ignoring.go @@ -21,7 +21,7 @@ func newStopIgnoringCommand() *stopIgnoringCommand { Example: ` hey stop-ignoring 12345 hey stop-ignoring 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Reverses hey ignore for each thread.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Reverses hey ignore for each thread.", }, RunE: stopIgnoringCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/trash.go b/internal/cmd/trash.go index 8ba89dae..8a9b1798 100644 --- a/internal/cmd/trash.go +++ b/internal/cmd/trash.go @@ -21,7 +21,7 @@ func newTrashCommand() *trashCommand { Example: ` hey trash 12345 hey trash 12345 67890`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Shared threads lose your access rather than being deleted for everyone.", + "agent_notes": "Accepts one or more box item IDs from hey box view output. Shared threads lose your access rather than being deleted for everyone.", }, RunE: trashCommand.run, Args: usageMinOneArg(), diff --git a/internal/cmd/watch.go b/internal/cmd/watch.go index 79cb14f3..01c8dc5f 100644 --- a/internal/cmd/watch.go +++ b/internal/cmd/watch.go @@ -610,7 +610,7 @@ func (w *postingsWatch) readBox(ctx context.Context, box *watchedBox) error { w.wasRead(box) if changes.FullSyncRequired { - fmt.Fprintf(w.errOut, "notice: too much changed in %s to follow one change at a time — skipping ahead, read the box with `hey box %s`\n", box.name, box.kind) + fmt.Fprintf(w.errOut, "notice: too much changed in %s to follow one change at a time — skipping ahead, read the box with `hey box view %s`\n", box.name, box.kind) skipped, err := w.skipAhead(ctx, box) if err != nil { return err diff --git a/internal/cmd/workflow.go b/internal/cmd/workflow.go index e342dc77..73212eec 100644 --- a/internal/cmd/workflow.go +++ b/internal/cmd/workflow.go @@ -43,23 +43,36 @@ type workflowsCommand struct { } func newWorkflowsCommand() *workflowsCommand { - workflowsCommand := &workflowsCommand{} - workflowsCommand.cmd = &cobra.Command{ - Use: "workflows", + command := newWorkflowsListingCommand("workflows", ` hey workflows + hey workflows --account 12345 + hey workflows --limit 10 + hey workflows --json`) + command.cmd.Annotations[compatibilityForAnnotation] = "workflow list" + return command +} + +func newWorkflowListCommand() *workflowsCommand { + return newWorkflowsListingCommand("list", ` hey workflow list + hey workflow list --account 12345 + hey workflow list --limit 10 + hey workflow list --json`) +} + +func newWorkflowsListingCommand(use, example string) *workflowsCommand { + command := &workflowsCommand{} + command.cmd = &cobra.Command{ + Use: use, Short: "List your email workflows", Annotations: map[string]string{ - "agent_notes": "Returns workflow IDs, names, and linked account IDs. Use an ID with hey workflow; use --account to limit the list to one linked mail account.", + "agent_notes": "Returns workflow IDs, names, and linked account IDs. Use an ID with hey workflow view; use --account to limit the list to one linked mail account.", }, - Example: ` hey workflows - hey workflows --account 12345 - hey workflows --limit 10 - hey workflows --json`, - RunE: workflowsCommand.run, - Args: cobra.NoArgs, + Example: example, + RunE: command.run, + Args: cobra.NoArgs, } - workflowsCommand.cmd.Flags().IntVar(&workflowsCommand.limit, "limit", 0, "Maximum number of workflows to show") - workflowsCommand.cmd.Flags().BoolVar(&workflowsCommand.all, "all", false, "Show every workflow (override --limit)") - return workflowsCommand + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of workflows to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Show every workflow (override --limit)") + return command } func (c *workflowsCommand) run(cmd *cobra.Command, _ []string) error { @@ -103,7 +116,7 @@ func (c *workflowsCommand) run(cmd *cobra.Command, _ []string) error { output.WithNotice(notice), output.WithBreadcrumbs(output.Breadcrumb{ Action: "view", - Command: "hey workflow ", + Command: "hey workflow view ", Description: "View a workflow and its stages", }), ) @@ -158,27 +171,50 @@ type workflowCommand struct { } func newWorkflowCommand() *workflowCommand { - workflowCommand := &workflowCommand{} - workflowCommand.cmd = &cobra.Command{ - Use: "workflow ", - Short: "View and manage an email workflow", + command := newWorkflowReaderCommand( + "workflow ", + "List and manage email workflows", + ` hey workflow list + hey workflow view 123 + hey workflow view 123 --json + hey workflow view 123 --ids-only`, + ) + command.cmd.Annotations[compatibilityUsageAnnotation] = "workflow " + command.cmd.AddCommand(newWorkflowListCommand().cmd) + command.cmd.AddCommand(newWorkflowViewCommand().cmd) + command.cmd.AddCommand(newWorkflowCreateCommand().cmd) + command.cmd.AddCommand(newWorkflowUpdateCommand().cmd) + command.cmd.AddCommand(newWorkflowDeleteCommand().cmd) + command.cmd.AddCommand(newWorkflowStageCommand().cmd) + command.cmd.AddCommand(newWorkflowAddCommand().cmd) + command.cmd.AddCommand(newWorkflowMoveCommand().cmd) + command.cmd.AddCommand(newWorkflowRemoveCommand().cmd) + return command +} + +func newWorkflowViewCommand() *workflowCommand { + return newWorkflowReaderCommand( + "view ", + "View a workflow and its stages", + ` hey workflow view 123 + hey workflow view 123 --json + hey workflow view 123 --ids-only`, + ) +} + +func newWorkflowReaderCommand(use, short, example string) *workflowCommand { + command := &workflowCommand{} + command.cmd = &cobra.Command{ + Use: use, + Short: short, Annotations: map[string]string{ - "agent_notes": "The workflow ID comes from hey workflows. Detail returns stage IDs in position order. Subcommands create, update, delete, stage, add, move, and remove workflows and their threads.", + "agent_notes": "The workflow ID comes from hey workflow list. Detail returns stage IDs in position order.", }, - Example: ` hey workflow 123 - hey workflow 123 --json - hey workflow 123 --ids-only`, - RunE: workflowCommand.run, - Args: usageExactOneArg(), + Example: example, + RunE: command.run, + Args: usageExactOneArg(), } - workflowCommand.cmd.AddCommand(newWorkflowCreateCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowUpdateCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowDeleteCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowStageCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowAddCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowMoveCommand().cmd) - workflowCommand.cmd.AddCommand(newWorkflowRemoveCommand().cmd) - return workflowCommand + return command } func (c *workflowCommand) run(cmd *cobra.Command, args []string) error { @@ -291,7 +327,7 @@ func (c *workflowCreateCommand) run(cmd *cobra.Command, args []string) error { return apierr.FromSDK(err) } return writeMutation(cmd, fmt.Sprintf("Workflow %q created", name), nil, - output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey workflows", Description: "Find the new workflow ID"}), + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey workflow list", Description: "Find the new workflow ID"}), ) } @@ -399,7 +435,7 @@ func newWorkflowStageCommand() *workflowStageCommand { Use: "stage", Short: "Manage workflow stages", Annotations: map[string]string{ - "agent_notes": "Create adds an Untitled stage; use hey workflow to find its stage ID, then update it with --name.", + "agent_notes": "Create adds an Untitled stage; use hey workflow view to find its stage ID, then update it with --name.", }, } workflowStageCommand.cmd.AddCommand(newWorkflowStageCreateCommand().cmd) @@ -436,7 +472,7 @@ func (c *workflowStageCreateCommand) run(cmd *cobra.Command, args []string) erro return apierr.FromSDK(err) } return writeMutation(cmd, fmt.Sprintf("Untitled stage added to workflow %d", workflowID), nil, - output.WithBreadcrumbs(output.Breadcrumb{Action: "view", Command: fmt.Sprintf("hey workflow %d", workflowID), Description: "Find the new stage ID"}), + output.WithBreadcrumbs(output.Breadcrumb{Action: "view", Command: fmt.Sprintf("hey workflow view %d", workflowID), Description: "Find the new stage ID"}), ) } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index e1f64789..3f70566f 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -10,12 +10,12 @@ triggers: - /hey # Email actions - hey accounts - - hey boxes - hey box - - hey labels - hey label - - hey collections - hey collection + - hey workflow + - hey clip + - hey snippet - hey search - hey contacts - hey threads @@ -122,9 +122,9 @@ CLI for HEY: mailboxes, labels, collections, email threads, contacts, replies, c `--jq` filters the full JSON success envelope, so result data is under `.data`. String results print as plain text; objects and arrays print as formatted JSON. Use `--quiet --jq` when the expression should run against result data directly. Errors retain their complete structured envelope. Commands with dedicated raw output (`auth token`, `completion`, `skill`, `tui`, and `--version`) reject `--jq`. ```bash -hey boxes --jq '.data[] | {id, name}' +hey box list --jq '.data[] | {id, name}' hey search "quarterly planning" --jq '.data[].id' -hey boxes --quiet --jq '.[].name' +hey box list --quiet --jq '.[].name' ``` An empty result is an empty array rather than `null`, so `.data[]` is safe to run against a @@ -132,11 +132,12 @@ listing that found nothing. For the two commonest shapes there is no need for an expression at all: `--ids-only` prints one ID per line and `--count` prints a bare number, both on stdout with any pagination -notice on stderr. Both need list data, so they work on `hey boxes`, `hey box`, -`hey labels`, `hey label`, `hey collections`, `hey collection`, `hey drafts`, `hey search`, +notice on stderr. Both need list data, so they work on `hey box list`, `hey box view`, +`hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, +`hey workflow view`, `hey clip list`, `hey snippet list`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list`, `hey journal list` and -`hey attachments`. On `hey box` they count and list its postings, not the box itself. +`hey attachments`. On `hey box view` they count and list its postings, not the box itself. ## Quick Reference @@ -144,22 +145,26 @@ notice on stderr. Both need list data, so they work on `hey boxes`, `hey box`, |------|---------| | List linked mail accounts | `hey accounts list --json` | | Set default mail account | `hey accounts use ` | -| Run once for one account | `hey --account boxes --json` | +| Run once for one account | `hey --account box list --json` | | Review trusted local settings | `hey config trusted-locals --json` | | Trust this repository's settings | `hey config trust-local` (requires explicit user approval) | -| List mailboxes | `hey boxes --json` | -| List emails in a box | `hey box imbox --json` | -| List labels | `hey labels --json` | -| List emails with a label | `hey label --all --json` | +| List mailboxes | `hey box list --json` | +| List emails in a box | `hey box view imbox --json` | +| List labels | `hey label list --json` | +| List emails with a label | `hey label view --all --json` | | Add a label to a thread | `hey label add --to ` | | Create and add a label | `hey label create "Travel receipts" ` | | Remove labels | `hey label remove --from ` | -| List collections | `hey collections --json` | -| List collection threads | `hey collection --all --json` | +| List collections | `hey collection list --json` | +| List collection threads | `hey collection view --all --json` | | Create a collection | `hey collection create "Kitchen remodel"` | | Update a collection | `hey collection update --name "Kitchen renovation"` | | Add a thread to a collection | `hey collection add --to ` | | Remove a thread from a collection | `hey collection remove --from ` | +| List workflows | `hey workflow list --json` | +| View workflow stages | `hey workflow view --json` | +| List clips | `hey clip list --json` | +| List snippets | `hey snippet list --json` | | Search email | `hey search "quarterly planning" --json` | | List search filters | `hey search filters --json` | | List contacts | `hey contacts list --json` | @@ -231,11 +236,11 @@ notice on stderr. Both need list data, so they work on `hey boxes`, `hey box`, ``` Want to read email? -├── Which mailbox? → hey boxes --json -├── List emails in box? → hey box --json -├── List labels or labeled email? → hey labels --json / hey label --json +├── Which mailbox? → hey box list --json +├── List emails in box? → hey box view --json +├── List labels or labeled email? → hey label list --json / hey label view --json ├── Add, create, or remove a label? → hey label add|create|remove -├── List collections or collection threads? → hey collections --json / hey collection --json +├── List collections or collection threads? → hey collection list --json / hey collection view --json ├── Create, update, add to, or remove from a collection? → hey collection create|update|add|remove ├── Search threads and messages? → hey search --json ├── Need available refinements? → hey search filters --json @@ -292,15 +297,15 @@ Want to manage todos? ### Email - Boxes ```bash -hey boxes --json # List all mailboxes -hey box imbox --json # List emails in Imbox (by name) -hey box 123 --json # List emails in box (by ID) -hey box imbox --page next-cursor --json # Continue from an earlier listing +hey box list --json # List all mailboxes +hey box view imbox --json # List emails in Imbox (by name) +hey box view 123 --json # List emails in box (by ID) +hey box view imbox --page next-cursor --json # Continue from an earlier listing ``` Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` -**Response format:** `hey box --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey threads`, `hey reply`, `hey forward`, `hey share` and `hey attachments`. A box item `id` passed to `hey threads` answers `not_found`, and so does a `topic_id` passed to `hey move`. +**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey threads`, `hey reply`, `hey forward`, `hey share` and `hey attachments`. A box item `id` passed to `hey threads` answers `not_found`, and so does a `topic_id` passed to `hey move`. `next_page` is the cursor `--page` takes, and it is the cursor inside `next_history_url` — `--page` accepts either. `--all` reads to the end instead. @@ -309,28 +314,28 @@ Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` ### Email - Labels ```bash -hey labels --json # List labels and stable IDs -hey label 789 --all --json # List every thread with a label +hey label list --json # List labels and stable IDs +hey label view 789 --all --json # List every thread with a label hey label add 12345 --to 789 # Add an existing label hey label create "Travel receipts" 12345 # Create and add a label hey label remove 12345 --from 789 # Remove one label hey label remove 12345 --from all # Remove every label ``` -Label mutations take box item IDs from `hey box`, `hey label`, or active `hey search` results. Label IDs come from `hey labels`. `hey label` returns `next_page` and `total_count`; pass `--page ` to continue or `--all` to fetch every page. HEY creates a label while adding it to at least one thread, so `label create` requires one or more thread item IDs. +Label mutations take box item IDs from `hey box view`, `hey label view`, or active `hey search` results. Label IDs come from `hey label list`. `hey label view` returns `next_page` and `total_count`; pass `--page ` to continue or `--all` to fetch every page. HEY creates a label while adding it to at least one thread, so `label create` requires one or more thread item IDs. ### Email - Collections ```bash -hey collections --json # List collections and stable IDs -hey collection 321 --all --json # List every thread in a collection +hey collection list --json # List collections and stable IDs +hey collection view 321 --all --json # List every thread in a collection hey collection create "Kitchen remodel" --summary "Plans and decisions" hey collection update 321 --name "Kitchen renovation" hey collection add 987 --to 321 # Add a topic ID hey collection remove 987 --from 321 # Remove a topic ID ``` -Collection IDs come from `hey collections`. `hey collection` returns posting `id`, thread `topic_id`, `next_page`, and `total_count`; pass `--page ` to continue or `--all` to fetch every page. Collection membership commands take `topic_id`. Creating a collection confirms the mutation, and listing collections provides its ID for later commands. +Collection IDs come from `hey collection list`. `hey collection view` returns posting `id`, thread `topic_id`, `next_page`, and `total_count`; pass `--page ` to continue or `--all` to fetch every page. Collection membership commands take `topic_id`. Creating a collection confirms the mutation, and listing collections provides its ID for later commands. ### Email - Search @@ -390,7 +395,7 @@ on an entry; use `hey reply`, which works the addressing out itself. **ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey threads`, `hey share`, `hey unshare`, `hey attachments`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect. -`hey box --json`, `hey label --json`, `hey collection --json` and `hey search --json` all carry both. +`hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry both. ### Email - Attachments @@ -472,7 +477,7 @@ hey unseen 12345 # Mark a thread as unseen hey unseen 12345 67890 # Mark multiple threads as unseen ``` -Takes box item IDs (the `id` field from `hey box` output). +Takes box item IDs (the `id` field from `hey box view` output). ### Email - Moving Threads @@ -481,7 +486,7 @@ hey move 12345 --to imbox # Move one thread hey move 12345 67890 --to "paper trail" # Move multiple threads ``` -Takes box item IDs (the `id` field from `hey box --json`). `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Bubble Up requires a scheduled date and is not supported by this command. +Takes box item IDs (the `id` field from `hey box view --json`). `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Bubble Up requires a scheduled date and is not supported by this command. ### Email - Trash and Spam @@ -492,7 +497,7 @@ hey spam 12345 # Mark one thread as spam hey spam 12345 67890 # Mark multiple threads as spam ``` -Takes box item IDs (the `id` field from `hey box --json`). Trashing a shared thread removes your access instead of deleting it for everyone. Marking a thread as spam moves it to Spam and trains HEY's filters. +Takes box item IDs (the `id` field from `hey box view --json`). Trashing a shared thread removes your access instead of deleting it for everyone. Marking a thread as spam moves it to Spam and trains HEY's filters. ### Email - Ignoring Threads @@ -503,7 +508,7 @@ hey stop-ignoring 12345 # Stop ignoring one thread hey stop-ignoring 12345 67890 # Stop ignoring multiple threads ``` -Takes box item IDs (the `id` field from `hey box --json`). Ignored threads remain in their box; new replies do not bring them back to your attention. `hey stop-ignoring` reverses the action. +Takes box item IDs (the `id` field from `hey box view --json`). Ignored threads remain in their box; new replies do not bring them back to your attention. `hey stop-ignoring` reverses the action. ### Email - Watching for changes diff --git a/tests/smoke/canonical_commands_test.go b/tests/smoke/canonical_commands_test.go new file mode 100644 index 00000000..14faa751 --- /dev/null +++ b/tests/smoke/canonical_commands_test.go @@ -0,0 +1,82 @@ +package smoke_test + +import ( + "encoding/json" + "reflect" + "strconv" + "testing" +) + +func TestCanonicalResourceCommandsMatchCompatibilityForms(t *testing.T) { + t.Run("box", func(t *testing.T) { + assertSameCommandResponse(t, []string{"boxes"}, []string{"box", "list"}) + assertSameCommandEnvelope(t, []string{"box", "imbox", "--limit", "2"}, []string{"box", "view", "imbox", "--limit", "2"}, false) + }) + + t.Run("label", func(t *testing.T) { + labels := dataAs[[]smokeFolder](t, heyJSON(t, "label", "list")) + assertSameCommandResponse(t, []string{"labels"}, []string{"label", "list"}) + if len(labels) == 0 { + skipf(t, "no labels available for canonical label view validation") + } + id := strconv.FormatInt(labels[0].ID, 10) + assertSameCommandResponse(t, []string{"label", id, "--limit", "2"}, []string{"label", "view", id, "--limit", "2"}) + }) + + t.Run("collection", func(t *testing.T) { + collections := dataAs[[]smokeCollection](t, heyJSON(t, "collection", "list")) + assertSameCommandResponse(t, []string{"collections"}, []string{"collection", "list"}) + if len(collections) == 0 { + skipf(t, "no collections available for canonical collection view validation") + } + id := strconv.FormatInt(collections[0].ID, 10) + assertSameCommandResponse(t, []string{"collection", id, "--limit", "2"}, []string{"collection", "view", id, "--limit", "2"}) + }) + + t.Run("workflow", func(t *testing.T) { + workflows := dataAs[[]smokeWorkflow](t, heyJSON(t, "workflow", "list")) + assertSameCommandResponse(t, []string{"workflows"}, []string{"workflow", "list"}) + if len(workflows) == 0 { + skipf(t, "no workflows available for canonical workflow view validation") + } + id := strconv.FormatInt(workflows[0].ID, 10) + assertSameCommandResponse(t, []string{"workflow", id}, []string{"workflow", "view", id}) + }) + + t.Run("clip", func(t *testing.T) { + assertSameCommandResponse(t, []string{"clips"}, []string{"clip", "list"}) + }) + + t.Run("snippet", func(t *testing.T) { + assertSameCommandResponse(t, []string{"snippets"}, []string{"snippet", "list"}) + }) +} + +func assertSameCommandResponse(t *testing.T, compatibility, canonical []string) { + t.Helper() + assertSameCommandEnvelope(t, compatibility, canonical, true) +} + +func assertSameCommandEnvelope(t *testing.T, compatibility, canonical []string, compareData bool) { + t.Helper() + oldResponse := heyJSON(t, compatibility...) + newResponse := heyJSON(t, canonical...) + + if compareData { + var oldData, newData any + if err := json.Unmarshal(oldResponse.Data, &oldData); err != nil { + t.Fatalf("decode compatibility data: %v", err) + } + if err := json.Unmarshal(newResponse.Data, &newData); err != nil { + t.Fatalf("decode canonical data: %v", err) + } + if !reflect.DeepEqual(newData, oldData) { + t.Errorf("canonical %v data differs from compatibility %v", canonical, compatibility) + } + } else if len(oldResponse.Data) == 0 || len(newResponse.Data) == 0 { + t.Errorf("canonical or compatibility response omitted data") + } + if newResponse.Summary != oldResponse.Summary || newResponse.Notice != oldResponse.Notice || !reflect.DeepEqual(newResponse.Breadcrumbs, oldResponse.Breadcrumbs) { + t.Errorf("canonical %v envelope differs from compatibility %v", canonical, compatibility) + } +} From df24dc84bbc9db77fa9c7323b1eb3105e5776ff7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 10:12:00 -0400 Subject: [PATCH 2/2] Show family help when no action is given --- internal/cmd/box.go | 11 +++++---- internal/cmd/box_test.go | 7 +++--- internal/cmd/canonical_commands_test.go | 33 +++++++++++++++++++++++++ internal/cmd/collection.go | 6 ++++- internal/cmd/help.go | 3 ++- internal/cmd/label.go | 6 ++++- internal/cmd/workflow.go | 6 ++++- 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/internal/cmd/box.go b/internal/cmd/box.go index 5511d130..3e3eb2fd 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -47,7 +47,7 @@ var boxListing = postingsListing{ func newBoxCommand() *boxCommand { command := newBoxReaderCommand( - "box ", + "box", "List HEY boxes and their email threads", "List HEY boxes or list email threads in one box.", ` hey box list @@ -94,18 +94,19 @@ func newBoxReaderCommand(use, short, long, example string) *boxCommand { return command } -func validateBoxArgs(cmd *cobra.Command, args []string) error { +func validateBoxArgs(_ *cobra.Command, args []string) error { switch len(args) { - case 1: + case 0, 1: return nil - case 0: - return usageErrorf("%s (example: hey box view imbox)", cmd.CommandPath()) default: return fmt.Errorf("expected 1 mailbox argument, got %d", len(args)) } } func (c *boxCommand) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index 017fac98..76b9302c 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -22,10 +22,9 @@ func TestValidateBoxArgs(t *testing.T) { errContains string }{ { - name: "missing arg", - args: nil, - wantErr: true, - errContains: "Usage:", + name: "no arg opens family help", + args: nil, + wantErr: false, }, { name: "one arg", diff --git a/internal/cmd/canonical_commands_test.go b/internal/cmd/canonical_commands_test.go index c8b6ed8e..642e0513 100644 --- a/internal/cmd/canonical_commands_test.go +++ b/internal/cmd/canonical_commands_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "reflect" + "strings" "testing" ) @@ -250,6 +251,38 @@ func withJSONContentType(next http.Handler) http.Handler { }) } +func TestCanonicalFamiliesShowHelpWithoutArguments(t *testing.T) { + tests := []struct { + family string + compatibilityUsage string + }{ + {family: "box", compatibilityUsage: "hey box "}, + {family: "label", compatibilityUsage: "hey label "}, + {family: "collection", compatibilityUsage: "hey collection "}, + {family: "workflow", compatibilityUsage: "hey workflow "}, + } + for _, tt := range tests { + t.Run(tt.family, func(t *testing.T) { + server := quietServer(t) + stdout, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", false, tt.family) + if err != nil { + t.Fatalf("hey %s: %v", tt.family, err) + } + for _, want := range []string{"COMMANDS", "hey " + tt.family + " list", "hey " + tt.family + " view"} { + if !strings.Contains(stdout, want) { + t.Errorf("hey %s output is missing %q:\n%s", tt.family, want, stdout) + } + } + if strings.Contains(stdout, tt.compatibilityUsage) { + t.Errorf("hey %s help promotes compatibility usage %q:\n%s", tt.family, tt.compatibilityUsage, stdout) + } + if strings.Contains(stdout, " hey "+tt.family+" [flags]\n") { + t.Errorf("hey %s help presents the compatibility runner as canonical:\n%s", tt.family, stdout) + } + }) + } +} + func TestAgentHelpPrefersCanonicalFamilies(t *testing.T) { root := newRootCmd() var output bytes.Buffer diff --git a/internal/cmd/collection.go b/internal/cmd/collection.go index 28ed27fe..9085ec45 100644 --- a/internal/cmd/collection.go +++ b/internal/cmd/collection.go @@ -127,7 +127,7 @@ var collectionListing = postingsListing{ func newCollectionCommand() *collectionCommand { command := newCollectionReaderCommand( - "collection ", + "collection", "List and manage email collections", ` hey collection list hey collection view 123 @@ -135,6 +135,7 @@ func newCollectionCommand() *collectionCommand { hey collection view 123 --json`, ) command.cmd.Annotations[compatibilityUsageAnnotation] = "collection " + command.cmd.Args = cobra.MaximumNArgs(1) command.cmd.AddCommand(newCollectionListCommand().cmd) command.cmd.AddCommand(newCollectionViewCommand().cmd) command.cmd.AddCommand(newCollectionAddCommand().cmd) @@ -175,6 +176,9 @@ func newCollectionReaderCommand(use, short, example string) *collectionCommand { } func (c *collectionCommand) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index e3957d58..0f1e58e2 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -221,7 +221,8 @@ func renderCommandHelp(cmd *cobra.Command) { // USAGE b.WriteString("\n") b.WriteString(bold.format("USAGE") + "\n") - if cmd.Runnable() { + _, hasCompatibilityUsage := cmd.Annotations[compatibilityUsageAnnotation] + if cmd.Runnable() && !hasCompatibilityUsage { b.WriteString(" " + cmd.UseLine() + "\n") } if cmd.HasAvailableSubCommands() { diff --git a/internal/cmd/label.go b/internal/cmd/label.go index d1c01c6d..abe02f2d 100644 --- a/internal/cmd/label.go +++ b/internal/cmd/label.go @@ -120,7 +120,7 @@ var labelListing = postingsListing{ func newLabelCommand() *labelCommand { command := newLabelReaderCommand( - "label ", + "label", "List and manage email labels", ` hey label list hey label view 123 @@ -128,6 +128,7 @@ func newLabelCommand() *labelCommand { hey label view 123 --json`, ) command.cmd.Annotations[compatibilityUsageAnnotation] = "label " + command.cmd.Args = cobra.MaximumNArgs(1) command.cmd.AddCommand(newLabelListCommand().cmd) command.cmd.AddCommand(newLabelViewCommand().cmd) command.cmd.AddCommand(newLabelAddCommand().cmd) @@ -167,6 +168,9 @@ func newLabelReaderCommand(use, short, example string) *labelCommand { } func (c *labelCommand) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/workflow.go b/internal/cmd/workflow.go index 73212eec..63020147 100644 --- a/internal/cmd/workflow.go +++ b/internal/cmd/workflow.go @@ -172,7 +172,7 @@ type workflowCommand struct { func newWorkflowCommand() *workflowCommand { command := newWorkflowReaderCommand( - "workflow ", + "workflow", "List and manage email workflows", ` hey workflow list hey workflow view 123 @@ -180,6 +180,7 @@ func newWorkflowCommand() *workflowCommand { hey workflow view 123 --ids-only`, ) command.cmd.Annotations[compatibilityUsageAnnotation] = "workflow " + command.cmd.Args = cobra.MaximumNArgs(1) command.cmd.AddCommand(newWorkflowListCommand().cmd) command.cmd.AddCommand(newWorkflowViewCommand().cmd) command.cmd.AddCommand(newWorkflowCreateCommand().cmd) @@ -218,6 +219,9 @@ func newWorkflowReaderCommand(use, short, example string) *workflowCommand { } func (c *workflowCommand) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } if err := requireAuth(); err != nil { return err }