From 0919bf1183ad710d127740cb6232174c4a7ffd98 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 04:13:59 -0400 Subject: [PATCH 1/5] Manage email clips from the CLI --- .surface | 5 + README.md | 9 +- internal/cmd/clip.go | 201 ++++++++++++++++++++++++++++++++++++ internal/cmd/clip_test.go | 207 ++++++++++++++++++++++++++++++++++++++ internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 4 +- internal/cmd/root.go | 2 + tests/smoke/clips_test.go | 102 +++++++++++++++++++ 8 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/clip.go create mode 100644 internal/cmd/clip_test.go create mode 100644 tests/smoke/clips_test.go diff --git a/.surface b/.surface index e42ccfe3..44464090 100644 --- a/.surface +++ b/.surface @@ -45,6 +45,11 @@ hey bulk-reply send --attach hey bulk-reply send --message hey bulk-reply undo hey calendars +hey clip +hey clip create +hey clip create --content +hey clip delete +hey clips hey collection hey collection --all hey collection --limit diff --git a/README.md b/README.md index 08758ac4..f18fccb3 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Listing commands also answer `--markdown` for a table, `--styled` to force the h rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a bare number. `--ids-only` and `--count` need list data, so they work on `hey boxes`, `hey box`, `hey labels`, `hey label`, `hey collections`, `hey collection`, `hey workflows`, -`hey workflow`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, +`hey workflow`, `hey clips`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay pipeable. @@ -317,7 +317,10 @@ hey workflow stage update 654 321 --name "Interviewing" hey workflow add 987 --to 654 --stage 321 # add a topic ID to a stage hey workflow move 987 --workflow 654 --to 322 # move it to another stage hey workflow remove 987 --from 654 # remove it from the workflow -hey snippets # list reusable email snippets +hey clips # list saved passages and source context +hey clip create 456 --content "The launch moves to Wednesday." +hey clip delete 44 +hey snippets # list reusable email snippets hey snippet create --name "Scheduling reply" --content "Tuesday works for me." hey snippet update 44 --content "Wednesday works for me." hey snippet delete 44 @@ -392,6 +395,8 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. +Clips are passages saved from existing email entries. `hey clips` lists each clip newest first with its source entry and thread context. `hey clip create --content ` saves the selected text against that entry, and `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. + Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. `hey box `, `hey label ` and `hey collection ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go new file mode 100644 index 00000000..2e1216db --- /dev/null +++ b/internal/cmd/clip.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +type clipsCommand struct { + cmd *cobra.Command +} + +func newClipsCommand() *clipsCommand { + clipsCommand := &clipsCommand{} + clipsCommand.cmd = &cobra.Command{ + Use: "clips", + Short: "List passages clipped from email", + Annotations: map[string]string{ + "agent_notes": "Returns clip IDs, content, source entry IDs, and source thread context. Use an ID with hey clip delete.", + }, + Example: ` hey clips + hey clips --json + hey clips --ids-only`, + RunE: clipsCommand.run, + Args: cobra.NoArgs, + } + return clipsCommand +} + +func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + + clips, err := sdk.Clips().List(cmd.Context()) + if err != nil { + return apierr.FromSDK(err) + } + + switch writer.EffectiveFormat() { + case output.FormatStyled: + if len(clips) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No clips found") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Content", "Entry", "Thread", "Saved"}) + for _, clip := range clips { + table.addRow([]string{ + fmt.Sprintf("%d", clip.Id), + truncate(terminal.SanitizeLine(clip.Content), 60), + fmt.Sprintf("%d", clip.EntryId), + clipTopicLabel(clip.Topic), + formatDate(clip.CreatedAt), + }) + } + table.print() + return nil + case output.FormatMarkdown: + return writeClipsMarkdown(cmd, clips) + default: + return writeOK(clips, + output.WithSummary(fmt.Sprintf("%d %s", len(clips), clipNoun(len(clips)))), + output.WithBreadcrumbs( + output.Breadcrumb{Action: "create", Command: "hey clip create --content ", Description: "Save text from an email entry"}, + output.Breadcrumb{Action: "delete", Command: "hey clip delete ", Description: "Delete a clip"}, + ), + ) + } +} + +func clipTopicLabel(topic generated.ClipTopic) string { + name := terminal.SanitizeLine(topic.Name) + if name == "" { + return fmt.Sprintf("%d", topic.Id) + } + return fmt.Sprintf("%s (%d)", name, topic.Id) +} + +func writeClipsMarkdown(cmd *cobra.Command, clips []generated.Clip) error { + if len(clips) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "(no results)") + return err + } + var document strings.Builder + document.WriteString("| id | content | entry_id | topic_id | topic | saved |\n") + document.WriteString("| --- | --- | --- | --- | --- | --- |\n") + for _, clip := range clips { + fmt.Fprintf(&document, "| %d | %s | %d | %d | %s | %s |\n", + clip.Id, + markdownSafeText(clip.Content), + clip.EntryId, + clip.Topic.Id, + markdownSafeText(clip.Topic.Name), + formatDate(clip.CreatedAt), + ) + } + _, err := fmt.Fprint(cmd.OutOrStdout(), document.String()) + return err +} + +func clipNoun(count int) string { + if count == 1 { + return "clip" + } + return "clips" +} + +type clipCommand struct { + cmd *cobra.Command +} + +func newClipCommand() *clipCommand { + clipCommand := &clipCommand{} + clipCommand.cmd = &cobra.Command{ + Use: "clip", + Short: "Save and manage passages from email", + Annotations: map[string]string{ + "agent_notes": "Create a clip from an email entry ID and selected text, or delete a clip. Find clip IDs with hey clips.", + }, + } + clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) + clipCommand.cmd.AddCommand(newClipDeleteCommand().cmd) + return clipCommand +} + +type clipCreateCommand struct { + cmd *cobra.Command + content string +} + +func newClipCreateCommand() *clipCreateCommand { + createCommand := &clipCreateCommand{} + createCommand.cmd = &cobra.Command{ + Use: "create ", + Aliases: []string{"add"}, + Short: "Save text from an email entry", + Example: ` hey clip create 987 --content "The launch moves to Wednesday."`, + RunE: createCommand.run, + Args: usageExactOneArg(), + } + createCommand.cmd.Flags().StringVar(&createCommand.content, "content", "", "Text selected from the email entry (required)") + return createCommand +} + +func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + entryID, err := parsePositiveID(args[0], "entry") + if err != nil { + return err + } + if strings.TrimSpace(c.content) == "" { + return apierr.ErrUsage("--content is required") + } + if err := sdk.Clips().Create(cmd.Context(), entryID, c.content); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Clip from entry %d created", entryID), map[string]any{"entry_id": entryID}, + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey clips", Description: "Find the new clip ID"}), + ) +} + +type clipDeleteCommand struct { + cmd *cobra.Command +} + +func newClipDeleteCommand() *clipDeleteCommand { + deleteCommand := &clipDeleteCommand{} + deleteCommand.cmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"remove", "rm"}, + Short: "Delete a saved clip", + Example: ` hey clip delete 44`, + RunE: deleteCommand.run, + Args: usageExactOneArg(), + } + return deleteCommand +} + +func (c *clipDeleteCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + clipID, err := parsePositiveID(args[0], "clip") + if err != nil { + return err + } + if err := sdk.Clips().Delete(cmd.Context(), clipID); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Clip %d deleted", clipID), map[string]any{"id": clipID}) +} diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go new file mode 100644 index 00000000..41c60ce3 --- /dev/null +++ b/internal/cmd/clip_test.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +const clipsJSON = `[ + {"id":4,"content":"The launch moves to Wednesday.","entry_id":987,"topic":{"id":55,"name":"Launch planning","app_url":"https://app.hey.com/topics/55"},"created_at":"2026-08-22T02:03:04Z"}, + {"id":3,"content":"Keep the existing rollout window.","entry_id":876,"topic":{"id":44,"name":"Release notes","app_url":"https://app.hey.com/topics/44"},"created_at":"2026-08-21T01:02:03Z"} +]` + +func TestClipsCommandListsClipsInEveryFormat(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/clips.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, clipsJSON) + }) + + response, err := runJSONCommand(t, handler, "clips") + if err != nil { + t.Fatal(err) + } + if response.Summary != "2 clips" { + t.Errorf("summary = %q", response.Summary) + } + items := response.Data.([]any) + first := items[0].(map[string]any) + topic := first["topic"].(map[string]any) + if len(items) != 2 || first["entry_id"] != float64(987) || topic["app_url"] == "" { + t.Errorf("items = %#v", items) + } + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, "clips") + if err != nil || ids != "4\n3\n" { + t.Errorf("ids = %q, err = %v", ids, err) + } + count, err := runFormattedCommand(t, handler, []string{"--count"}, "clips") + if err != nil || count != "2\n" { + t.Errorf("count = %q, err = %v", count, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || !strings.Contains(markdown, "| 4 | The launch moves to Wednesday\\. | 987 | 55 | Launch planning |") { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, err := runStyledCommand(t, handler, "clips") + if err != nil || !strings.Contains(styled, "Launch planning (55)") || !strings.Contains(styled, "Entry") { + t.Errorf("styled = %q, err = %v", styled, err) + } +} + +func TestClipsMarkdownSurfacesWriteFailure(t *testing.T) { + cmd := newClipsCommand().cmd + cmd.SetOut(failingWriter{}) + if err := writeClipsMarkdown(cmd, []generated.Clip{{Id: 4, Content: "Keep this", EntryId: 987}}); err == nil { + t.Fatal("expected the write failure") + } +} + +func TestClipsCommandPreservesEmptyList(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + }) + response, err := runJSONCommand(t, handler, "clips") + if err != nil { + t.Fatal(err) + } + if items := response.Data.([]any); len(items) != 0 || response.Summary != "0 clips" { + t.Errorf("response = %#v", response) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || markdown != "(no results)\n" { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, err := runStyledCommand(t, handler, "clips") + if err != nil || styled != "No clips found\n" { + t.Errorf("styled = %q, err = %v", styled, err) + } +} + +func TestClipsCommandSanitizesHumanOutput(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"id":4,"content":"[click](https://example.invalid)\u001b[31m","entry_id":987,"topic":{"id":55,"name":"Safe\u001b[31mRed"}}]`) + }) + styled, err := runStyledCommand(t, handler, "clips") + if err != nil || strings.Contains(styled, "\x1b[31m") { + t.Errorf("styled = %q, err = %v", styled, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || strings.Contains(markdown, "[click](") || !strings.Contains(markdown, `\[click\]`) { + t.Errorf("markdown = %q, err = %v", markdown, err) + } +} + +func TestClipCreateSendsEntryAndExactContent(t *testing.T) { + content := " The launch moves to Wednesday.\nPlease tell the team. " + response, err := runJSONCommand(t, clipMutationHandler(t, http.MethodPost, "/clips", func(r *http.Request) { + if got := r.PostForm.Get("clip[entry_id]"); got != "987" { + t.Errorf("entry_id = %q", got) + } + if got := r.PostForm.Get("clip[content]"); got != content { + t.Errorf("content = %q", got) + } + }), "clip", "create", "987", "--content", content) + if err != nil { + t.Fatal(err) + } + if response.Summary != "Clip from entry 987 created" || response.Data.(map[string]any)["entry_id"] != float64(987) { + t.Errorf("response = %#v", response) + } +} + +func TestClipDeleteUsesClipID(t *testing.T) { + response, err := runJSONCommand(t, clipMutationHandler(t, http.MethodDelete, "/clips/44", nil), "clip", "delete", "44") + if err != nil { + t.Fatal(err) + } + if response.Summary != "Clip 44 deleted" || response.Data.(map[string]any)["id"] != float64(44) { + t.Errorf("response = %#v", response) + } +} + +func TestClipCommandsUseTheSelectedAccount(t *testing.T) { + var requested []string + server := linkedAccountServer(t, func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.Method+" "+r.URL.Path+" account="+r.URL.Query().Get("filtered_account_id")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/clips.json": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + case r.Method == http.MethodPost && r.URL.Path == "/clips": + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + case r.Method == http.MethodDelete && r.URL.Path == "/clips/44": + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + default: + http.NotFound(w, r) + } + }) + + for _, args := range [][]string{ + {"--account", "2", "clips"}, + {"--account", "2", "clip", "create", "987", "--content", "Keep this"}, + {"--account", "2", "clip", "delete", "44"}, + } { + if output, err := runAccountsCLI(t, server, args...); err != nil { + t.Fatalf("hey %s: %v\n%s", strings.Join(args, " "), err, output) + } + } + if got := strings.Join(requested, "\n"); got != "GET /clips.json account=2\nPOST /clips account=2\nDELETE /clips/44 account=2" { + t.Errorf("requests:\n%s", got) + } +} + +func TestClipCommandsValidateInput(t *testing.T) { + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("unexpected request") + }) + tests := []struct { + name string + args []string + want string + }{ + {name: "create content", args: []string{"clip", "create", "987"}, want: "--content is required"}, + {name: "blank content", args: []string{"clip", "create", "987", "--content", " "}, want: "--content is required"}, + {name: "invalid entry", args: []string{"clip", "create", "zero", "--content", "Keep this"}, want: "invalid entry ID: zero"}, + {name: "invalid clip", args: []string{"clip", "delete", "zero"}, want: "invalid clip ID: zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runJSONCommand(t, handler, tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func clipMutationHandler(t *testing.T, method, path string, validate func(*http.Request)) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method || r.URL.Path != path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, method, path) + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if validate != nil { + validate(r) + } + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + }) +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go index bdb8014b..c5d45da1 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -21,7 +21,7 @@ var curatedCategories = []struct { }, { heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "clips", "clip", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 79136fc2..5a0f393a 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -32,7 +32,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { root := newRootCmd() - for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "snippets", "snippet", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { + for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "clips", "clip", "snippets", "snippet", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { t.Run(name, func(t *testing.T) { command, _, err := root.Find([]string{name}) if err != nil { @@ -105,6 +105,8 @@ EMAIL collection View and manage an email collection workflows List your email workflows workflow View and manage an email workflow + clips List passages clipped from email + clip Save and manage passages from email snippets List reusable email snippets snippet Create and manage reusable email snippets search Search email threads and messages diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a0b16078..7983e97f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -189,6 +189,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newCollectionCommand().cmd) root.AddCommand(newWorkflowsCommand().cmd) root.AddCommand(newWorkflowCommand().cmd) + root.AddCommand(newClipsCommand().cmd) + root.AddCommand(newClipCommand().cmd) root.AddCommand(newSnippetsCommand().cmd) root.AddCommand(newSnippetCommand().cmd) root.AddCommand(newSearchCommand().cmd) diff --git a/tests/smoke/clips_test.go b/tests/smoke/clips_test.go new file mode 100644 index 00000000..a4fb4624 --- /dev/null +++ b/tests/smoke/clips_test.go @@ -0,0 +1,102 @@ +package smoke_test + +import ( + "strconv" + "strings" + "testing" +) + +type smokeClip struct { + ID int64 `json:"id"` + Content string `json:"content"` + EntryID int64 `json:"entry_id"` + Topic struct { + ID int64 `json:"id"` + Name string `json:"name"` + AppURL string `json:"app_url"` + } `json:"topic"` +} + +func TestClipLifecycle(t *testing.T) { + topicID, subject := longThread(t, 0) + entries := dataAs[[]threadEntry](t, heyJSON(t, "threads", topicID)) + if len(entries) == 0 || entries[0].ID == 0 { + t.Fatalf("thread %s has no source entry", topicID) + } + entryID := strconv.FormatInt(entries[0].ID, 10) + content := "Revenue up\nChurn down" + + _, stderr, code := hey(t, "clip", "create", entryID, "--content", content, "--json") + if code != 0 { + skipf(t, "clip create unavailable (exit %d): %s", code, stderr) + } + + deleted := false + t.Cleanup(func() { + if deleted { + return + } + for _, listed := range listClips(t) { + if listed.EntryID == entries[0].ID { + _, _, _ = hey(t, "clip", "delete", strconv.FormatInt(listed.ID, 10)) + } + } + }) + + clip := findClipByEntry(t, entries[0].ID) + if clip.ID == 0 || clip.Content != content || strconv.FormatInt(clip.Topic.ID, 10) != topicID || clip.Topic.Name != subject || clip.Topic.AppURL == "" { + t.Fatalf("created clip = %+v", clip) + } + clipID := strconv.FormatInt(clip.ID, 10) + + page := browserPageText(t, baseURL+"/clips") + if !strings.Contains(page, "Revenue up") || !strings.Contains(page, subject) { + t.Errorf("browser clips page does not show the clip and source thread") + } + + _, stderr, code = hey(t, "clip", "delete", clipID, "--json") + if code != 0 { + t.Fatalf("clip delete failed (exit %d): %s", code, stderr) + } + deleted = true + for _, listed := range listClips(t) { + if listed.ID == clip.ID { + t.Fatalf("deleted clip %d is still listed", clip.ID) + } + } +} + +func TestClipOutputFormatsAndValidation(t *testing.T) { + for _, args := range [][]string{ + {"clips", "--quiet"}, + {"clips", "--ids-only"}, + {"clips", "--count"}, + {"clips", "--markdown"}, + {"clips", "--styled"}, + } { + _, stderr, code := hey(t, args...) + if code != 0 { + t.Errorf("hey %s failed (exit %d): %s", strings.Join(args, " "), code, stderr) + } + } + + heyFail(t, "clip", "create", "987") + heyFail(t, "clip", "create", "not-an-id", "--content", "Keep this") + heyFail(t, "clip", "delete", "not-an-id") +} + +func listClips(t *testing.T) []smokeClip { + t.Helper() + return dataAs[[]smokeClip](t, heyJSON(t, "clips")) +} + +func findClipByEntry(t *testing.T, entryID int64) smokeClip { + t.Helper() + for _, clip := range listClips(t) { + if clip.EntryID == entryID { + return clip + } + } + t.Fatalf("clip from entry %d not found", entryID) + return smokeClip{} +} From 45fadb06725b1fdbd2e710475bd9f9bac8a238d9 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 04:22:54 -0400 Subject: [PATCH 2/5] Disclose the clip page boundary --- README.md | 7 ++++--- internal/cmd/clip.go | 19 +++++++++++++++++-- internal/cmd/clip_test.go | 18 +++++++++--------- internal/cmd/help_test.go | 2 +- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f18fccb3..976cada6 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,8 @@ bare number. `--ids-only` and `--count` need list data, so they work on `hey box `hey workflow`, `hey clips`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay -pipeable. +pipeable. `hey clips --ids-only` and `--count` cover the newest page only because the +released SDK does not expose HEY's cursor for older clip pages. `--html` writes the original HTML, for the commands that hold some: `hey threads`, `hey journal read`, `hey contacts show` and `hey contacts note show`. It is a format of @@ -317,7 +318,7 @@ hey workflow stage update 654 321 --name "Interviewing" hey workflow add 987 --to 654 --stage 321 # add a topic ID to a stage hey workflow move 987 --workflow 654 --to 322 # move it to another stage hey workflow remove 987 --from 654 # remove it from the workflow -hey clips # list saved passages and source context +hey clips # newest page of saved passages and source context hey clip create 456 --content "The launch moves to Wednesday." hey clip delete 44 hey snippets # list reusable email snippets @@ -395,7 +396,7 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. -Clips are passages saved from existing email entries. `hey clips` lists each clip newest first with its source entry and thread context. `hey clip create --content ` saves the selected text against that entry, and `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. +Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` saves the selected text against that entry, and `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go index 2e1216db..7eba38ca 100644 --- a/internal/cmd/clip.go +++ b/internal/cmd/clip.go @@ -21,9 +21,9 @@ func newClipsCommand() *clipsCommand { clipsCommand := &clipsCommand{} clipsCommand.cmd = &cobra.Command{ Use: "clips", - Short: "List passages clipped from email", + Short: "List the newest page of passages clipped from email", Annotations: map[string]string{ - "agent_notes": "Returns clip IDs, content, source entry IDs, and source thread context. Use an ID with hey clip delete.", + "agent_notes": "Returns the newest page of clip IDs, content, source entry IDs, and source thread context. The SDK does not expose the cursor for older pages. Use an ID with hey clip delete.", }, Example: ` hey clips hey clips --json @@ -43,6 +43,10 @@ func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { if err != nil { return apierr.FromSDK(err) } + notice := clipsPageNotice(clips) + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } switch writer.EffectiveFormat() { case output.FormatStyled: @@ -62,12 +66,16 @@ func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { }) } table.print() + if notice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "Notice: "+notice) + } return nil case output.FormatMarkdown: return writeClipsMarkdown(cmd, clips) default: return writeOK(clips, output.WithSummary(fmt.Sprintf("%d %s", len(clips), clipNoun(len(clips)))), + output.WithNotice(notice), output.WithBreadcrumbs( output.Breadcrumb{Action: "create", Command: "hey clip create --content ", Description: "Save text from an email entry"}, output.Breadcrumb{Action: "delete", Command: "hey clip delete ", Description: "Delete a clip"}, @@ -76,6 +84,13 @@ func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { } } +func clipsPageNotice(clips []generated.Clip) string { + if len(clips) == 0 { + return "" + } + return "Showing HEY's newest clips page. The SDK does not expose the cursor for older pages." +} + func clipTopicLabel(topic generated.ClipTopic) string { name := terminal.SanitizeLine(topic.Name) if name == "" { diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go index 41c60ce3..9c38fab3 100644 --- a/internal/cmd/clip_test.go +++ b/internal/cmd/clip_test.go @@ -29,8 +29,8 @@ func TestClipsCommandListsClipsInEveryFormat(t *testing.T) { if err != nil { t.Fatal(err) } - if response.Summary != "2 clips" { - t.Errorf("summary = %q", response.Summary) + if response.Summary != "2 clips" || !strings.Contains(response.Notice, "newest clips page") { + t.Errorf("summary = %q, notice = %q", response.Summary, response.Notice) } items := response.Data.([]any) first := items[0].(map[string]any) @@ -39,9 +39,9 @@ func TestClipsCommandListsClipsInEveryFormat(t *testing.T) { t.Errorf("items = %#v", items) } - ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, "clips") - if err != nil || ids != "4\n3\n" { - t.Errorf("ids = %q, err = %v", ids, err) + ids, idsStderr, err := runFormattedCommandWithStderr(t, handler, []string{"--ids-only"}, "clips") + if err != nil || ids != "4\n3\n" || !strings.Contains(idsStderr, "newest clips page") { + t.Errorf("ids = %q, stderr = %q, err = %v", ids, idsStderr, err) } count, err := runFormattedCommand(t, handler, []string{"--count"}, "clips") if err != nil || count != "2\n" { @@ -51,9 +51,9 @@ func TestClipsCommandListsClipsInEveryFormat(t *testing.T) { if err != nil || !strings.Contains(markdown, "| 4 | The launch moves to Wednesday\\. | 987 | 55 | Launch planning |") { t.Errorf("markdown = %q, err = %v", markdown, err) } - styled, err := runStyledCommand(t, handler, "clips") - if err != nil || !strings.Contains(styled, "Launch planning (55)") || !strings.Contains(styled, "Entry") { - t.Errorf("styled = %q, err = %v", styled, err) + styled, styledStderr, err := runFormattedCommandWithStderr(t, handler, []string{"--styled"}, "clips") + if err != nil || !strings.Contains(styled, "Launch planning (55)") || !strings.Contains(styled, "Entry") || !strings.Contains(styledStderr, "newest clips page") { + t.Errorf("styled = %q, stderr = %q, err = %v", styled, styledStderr, err) } } @@ -74,7 +74,7 @@ func TestClipsCommandPreservesEmptyList(t *testing.T) { if err != nil { t.Fatal(err) } - if items := response.Data.([]any); len(items) != 0 || response.Summary != "0 clips" { + if items := response.Data.([]any); len(items) != 0 || response.Summary != "0 clips" || response.Notice != "" { t.Errorf("response = %#v", response) } markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 5a0f393a..f00fb408 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -105,7 +105,7 @@ EMAIL collection View and manage an email collection workflows List your email workflows workflow View and manage an email workflow - clips List passages clipped from email + clips List the newest page of passages clipped from email clip Save and manage passages from email snippets List reusable email snippets snippet Create and manage reusable email snippets From 776bab78ffe174ad6eaa937b2dc1a95173e967ad Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 05:03:30 -0400 Subject: [PATCH 3/5] Validate clip content against its source entry --- README.md | 2 +- internal/cmd/clip.go | 27 ++++- internal/cmd/clip_test.go | 153 +++++++++++++++++++++++++++- internal/htmlutil/htmlutil.go | 156 +++++++++++++++++++++++++++++ internal/htmlutil/htmlutil_test.go | 64 ++++++++++++ tests/smoke/clips_test.go | 15 ++- 6 files changed, 409 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 976cada6..c32e09ae 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. -Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` saves the selected text against that entry, and `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. +Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; HEY's web UI remains authoritative for stylesheet-driven visibility. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go index 7eba38ca..15599b3f 100644 --- a/internal/cmd/clip.go +++ b/internal/cmd/clip.go @@ -9,6 +9,7 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/output" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -138,7 +139,7 @@ func newClipCommand() *clipCommand { Use: "clip", Short: "Save and manage passages from email", Annotations: map[string]string{ - "agent_notes": "Create a clip from an email entry ID and selected text, or delete a clip. Find clip IDs with hey clips.", + "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. The CLI verifies that the passage is source-backed by the entry's message content before saving it. Find clip IDs with hey clips.", }, } clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) @@ -157,6 +158,7 @@ func newClipCreateCommand() *clipCreateCommand { Use: "create ", Aliases: []string{"add"}, Short: "Save text from an email entry", + Long: "Save a passage from an email entry. The content must be present in the entry's message text; whitespace differences are accepted.", Example: ` hey clip create 987 --content "The launch moves to Wednesday."`, RunE: createCommand.run, Args: usageExactOneArg(), @@ -176,6 +178,19 @@ func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { if strings.TrimSpace(c.content) == "" { return apierr.ErrUsage("--content is required") } + message, err := sdk.Messages().Get(cmd.Context(), entryID) + if err != nil { + return apierr.FromSDK(err) + } + if message == nil { + return apierr.ErrNotFound("message", fmt.Sprintf("%d", entryID)) + } + if !clipContentMatches(c.content, message.Content) { + return apierr.ErrUsageHint( + fmt.Sprintf("--content does not match text in entry %d", entryID), + "Copy an exact passage from the entry; whitespace differences are allowed.", + ) + } if err := sdk.Clips().Create(cmd.Context(), entryID, c.content); err != nil { return apierr.FromSDK(err) } @@ -184,6 +199,16 @@ func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { ) } +func clipContentMatches(content, entryHTML string) bool { + selected := normalizeClipText(content) + entry := normalizeClipText(htmlutil.MessageSourceText(entryHTML)) + return selected != "" && strings.Contains(entry, selected) +} + +func normalizeClipText(text string) string { + return strings.Join(strings.Fields(text), " ") +} + type clipDeleteCommand struct { cmd *cobra.Command } diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go index 9c38fab3..51bb8f06 100644 --- a/internal/cmd/clip_test.go +++ b/internal/cmd/clip_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "io" "net/http" "strings" @@ -104,14 +105,15 @@ func TestClipsCommandSanitizesHumanOutput(t *testing.T) { func TestClipCreateSendsEntryAndExactContent(t *testing.T) { content := " The launch moves to Wednesday.\nPlease tell the team. " - response, err := runJSONCommand(t, clipMutationHandler(t, http.MethodPost, "/clips", func(r *http.Request) { + handler := clipCreateHandler(t, 987, `

