From 590ad1f575f11e720cadfa82bbb9ed92e7dd2319 Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Wed, 19 Aug 2026 12:47:05 -0500 Subject: [PATCH 1/4] Distinguish HEY World posts from email --- .surface | 10 +++ API-COVERAGE.md | 1 + README.md | 15 ++-- internal/cmd/box.go | 83 +++++++++++++++++--- internal/cmd/box_test.go | 126 +++++++++++++++++++++++++----- internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 1 + internal/cmd/ignore.go | 9 ++- internal/cmd/move.go | 11 ++- internal/cmd/posting_kind.go | 19 +++++ internal/cmd/posting_kind_test.go | 66 ++++++++++++++++ internal/cmd/root.go | 1 + internal/cmd/seen.go | 18 ++++- internal/cmd/spam.go | 9 ++- internal/cmd/stop_ignoring.go | 9 ++- internal/cmd/trash.go | 9 ++- internal/cmd/trash_test.go | 15 ++++ internal/cmd/world.go | 73 +++++++++++++++++ internal/cmd/world_test.go | 80 +++++++++++++++++++ internal/tui/mail.go | 16 ++++ internal/tui/mail_test.go | 20 +++++ skills/hey/SKILL.md | 73 ++++++++++------- tests/smoke/boxes_test.go | 13 +-- tests/smoke/ignore_test.go | 28 +++++-- tests/smoke/seen_test.go | 37 ++++++--- 25 files changed, 640 insertions(+), 104 deletions(-) create mode 100644 internal/cmd/posting_kind.go create mode 100644 internal/cmd/posting_kind_test.go create mode 100644 internal/cmd/world.go create mode 100644 internal/cmd/world_test.go diff --git a/.surface b/.surface index 19194ec4..ac6bed3a 100644 --- a/.surface +++ b/.surface @@ -97,6 +97,7 @@ hey habit complete --date hey habit uncomplete hey habit uncomplete --date hey ignore +hey ignore --kind hey journal hey journal list hey journal list --all @@ -117,6 +118,7 @@ hey labels hey labels --all hey labels --limit hey move +hey move --kind hey move --to hey recordings hey recordings --all @@ -142,11 +144,14 @@ hey search --subject hey search --to hey search filters hey seen +hey seen --kind hey setup hey skill hey skill install hey spam +hey spam --kind hey stop-ignoring +hey stop-ignoring --kind hey threads hey timetrack hey timetrack current @@ -166,8 +171,10 @@ hey todo list --all hey todo list --limit hey todo uncomplete hey trash +hey trash --kind hey tui hey unseen +hey unseen --kind hey upgrade hey version hey watch @@ -178,3 +185,6 @@ hey watch --run-async hey watch --run-sync hey watch --since hey watch --timeout +hey world +hey world delete +hey world delete --confirm diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2b94d1..be058fba 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -53,6 +53,7 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are | `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam `, TUI `s` | covered | | `/postings/mutings.json` | POST | SDK `Postings().Mute` | `hey ignore `, TUI `-` | covered | | `/postings/mutings.json` | DELETE | SDK `Postings().Unmute` | `hey stop-ignoring `, TUI `+` | covered | +| `/world/posts/{token}` | DELETE | SDK `World().Delete` | `hey world delete --confirm` | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete ` | covered | | `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete ` | covered | | `/calendar/days/{date}/journal_entry.json` | GET | SDK `Journal().Get` | `hey journal read [date]` | partial: falls back to legacy | diff --git a/README.md b/README.md index e0f55b1a..4331d0e3 100644 --- a/README.md +++ b/README.md @@ -240,12 +240,13 @@ hey compose --to user@example.com --subject "Hello" # compose a new message hey compose --to user@example.com --subject "Report" -m "Attached." --attach ./report.pdf hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" # with CC/BCC hey drafts # list drafts -hey move 12345 --to feed # move a thread to another box -hey move 12345 67890 --to "paper trail" # move multiple threads -hey trash 12345 # move a thread to Trash -hey spam 12345 # mark a thread as spam -hey ignore 12345 # ignore future activity on a thread -hey stop-ignoring 12345 # resume attention for a thread +hey move 12345 --to feed --kind topic # move an email thread to another box +hey move 12345 67890 --to "paper trail" --kind topic # move multiple email threads +hey trash 12345 --kind topic # move an email thread to Trash +hey spam 12345 --kind topic # mark an email thread as spam +hey ignore 12345 --kind topic # ignore future activity on an email thread +hey stop-ignoring 12345 --kind topic # resume attention for an email thread +hey world delete abc123 --confirm # permanently remove a published HEY World post ``` Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--from`, `--to`, `--subject`, `--date`, `--in`, `--label`, and `--attachment`. Use `--page` for one page or `--all` to fetch up to 100 pages; capped searches report the next page for continuation. Search results include `topic_id` for reading the thread and the matching message summaries. Results with an active box item also include `id` for organization actions. @@ -256,7 +257,7 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali `--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`. 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. 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`. +Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Pass the accompanying `kind` with `--kind` when it is available. Email actions reject `world/post`; published HEY World content has a separate `hey world delete --confirm` path. 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. 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`. ### Watching for changes diff --git a/internal/cmd/box.go b/internal/cmd/box.go index fd284686..bff2ce67 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -27,9 +27,9 @@ func newBoxCommand() *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.", + Long: "List email threads and HEY World posts in a box. Each kind is counted separately. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", 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.", + "agent_notes": "Accepts a box name or numeric ID. Preserve each item's kind. Use topic_id with hey threads, reply, and forward. Use id plus --kind with email organization actions. HEY World posts are published content, not email.", }, Example: ` hey box imbox hey box imbox --limit 10 @@ -82,20 +82,19 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { resp.NextHistoryUrl = finalNextURL } notice := boxTruncationNotice(len(postings), total, hasMore, c.all) + counts := countBoxPostings(postings) + summary := counts.summary(resp.Name) if writer.IsStyled() { fmt.Fprintf(cmd.OutOrStdout(), "Box: %s (%s)\n\n", resp.Name, resp.Kind) table := newTable(cmd.OutOrStdout()) - table.addRow([]string{"Thread", "From", "Summary", "Date"}) + table.addRow(boxTableHeaders()) for _, p := range postings { - displayID := resolvePostingTopicID(p) - if displayID == 0 { - displayID = p.Id - } - table.addRow([]string{fmt.Sprintf("%d", displayID), p.Creator.Name, truncate(p.Summary, 60), formatDate(p.CreatedAt)}) + table.addRow(boxTableRow(p)) } table.print() + fmt.Fprintln(cmd.OutOrStdout(), summary+".") if notice != "" { fmt.Fprintln(cmd.OutOrStdout(), notice) } @@ -104,8 +103,11 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { resp.Postings = postings return writeOK(resp, - output.WithSummary(boxSummary(len(postings), resp.Name)), + output.WithSummary(summary), output.WithNotice(notice), + output.WithMeta("posting_count", counts.postings), + output.WithMeta("email_count", counts.emails), + output.WithMeta("world_post_count", counts.worldPosts), output.WithBreadcrumbs( output.Breadcrumb{ Action: "read", @@ -114,7 +116,7 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { }, output.Breadcrumb{ Action: "move", - Command: "hey move --to ", + Command: "hey move --to --kind ", Description: "Move an email thread to another box", }, output.Breadcrumb{ @@ -126,8 +128,65 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { ) } -func boxSummary(count int, name string) string { - return fmt.Sprintf("%d %s in %s", count, threadNoun(count), name) +func boxTableHeaders() []string { + return []string{"Item", "Kind", "From", "Summary", "Date"} +} + +func boxTableRow(posting generated.Posting) []string { + return []string{ + fmt.Sprintf("%d", resolvePostingTopicID(posting)), + posting.Kind, + posting.Creator.Name, + truncate(posting.Summary, 60), + formatDate(posting.CreatedAt), + } +} + +type boxPostingCounts struct { + postings int + emails int + worldPosts int +} + +func countBoxPostings(postings []generated.Posting) boxPostingCounts { + counts := boxPostingCounts{postings: len(postings)} + for _, posting := range postings { + if strings.EqualFold(strings.TrimSpace(posting.Kind), "world/post") { + counts.worldPosts++ + continue + } + counts.emails++ + } + return counts +} + +func (c boxPostingCounts) summary(boxName string) string { + emails := countPhrase(c.emails, "email", "emails") + if c.worldPosts == 0 { + return fmt.Sprintf("%s in %s", emails, boxName) + } + + worldPosts := countPhrase(c.worldPosts, "HEY World post", "HEY World posts") + if c.emails == 0 { + return fmt.Sprintf("%s in %s", worldPosts, boxName) + } + return fmt.Sprintf("%s and %s in %s", emails, worldPosts, boxName) +} + +func countPhrase(count int, singular, plural string) string { + noun := plural + if count == 1 { + noun = singular + } + return fmt.Sprintf("%s %s", formatCount(count), noun) +} + +func formatCount(count int) string { + digits := strconv.Itoa(count) + for i := len(digits) - 3; i > 0; i -= 3 { + digits = digits[:i] + "," + digits[i:] + } + return digits } // resolveBox fetches a box by name or ID, using named SDK getters for diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index b5522b5c..de34cf5f 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -1,16 +1,21 @@ package cmd import ( + "bytes" "context" + "encoding/json" "fmt" "io" "net/http" + "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" ) func TestValidateBoxArgs(t *testing.T) { @@ -66,7 +71,7 @@ func TestValidateBoxArgs(t *testing.T) { func makePostings(n, offset int) []generated.Posting { postings := make([]generated.Posting, n) for i := range postings { - postings[i] = generated.Posting{Id: int64(offset + i + 1)} + postings[i] = generated.Posting{Id: int64(offset + i + 1), Kind: "topic"} } return postings } @@ -117,7 +122,7 @@ func TestBoxCommandNamedRoutes(t *testing.T) { if requests.Load() != 1 { t.Errorf("requests = %d, want one named lookup", requests.Load()) } - if response.Summary != "0 threads in "+tt.name { + if response.Summary != "0 emails in "+tt.name { t.Errorf("summary = %q", response.Summary) } }) @@ -137,7 +142,7 @@ func TestBoxCommandNumericIDAndLimit(t *testing.T) { if err != nil { t.Fatalf("execute box: %v", err) } - if response.Summary != "1 thread in Receipts" { + if response.Summary != "1 email in Receipts" { t.Errorf("summary = %q", response.Summary) } if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { @@ -175,7 +180,7 @@ func TestBoxCommandUnknownNameFallsBackToList(t *testing.T) { if got, want := fmt.Sprint(requests), "[GET /boxes.json GET /boxes/17.json]"; got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "0 threads in Receipts" { + if response.Summary != "0 emails in Receipts" { t.Errorf("summary = %q", response.Summary) } } @@ -205,22 +210,107 @@ func TestBoxCommandUnknownNameReturnsNotFound(t *testing.T) { } } -func TestBoxSummaryUsesThreadTerminology(t *testing.T) { - tests := []struct { - name string - count int - want string - }{ - {"one thread", 1, "1 thread in Imbox"}, - {"multiple threads", 2, "2 threads in Imbox"}, +func TestBoxPostingCountsAndSummary(t *testing.T) { + postings := makePostings(2944, 0) + for i := 0; i < 21; i++ { + postings = append(postings, generated.Posting{Id: int64(3000 + i), Kind: "world/post"}) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := boxSummary(tt.count, "Imbox"); got != tt.want { - t.Errorf("boxSummary(%d) = %q, want %q", tt.count, got, tt.want) - } - }) + counts := countBoxPostings(postings) + if counts.postings != 2965 || counts.emails != 2944 || counts.worldPosts != 21 { + t.Fatalf("counts = %+v", counts) + } + if got := counts.summary("Imbox"); got != "2,944 emails and 21 HEY World posts in Imbox" { + t.Errorf("summary = %q", got) + } +} + +func TestBoxTableLabelsMixedItemsByKind(t *testing.T) { + headers := boxTableHeaders() + if len(headers) < 2 || headers[0] != "Item" || headers[1] != "Kind" { + t.Fatalf("headers = %v, want Item and Kind columns", headers) + } + + row := boxTableRow(generated.Posting{Id: 102, Kind: "world/post", Summary: "Published note"}) + if len(row) < 2 || row[0] != "102" || row[1] != "world/post" { + t.Fatalf("row = %v, want World item ID and kind", row) + } +} + +func runBox(t *testing.T, server *httptest.Server, args ...string) (output.Response, error) { + t.Helper() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append([]string{"box", "--json", "--base-url", server.URL}, args...)) + + err := root.Execute() + var resp output.Response + if buf.Len() > 0 { + if decodeErr := json.Unmarshal(buf.Bytes(), &resp); decodeErr != nil { + t.Fatalf("decode response: %v\n%s", decodeErr, buf.String()) + } + } + return resp, err +} + +func TestBoxMixedPostingKindsJSONContract(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/imbox.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": 1, + "kind": "imbox", + "name": "Imbox", + "postings": [ + {"id": 101, "kind": "topic", "summary": "Project update"}, + {"id": 102, "kind": "world/post", "summary": "Published note"} + ] + }`)) + })) + defer server.Close() + + resp, err := runBox(t, server, "imbox") + if err != nil { + t.Fatalf("execute: %v", err) + } + if resp.Summary != "1 email and 1 HEY World post in Imbox" { + t.Errorf("summary = %q", resp.Summary) + } + if got := resp.Meta["posting_count"]; got != float64(2) { + t.Errorf("posting_count = %v, want 2", got) + } + if got := resp.Meta["email_count"]; got != float64(1) { + t.Errorf("email_count = %v, want 1", got) + } + if got := resp.Meta["world_post_count"]; got != float64(1) { + t.Errorf("world_post_count = %v, want 1", got) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data type = %T, want map[string]any", resp.Data) + } + postings, ok := data["postings"].([]any) + if !ok || len(postings) != 2 { + t.Fatalf("postings = %#v, want 2 entries", data["postings"]) + } + first, _ := postings[0].(map[string]any) + second, _ := postings[1].(map[string]any) + if first["kind"] != "topic" || second["kind"] != "world/post" { + t.Errorf("posting kinds = %q, %q", first["kind"], second["kind"]) } } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 2f2c908a..9098fa89 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,7 @@ var curatedCategories = []struct { }{ { heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch", "world"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index fa64fea9..84864549 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -113,6 +113,7 @@ EMAIL ignore Ignore email threads stop-ignoring Stop ignoring email threads watch Follow email threads as they change + world Manage HEY World posts CALENDAR & TASKS calendars List calendars diff --git a/internal/cmd/ignore.go b/internal/cmd/ignore.go index 262da5a4..a0f5b943 100644 --- a/internal/cmd/ignore.go +++ b/internal/cmd/ignore.go @@ -9,7 +9,8 @@ import ( ) type ignoreCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newIgnoreCommand() *ignoreCommand { @@ -21,16 +22,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Ignored threads remain in their box and can be restored with hey stop-ignoring.", }, RunE: ignoreCommand.run, Args: usageMinOneArg(), } + ignoreCommand.cmd.Flags().StringVar(&ignoreCommand.kind, "kind", "", "Item kind from hey box --json") return ignoreCommand } func (c *ignoreCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/move.go b/internal/cmd/move.go index 9661852a..7742a630 100644 --- a/internal/cmd/move.go +++ b/internal/cmd/move.go @@ -15,8 +15,9 @@ import ( ) type moveCommand struct { - cmd *cobra.Command - to string + cmd *cobra.Command + to string + kind string } func newMoveCommand() *moveCommand { @@ -29,18 +30,22 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", }, RunE: moveCommand.run, Args: usageMinOneArg(), } moveCommand.cmd.Flags().StringVar(&moveCommand.to, "to", "", "Destination box name, kind, or ID (required)") + moveCommand.cmd.Flags().StringVar(&moveCommand.kind, "kind", "", "Item kind from hey box --json") return moveCommand } func (c *moveCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/posting_kind.go b/internal/cmd/posting_kind.go new file mode 100644 index 00000000..4f169f1e --- /dev/null +++ b/internal/cmd/posting_kind.go @@ -0,0 +1,19 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/basecamp/hey-cli/internal/output" +) + +func validateEmailPostingKind(action, kind string) error { + if !strings.EqualFold(strings.TrimSpace(kind), "world/post") { + return nil + } + + return output.ErrUsageHint( + fmt.Sprintf("hey %s cannot act on a HEY World post", action), + "HEY World posts are published content. Use `hey world delete --confirm` only when you intend to remove a published post.", + ) +} diff --git a/internal/cmd/posting_kind_test.go b/internal/cmd/posting_kind_test.go new file mode 100644 index 00000000..5b291b57 --- /dev/null +++ b/internal/cmd/posting_kind_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +func TestEmailActionsRejectWorldPostsBeforeAnyRequest(t *testing.T) { + tests := []struct { + command string + args []string + }{ + {command: "seen", args: []string{"12345", "--kind", "world/post"}}, + {command: "unseen", args: []string{"12345", "--kind", "world/post"}}, + {command: "move", args: []string{"12345", "--to", "feed", "--kind", "world/post"}}, + {command: "trash", args: []string{"12345", "--kind", "world/post"}}, + {command: "spam", args: []string{"12345", "--kind", "world/post"}}, + {command: "ignore", args: []string{"12345", "--kind", "world/post"}}, + {command: "stop-ignoring", args: []string{"12345", "--kind", "world/post"}}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + args := append([]string{tt.command, "--json", "--base-url", server.URL}, tt.args...) + root.SetArgs(args) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "cannot act on a HEY World post") { + t.Fatalf("error = %v, want HEY World rejection", err) + } + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || !strings.Contains(cliErr.Hint, "hey world delete --confirm") { + t.Fatalf("error = %#v, want separate World deletion guidance", err) + } + if requests.Load() != 0 { + t.Fatalf("HEY World rejection made %d requests", requests.Load()) + } + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 10720122..689c8373 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -180,6 +180,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newSpamCommand().cmd) root.AddCommand(newIgnoreCommand().cmd) root.AddCommand(newStopIgnoringCommand().cmd) + root.AddCommand(newWorldCommand().cmd) root.AddCommand(newSetupCommand()) root.AddCommand(newTuiCommand().cmd) root.AddCommand(newSkillCommand().cmd) diff --git a/internal/cmd/seen.go b/internal/cmd/seen.go index fdd1d0b8..07bd4c47 100644 --- a/internal/cmd/seen.go +++ b/internal/cmd/seen.go @@ -10,7 +10,8 @@ import ( ) type seenCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newSeenCommand() *seenCommand { @@ -21,16 +22,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each email thread as seen/read.", }, RunE: seenCommand.run, Args: usageMinOneArg(), } + seenCommand.cmd.Flags().StringVar(&seenCommand.kind, "kind", "", "Item kind from hey box --json") return seenCommand } func (c *seenCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } @@ -57,7 +62,8 @@ func (c *seenCommand) run(cmd *cobra.Command, args []string) error { // unseen type unseenCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newUnseenCommand() *unseenCommand { @@ -68,16 +74,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each email thread as unseen/unread.", }, RunE: unseenCommand.run, Args: usageMinOneArg(), } + unseenCommand.cmd.Flags().StringVar(&unseenCommand.kind, "kind", "", "Item kind from hey box --json") return unseenCommand } func (c *unseenCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/spam.go b/internal/cmd/spam.go index 8793806f..6aca1e5f 100644 --- a/internal/cmd/spam.go +++ b/internal/cmd/spam.go @@ -9,7 +9,8 @@ import ( ) type spamCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newSpamCommand() *spamCommand { @@ -21,16 +22,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each thread as spam and removes it from the current box.", }, RunE: spamCommand.run, Args: usageMinOneArg(), } + spamCommand.cmd.Flags().StringVar(&spamCommand.kind, "kind", "", "Item kind from hey box --json") return spamCommand } func (c *spamCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/stop_ignoring.go b/internal/cmd/stop_ignoring.go index c2e6184e..6ad59bc4 100644 --- a/internal/cmd/stop_ignoring.go +++ b/internal/cmd/stop_ignoring.go @@ -9,7 +9,8 @@ import ( ) type stopIgnoringCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newStopIgnoringCommand() *stopIgnoringCommand { @@ -21,16 +22,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Reverses hey ignore for each thread.", }, RunE: stopIgnoringCommand.run, Args: usageMinOneArg(), } + stopIgnoringCommand.cmd.Flags().StringVar(&stopIgnoringCommand.kind, "kind", "", "Item kind from hey box --json") return stopIgnoringCommand } func (c *stopIgnoringCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/trash.go b/internal/cmd/trash.go index 52bb2271..aa9761cb 100644 --- a/internal/cmd/trash.go +++ b/internal/cmd/trash.go @@ -9,7 +9,8 @@ import ( ) type trashCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newTrashCommand() *trashCommand { @@ -21,16 +22,20 @@ 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 output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Shared threads lose your access rather than being deleted for everyone.", }, RunE: trashCommand.run, Args: usageMinOneArg(), } + trashCommand.cmd.Flags().StringVar(&trashCommand.kind, "kind", "", "Item kind from hey box --json") return trashCommand } func (c *trashCommand) run(cmd *cobra.Command, args []string) error { + if err := validateEmailPostingKind(c.cmd.Name(), c.kind); err != nil { + return err + } if err := requireAuth(); err != nil { return err } diff --git a/internal/cmd/trash_test.go b/internal/cmd/trash_test.go index abc46c68..c6c5c6e3 100644 --- a/internal/cmd/trash_test.go +++ b/internal/cmd/trash_test.go @@ -150,3 +150,18 @@ func TestTrashAndSpamReportServerFailures(t *testing.T) { }) } } + +func TestTrashAndSpamRejectWorldPostsBeforeRequest(t *testing.T) { + for _, command := range []string{"trash", "spam"} { + t.Run(command, func(t *testing.T) { + server, recorded := removalServer(t) + _, err := runRemoval(t, server, command, "12345", "--kind", "world/post") + if err == nil || !strings.Contains(err.Error(), "cannot act on a HEY World post") { + t.Fatalf("error = %v, want HEY World rejection", err) + } + if recorded.requests != 0 { + t.Errorf("HEY World rejection made %d requests", recorded.requests) + } + }) + } +} diff --git a/internal/cmd/world.go b/internal/cmd/world.go new file mode 100644 index 00000000..3d17e229 --- /dev/null +++ b/internal/cmd/world.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +type worldCommand struct { + cmd *cobra.Command +} + +func newWorldCommand() *worldCommand { + worldCommand := &worldCommand{} + worldCommand.cmd = &cobra.Command{ + Use: "world", + Short: "Manage HEY World posts", + } + worldCommand.cmd.AddCommand(newWorldDeleteCommand().cmd) + return worldCommand +} + +type worldDeleteCommand struct { + cmd *cobra.Command + confirm bool +} + +func newWorldDeleteCommand() *worldDeleteCommand { + deleteCommand := &worldDeleteCommand{} + deleteCommand.cmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a published HEY World post", + Long: "Delete a published HEY World post by its token. This does not move an email to Trash and cannot be undone through the CLI.", + Example: ` hey world delete abc123 --confirm`, + Annotations: map[string]string{ + "agent_notes": "Destructive action for published HEY World content. Requires the post token and an explicit --confirm flag. Never substitute a box posting ID for the token.", + }, + RunE: deleteCommand.run, + Args: usageExactOneArg(), + } + deleteCommand.cmd.Flags().BoolVar(&deleteCommand.confirm, "confirm", false, "Confirm permanent removal of the published post") + return deleteCommand +} + +func (c *worldDeleteCommand) run(cmd *cobra.Command, args []string) error { + token := strings.TrimSpace(args[0]) + if token == "" { + return output.ErrUsage("HEY World post token cannot be empty") + } + if !c.confirm { + return output.ErrUsageHint( + "deleting a HEY World post requires --confirm", + "This removes published content. Re-run with `--confirm` only if that is your intent.", + ) + } + if err := requireAuth(); err != nil { + return err + } + + if err := sdk.World().Delete(cmd.Context(), token); err != nil { + return convertSDKError(err) + } + + summary := fmt.Sprintf("HEY World post %s deleted", token) + if writer.IsStyled() { + fmt.Fprintln(cmd.OutOrStdout(), summary+".") + return nil + } + return writeOK(nil, output.WithSummary(summary)) +} diff --git a/internal/cmd/world_test.go b/internal/cmd/world_test.go new file mode 100644 index 00000000..ef7f33ca --- /dev/null +++ b/internal/cmd/world_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +func runWorldDelete(t *testing.T, server *httptest.Server, args ...string) (output.Response, error) { + t.Helper() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append([]string{"world", "delete", "--json", "--base-url", server.URL}, args...)) + + err := root.Execute() + var resp output.Response + if buf.Len() > 0 { + _ = json.Unmarshal(buf.Bytes(), &resp) + } + return resp, err +} + +func TestWorldDeleteRequiresConfirmationBeforeRequest(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + _, err := runWorldDelete(t, server, "abc123") + if err == nil || !strings.Contains(err.Error(), "requires --confirm") { + t.Fatalf("error = %v, want confirmation requirement", err) + } + if requests.Load() != 0 { + t.Fatalf("unconfirmed delete made %d requests", requests.Load()) + } +} + +func TestWorldDeleteUsesSeparateConfirmedEndpoint(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodDelete || r.URL.Path != "/world/posts/abc123" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.URL.Path == "/postings/trash.json" { + t.Fatal("world deletion used the email Trash endpoint") + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + resp, err := runWorldDelete(t, server, "abc123", "--confirm") + if err != nil { + t.Fatalf("delete: %v", err) + } + if requests.Load() != 1 { + t.Fatalf("requests = %d, want 1", requests.Load()) + } + if resp.Summary != "HEY World post abc123 deleted" { + t.Errorf("summary = %q", resp.Summary) + } +} diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 918e2706..23d794ac 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1164,6 +1164,10 @@ func (v *mailView) openSelected() tea.Cmd { if selected == nil { return nil } + if isWorldPosting(selected) { + v.notice = "HEY World posts are published content; email thread actions are unavailable" + return nil + } topicID := selected.ResolveTopicID() if topicID == 0 { topicID = selected.ID @@ -1183,6 +1187,10 @@ func (v *mailView) startMove() { if selected == nil || currentSource == nil { return } + if isWorldPosting(selected) { + v.notice = "HEY World posts cannot be moved with email actions" + return + } picker := newMovePicker(*selected, v.boxes, *currentSource) if len(picker.destinations) == 0 { v.notice = "No other boxes available" @@ -1252,6 +1260,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { if selected == nil { return nil } + if isWorldPosting(selected) { + v.notice = "HEY World posts are published content; email actions are unavailable" + return nil + } p := *selected boxID := v.currentBoxID() @@ -1316,6 +1328,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return nil } +func isWorldPosting(posting *models.Posting) bool { + return posting != nil && strings.EqualFold(strings.TrimSpace(posting.Kind), "world/post") +} + func (v *mailView) moveSelectedToKnownBox(name, kind string, boxID, postingID int64, fn func() error) tea.Cmd { if !v.movesOutOfCurrentBox(kind) { v.notice = "Already in " + name diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 3e92feec..09108a47 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -979,6 +979,26 @@ func TestMailViewPostingKeyFailureKeepsPosting(t *testing.T) { } } +func TestMailViewRejectsWorldPostEmailActionsWithoutRequests(t *testing.T) { + for _, key := range []string{"t", "s", "m", "enter"} { + t.Run(key, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings = []models.Posting{{ID: 900, Kind: "world/post", Summary: "Published note"}} + v.postingList.cursor = 0 + + if cmd := v.HandleContentKey(keyPress(key)); cmd != nil { + t.Fatalf("world post action %q returned a command", key) + } + if len(recorded.requests) != 0 { + t.Fatalf("world post action %q made requests: %v", key, recorded.requests) + } + if !strings.Contains(v.notice, "HEY World") { + t.Fatalf("world post action %q notice = %q", key, v.notice) + } + }) + } +} + func TestMailViewPostingActionCopiesSelectedPostingBeforeAsyncRequest(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index cbf15506..842a72d4 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -21,6 +21,7 @@ triggers: - hey forward - hey compose - hey drafts + - hey world # Calendar actions - hey calendars - hey recordings @@ -105,6 +106,9 @@ CLI for HEY: mailboxes, labels, email threads, contacts, replies, compose, calen 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey accounts list --json`, then `--account ` when a task must target one account 5. **Local HEY configuration requires human trust** — never run `hey config trust-local` without the user's explicit approval +6. **Preserve item kind** from `hey box --json` and pass it to email actions with `--kind` +7. **Never use email actions on `world/post`** — it is published HEY World content, not email +8. **Deleting a HEY World post is separate and destructive** — use `hey world delete --confirm` only after explicit user approval ## Output Filtering @@ -160,13 +164,14 @@ hey boxes --quiet --jq '.[].name' | Delete todo | `hey todo delete 123` | | Wait for new mail | `hey watch --box imbox --exit-on-first` | | Follow every change | `hey watch` | -| Mark as seen | `hey seen 12345` | -| Mark as unseen | `hey unseen 12345` | -| Move email threads | `hey move 12345 --to feed` | -| Move email threads to Trash | `hey trash 12345` | -| Mark email threads as spam | `hey spam 12345` | -| Ignore email threads | `hey ignore 12345` | -| Stop ignoring email threads | `hey stop-ignoring 12345` | +| Mark as seen | `hey seen 12345 --kind topic` | +| Mark as unseen | `hey unseen 12345 --kind topic` | +| Move email threads | `hey move 12345 --to feed --kind topic` | +| Move email threads to Trash | `hey trash 12345 --kind topic` | +| Mark email threads as spam | `hey spam 12345 --kind topic` | +| Ignore email threads | `hey ignore 12345 --kind topic` | +| Stop ignoring email threads | `hey stop-ignoring 12345 --kind topic` | +| Delete published HEY World post | `hey world delete --confirm` | | Complete habit | `hey habit complete 123` | | Uncomplete habit | `hey habit uncomplete 123` | | Start time tracking | `hey timetrack start` | @@ -194,13 +199,13 @@ Want to read email? ├── Need available refinements? → hey search filters --json ├── List or view contacts? → hey contacts list --json / hey contacts show --json ├── Read full thread? → hey threads --json -├── Mark as seen? → hey seen -├── Mark as unseen? → hey unseen -├── Move to another box? → hey move --to -├── Move to Trash? → hey trash -├── Mark as spam? → hey spam -├── Ignore future activity? → hey ignore -├── Stop ignoring? → hey stop-ignoring +├── Mark as seen? → hey seen --kind +├── Mark as unseen? → hey unseen --kind +├── Move to another box? → hey move --to --kind +├── Move to Trash? → hey trash --kind +├── Mark as spam? → hey spam --kind +├── Ignore future activity? → hey ignore --kind +├── Stop ignoring? → hey stop-ignoring --kind └── Launch interactive UI? → hey (no args, launches TUI) ``` @@ -246,7 +251,7 @@ hey box 123 --json # List emails in box (by ID) Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` -**Response format:** `hey box` returns `{"box": {...}, "postings": [...]}`. The `postings` array is the API representation of the email threads in that box. Each item has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`. 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`. Use `topic_id` for `hey threads`, `hey reply`, and `hey forward`. +**Response format:** `hey box` returns `{"box": {...}, "postings": [...]}` plus `posting_count`, `email_count`, and `world_post_count` metadata. Preserve each item's `kind`. A `topic` is email; `world/post` is published HEY World content and must not be passed to email actions. Email items include `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen`, `created_at`, `contacts`, `summary`, and `app_url`. Use `id` plus `--kind topic` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`. Use `topic_id` for `hey threads`, `hey reply`, and `hey forward`. ### Email - Labels @@ -337,10 +342,10 @@ hey compose --thread-id 12345 -m "msg" # Reply to an exist ### Email - Seen/Unseen ```bash -hey seen 12345 # Mark a thread as seen -hey seen 12345 67890 # Mark multiple threads as seen -hey unseen 12345 # Mark a thread as unseen -hey unseen 12345 67890 # Mark multiple threads as unseen +hey seen 12345 --kind topic # Mark a thread as seen +hey seen 12345 67890 --kind topic # Mark multiple threads as seen +hey unseen 12345 --kind topic # Mark a thread as unseen +hey unseen 12345 67890 --kind topic # Mark multiple threads as unseen ``` Takes box item IDs (the `id` field from `hey box` output). @@ -348,8 +353,8 @@ Takes box item IDs (the `id` field from `hey box` output). ### Email - Moving Threads ```bash -hey move 12345 --to imbox # Move one thread -hey move 12345 67890 --to "paper trail" # Move multiple threads +hey move 12345 --to imbox --kind topic # Move one thread +hey move 12345 67890 --to "paper trail" --kind topic ``` 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. @@ -357,21 +362,31 @@ Takes box item IDs (the `id` field from `hey box --json`). `--to` accepts a box ### Email - Trash and Spam ```bash -hey trash 12345 # Move one thread to Trash -hey trash 12345 67890 # Move multiple threads to Trash -hey spam 12345 # Mark one thread as spam -hey spam 12345 67890 # Mark multiple threads as spam +hey trash 12345 --kind topic # Move one thread to Trash +hey trash 12345 67890 --kind topic # Move multiple threads to Trash +hey spam 12345 --kind topic # Mark one thread as spam +hey spam 12345 67890 --kind topic # 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. +### HEY World + +`world/post` items are published content, not email. Email organization commands reject them before making a request. Removing a published post uses its World token, not its box item ID: + +```bash +hey world delete --confirm +``` + +This is a separate destructive action. Do not run it without explicit user approval. + ### Email - Ignoring Threads ```bash -hey ignore 12345 # Ignore one thread -hey ignore 12345 67890 # Ignore multiple threads -hey stop-ignoring 12345 # Stop ignoring one thread -hey stop-ignoring 12345 67890 # Stop ignoring multiple threads +hey ignore 12345 --kind topic # Ignore one thread +hey ignore 12345 67890 --kind topic # Ignore multiple threads +hey stop-ignoring 12345 --kind topic # Stop ignoring one thread +hey stop-ignoring 12345 67890 --kind topic # 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. diff --git a/tests/smoke/boxes_test.go b/tests/smoke/boxes_test.go index 89abb728..1cc889ca 100644 --- a/tests/smoke/boxes_test.go +++ b/tests/smoke/boxes_test.go @@ -174,17 +174,20 @@ func TestBoxesAll(t *testing.T) { func TestMovePosting(t *testing.T) { resp := heyJSON(t, "box", "imbox", "--limit", "10") type Posting struct { - ID int `json:"id"` - Seen bool `json:"seen"` + ID int `json:"id"` + Kind string `json:"kind"` + Seen bool `json:"seen"` } type BoxResponse struct { Postings []Posting `json:"postings"` } imbox := dataAs[BoxResponse](t, resp) postingID := 0 + postingKind := "" for _, posting := range imbox.Postings { - if posting.Seen { + if posting.Kind == "topic" && posting.Seen { postingID = posting.ID + postingKind = posting.Kind break } } @@ -192,12 +195,12 @@ func TestMovePosting(t *testing.T) { t.Skip("no seen postings in Imbox to move without changing unread state") } - stdout, stderr, code := hey(t, "move", intStr(postingID), "--to", "feedbox", "--json") + stdout, stderr, code := hey(t, "move", intStr(postingID), "--to", "feedbox", "--kind", postingKind, "--json") if code != 0 { t.Skipf("moving postings is unavailable on this server (exit %d): %s", code, stderr) } t.Cleanup(func() { - _, cleanupStderr, cleanupCode := hey(t, "move", intStr(postingID), "--to", "imbox", "--json") + _, cleanupStderr, cleanupCode := hey(t, "move", intStr(postingID), "--to", "imbox", "--kind", postingKind, "--json") if cleanupCode != 0 { t.Logf("could not restore posting %d to Imbox: %s", postingID, cleanupStderr) } diff --git a/tests/smoke/ignore_test.go b/tests/smoke/ignore_test.go index 9d9eee47..aec68b2e 100644 --- a/tests/smoke/ignore_test.go +++ b/tests/smoke/ignore_test.go @@ -6,8 +6,9 @@ import ( ) type ignorePosting struct { - ID int `json:"id"` - Muted bool `json:"muted"` + ID int `json:"id"` + Kind string `json:"kind"` + Muted bool `json:"muted"` } func TestIgnoreAndStopIgnoring(t *testing.T) { @@ -19,17 +20,28 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { if len(box.Postings) == 0 { t.Skip("no threads in Imbox to ignore") } - posting := box.Postings[0] + var posting ignorePosting + found := false + for _, candidate := range box.Postings { + if candidate.Kind == "topic" { + posting = candidate + found = true + break + } + } + if !found { + t.Skip("no email topics in Imbox to ignore") + } postingID := intStr(posting.ID) if posting.Muted { - heyOK(t, "stop-ignoring", postingID, "--json") - t.Cleanup(func() { heyOK(t, "ignore", postingID, "--json") }) + heyOK(t, "stop-ignoring", postingID, "--kind", posting.Kind, "--json") + t.Cleanup(func() { heyOK(t, "ignore", postingID, "--kind", posting.Kind, "--json") }) } else { - t.Cleanup(func() { heyOK(t, "stop-ignoring", postingID, "--json") }) + t.Cleanup(func() { heyOK(t, "stop-ignoring", postingID, "--kind", posting.Kind, "--json") }) } - stdout := heyOK(t, "ignore", postingID, "--json") + stdout := heyOK(t, "ignore", postingID, "--kind", posting.Kind, "--json") var ignoreResp Response if err := json.Unmarshal([]byte(stdout), &ignoreResp); err != nil { t.Fatalf("failed to parse ignore response: %v", err) @@ -45,7 +57,7 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { t.Error("thread is not ignored after hey ignore") } - stdout = heyOK(t, "stop-ignoring", postingID, "--json") + stdout = heyOK(t, "stop-ignoring", postingID, "--kind", posting.Kind, "--json") var stopResp Response if err := json.Unmarshal([]byte(stdout), &stopResp); err != nil { t.Fatalf("failed to parse stop-ignoring response: %v", err) diff --git a/tests/smoke/seen_test.go b/tests/smoke/seen_test.go index 421ac416..85fd4f8f 100644 --- a/tests/smoke/seen_test.go +++ b/tests/smoke/seen_test.go @@ -12,6 +12,7 @@ func TestSeenUnseen(t *testing.T) { type Posting struct { ID int `json:"id"` AppURL string `json:"app_url"` + Kind string `json:"kind"` } type BoxResp struct { Postings []Posting `json:"postings"` @@ -21,11 +22,22 @@ func TestSeenUnseen(t *testing.T) { t.Fatal("no postings in imbox to test seen/unseen") } - posting := data.Postings[0] + var posting Posting + found := false + for _, candidate := range data.Postings { + if candidate.Kind == "topic" { + posting = candidate + found = true + break + } + } + if !found { + t.Skip("no email topics in Imbox to test seen/unseen") + } postingID := intStr(posting.ID) // Mark as unseen. - stdout := heyOK(t, "unseen", postingID, "--json") + stdout := heyOK(t, "unseen", postingID, "--kind", posting.Kind, "--json") var unseenResp Response if err := json.Unmarshal([]byte(stdout), &unseenResp); err != nil { t.Fatalf("failed to parse unseen response: %v", err) @@ -42,7 +54,7 @@ func TestSeenUnseen(t *testing.T) { } // Mark as seen. - stdout = heyOK(t, "seen", postingID, "--json") + stdout = heyOK(t, "seen", postingID, "--kind", posting.Kind, "--json") var seenResp Response if err := json.Unmarshal([]byte(stdout), &seenResp); err != nil { t.Fatalf("failed to parse seen response: %v", err) @@ -53,20 +65,27 @@ func TestSeenUnseen(t *testing.T) { func TestSeenMultiple(t *testing.T) { resp := heyJSON(t, "box", "imbox") type Posting struct { - ID int `json:"id"` + ID int `json:"id"` + Kind string `json:"kind"` } type BoxResp struct { Postings []Posting `json:"postings"` } data := dataAs[BoxResp](t, resp) - if len(data.Postings) < 2 { + var topicIDs []string + for _, posting := range data.Postings { + if posting.Kind == "topic" { + topicIDs = append(topicIDs, intStr(posting.ID)) + if len(topicIDs) == 2 { + break + } + } + } + if len(topicIDs) < 2 { t.Fatal("need at least 2 postings to test multi-seen") } - id1 := intStr(data.Postings[0].ID) - id2 := intStr(data.Postings[1].ID) - - stdout := heyOK(t, "seen", id1, id2, "--json") + stdout := heyOK(t, "seen", topicIDs[0], topicIDs[1], "--kind", "topic", "--json") var resp2 Response if err := json.Unmarshal([]byte(stdout), &resp2); err != nil { t.Fatalf("failed to parse response: %v", err) From 4b7a2416f426db2b233012fd98ebd097f2d86822 Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Wed, 19 Aug 2026 14:27:50 -0500 Subject: [PATCH 2/4] Clarify HEY World help boundaries --- internal/cmd/box.go | 4 ++-- internal/cmd/box_test.go | 10 ++++++++++ internal/cmd/help.go | 6 +++++- internal/cmd/help_test.go | 6 ++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/cmd/box.go b/internal/cmd/box.go index bff2ce67..083ef919 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -26,7 +26,7 @@ func newBoxCommand() *boxCommand { boxCommand := &boxCommand{} boxCommand.cmd = &cobra.Command{ Use: "box ", - Short: "List email threads in a box", + Short: "List email and HEY World items in a box", Long: "List email threads and HEY World posts in a box. Each kind is counted separately. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", Annotations: map[string]string{ "agent_notes": "Accepts a box name or numeric ID. Preserve each item's kind. Use topic_id with hey threads, reply, and forward. Use id plus --kind with email organization actions. HEY World posts are published content, not email.", @@ -38,7 +38,7 @@ func newBoxCommand() *boxCommand { Args: validateBoxArgs, } - boxCommand.cmd.Flags().IntVar(&boxCommand.limit, "limit", 0, "Maximum number of threads to show") + boxCommand.cmd.Flags().IntVar(&boxCommand.limit, "limit", 0, "Maximum number of items to show") boxCommand.cmd.Flags().BoolVar(&boxCommand.all, "all", false, "Fetch all results (override --limit)") return boxCommand diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index de34cf5f..52adb4b8 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -67,6 +67,16 @@ func TestValidateBoxArgs(t *testing.T) { } } +func TestBoxHelpUsesMixedItemTerminology(t *testing.T) { + command := newBoxCommand().cmd + if command.Short != "List email and HEY World items in a box" { + t.Errorf("short help = %q", command.Short) + } + if usage := command.Flags().Lookup("limit").Usage; usage != "Maximum number of items to show" { + t.Errorf("--limit help = %q", usage) + } +} + // makePostings creates n test postings with sequential IDs starting at offset+1. func makePostings(n, offset int) []generated.Posting { postings := make([]generated.Posting, n) diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 9098fa89..91e0ff7d 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,11 @@ var curatedCategories = []struct { }{ { heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch", "world"}, + names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + }, + { + heading: "HEY WORLD", + names: []string{"world"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 84864549..bcfb16b0 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -93,7 +93,7 @@ USAGE EMAIL boxes List your HEY boxes - box List email threads in a box + box List email and HEY World items in a box labels List your email labels label View and manage an email label search Search email threads and messages @@ -113,7 +113,9 @@ EMAIL ignore Ignore email threads stop-ignoring Stop ignoring email threads watch Follow email threads as they change - world Manage HEY World posts + +HEY WORLD + world Manage HEY World posts CALENDAR & TASKS calendars List calendars From 1f71d5fe9a9d2957f7e2190c1e6bb4e111fd084e Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Wed, 19 Aug 2026 14:41:58 -0500 Subject: [PATCH 3/4] Separate box item and topic IDs --- internal/cmd/box.go | 12 ++++++++++-- internal/cmd/box_test.go | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/internal/cmd/box.go b/internal/cmd/box.go index 083ef919..9449068b 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -129,19 +129,27 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { } func boxTableHeaders() []string { - return []string{"Item", "Kind", "From", "Summary", "Date"} + return []string{"Item", "Kind", "Topic", "From", "Summary", "Date"} } func boxTableRow(posting generated.Posting) []string { return []string{ - fmt.Sprintf("%d", resolvePostingTopicID(posting)), + fmt.Sprintf("%d", posting.Id), posting.Kind, + boxPostingTopicID(posting), posting.Creator.Name, truncate(posting.Summary, 60), formatDate(posting.CreatedAt), } } +func boxPostingTopicID(posting generated.Posting) string { + if strings.EqualFold(strings.TrimSpace(posting.Kind), "world/post") { + return "" + } + return fmt.Sprintf("%d", resolvePostingTopicID(posting)) +} + type boxPostingCounts struct { postings int emails int diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index 52adb4b8..ea19339e 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -237,13 +237,22 @@ func TestBoxPostingCountsAndSummary(t *testing.T) { func TestBoxTableLabelsMixedItemsByKind(t *testing.T) { headers := boxTableHeaders() - if len(headers) < 2 || headers[0] != "Item" || headers[1] != "Kind" { - t.Fatalf("headers = %v, want Item and Kind columns", headers) + if len(headers) < 3 || headers[0] != "Item" || headers[1] != "Kind" || headers[2] != "Topic" { + t.Fatalf("headers = %v, want Item, Kind, and Topic columns", headers) } - row := boxTableRow(generated.Posting{Id: 102, Kind: "world/post", Summary: "Published note"}) - if len(row) < 2 || row[0] != "102" || row[1] != "world/post" { - t.Fatalf("row = %v, want World item ID and kind", row) + worldRow := boxTableRow(generated.Posting{Id: 102, Kind: "world/post", Summary: "Published note"}) + if len(worldRow) < 3 || worldRow[0] != "102" || worldRow[1] != "world/post" || worldRow[2] != "" { + t.Fatalf("row = %v, want World item ID, kind, and no topic ID", worldRow) + } + + emailRow := boxTableRow(generated.Posting{ + Id: 103, + Kind: "topic", + AppUrl: "https://app.hey.com/topics/2103970613", + }) + if len(emailRow) < 3 || emailRow[0] != "103" || emailRow[1] != "topic" || emailRow[2] != "2103970613" { + t.Fatalf("row = %v, want separate email item and topic IDs", emailRow) } } From 934ce670a1037ec773bd2d0119bd6d459741fc85 Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Thu, 20 Aug 2026 13:21:26 -0500 Subject: [PATCH 4/4] Use topic kind in email action examples --- internal/cmd/help_test.go | 17 +++++++++++++++++ internal/cmd/ignore.go | 4 ++-- internal/cmd/move.go | 6 +++--- internal/cmd/seen.go | 8 ++++---- internal/cmd/spam.go | 4 ++-- internal/cmd/stop_ignoring.go | 4 ++-- internal/cmd/trash.go | 4 ++-- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index bcfb16b0..1bd71b3c 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -51,6 +51,23 @@ func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { } } +func TestEmailActionExamplesCarryTopicKind(t *testing.T) { + root := newRootCmd() + for _, name := range []string{"seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"} { + t.Run(name, func(t *testing.T) { + command, _, err := root.Find([]string{name}) + if err != nil { + t.Fatal(err) + } + for _, example := range strings.Split(strings.TrimSpace(command.Example), "\n") { + if !strings.Contains(example, "--kind topic") { + t.Errorf("example does not preserve email kind: %q", example) + } + } + }) + } +} + func TestContactCommandHelpUsesHEYTerminology(t *testing.T) { root := newRootCmd() contacts, _, err := root.Find([]string{"contacts"}) diff --git a/internal/cmd/ignore.go b/internal/cmd/ignore.go index a0f5b943..ece8f134 100644 --- a/internal/cmd/ignore.go +++ b/internal/cmd/ignore.go @@ -19,8 +19,8 @@ func newIgnoreCommand() *ignoreCommand { Use: "ignore ...", Short: "Ignore email threads", Long: "Ignore one or more email threads so new replies do not bring them back to your attention.", - Example: ` hey ignore 12345 - hey ignore 12345 67890`, + Example: ` hey ignore 12345 --kind topic + hey ignore 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Ignored threads remain in their box and can be restored with hey stop-ignoring.", }, diff --git a/internal/cmd/move.go b/internal/cmd/move.go index 7742a630..98eff106 100644 --- a/internal/cmd/move.go +++ b/internal/cmd/move.go @@ -26,9 +26,9 @@ func newMoveCommand() *moveCommand { Use: "move ...", Short: "Move email threads to another box", Long: "Move one or more email threads to Imbox, The Feed, Set Aside, Reply Later, or Paper Trail.", - Example: ` hey move 12345 --to feed - hey move 12345 67890 --to "paper trail" - hey move 12345 --to 987`, + Example: ` hey move 12345 --to feed --kind topic + hey move 12345 67890 --to "paper trail" --kind topic + hey move 12345 --to 987 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", }, diff --git a/internal/cmd/seen.go b/internal/cmd/seen.go index 07bd4c47..d4e785fa 100644 --- a/internal/cmd/seen.go +++ b/internal/cmd/seen.go @@ -19,8 +19,8 @@ func newSeenCommand() *seenCommand { seenCommand.cmd = &cobra.Command{ Use: "seen ...", Short: "Mark email threads as seen", - Example: ` hey seen 12345 - hey seen 12345 67890`, + Example: ` hey seen 12345 --kind topic + hey seen 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each email thread as seen/read.", }, @@ -71,8 +71,8 @@ func newUnseenCommand() *unseenCommand { unseenCommand.cmd = &cobra.Command{ Use: "unseen ...", Short: "Mark email threads as unseen", - Example: ` hey unseen 12345 - hey unseen 12345 67890`, + Example: ` hey unseen 12345 --kind topic + hey unseen 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each email thread as unseen/unread.", }, diff --git a/internal/cmd/spam.go b/internal/cmd/spam.go index 6aca1e5f..8f46cdd2 100644 --- a/internal/cmd/spam.go +++ b/internal/cmd/spam.go @@ -19,8 +19,8 @@ func newSpamCommand() *spamCommand { Use: "spam ...", Short: "Mark email threads as spam", Long: "Mark one or more email threads as spam. HEY moves the threads to Spam and trains its filters.", - Example: ` hey spam 12345 - hey spam 12345 67890`, + Example: ` hey spam 12345 --kind topic + hey spam 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Marks each thread as spam and removes it from the current box.", }, diff --git a/internal/cmd/stop_ignoring.go b/internal/cmd/stop_ignoring.go index 6ad59bc4..2da6bfb1 100644 --- a/internal/cmd/stop_ignoring.go +++ b/internal/cmd/stop_ignoring.go @@ -19,8 +19,8 @@ func newStopIgnoringCommand() *stopIgnoringCommand { Use: "stop-ignoring ...", Short: "Stop ignoring email threads", Long: "Stop ignoring one or more email threads so new replies can bring them back to your attention.", - Example: ` hey stop-ignoring 12345 - hey stop-ignoring 12345 67890`, + Example: ` hey stop-ignoring 12345 --kind topic + hey stop-ignoring 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Reverses hey ignore for each thread.", }, diff --git a/internal/cmd/trash.go b/internal/cmd/trash.go index aa9761cb..5a16e2d0 100644 --- a/internal/cmd/trash.go +++ b/internal/cmd/trash.go @@ -19,8 +19,8 @@ func newTrashCommand() *trashCommand { Use: "trash ...", Short: "Move email threads to Trash", Long: "Move one or more email threads to Trash. For a shared thread, HEY removes your access instead of deleting it for everyone.", - Example: ` hey trash 12345 - hey trash 12345 67890`, + Example: ` hey trash 12345 --kind topic + hey trash 12345 67890 --kind topic`, Annotations: map[string]string{ "agent_notes": "Accepts one or more box item IDs from hey box output. Pass --kind exactly as returned by hey box --json. HEY World posts are rejected before any email action is requested. Shared threads lose your access rather than being deleted for everyone.", },