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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,11 @@ 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.
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
Expand Down Expand Up @@ -317,7 +318,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 # newest page of saved passages and source context
hey clip create 456 --content "The launch moves to Wednesday."
hey clip delete 44
hey snippets # list reusable email snippets
hey snippet create --name "Scheduling reply" --content "Tuesday works for me."
hey snippet update 44 --content "Wednesday works for me."
hey snippet delete 44
Expand Down Expand Up @@ -392,6 +396,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 <id>` 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 <id>`, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error.

Clips are passages saved from existing email entries. `hey clips` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create <entry-id> --content <text>` 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 <clip-id>` 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 <name|id>`, `hey label <id>` and `hey collection <id>` 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`.
Expand Down
268 changes: 268 additions & 0 deletions internal/cmd/clip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package cmd

import (
"fmt"
"strings"
"unicode"

"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/htmlutil"
"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 the newest page of passages clipped from email",
Annotations: map[string]string{
"agent_notes": "Returns the newest page of clip IDs, content, source entry IDs, and source thread context. The SDK does not expose the cursor for older pages. Use an ID with hey clip delete.",
},
Example: ` hey clips
hey clips --json
hey clips --ids-only`,
RunE: clipsCommand.run,
Args: cobra.NoArgs,
}
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())
Comment thread
robzolkos marked this conversation as resolved.
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:
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()
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 <entry-id> --content <text>", Description: "Save text from an email entry"},
output.Breadcrumb{Action: "delete", Command: "hey clip delete <clip-id>", Description: "Delete a clip"},
),
)
}
}

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 == "" {
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 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)
clipCommand.cmd.AddCommand(newClipDeleteCommand().cmd)
return clipCommand
}

const (
maxClipContentBytes = 64 << 10
maxClipSourceBytes = 1 << 20
)

type clipCreateCommand struct {
cmd *cobra.Command
content string
}

func newClipCreateCommand() *clipCreateCommand {
createCommand := &clipCreateCommand{}
createCommand.cmd = &cobra.Command{
Use: "create <entry-id>",
Aliases: []string{"add"},
Short: "Save text from an email entry",
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(),
}
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 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)
}
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),
"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)
}
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"}),
)
}

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 {
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 {
cmd *cobra.Command
}

func newClipDeleteCommand() *clipDeleteCommand {
deleteCommand := &clipDeleteCommand{}
deleteCommand.cmd = &cobra.Command{
Use: "delete <clip-id>",
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(),
}
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})
}
Loading
Loading