The launch moves to Wednesday.

Please tell the team.

`, func(r *http.Request) { if got := r.PostForm.Get("clip[entry_id]"); got != "987" { t.Errorf("entry_id = %q", got) } if got := r.PostForm.Get("clip[content]"); got != content { t.Errorf("content = %q", got) } - }), "clip", "create", "987", "--content", content) + }) + response, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", content) if err != nil { t.Fatal(err) } @@ -120,6 +122,119 @@ func TestClipCreateSendsEntryAndExactContent(t *testing.T) { } } +func TestClipCreateMatchesBrowserSelectionText(t *testing.T) { + tests := []struct { + name string + source string + content string + }{ + { + name: "rich text and whitespace", + source: `

The launch moves to
Wednesday & Thursday.

`, + content: "The launch moves\r\n\tto Wednesday & Thursday.", + }, + { + name: "adjacent inline elements", + source: `

quarterly rollout

`, + content: "quarterly rollout", + }, + { + name: "selection across list items", + source: `
  • Revenue up
  • Churn down
`, + content: "Revenue up\nChurn down", + }, + { + name: "unicode line separator", + source: "

First\u2028Second

", + content: "First Second", + }, + { + name: "embedded inbound email body", + source: `
`, + content: "External confirmation: BLUE-42", + }, + { + name: "literal HTML-shaped text", + source: `

