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
9 changes: 9 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ hey setup omarchy --remove
hey share
hey skill
hey skill install
hey snippet
hey snippet create
hey snippet create --content
hey snippet create --name
hey snippet delete
hey snippet update
hey snippet update --content
hey snippet update --name
hey snippets
hey spam
hey stop-ignoring
hey threads
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Accounts and individual email addresses.
Switching cancels requests from the previous account and reloads the active section;
Calendar and Journal remain identity-wide.

Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+K opens Collections directly, while Labels remains available from the navigation row. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case: `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list.
Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive shortcut bar is visible by default; press `?` to hide or restore it, and the choice is remembered across restarts. Mail navigation includes HEY boxes plus separate Labels and Collections tabs; Shift+K opens Collections directly, while Labels remains available from the navigation row. Every list keeps going: scroll towards the bottom of a box, label, or collection and the next threads are read in behind you, so there are no pages to step through. The mail actions use HEY's web shortcuts in either letter case: `/` or `s` searches, `r` replies, `f` forwards, `v` moves, `b` manages labels, `n` adds or removes the selected thread from collections, `e` marks seen, `u` marks unseen, `i` moves to the Imbox, `l` moves to Reply Later, `a` moves to Set Aside, `d` moves to The Feed, `p` moves to Paper Trail, and `t` trashes. Press `!` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press Ctrl+B to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with Ctrl+U while HEY's undo window remains open. Search results retain the matching-message summary and keep going as you scroll, like every other list. While writing a new message, reply, or forward, press Ctrl+T to open the searchable Snippets picker. HEY never chooses a default: Enter inserts the selected snippet at the body cursor, Escape returns without changing the draft, and the picker can be reopened to insert another snippet.

The mail list follows the server. HEY tells the TUI when a box changed over the same
Action Cable connection `hey watch` uses, and the box on screen is read again a moment
Expand Down Expand Up @@ -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 drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`,
`hey workflow`, `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.
Expand Down Expand Up @@ -317,6 +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 snippet create --name "Scheduling reply" --content "Tuesday works for me."
hey snippet update 44 --content "Wednesday works for me."
hey snippet delete 44
hey search "quarterly planning" # search threads and matching messages
hey search --from jane@example.com --date last_30_days # refine a search
hey search filters # list available refinement values
Expand Down Expand Up @@ -388,6 +392,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.

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`.

Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`.
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var curatedCategories = []struct {
},
{
heading: "EMAIL",
names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "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", "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",
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} {
for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "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 {
Expand Down Expand Up @@ -105,6 +105,8 @@ EMAIL
collection View and manage an email collection
workflows List your email workflows
workflow View and manage an email workflow
snippets List reusable email snippets
snippet Create and manage reusable email snippets
search Search email threads and messages
contacts Manage contacts
screener Decide who gets to email you
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newCollectionCommand().cmd)
root.AddCommand(newWorkflowsCommand().cmd)
root.AddCommand(newWorkflowCommand().cmd)
root.AddCommand(newSnippetsCommand().cmd)
root.AddCommand(newSnippetCommand().cmd)
root.AddCommand(newSearchCommand().cmd)
root.AddCommand(newContactsCommand().cmd)
root.AddCommand(newScreenerCommand().cmd)
Expand Down
245 changes: 245 additions & 0 deletions internal/cmd/snippet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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 snippetsCommand struct {
cmd *cobra.Command
}

func newSnippetsCommand() *snippetsCommand {
snippetsCommand := &snippetsCommand{}
snippetsCommand.cmd = &cobra.Command{
Use: "snippets",
Short: "List reusable email snippets",
Annotations: map[string]string{
"agent_notes": "Returns snippet IDs, names, plain text, and rich-text HTML. Use an ID with hey snippet update or delete.",
},
Example: ` hey snippets
hey snippets --json
hey snippets --ids-only`,
RunE: snippetsCommand.run,
Args: cobra.NoArgs,
}
return snippetsCommand
}

func (c *snippetsCommand) run(cmd *cobra.Command, _ []string) error {
if err := requireAuth(); err != nil {
return err
}

snippets, err := sdk.Snippets().List(cmd.Context())
if err != nil {
return apierr.FromSDK(err)
}

switch writer.EffectiveFormat() {
case output.FormatStyled:
if len(snippets) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No snippets found")
return nil
}
table := newTable(cmd.OutOrStdout())
table.addRow([]string{"ID", "Name", "Content", "Updated"})
for _, snippet := range snippets {
table.addRow([]string{
fmt.Sprintf("%d", snippet.Id),
terminal.SanitizeLine(snippet.Name),
truncate(terminal.SanitizeLine(snippet.Content), 60),
formatDate(snippet.UpdatedAt),
})
}
table.print()
return nil
case output.FormatMarkdown:
return writeSnippetsMarkdown(cmd, snippets)
default:
return writeOK(snippets,
output.WithSummary(fmt.Sprintf("%d %s", len(snippets), snippetNoun(len(snippets)))),
output.WithBreadcrumbs(
output.Breadcrumb{Action: "create", Command: "hey snippet create --name <name> --content <content>", Description: "Create a snippet"},
output.Breadcrumb{Action: "update", Command: "hey snippet update <id> --name <name> --content <content>", Description: "Update a snippet"},
),
)
}
}

func writeSnippetsMarkdown(cmd *cobra.Command, snippets []generated.Snippet) error {
if len(snippets) == 0 {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "(no results)")
return err
}
var document strings.Builder
document.WriteString("| id | name | content | updated |\n")
document.WriteString("| --- | --- | --- | --- |\n")
for _, snippet := range snippets {
fmt.Fprintf(&document, "| %d | %s | %s | %s |\n",
snippet.Id,
markdownSafeText(snippet.Name),
markdownSafeText(snippet.Content),
formatDate(snippet.UpdatedAt),
)
}
_, err := fmt.Fprint(cmd.OutOrStdout(), document.String())
return err
}

func snippetNoun(count int) string {
if count == 1 {
return "snippet"
}
return "snippets"
}

type snippetCommand struct {
cmd *cobra.Command
}

func newSnippetCommand() *snippetCommand {
snippetCommand := &snippetCommand{}
snippetCommand.cmd = &cobra.Command{
Use: "snippet",
Short: "Create and manage reusable email snippets",
Annotations: map[string]string{
"agent_notes": "Create, update, or delete snippets. Find snippet IDs with hey snippets.",
},
}
snippetCommand.cmd.AddCommand(newSnippetCreateCommand().cmd)
snippetCommand.cmd.AddCommand(newSnippetUpdateCommand().cmd)
snippetCommand.cmd.AddCommand(newSnippetDeleteCommand().cmd)
return snippetCommand
}

type snippetCreateCommand struct {
cmd *cobra.Command
name string
content string
}

func newSnippetCreateCommand() *snippetCreateCommand {
createCommand := &snippetCreateCommand{}
createCommand.cmd = &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a reusable email snippet",
Example: ` hey snippet create --name "Scheduling reply" --content "Tuesday works for me."
hey snippet create --name "Office hours" --content "<p>Office hours are Monday through Thursday.</p>"`,
RunE: createCommand.run,
Args: cobra.NoArgs,
}
createCommand.cmd.Flags().StringVar(&createCommand.name, "name", "", "Snippet name (required)")
createCommand.cmd.Flags().StringVar(&createCommand.content, "content", "", "Snippet content as text or HTML (required)")
return createCommand
}

func (c *snippetCreateCommand) run(cmd *cobra.Command, _ []string) error {
if err := requireAuth(); err != nil {
return err
}
name := strings.TrimSpace(c.name)
if name == "" {
return apierr.ErrUsage("--name is required")
}
if strings.TrimSpace(c.content) == "" {
return apierr.ErrUsage("--content is required")
}
if err := sdk.Snippets().Create(cmd.Context(), name, c.content); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("Snippet %q created", name), map[string]any{"name": name},
output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey snippets", Description: "Find the new snippet ID"}),
)
}

type snippetUpdateCommand struct {
cmd *cobra.Command
name string
content string
}

func newSnippetUpdateCommand() *snippetUpdateCommand {
updateCommand := &snippetUpdateCommand{}
updateCommand.cmd = &cobra.Command{
Use: "update <id>",
Aliases: []string{"edit"},
Short: "Update a reusable email snippet",
Example: ` hey snippet update 44 --name "Scheduling"
hey snippet update 44 --content "Wednesday works for me."`,
RunE: updateCommand.run,
Args: usageExactOneArg(),
}
updateCommand.cmd.Flags().StringVar(&updateCommand.name, "name", "", "New snippet name")
updateCommand.cmd.Flags().StringVar(&updateCommand.content, "content", "", "New snippet content as text or HTML")
return updateCommand
}

func (c *snippetUpdateCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}
snippetID, err := parsePositiveID(args[0], "snippet")
if err != nil {
return err
}
nameChanged := cmd.Flags().Changed("name")
contentChanged := cmd.Flags().Changed("content")
if !nameChanged && !contentChanged {
return apierr.ErrUsage("provide --name or --content")
}
name := c.name
if nameChanged {
name = strings.TrimSpace(name)
if name == "" {
return apierr.ErrUsage("--name cannot be empty")
}
}
if contentChanged && strings.TrimSpace(c.content) == "" {
return apierr.ErrUsage("--content cannot be empty")
}
if err := sdk.Snippets().Update(cmd.Context(), snippetID, name, c.content); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("Snippet %d updated", snippetID), map[string]any{"id": snippetID})
}

type snippetDeleteCommand struct {
cmd *cobra.Command
}

func newSnippetDeleteCommand() *snippetDeleteCommand {
deleteCommand := &snippetDeleteCommand{}
deleteCommand.cmd = &cobra.Command{
Use: "delete <id>",
Aliases: []string{"remove"},
Short: "Delete a reusable email snippet",
Example: ` hey snippet delete 44`,
RunE: deleteCommand.run,
Args: usageExactOneArg(),
}
return deleteCommand
}

func (c *snippetDeleteCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}
snippetID, err := parsePositiveID(args[0], "snippet")
if err != nil {
return err
}
if err := sdk.Snippets().Delete(cmd.Context(), snippetID); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("Snippet %d deleted", snippetID), map[string]any{"id": snippetID})
}
Loading
Loading