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
8 changes: 8 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,11 @@ hey tui
hey unseen
hey upgrade
hey version
hey watch
hey watch --box
hey watch --events
hey watch --exit-on-first
hey watch --run-async
hey watch --run-sync
hey watch --since
hey watch --timeout
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,23 @@ The TUI renders inline images using the Kitty graphics protocol's Unicode Placeh

This works in Kitty and Ghostty. Other terminals show the text content normally (placeholders are invisible).

### Watching for changes over Action Cable

`hey watch` is told when a box changed instead of polling for it.
`internal/cable` dials HEY's cable server with [actioncable-go](github.com/basecamp/actioncable-go),
authorizing the upgrade request with the same credentials the SDK sends on an API request
(`HEY_CABLE_URL` overrides the endpoint). `internal/cmd/watch.go` subscribes to
haystack's `Postings::ChangesChannel`, which broadcasts only `{change, account_id, box_id,
box_kind, posting_ids, at}` — a doorbell, not the change itself.

The change is then read through `Postings().AllChanges`, the same incremental sync feed the
mail clients use, starting from the cursor in the box's `posting_changes_url`. That is what
makes a reconnect safe: the cursor, not the notification, is the source of truth, so a
missed broadcast costs nothing, and a 409 means catch up in full instead. A read that
fails leaves the cursor where it was and is retried on a doubling backoff, so a change
isn't lost with the notification that announced it, and a subscription that closes without
the watch being interrupted is an error rather than a quiet exit.

### API documentation

If you are unsure what the API endpoints are, what they expect or what they respond to you can read through the server implementation to understand how the API works.
Expand Down
2 changes: 2 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| `/calendar/todos/{id}/completions.json` | POST | SDK `CalendarTodos().Complete` | `hey todo complete <id>` | covered |
| `/calendar/todos/{id}/completions.json` | DELETE | SDK `CalendarTodos().Uncomplete` | `hey todo uncomplete <id>` | covered |
| `/calendar/todos/{id}.json` | DELETE | SDK `CalendarTodos().Delete` | `hey todo delete <id>` | covered |
| `/boxes/{id}/postings/changes.json` | GET | SDK `Postings().AllChanges` | `hey watch` | covered |
| `/cable` (`Postings::ChangesChannel`) | WS | `internal/cable` + actioncable-go | `hey watch` | covered |
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali

Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Label IDs come from `hey labels`; `hey label` returns `next_page` and `total_count`, accepts `--page <next_page>` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`.

### Watching for changes

```bash
hey watch # follow every box, a line of JSON per change
hey watch --box imbox --events added # only new postings in the Imbox
hey watch --box imbox --exit-on-first # block until something lands, then exit
hey watch --since 2026-08-18T09:00:00Z # catch up from a time first, then follow
hey watch --run-async 'notify-send "New mail in $HEY_BOX_KIND"'
hey watch --run-sync ./triage.sh # one at a time, waiting for each
```

Runs until interrupted, printing changes as they happen, one line each:

```json
{"change":"added","at":"2026-08-18T09:14:22.031Z","box":{"id":24088,"kind":"imbox","name":"Imbox"},"posting_id":98765,"thread_id":54321,"posting":{}}
```

A change can drive a command instead of being printed, and there's a choice to make
between two behaviours — pass one or the other, not both. `--run-async` spawns the
command per change and moves on, so a slow one never holds up the watch and two can
overlap. `--run-sync` waits for each and runs them in order, so they never overlap and a
slow one delays the next.

Both hand the JSON to the command on its stdin, and the same fields as `HEY_CHANGE`,
`HEY_AT`, `HEY_BOX_ID`, `HEY_BOX_KIND`, `HEY_BOX_NAME`, `HEY_POSTING_ID` and
`HEY_THREAD_ID`. Both also take over stdout, so the JSON isn't printed as well.

### Calendars