Keep <strong>literal</strong> text.

`, + content: `literal`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := clipCreateHandler(t, 987, tt.source, func(r *http.Request) { + if got := r.PostForm.Get("clip[content]"); got != tt.content { + t.Errorf("stored content = %q, want exact input %q", got, tt.content) + } + }) + if _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", tt.content); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestClipCreateRejectsTextOutsideTheEntry(t *testing.T) { + tests := []struct { + name string + source string + content string + }{ + {name: "unrelated text", source: `

The launch moves to Wednesday.

`, content: "An unrelated reminder"}, + {name: "case differs", source: `

Confirmation Code: BLUE-42

`, content: "confirmation code: BLUE-42"}, + {name: "markup is not selected text", source: `

The launch moved.

`, content: `launch`}, + {name: "script text is not selectable", source: `

Visible text

`, content: "secret value"}, + {name: "hidden text is not selectable", source: `

Visible text

`, content: "Hidden preheader"}, + {name: "block boundaries remain distinct", source: `
Alpha
Beta
`, content: "AlphaBeta"}, + {name: "summary is not entry content", source: ``, content: "Preview summary"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected mutation", http.StatusInternalServerError) + return + } + writeClipSource(t, w, 987, tt.source, "Preview summary") + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", tt.content) + if err == nil || !strings.Contains(err.Error(), "--content does not match text in entry 987") { + t.Fatalf("error = %v", err) + } + if requests != 1 { + t.Errorf("requests = %d, want the source read without a clip mutation", requests) + } + }) + } +} + +func TestClipCreateRequiresAnAvailableSourceMessage(t *testing.T) { + t.Run("read failure", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Fatalf("unexpected request = %s %s", r.Method, r.URL.Path) + } + http.NotFound(w, r) + }) + if _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "Keep this"); err == nil { + t.Fatal("expected the source read failure") + } + }) + + t.Run("empty response", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `null`) + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "Keep this") + if err == nil || !strings.Contains(err.Error(), "--content does not match text in entry 987") { + t.Fatalf("error = %v", err) + } + }) +} + func TestClipDeleteUsesClipID(t *testing.T) { response, err := runJSONCommand(t, clipMutationHandler(t, http.MethodDelete, "/clips/44", nil), "clip", "delete", "44") if err != nil { @@ -138,6 +253,8 @@ func TestClipCommandsUseTheSelectedAccount(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/clips.json": w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `[]`) + case r.Method == http.MethodGet && r.URL.Path == "/messages/987.json": + writeClipSource(t, w, 987, "Keep this", "") case r.Method == http.MethodPost && r.URL.Path == "/clips": w.Header().Set("Location", "/clips") w.WriteHeader(http.StatusFound) @@ -158,7 +275,7 @@ func TestClipCommandsUseTheSelectedAccount(t *testing.T) { t.Fatalf("hey %s: %v\n%s", strings.Join(args, " "), err, output) } } - if got := strings.Join(requested, "\n"); got != "GET /clips.json account=2\nPOST /clips account=2\nDELETE /clips/44 account=2" { + if got := strings.Join(requested, "\n"); got != "GET /clips.json account=2\nGET /messages/987.json account=2\nPOST /clips account=2\nDELETE /clips/44 account=2" { t.Errorf("requests:\n%s", got) } } @@ -187,6 +304,36 @@ func TestClipCommandsValidateInput(t *testing.T) { } } +func clipCreateHandler(t *testing.T, entryID int64, source string, validate func(*http.Request)) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/987.json": + writeClipSource(t, w, entryID, source, "") + case r.Method == http.MethodPost && r.URL.Path == "/clips": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if validate != nil { + validate(r) + } + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) +} + +func writeClipSource(t *testing.T, w http.ResponseWriter, entryID int64, content, summary string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{"id": entryID, "content": content, "summary": summary}); err != nil { + t.Fatal(err) + } +} + func clipMutationHandler(t *testing.T, method, path string, validate func(*http.Request)) http.Handler { t.Helper() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/htmlutil/htmlutil.go b/internal/htmlutil/htmlutil.go index 8e99414c..c12a009e 100644 --- a/internal/htmlutil/htmlutil.go +++ b/internal/htmlutil/htmlutil.go @@ -26,6 +26,17 @@ func ToText(s string) string { return strings.TrimSpace(result) } +// MessageSourceText returns the source-backed text carried by HTML message content. +func MessageSourceText(s string) string { + doc, err := html.Parse(strings.NewReader(s)) + if err != nil { + return s + } + var b strings.Builder + walkMessageSourceNode(&b, doc, 0) + return strings.TrimSpace(b.String()) +} + // PrependText adds a plain-text note before existing HTML content. func PrependText(content, note string) string { note = strings.TrimSpace(strings.ReplaceAll(note, "\r\n", "\n")) @@ -139,6 +150,142 @@ type trixAttachment struct { Content string `json:"content"` } +func walkMessageSourceNode(b *strings.Builder, n *html.Node, depth int) { + switch n.Type { //nolint:exhaustive // only text and element nodes need handling + case html.TextNode: + b.WriteString(n.Data) + case html.ElementNode: + if !elementProvidesMessageSourceText(n) { + return + } + switch n.Data { + case "br": + b.WriteByte('\n') + return + case "hr": + writeMessageSourceBoundary(b) + return + case "template": + if depth == 0 || n.Parent == nil || n.Parent.Data != "shadow-content" { + return + } + case "details": + if !hasAttr(n, "open") { + walkClosedDetailsSummary(b, n, depth) + return + } + case "figure": + if att := parseTrixAttachment(n); att != nil && att.Filename == "" && att.Content != "" { + if doc := parseEmbeddedContent(att.Content, depth); doc != nil { + writeMessageSourceBoundary(b) + walkMessageSourceNode(b, doc, depth+1) + writeMessageSourceBoundary(b) + return + } + } + } + if messageBlockElement(n) { + writeMessageSourceBoundary(b) + walkMessageSourceChildren(b, n, depth) + writeMessageSourceBoundary(b) + return + } + } + walkMessageSourceChildren(b, n, depth) +} + +func elementProvidesMessageSourceText(n *html.Node) bool { + if hasAttr(n, "hidden") || hasAttr(n, "inert") || inlineStyleHidesText(getAttr(n, "style")) { + return false + } + switch n.Data { + case "script", "style", "noscript", "head", "action-text-attachment": + return false + case "dialog": + return hasAttr(n, "open") + default: + return true + } +} + +func inlineStyleHidesText(style string) bool { + display, _ := inlineStyleProperty(style, "display") + visibility, _ := inlineStyleProperty(style, "visibility") + contentVisibility, _ := inlineStyleProperty(style, "content-visibility") + userSelect, _ := inlineStyleProperty(style, "user-select", "-webkit-user-select") + return display == "none" || visibility == "hidden" || visibility == "collapse" || + contentVisibility == "hidden" || userSelect == "none" +} + +func inlineStyleProperty(style string, names ...string) (string, bool) { + wanted := make(map[string]bool, len(names)) + for _, name := range names { + wanted[name] = true + } + var selected string + selectedImportant := false + found := false + for declaration := range strings.SplitSeq(strings.ToLower(style), ";") { + property, value, ok := strings.Cut(declaration, ":") + if !ok || !wanted[strings.TrimSpace(property)] { + continue + } + value = strings.TrimSpace(value) + important := strings.HasSuffix(value, "!important") + if important { + value = strings.TrimSpace(strings.TrimSuffix(value, "!important")) + } + if !found || important || !selectedImportant { + selected = value + selectedImportant = important + found = true + } + } + return selected, found +} + +func messageBlockElement(n *html.Node) bool { + if display, ok := inlineStyleProperty(getAttr(n, "style"), "display"); ok { + kind := display + if fields := strings.Fields(display); len(fields) > 0 { + kind = fields[0] + } + switch kind { + case "inline", "inline-block", "inline-flex", "inline-grid", "inline-table", "contents": + return false + case "block", "flow-root", "flex", "grid", "list-item", "table", "table-caption", "table-cell", "table-footer-group", "table-header-group", "table-row", "table-row-group": + return true + } + } + switch n.Data { + case "address", "article", "aside", "blockquote", "caption", "dd", "details", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "li", "main", "menu", "nav", "ol", "p", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul": + return true + default: + return false + } +} + +func walkClosedDetailsSummary(b *strings.Builder, details *html.Node, depth int) { + for child := details.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "summary" { + walkMessageSourceNode(b, child, depth) + return + } + } +} + +func walkMessageSourceChildren(b *strings.Builder, n *html.Node, depth int) { + for child := n.FirstChild; child != nil; child = child.NextSibling { + walkMessageSourceNode(b, child, depth) + } +} + +func writeMessageSourceBoundary(b *strings.Builder) { + if b.Len() > 0 { + b.WriteByte('\n') + } +} + func parseTrixAttachment(n *html.Node) *trixAttachment { raw := getAttr(n, "data-trix-attachment") if raw == "" { @@ -160,6 +307,15 @@ func getAttr(n *html.Node, key string) string { return "" } +func hasAttr(n *html.Node, key string) bool { + for _, attribute := range n.Attr { + if attribute.Key == key { + return true + } + } + return false +} + func walkChildren(b *strings.Builder, n *html.Node, depth int) { for c := n.FirstChild; c != nil; c = c.NextSibling { walkNode(b, c, depth) diff --git a/internal/htmlutil/htmlutil_test.go b/internal/htmlutil/htmlutil_test.go index 5c1b3f22..f02ccbae 100644 --- a/internal/htmlutil/htmlutil_test.go +++ b/internal/htmlutil/htmlutil_test.go @@ -57,6 +57,70 @@ func TestToTextEmpty(t *testing.T) { } } +func TestMessageSourceTextMatchesBrowserSelectionContent(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + {name: "inline formatting", html: `

