diff --git a/README.md b/README.md index 0c27d406..14c1888a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ screen a moment later. ## Using the CLI ```bash -hey box view imbox # threads in a box +hey box view imbox # email and HEY World items in a box hey thread read 12345 # a whole thread, as Markdown hey reply 12345 -m "Friday works for me." hey compose --to alice@example.com --subject "Lunch?" -m "Thursday at noon?" @@ -78,7 +78,7 @@ Piped, a command that returns data writes JSON, and `--jq` filters it without a `jq`: ```bash -hey box view imbox --jq '.data.postings[] | {topic_id, subject}' +hey box view imbox --jq '.data.postings[] | {id, kind, topic_id, subject}' hey label view 789 --ids-only # one ID per line, for xargs ``` diff --git a/docs/cli.md b/docs/cli.md index 830e78bf..057102f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -154,7 +154,7 @@ or through the direct-form escape (`hey box -- list`). ```bash hey box list # list mailboxes -hey box view imbox # list email threads in a box (by name or ID) +hey box view imbox # list email and HEY World items (by box name or ID) hey bundle view 456 # list the unseen threads a bundle row groups hey label list # list labels and their IDs hey label view 789 --all # list all email threads with a label @@ -275,6 +275,8 @@ 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 attachment list ` returns every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. +`hey box view --json` preserves every row's `kind`. A `world/post` row is published HEY World content, not email; preserve that kind while selecting IDs and never pass its `id` to email organization actions, because those commands receive bare IDs and cannot infer the kind. The response metadata reports `posting_count`, `email_count`, and `world_post_count`, and the summary names email and World counts separately when both are present. + 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 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. diff --git a/internal/cmd/box.go b/internal/cmd/box.go index db7ffd7c..e1d27965 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -33,8 +33,11 @@ type boxOutput struct { } var boxListing = postingsListing{ - heading: "Box", - summary: boxSummary, + heading: "Box", + summary: boxSummary, + summarize: boxPostingSummary, + metadata: boxPostingMetadata, + showSummary: true, cursorNotice: func(shown, total int) string { return fmt.Sprintf("Showing %d remaining results from this cursor (%d threads read).", shown, total) }, @@ -49,8 +52,8 @@ var boxListing = postingsListing{ func newBoxCommand() *boxCommand { command := newBoxReaderCommand( "box", - "List HEY boxes and their email threads", - "List HEY boxes or list email threads in one box.", + "List HEY boxes and their items", + "List HEY boxes, or list email threads and HEY World posts in one box.", ` hey box list hey box view imbox hey box view imbox --limit 10 @@ -65,8 +68,8 @@ func newBoxCommand() *boxCommand { 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.", + "List email and HEY World items in a box", + "List email threads and HEY World posts in a 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 @@ -81,14 +84,14 @@ func newBoxReaderCommand(use, short, long, example string) *boxCommand { 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 thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", + "agent_notes": "Accepts a box name or numeric ID. Returns email threads and HEY World posts; preserve each row's kind and never pass a world/post ID to email actions. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", }, Example: example, RunE: command.run, Args: validateBoxArgs, } - command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of items 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") @@ -129,6 +132,66 @@ func boxSummary(count int, name string) string { return fmt.Sprintf("%d %s in %s", count, threadNoun(count), name) } +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 mail.IsWorldPostKind(posting.Kind) { + counts.worldPosts++ + continue + } + counts.emails++ + } + return counts +} + +func boxPostingSummary(postings []generated.Posting, boxName string) string { + return countBoxPostings(postings).summary(boxName) +} + +func boxPostingMetadata(postings []generated.Posting) []output.ResponseOption { + counts := countBoxPostings(postings) + return []output.ResponseOption{ + output.WithMeta("posting_count", counts.postings), + output.WithMeta("email_count", counts.emails), + output.WithMeta("world_post_count", counts.worldPosts), + } +} + +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 +} + // boxPayload answers with the box HEY served, its postings replaced by the ones the // listing read and its cursor by the one the next read carries on from. next_page is that // cursor on its own, which is what --page takes; next_history_url keeps the whole URL. diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index d64c8ddc..cdcc5db0 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "testing" + "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/spf13/cobra" ) @@ -59,6 +60,19 @@ func TestValidateBoxArgs(t *testing.T) { } } +func TestBoxViewHelpUsesMixedItemTerminology(t *testing.T) { + command := newBoxViewCommand().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) + } + if notes := command.Annotations["agent_notes"]; !strings.Contains(notes, "world/post") { + t.Errorf("agent notes omit the World-post boundary: %q", notes) + } +} + func TestBoxCommandNamedRoutes(t *testing.T) { tests := []struct { name string @@ -91,7 +105,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) } }) @@ -111,7 +125,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." { @@ -149,7 +163,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) } } @@ -195,7 +209,7 @@ func TestBoxCommandFollowsPagesOnTheNamedRoute(t *testing.T) { if got := fmt.Sprint(requests); got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "2 threads in The Feed" { + if response.Summary != "2 emails in The Feed" { t.Errorf("summary = %q", response.Summary) } } @@ -241,7 +255,7 @@ func TestBoxCommandFollowsPagesForACustomBox(t *testing.T) { if got := fmt.Sprint(requests); got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "2 threads in Receipts" { + if response.Summary != "2 emails in Receipts" { t.Errorf("summary = %q", response.Summary) } data, _ := response.Data.(map[string]any) @@ -288,7 +302,7 @@ func TestBoxCommandStopsAtAnEmptyPage(t *testing.T) { if requests.Load() != 2 { t.Errorf("requests = %d, want two", requests.Load()) } - if response.Summary != "1 thread in Imbox" || response.Notice != "" { + if response.Summary != "1 email in Imbox" || response.Notice != "" { t.Errorf("summary = %q notice = %q", response.Summary, response.Notice) } } @@ -309,19 +323,85 @@ func TestBoxSummaryUsesThreadTerminology(t *testing.T) { count int want string }{ - {"one thread", 1, "1 thread in Imbox"}, - {"multiple threads", 2, "2 threads in Imbox"}, + {name: "one thread", count: 1, want: "1 thread in Imbox"}, + {name: "multiple threads", count: 2, want: "2 threads in Imbox"}, } - 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) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := boxSummary(test.count, "Imbox"); got != test.want { + t.Errorf("boxSummary(%d) = %q, want %q", test.count, got, test.want) } }) } } +func TestBoxPostingCountsAndSummary(t *testing.T) { + postings := make([]generated.Posting, 2944) + for i := range postings { + postings[i] = generated.Posting{Id: int64(i + 1), Kind: "topic"} + } + for i := 0; i < 21; i++ { + postings = append(postings, generated.Posting{Id: int64(3000 + i), Kind: "world/post"}) + } + + 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 TestBoxMixedPostingKindsJSONContract(t *testing.T) { + resp, err := runJSONCommand(t, 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"} + ] + }`)) + }), "box", "view", "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"]) + } +} + // The thread ID is the point of a listing: whatever `hey box --json` calls topic_id is // what `hey thread read` reads, and the box item ID is not. func TestBoxCommandCarriesAThreadIDThatThreadsReads(t *testing.T) { @@ -416,7 +496,7 @@ func TestBoxCommandContinuesFromAPageCursor(t *testing.T) { if err != nil { t.Fatalf("execute box --page %s: %v", page, err) } - if response.Summary != "1 thread in Imbox" { + if response.Summary != "1 email in Imbox" { t.Errorf("summary = %q", response.Summary) } } @@ -452,7 +532,7 @@ func TestBoxCommandOutputFormats(t *testing.T) { if err != nil { t.Fatalf("styled box: %v", err) } - for _, want := range []string{"Box: Imbox (imbox)", "Thread", "Jane Doe", "Studio invoice", "101", "501"} { + for _, want := range []string{"Box: Imbox (imbox)", "Thread", "Jane Doe", "Studio invoice", "101", "501", "2 emails in Imbox."} { if !strings.Contains(styled, want) { t.Errorf("styled output %q does not contain %q", styled, want) } diff --git a/internal/cmd/canonical_commands_test.go b/internal/cmd/canonical_commands_test.go index 41d5d0c7..3061e7b9 100644 --- a/internal/cmd/canonical_commands_test.go +++ b/internal/cmd/canonical_commands_test.go @@ -116,7 +116,7 @@ func TestBoxListIsReservedAndViewStillOpensABoxNamedList(t *testing.T) { if err != nil { t.Fatal(err) } - if viewed.Summary != "0 threads in list" { + if viewed.Summary != "0 emails in list" { t.Errorf("box view list summary = %q", viewed.Summary) } diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a83..a56654aa 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -106,7 +106,7 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI - box List HEY boxes and their email threads + box List HEY boxes and their items thread Read email threads reply Reply to a thread compose Write and send a new email diff --git a/internal/cmd/postings_listing.go b/internal/cmd/postings_listing.go index 61a547cd..4dbebacb 100644 --- a/internal/cmd/postings_listing.go +++ b/internal/cmd/postings_listing.go @@ -68,6 +68,9 @@ type groupedPostingRow struct { type postingsListing struct { heading string summary func(count int, name string) string + summarize func(postings []generated.Posting, name string) string + metadata func(postings []generated.Posting) []output.ResponseOption + showSummary bool cursorNotice func(shown, total int) string breadcrumbs []output.Breadcrumb payload func(source mail.Source, postings []sourcePostingOutput, nextPage string, total int) any @@ -113,12 +116,23 @@ func (l postingsListing) writePages(cmd *cobra.Command, source mail.Source, firs case output.FormatMarkdown: return l.writeMarkdown(cmd, source, postings, nextPage, collected.Total, notice) default: - return writeOK(l.sourcePayload(source, postings, nextPage, collected.Total), - output.WithSummary(l.summary(len(postings), source.Name)), + options := []output.ResponseOption{ + output.WithSummary(l.summaryFor(postings, source.Name)), output.WithNotice(notice), output.WithBreadcrumbs(l.breadcrumbs...), - ) + } + if l.metadata != nil { + options = append(options, l.metadata(postings)...) + } + return writeOK(l.sourcePayload(source, postings, nextPage, collected.Total), options...) + } +} + +func (l postingsListing) summaryFor(postings []generated.Posting, name string) string { + if l.summarize != nil { + return l.summarize(postings, name) } + return l.summary(len(postings), name) } // sourcePayload is what `--json` answers with: the listing's own payload where it has one, @@ -181,6 +195,9 @@ func (l postingsListing) writeStyled(cmd *cobra.Command, source mail.Source, pos )) } table.print() + if l.showSummary { + fmt.Fprintln(cmd.OutOrStdout(), terminal.SanitizeLine(l.summaryFor(postings, source.Name))+".") + } if notice != "" { fmt.Fprintln(cmd.OutOrStdout(), notice) } diff --git a/internal/mail/posting.go b/internal/mail/posting.go index 6e31b949..0fea06e0 100644 --- a/internal/mail/posting.go +++ b/internal/mail/posting.go @@ -21,6 +21,7 @@ type Posting struct { // so the box a thread files out of is the row's own rather than the list's. BoxID int64 TopicID int64 + Kind string CreatedAt time.Time Name string Summary string @@ -79,6 +80,7 @@ func NewPosting(posting generated.Posting) Posting { ID: posting.Id, BoxID: posting.BoxId, TopicID: TopicIDOf(posting), + Kind: terminal.SanitizeLine(posting.Kind), CreatedAt: posting.CreatedAt, Name: terminal.SanitizeLine(posting.Name), Summary: terminal.SanitizeLine(posting.Summary), @@ -95,6 +97,18 @@ func NewPosting(posting generated.Posting) Posting { } } +// IsWorldPost reports whether this row is published HEY World content rather than +// an email thread. +func (p Posting) IsWorldPost() bool { + return IsWorldPostKind(p.Kind) +} + +// IsWorldPostKind reports whether HEY's posting discriminator names published +// HEY World content. +func IsWorldPostKind(kind string) bool { + return strings.EqualFold(strings.TrimSpace(kind), "world/post") +} + func contactOf(contact generated.Contact) Contact { return Contact{ ID: contact.Id, diff --git a/internal/mail/posting_test.go b/internal/mail/posting_test.go index f1a3e4e6..c14fac8b 100644 --- a/internal/mail/posting_test.go +++ b/internal/mail/posting_test.go @@ -33,7 +33,7 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { if !posting.CreatedAt.Equal(created) { t.Errorf("created at = %s, want %s", posting.CreatedAt, created) } - if posting.ID != 4471829 || posting.TopicID != 501 || posting.Name != "Kitchen remodel quote" { + if posting.ID != 4471829 || posting.TopicID != 501 || posting.Kind != "topic" || posting.Name != "Kitchen remodel quote" { t.Errorf("posting = %+v", posting) } // The box is the row's own, not the list's: it is what a thread found through a @@ -62,6 +62,18 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { } } +func TestPostingIdentifiesWorldContentByKind(t *testing.T) { + for _, kind := range []string{"world/post", " WORLD/POST "} { + posting := NewPosting(generated.Posting{Kind: kind}) + if !posting.IsWorldPost() { + t.Errorf("kind %q was not identified as HEY World content", kind) + } + } + if NewPosting(generated.Posting{Kind: "topic"}).IsWorldPost() { + t.Error("an email topic was identified as HEY World content") + } +} + // The zone HEY served is the reader's own, and it decides which day a late-evening thread // falls on. Normalizing it away is the bug that made `hey journal list` print yesterday. func TestNewPostingKeepsTheZoneHEYServed(t *testing.T) { diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 2a634ade..47eff9f0 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -2090,6 +2090,10 @@ func (v *mailView) openSelected() tea.Cmd { if selected == nil { return nil } + if selected.IsWorldPost() { + v.notice = "HEY World posts are published content; email thread actions are unavailable" + return nil + } // A bundle names a topic only when it holds one unseen thread — otherwise its row // opens the bundle itself: the unseen threads while there are any, or every thread // with its contact once it has been read through, which is where the web app sends @@ -2159,6 +2163,10 @@ func (v *mailView) startMove() { if selected == nil || currentSource == nil { return } + if selected.IsWorldPost() { + 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" @@ -2432,6 +2440,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { if selected == nil { return nil } + if selected.IsWorldPost() { + v.notice = "HEY World posts are published content; email actions are unavailable" + return nil + } return v.postingAction(key, *selected, v.postingBoxKind(*selected)) } diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8da197f8..84abc11f 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -1901,6 +1901,37 @@ func TestMailViewPostingKeyFailureKeepsPosting(t *testing.T) { } } +func TestMailViewRejectsWorldPostEmailActionsWithoutRequests(t *testing.T) { + tests := []struct { + name string + key string + }{ + {name: "open", key: "enter"}, + {name: "move picker", key: "v"}, + {name: "move shortcut", key: "d"}, + {name: "trash", key: "t"}, + {name: "spam", key: "!"}, + {name: "ignore", key: "-"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings = []mail.Posting{{ID: 900, Kind: "world/post", Summary: "Published note"}} + v.postingList.cursor = 0 + + if cmd := v.HandleContentKey(keyPress(test.key)); cmd != nil { + t.Fatalf("world post action %q returned a command", test.key) + } + if len(recorded.requests) != 0 { + t.Fatalf("world post action %q made requests: %v", test.key, recorded.requests) + } + if !strings.Contains(v.notice, "HEY World") { + t.Fatalf("world post action %q notice = %q", test.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 747ffe67..6a87b20a 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -123,6 +123,7 @@ CLI for HEY: mailboxes, labels, collections, email threads, contacts, replies, c 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey account 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 view --json` — `world/post` is published HEY World content, not email, and must not be passed to email-only actions ## Output Filtering @@ -580,6 +581,14 @@ hey spam 12345 67890 # Mark multiple threads as spam 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. +### HEY World + +`hey box view --json` can return `world/post` items beside email topics. These are +published content, not email. The response metadata reports `posting_count`, +`email_count`, and `world_post_count`. Preserve each row's `kind` and exclude +`world/post` rows before passing box item IDs to email organization commands; those +commands receive bare IDs and cannot validate the kind themselves. + ### Email - Ignoring Threads ```bash diff --git a/tests/smoke/boxes_test.go b/tests/smoke/boxes_test.go index f177021c..e4eec2ce 100644 --- a/tests/smoke/boxes_test.go +++ b/tests/smoke/boxes_test.go @@ -58,6 +58,7 @@ func TestBoxImbox(t *testing.T) { type Posting struct { ID int `json:"id"` AppURL string `json:"app_url"` + Kind string `json:"kind"` Summary string `json:"summary"` } type BoxResponse struct { @@ -70,6 +71,11 @@ func TestBoxImbox(t *testing.T) { if data.Kind != "imbox" { t.Errorf("expected kind=imbox, got %s", data.Kind) } + for _, posting := range data.Postings { + if posting.Kind == "" { + t.Errorf("posting %d has no kind", posting.ID) + } + } // Cross-verify: pick a posting and verify its topic page exists on the server. if len(data.Postings) > 0 { @@ -174,8 +180,9 @@ 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"` @@ -183,19 +190,24 @@ func TestMovePosting(t *testing.T) { imbox := dataAs[BoxResponse](t, resp) postingID := 0 for _, posting := range imbox.Postings { - if posting.Seen { + if posting.Kind == "topic" && posting.Seen { postingID = posting.ID break } } if postingID == 0 { - if len(imbox.Postings) == 0 { - skipf(t, "no postings in Imbox to move") + for _, posting := range imbox.Postings { + if posting.Kind == "topic" { + postingID = posting.ID + break + } + } + if postingID == 0 { + skipf(t, "no email topics in Imbox to move") } // No seen posting to borrow: mark one seen and put its unread // state back afterwards. The restore runs after the move-back // cleanup below, so the posting is home before it goes unseen. - postingID = imbox.Postings[0].ID heyOK(t, "seen", intStr(postingID), "--json") t.Cleanup(func() { _, cleanupStderr, cleanupCode := hey(t, "unseen", intStr(postingID), "--json") diff --git a/tests/smoke/ignore_test.go b/tests/smoke/ignore_test.go index aadc0a16..f2501ca5 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,7 +20,18 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { if len(box.Postings) == 0 { skipf(t, "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 { diff --git a/tests/smoke/seen_test.go b/tests/smoke/seen_test.go index 421ac416..5c5abc29 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,7 +22,18 @@ 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. @@ -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 { - t.Fatal("need at least 2 postings to test multi-seen") + 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 email topics 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], "--json") var resp2 Response if err := json.Unmarshal([]byte(stdout), &resp2); err != nil { t.Fatalf("failed to parse response: %v", err)