```bash
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/actioncable-go v0.0.0-20260819125529-39dd29b8e4d1
github.com/basecamp/hey-sdk/go v0.6.1
github.com/charmbracelet/x/ansi v0.11.8
github.com/gofrs/flock v0.13.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/basecamp/actioncable-go v0.0.0-20260819125529-39dd29b8e4d1 h1:aNCaFvx7FosjImt1A3TM3aWizOgWGHtKUoQ4RTa6Mx8=
github.com/basecamp/actioncable-go v0.0.0-20260819125529-39dd29b8e4d1/go.mod h1:9+DEydJMniIKraEsd4fDJpFEnqlLUJ6XhAswxRBaITk=
github.com/basecamp/hey-sdk/go v0.6.1 h1:NlruAUq1GOk+VCkc1HyxAsvLCbNf54n0kZ6kNfhZkyc=
github.com/basecamp/hey-sdk/go v0.6.1/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
Expand Down
77 changes: 77 additions & 0 deletions internal/cable/cable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package cable connects to HEY's Action Cable server, so commands can be told
// when something changed instead of polling for it.
package cable

import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"strings"

"github.com/basecamp/actioncable-go"

"github.com/basecamp/hey-cli/internal/auth"
)

// Dial connects to the cable server for a HEY base URL, authorizing the upgrade
// request with the same credentials the SDK sends on an API request.
func Dial(ctx context.Context, baseURL string, authMgr *auth.Manager, options ...actioncable.Option) (*actioncable.Client, error) {
cableURL, err := URL(baseURL)
if err != nil {
return nil, err
}

header, err := authHeader(ctx, baseURL, authMgr)
if err != nil {
return nil, err
}

client := actioncable.New(cableURL, append([]actioncable.Option{actioncable.WithHeader(header)}, options...)...)
if err := client.Connect(ctx); err != nil {
return nil, err
}

return client, nil
}

// URL is the cable endpoint for a base URL: https://app.hey.com becomes
// wss://app.hey.com/cable. HEY_CABLE_URL overrides it outright.
func URL(baseURL string) (string, error) {
if override := os.Getenv("HEY_CABLE_URL"); override != "" {
return override, nil
}

parsed, err := url.Parse(strings.TrimSuffix(baseURL, "/"))
if err != nil {
return "", fmt.Errorf("could not read base URL %q: %w", baseURL, err)
}

switch parsed.Scheme {
case "https":
parsed.Scheme = "wss"
case "http":
parsed.Scheme = "ws"
default:
return "", fmt.Errorf("base URL %q is neither http nor https", baseURL)
}

parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/cable"
parsed.RawQuery = ""

return parsed.String(), nil
}

func authHeader(ctx context.Context, baseURL string, authMgr *auth.Manager) (http.Header, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
if err != nil {
return nil, err
}

if err := authMgr.AuthenticateRequest(ctx, request); err != nil {
return nil, err
}

return request.Header, nil
}
42 changes: 42 additions & 0 deletions internal/cable/cable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cable

import "testing"

func TestURL(t *testing.T) {
cases := []struct {
baseURL string
want string
}{
{"https://app.hey.com", "wss://app.hey.com/cable"},
{"https://app.hey.com/", "wss://app.hey.com/cable"},
{"http://app.hey.localhost:3003", "ws://app.hey.localhost:3003/cable"},
}

for _, c := range cases {
got, err := URL(c.baseURL)
if err != nil {
t.Fatalf("URL(%q) failed: %v", c.baseURL, err)
}
if got != c.want {
t.Errorf("URL(%q) = %q, want %q", c.baseURL, got, c.want)
}
}
}

func TestURLOverride(t *testing.T) {
t.Setenv("HEY_CABLE_URL", "ws://cable.example.com/cable")

got, err := URL("https://app.hey.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "ws://cable.example.com/cable" {
t.Errorf("URL = %q, want the HEY_CABLE_URL override", got)
}
}

func TestURLRejectsOtherSchemes(t *testing.T) {
if _, err := URL("ftp://app.hey.com"); err == nil {
t.Fatal("expected an error for a base URL that isn't http or https")
}
}
2 changes: 1 addition & 1 deletion internal/cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var curatedCategories = []struct {
}{
{
heading: "EMAIL",
names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"},
names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"},
},
{
heading: "CALENDAR & TASKS",
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) {

func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) {
root := newRootCmd()
for _, name := range []string{"boxes", "box", "labels", "label", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"} {
for _, name := range []string{"boxes", "box", "labels", "label", "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 @@ -112,6 +112,7 @@ EMAIL
spam Mark email threads as spam
ignore Ignore email threads
stop-ignoring Stop ignoring email threads
watch Follow email threads as they change

CALENDAR & TASKS
calendars List calendars
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newHabitCommand().cmd)
root.AddCommand(newTimetrackCommand().cmd)
root.AddCommand(newJournalCommand().cmd)
root.AddCommand(newWatchCommand().cmd)
root.AddCommand(newSeenCommand().cmd)
root.AddCommand(newUnseenCommand().cmd)
root.AddCommand(newMoveCommand().cmd)
Expand Down
Loading
Loading