quarterly plan

`, want: "quarterly plan"}, + {name: "blocks and list items", html: `

First line

  • Revenue up
  • Churn down
`, want: "First line Revenue up Churn down"}, + {name: "section boundaries", html: `
Alpha
Beta
`, want: "Alpha Beta"}, + {name: "computed block boundaries", html: `AlphaBeta`, want: "Alpha Beta"}, + {name: "computed inline flow", html: `
Alpha
Beta
`, want: "AlphaBeta"}, + {name: "entities", html: `

R&D uses <draft> today

`, want: "R&D uses today"}, + {name: "line break", html: `

First
Second

`, want: "First Second"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strings.Join(strings.Fields(MessageSourceText(tt.html)), " "); got != tt.want { + t.Errorf("MessageSourceText = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMessageSourceTextIncludesEmbeddedEmailBody(t *testing.T) { + html := `
` + if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "External confirmation: BLUE-42" { + t.Errorf("MessageSourceText = %q", got) + } +} + +func TestMessageSourceTextExcludesNonselectableContent(t *testing.T) { + html := `

Visible

attachment internals` + if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "Visible" { + t.Errorf("MessageSourceText = %q, want visible message text only", got) + } +} + +func TestMessageSourceTextHonorsHTMLVisibility(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + {name: "hidden attribute", html: `

Before

After

`, want: "Before After"}, + {name: "inert subtree", html: `

Before

Inactive

After

`, want: "Before After"}, + {name: "display none", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "later display wins", html: `

Visible

`, want: "Visible"}, + {name: "important display wins", html: `

Hidden

`, want: ""}, + {name: "visibility hidden", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "selection disabled", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "ordinary template", html: `

Before

After

`, want: "Before After"}, + {name: "closed dialog", html: `

Before

Closed dialog

After

`, want: "Before After"}, + {name: "closed details", html: `
Visible summary

Closed content

`, want: "Visible summary"}, + {name: "open details and dialog", html: `
Summary

Details

Dialog`, want: "Summary Details Dialog"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strings.Join(strings.Fields(MessageSourceText(tt.html)), " "); got != tt.want { + t.Errorf("MessageSourceText = %q, want %q", got, tt.want) + } + }) + } +} + func TestToTextImgTag(t *testing.T) { got := ToText(`

Before

photo

After

`) if !strings.Contains(got, "[photo]") { diff --git a/tests/smoke/clips_test.go b/tests/smoke/clips_test.go index a4fb4624..bb1906f4 100644 --- a/tests/smoke/clips_test.go +++ b/tests/smoke/clips_test.go @@ -24,7 +24,12 @@ func TestClipLifecycle(t *testing.T) { t.Fatalf("thread %s has no source entry", topicID) } entryID := strconv.FormatInt(entries[0].ID, 10) - content := "Revenue up\nChurn down" + content := "**quarterly** numbers are at https://example.com/reports/q3" + + _, stderr := heyFail(t, "clip", "create", entryID, "--content", "Text that is not present in the source entry", "--json") + if !strings.Contains(stderr, "does not match text in entry") { + t.Fatalf("unmatched clip error = %q", stderr) + } _, stderr, code := hey(t, "clip", "create", entryID, "--content", content, "--json") if code != 0 { @@ -50,8 +55,12 @@ func TestClipLifecycle(t *testing.T) { clipID := strconv.FormatInt(clip.ID, 10) page := browserPageText(t, baseURL+"/clips") - if !strings.Contains(page, "Revenue up") || !strings.Contains(page, subject) { - t.Errorf("browser clips page does not show the clip and source thread") + if !strings.Contains(page, content) || !strings.Contains(page, subject) { + t.Errorf("browser clips page does not show the exact clip text and source thread") + } + html := fetchHTML(t, baseURL+"/clips") + if strings.Contains(html, "quarterly") { + t.Errorf("browser clips page interpreted plain clip content as HTML") } _, stderr, code = hey(t, "clip", "delete", clipID, "--json") From 37de369c638bf0b93ff0de76f839f1be18a3cf2c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 05:33:29 -0400 Subject: [PATCH 4/5] Bound clip source validation --- README.md | 2 +- internal/cmd/clip.go | 32 +++++++++++++++++++++++++++--- internal/cmd/clip_test.go | 15 ++++++++++++++ internal/htmlutil/htmlutil.go | 2 +- internal/htmlutil/htmlutil_test.go | 7 +++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c32e09ae..7f1c9641 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. -Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; HEY's web UI remains authoritative for stylesheet-driven visibility. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. +Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go index 15599b3f..6107881b 100644 --- a/internal/cmd/clip.go +++ b/internal/cmd/clip.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "strings" + "unicode" "github.com/spf13/cobra" @@ -139,7 +140,7 @@ func newClipCommand() *clipCommand { Use: "clip", Short: "Save and manage passages from email", Annotations: map[string]string{ - "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. The CLI verifies that the passage is source-backed by the entry's message content before saving it. Find clip IDs with hey clips.", + "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clips.", }, } clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) @@ -147,6 +148,11 @@ func newClipCommand() *clipCommand { return clipCommand } +const ( + maxClipContentBytes = 64 << 10 + maxClipSourceBytes = 1 << 20 +) + type clipCreateCommand struct { cmd *cobra.Command content string @@ -158,7 +164,7 @@ func newClipCreateCommand() *clipCreateCommand { Use: "create ", Aliases: []string{"add"}, Short: "Save text from an email entry", - Long: "Save a passage from an email entry. The content must be present in the entry's message text; whitespace differences are accepted.", + Long: "Save a passage from an email entry. The content must be present in the entry's message text; whitespace differences are accepted. Passages are limited to 64 KiB and source entries to 1 MiB for validation.", Example: ` hey clip create 987 --content "The launch moves to Wednesday."`, RunE: createCommand.run, Args: usageExactOneArg(), @@ -178,6 +184,9 @@ func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { if strings.TrimSpace(c.content) == "" { return apierr.ErrUsage("--content is required") } + if len(c.content) > maxClipContentBytes { + return apierr.ErrUsage(fmt.Sprintf("--content exceeds the %d KiB clip limit", maxClipContentBytes>>10)) + } message, err := sdk.Messages().Get(cmd.Context(), entryID) if err != nil { return apierr.FromSDK(err) @@ -185,6 +194,9 @@ func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { if message == nil { return apierr.ErrNotFound("message", fmt.Sprintf("%d", entryID)) } + if len(message.Content) > maxClipSourceBytes { + return apierr.ErrAPI(0, fmt.Sprintf("entry %d content exceeds the %d MiB clip validation limit", entryID, maxClipSourceBytes>>20)) + } if !clipContentMatches(c.content, message.Content) { return apierr.ErrUsageHint( fmt.Sprintf("--content does not match text in entry %d", entryID), @@ -206,7 +218,21 @@ func clipContentMatches(content, entryHTML string) bool { } func normalizeClipText(text string) string { - return strings.Join(strings.Fields(text), " ") + var normalized strings.Builder + normalized.Grow(len(text)) + pendingSpace := false + for _, r := range text { + if unicode.IsSpace(r) { + pendingSpace = normalized.Len() > 0 + continue + } + if pendingSpace { + normalized.WriteByte(' ') + pendingSpace = false + } + normalized.WriteRune(r) + } + return normalized.String() } type clipDeleteCommand struct { diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go index 51bb8f06..42504d7e 100644 --- a/internal/cmd/clip_test.go +++ b/internal/cmd/clip_test.go @@ -185,6 +185,7 @@ func TestClipCreateRejectsTextOutsideTheEntry(t *testing.T) { {name: "script text is not selectable", source: `

Visible text

`, content: "secret value"}, {name: "hidden text is not selectable", source: `

Visible text

`, content: "Hidden preheader"}, {name: "block boundaries remain distinct", source: `
Alpha
Beta
`, content: "AlphaBeta"}, + {name: "unparseable source fails closed", source: strings.Repeat("
", 1_000) + "not selectable" + strings.Repeat("
", 1_000), content: "not selectable"}, {name: "summary is not entry content", source: ``, content: "Preview summary"}, } for _, tt := range tests { @@ -233,6 +234,19 @@ func TestClipCreateRequiresAnAvailableSourceMessage(t *testing.T) { t.Fatalf("error = %v", err) } }) + + t.Run("source over validation limit", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Fatalf("unexpected request = %s %s", r.Method, r.URL.Path) + } + writeClipSource(t, w, 987, strings.Repeat("x", maxClipSourceBytes+1), "") + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "x") + if err == nil || !strings.Contains(err.Error(), "content exceeds the 1 MiB clip validation limit") { + t.Fatalf("error = %v", err) + } + }) } func TestClipDeleteUsesClipID(t *testing.T) { @@ -291,6 +305,7 @@ func TestClipCommandsValidateInput(t *testing.T) { }{ {name: "create content", args: []string{"clip", "create", "987"}, want: "--content is required"}, {name: "blank content", args: []string{"clip", "create", "987", "--content", " "}, want: "--content is required"}, + {name: "oversized content", args: []string{"clip", "create", "987", "--content", strings.Repeat("x", maxClipContentBytes+1)}, want: "--content exceeds the 64 KiB clip limit"}, {name: "invalid entry", args: []string{"clip", "create", "zero", "--content", "Keep this"}, want: "invalid entry ID: zero"}, {name: "invalid clip", args: []string{"clip", "delete", "zero"}, want: "invalid clip ID: zero"}, } diff --git a/internal/htmlutil/htmlutil.go b/internal/htmlutil/htmlutil.go index c12a009e..ccfe248d 100644 --- a/internal/htmlutil/htmlutil.go +++ b/internal/htmlutil/htmlutil.go @@ -30,7 +30,7 @@ func ToText(s string) string { func MessageSourceText(s string) string { doc, err := html.Parse(strings.NewReader(s)) if err != nil { - return s + return "" } var b strings.Builder walkMessageSourceNode(&b, doc, 0) diff --git a/internal/htmlutil/htmlutil_test.go b/internal/htmlutil/htmlutil_test.go index f02ccbae..e0ad085f 100644 --- a/internal/htmlutil/htmlutil_test.go +++ b/internal/htmlutil/htmlutil_test.go @@ -87,6 +87,13 @@ func TestMessageSourceTextIncludesEmbeddedEmailBody(t *testing.T) { } } +func TestMessageSourceTextFailsClosedWhenHTMLExceedsParserDepth(t *testing.T) { + html := strings.Repeat("
", 1_000) + "not selectable" + strings.Repeat("
", 1_000) + if got := MessageSourceText(html); got != "" { + t.Errorf("MessageSourceText returned unparsed source bytes: %q", got[:min(len(got), 80)]) + } +} + func TestMessageSourceTextExcludesNonselectableContent(t *testing.T) { html := `

Visible

attachment internals` if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "Visible" { From 71ceddddb52a36deb740a838775b9ddc8be02e6e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 22 Aug 2026 05:39:05 -0400 Subject: [PATCH 5/5] Document clip account semantics --- README.md | 2 +- internal/cmd/clip.go | 5 +++-- internal/cmd/clip_test.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7f1c9641..82306ba8 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. -Clips are passages saved from existing email entries. `hey clips` lists the newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. +Clips are passages saved from existing email entries. `hey clips` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. HEY assigns a created clip to its source entry's account and resolves deletion by identity-owned clip ID across linked accounts; `--account` selects list presentation. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go index 6107881b..93fb1c3d 100644 --- a/internal/cmd/clip.go +++ b/internal/cmd/clip.go @@ -140,7 +140,7 @@ func newClipCommand() *clipCommand { Use: "clip", Short: "Save and manage passages from email", Annotations: map[string]string{ - "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clips.", + "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. HEY assigns a created clip to the source entry's account and resolves deletion by identity-owned clip ID across linked accounts; --account selects list presentation. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clips.", }, } clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) @@ -164,7 +164,7 @@ func newClipCreateCommand() *clipCreateCommand { Use: "create ", Aliases: []string{"add"}, Short: "Save text from an email entry", - Long: "Save a passage from an email entry. The content must be present in the entry's message text; whitespace differences are accepted. Passages are limited to 64 KiB and source entries to 1 MiB for validation.", + Long: "Save a passage from an email entry. HEY assigns the clip to the source entry's account. The content must be present in the entry's message text; whitespace differences are accepted. Passages are limited to 64 KiB and source entries to 1 MiB for validation.", Example: ` hey clip create 987 --content "The launch moves to Wednesday."`, RunE: createCommand.run, Args: usageExactOneArg(), @@ -245,6 +245,7 @@ func newClipDeleteCommand() *clipDeleteCommand { Use: "delete ", Aliases: []string{"remove", "rm"}, Short: "Delete a saved clip", + Long: "Delete an identity-owned clip by ID across linked accounts.", Example: ` hey clip delete 44`, RunE: deleteCommand.run, Args: usageExactOneArg(), diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go index 42504d7e..14830c65 100644 --- a/internal/cmd/clip_test.go +++ b/internal/cmd/clip_test.go @@ -259,7 +259,7 @@ func TestClipDeleteUsesClipID(t *testing.T) { } } -func TestClipCommandsUseTheSelectedAccount(t *testing.T) { +func TestClipCommandsForwardTheSelectedAccountContext(t *testing.T) { var requested []string server := linkedAccountServer(t, func(w http.ResponseWriter, r *http.Request) { requested = append(requested, r.Method+" "+r.URL.Path+" account="+r.URL.Query().Get("filtered_account_id"))