diff --git a/.surface b/.surface index 3100de6c6..274fb64bf 100644 --- a/.surface +++ b/.surface @@ -281,6 +281,24 @@ hey label view hey label view --all hey label view --limit hey label view --page +hey local +hey local --socket +hey local call +hey local call --arg +hey local drafts +hey local imbox +hey local reply +hey local reply --message +hey local screener +hey local screener approve +hey local screener deny +hey local screener list +hey local send +hey local tools +hey local write +hey local write --message +hey local write --subject +hey local write --to hey login hey login --cookie hey login --mcp diff --git a/docs/agents.md b/docs/agents.md index 2230797f9..7a1300a72 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,8 +2,9 @@ hey-cli is built to be driven by an agent as readily as by a person: every command that returns data answers `--json`, the exit codes are stable (`hey help exit-codes`), and `hey commands --json` -describes the whole surface. This page covers the two integrations that ship with it: an -agent skill for Claude Code and Codex, and an MCP server. +describes the whole surface. This page covers the integrations that ship with it: an +agent skill for Claude Code and Codex, an MCP server, and `hey local`, which asks the HEY +app on your computer to act for you. ## Agent skill and Claude Code plugin @@ -156,3 +157,83 @@ Earlier releases delivered mail when a call omitted `entry.status`; these action save drafts only. `describe` for the draft actions serves the draft-only schema, and the `hey_threads` tool description says the same. A person can still send any draft from HEY or with `hey draft send `. + +## The HEY app on your computer: `hey local` + +`hey local` talks to the HEY desktop app running on your computer rather than to HEY's +servers. The app runs its own commands over the mail it holds, as you would in its window, +so `hey local` needs no sign-in of its own and nothing it reads is marked seen. + +The app lets agents in only while "Let agents use HEY" is on in its Settings (General). It +is off by default. While it is on, the app listens on a Unix socket at +`$XDG_RUNTIME_DIR/hey/agent.sock`, owner-only, and turns away a connection from any other +user account. With the setting off or the app closed there is no socket, and `hey local` +fails with exit 6 and says to turn it on. `--socket ` or `HEY_LOCAL_SOCKET` points it +at another socket; with neither, and no absolute `XDG_RUNTIME_DIR`, it asks for one rather +than guess. + +```bash +hey local tools # what the app offers now, and what each does +hey local imbox # the Imbox, marking nothing seen +hey local screener list # who waits in The Screener, and who was decided +hey local screener deny 42 # screen sender 42 out; undo with approve +hey local drafts # the app's drafts, and which it would send now +hey local write --to marcus@example.com --subject "Stint plan" -m "Saturday works" +hey local reply 801 -m "Saturday works" # a reply to Imbox thread 801, as a draft +hey local send 1731280000123 # ask to send a draft: you confirm it in HEY +hey local call screener.decide --arg sender=42 --arg decision=in +``` + +Each command the app offers names its effect, and the effect decides what happens: + +| Effect | What happens | Commands | +|--------|--------------|----------| +| `read` | Runs, and changes nothing | `imbox.list`, `screener.list`, `drafts.list` | +| `reversible` | Runs at once, and can be undone | `screener.decide`, `draft.write`, `draft.reply` | +| `reaches_people` | Waits in the HEY window until you press Send there | `message.send` | + +Nothing that can't be undone, like emptying Trash or Spam, is offered at all. A send never +goes from `hey local`: the app shows you the sending address, recipients, subject, body +and files exactly as they would go, and sends only when you press Send in its window. +`hey local send` then exits 9 and says "Waiting in HEY": neither done (0) nor failed. +It takes the HEY app's own id for a draft, not the id `hey draft list` shows: the id +`hey local write` or `hey local reply` answered, or one `hey local drafts` lists (its `id`, +or its `made_as`, the id it was written as before HEY named it). Its JSON envelope carries `meta.status: "pending"`, the confirmation's id and what you were +shown. No command or flag here can confirm it. + +So an agent can carry a message end to end: `hey local write` (or `hey local reply` with a +thread from the Thread column of `hey local imbox`) saves a draft and answers its id, and +`hey local send ` asks HEY to send it, which waits for you. `--ids-only` prints the draft's +id. Writing sends nothing, and +each run writes one more draft: nothing here edits or deletes one, so a wrong draft is yours +to delete in HEY. The message is plain text, above your name tag; Cc, Bcc, files and a +schedule are yours to add in HEY. A reply answers the thread's last message and is +addressed as HEY's own Reply addresses one; it is saved beside any draft you have on the +thread, never over it. `hey local drafts` shows whom a draft goes to before you send it. +`hey local drafts` marks each draft HEY would send now as `sendable`; a scheduled one sends +on its own at its time. + +The draft commands' refusals say what happened, and nothing was saved or waits: + +| Refusal | Exit | Means | +|---------|------|-------| +| `not_an_address` | 1 | Pieces of `--to` read as no address; the message names them | +| `not_saved` | 1 or 7 | Past what HEY takes of one message: too many people, too many files, or too large. 1 for `write`, whose words and `--to` are yours to change; 7 for `reply`, whose recipients, files and quote come from the thread | +| `not_a_sender` | 7 | The draft goes from an address this account no longer sends from; change From in HEY, or write a new draft | +| `disabled` | 1, or 7 when HEY would send none | `send` of a draft HEY won't send now (scheduled, to nobody, a file still uploading, or a forward); the hint lists those it would | +| `unavailable` | 7 | `reply` of a thread the app couldn't read, or a draft `write` or `reply` saved that went before HEY listed it: check `hey local drafts` before writing it again | + +`hey local call ` runs any command `hey local tools` lists. Arguments come as a +JSON object, as `--arg name=value` pairs, or both, the pairs over the object; a value is +read as JSON where it is JSON, so `sender=42` is a number and `decision=out` a word. +A command the app refuses fails with its reason in the message, and in the JSON error's +`meta.refusal`, which lists the ids it would take where an id was wrong. +A command cut off by signing out of the app or switching accounts while it ran also exits 7, +with `session_ended` in `meta.refusal`: it may have been done, so check in HEY before running +it again. + +The wire is plain MCP: JSON-RPC 2.0, one message per line, `initialize`, then `tools/list` +and `tools/call`. The app's door is built for Linux first. A Mac has no socket yet: there, +HEY's sign-in sits behind a Keychain prompt, which a socket any program can reach would let +it skip, so the Mac app will first ask you to approve each agent and check the signature +of the program calling. diff --git a/docs/cli.md b/docs/cli.md index da0c866ec..596fd4924 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -362,6 +362,16 @@ Snippets are named reusable email content, separate from clips saved out of rece Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Moving to any of them but Imbox marks the threads seen, and a thread that cannot be replied to is silently not moved to Reply Later. Reply Later is a box rather than a separate flag: moving a Reply Later thread to Imbox removes Reply Later, preserves its seen state, and leaves a seen thread in Previously Seen. It does not return the thread to the box it occupied before Reply Later. A bundle row is refused rather than moved: it stands in for one sender's whole stream in the box they are delivered to, so moving it leaves nothing there for their next email to join and it arrives unbundled instead. Group or ungroup a sender with `hey contact bundle` and `hey contact unbundle`, and read a bundle with `hey bundle view`. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at 08:00 UTC that day, or 18:00 UTC when the date is today in UTC), or at 08:00 UTC tomorrow, the next Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one, moving the thread to the Imbox and marking it seen rather than returning it to its original box. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. Trashing a shared thread removes your access instead of deleting it for everyone. Ignoring marks a thread seen and leaves it in its box; while it is ignored `hey unseen` has no effect, and `hey stop-ignoring` resumes notifications but leaves it seen. +## The HEY app on your computer + +`hey local` asks the HEY desktop app on this computer to act for you, through the door it +opens while "Let agents use HEY" is on in its Settings: `hey local imbox`, +`hey local screener list`, `hey local screener approve`, `hey local screener deny`, +`hey local drafts`, `hey local write`, `hey local reply`, `hey local send`, and `hey local call` for any command `hey local tools` lists. The app +opens that door on Linux only, for now. It reaches the app alone, never HEY's servers, and needs +no sign-in. Writing or replying saves a draft and sends nothing; a send waits in the HEY window for you to press Send and exits 9, pending. +The effects, the socket and the exit codes are in [agents.md](agents.md#the-hey-app-on-your-computer-hey-local). + ## Watching for changes ```bash diff --git a/internal/cmd/accounts_test.go b/internal/cmd/accounts_test.go index 907c004d6..8b05fc7ad 100644 --- a/internal/cmd/accounts_test.go +++ b/internal/cmd/accounts_test.go @@ -29,6 +29,9 @@ func TestCommandAccountScopePolicy(t *testing.T) { {args: []string{"auth", "status"}, want: false}, {args: []string{"config", "show"}, want: false}, {args: []string{"shell-completion", "install"}, want: false}, + // hey local talks to the app on this computer, never to HEY's server. + {args: []string{"local", "tools"}, want: false}, + {args: []string{"local", "screener", "deny"}, want: false}, } { command, _, err := root.Find(test.args) if err != nil { @@ -399,3 +402,19 @@ func runAccountsCLIWithConfig(t *testing.T, server *httptest.Server, configDir s err := root.Execute() return output.String(), err } + +func TestLocalSkipsWhatOnlyHEYsServerNeeds(t *testing.T) { + root := newRootCmd() + for _, args := range [][]string{{"local", "imbox"}, {"local", "send"}} { + command, _, err := root.Find(args) + if err != nil { + t.Fatalf("Find(%v): %v", args, err) + } + if !commandSkipsCredentialMigration(command) { + t.Errorf("%v migrates credentials it never reads", args) + } + if !commandIgnoresLocalConfig(command) { + t.Errorf("%v reads a repository's server and account config", args) + } + } +} diff --git a/internal/cmd/help_topics.go b/internal/cmd/help_topics.go index 198d89e27..757f9ba54 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -46,6 +46,9 @@ EXIT CODES 7 An API, server, or local operational failure occurred, or HEY kept a message as a draft instead of sending it (not_delivered). 8 The request matched more than one resource. + 9 Pending: the HEY app took the request and waits in its window for you to + confirm it (hey local send, or hey local call of a send). Nothing has + happened yet. JSON failures also carry a machine-readable error code and an actionable hint when one is available.`, }, @@ -59,6 +62,7 @@ CONNECTION & AUTHENTICATION HEY_MCP_CLIENT_ID Override the HEY MCP (local) client id hey auth login --mcp signs in as. HEY_BASE_URL Override the HEY server URL. HEY_ACCOUNT_ID Select a linked mail account ID or all. + HEY_LOCAL_SOCKET The HEY app's socket for hey local, in place of $XDG_RUNTIME_DIR/hey/agent.sock. HEY_NO_KEYRING Store credentials in the config directory instead of a keyring. INTERACTION & DIAGNOSTICS diff --git a/internal/cmd/local.go b/internal/cmd/local.go new file mode 100644 index 000000000..e43c45b3d --- /dev/null +++ b/internal/cmd/local.go @@ -0,0 +1,458 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/localapp" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +// localTimeout bounds one hey local run, from the dial to the last answer. A read waits +// for the app's own load of what it lists, which is quick when the app is open. +const localTimeout = 30 * time.Second + +const localDoorHint = `Turn on "Let agents use HEY" in the HEY app's Settings (General), and keep the app running` + +// localSocketFlag is --socket, shared by every hey local subcommand. +var localSocketFlag string + +type localCommand struct { + cmd *cobra.Command +} + +func newLocalCommand() *localCommand { + localCommand := &localCommand{} + localCommand.cmd = &cobra.Command{ + Use: "local", + Short: "Ask the HEY app on this computer to act for you", + Long: `Talk to the HEY desktop app running on this computer, rather than to HEY's servers. + +The app opens a door for agents only while "Let agents use HEY" is on in its +Settings. Through it, hey local runs the app's own commands over the mail the app +holds, as you would in its window: it needs no sign-in of its own, and reading +marks nothing seen. + +What a command may do depends on its effect, which hey local tools lists: + read runs, and changes nothing + reversible runs, and can be undone (a sender screened out can be screened in) + reaches_people waits in the HEY window until you press Send there + +A command that waits for you exits 9 and says "Waiting in HEY": nothing has been +sent, and nothing hey local does can send it. Nothing that cannot be undone, like +emptying the trash, is offered at all. + +The door is a Unix socket at $XDG_RUNTIME_DIR/hey/agent.sock. --socket or +HEY_LOCAL_SOCKET points hey local at another one. The HEY app opens the door on +Linux; on other systems there is nothing for hey local to talk to yet.`, + Annotations: map[string]string{ + "agent_notes": "hey local talks to the person's own HEY app, not to HEY's servers, and works only while the app is running with \"Let agents use HEY\" on. `hey local tools` lists what the app offers now, with each command's effect. Reads change nothing and mark nothing seen. A screening decision runs at once and is undone with the opposite decision. hey local write and reply save a draft, send nothing, and answer the id hey local send takes. A send never goes from here: it exits 9, pending, and waits for the person to press Send in the HEY window, so say it is waiting for them, never that it was sent. Exit 6 with a hint means the door is off or the app is closed: ask the person to turn on \"Let agents use HEY\" in the app's Settings.", + }, + } + + localCommand.cmd.PersistentFlags().StringVar(&localSocketFlag, "socket", "", "The HEY app's socket (default $XDG_RUNTIME_DIR/hey/agent.sock, or HEY_LOCAL_SOCKET)") + + localCommand.cmd.AddCommand(newLocalToolsCommand().cmd) + localCommand.cmd.AddCommand(newLocalCallCommand().cmd) + localCommand.cmd.AddCommand(newLocalImboxCommand().cmd) + localCommand.cmd.AddCommand(newLocalScreenerCommand().cmd) + localCommand.cmd.AddCommand(newLocalDraftsCommand().cmd) + localCommand.cmd.AddCommand(newLocalWriteCommand().cmd) + localCommand.cmd.AddCommand(newLocalReplyCommand().cmd) + localCommand.cmd.AddCommand(newLocalSendCommand().cmd) + return localCommand +} + +// withLocalApp dials the app's door, runs talk over the connection and closes it, turning +// whatever went wrong on the way into what a person or a script can act on. +func withLocalApp(cmd *cobra.Command, talk func(context.Context, *localapp.Client) error) error { + path, err := localapp.SocketPath(localSocketFlag) + if err != nil { + return apierr.ErrUsageHint( + "XDG_RUNTIME_DIR is not set to an absolute path, so there is no HEY socket to look for", + "Run hey local from your desktop session, or pass --socket ", + ) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), localTimeout) + defer cancel() + client, err := localapp.Dial(ctx, path) + if err != nil { + return localFailure(err) + } + defer client.Close() + return localFailure(talk(ctx, client)) +} + +// callLocal runs one of the app's commands and hands back what it did. A refusal is an +// error. So is pending, once it is written: whatever the command, an answer that waits +// for the person is never read on as done. +func callLocal(cmd *cobra.Command, name string, arguments map[string]any) (*localapp.Result, error) { + var result *localapp.Result + err := withLocalApp(cmd, func(ctx context.Context, client *localapp.Client) error { + var err error + result, err = client.Call(ctx, name, arguments) + return err + }) + if err != nil { + return nil, err + } + if result.IsError { + return nil, localRefusal(name, arguments, result) + } + if pending, ok := result.Pending(); ok { + return nil, writeLocalPending(cmd, pending) + } + return result, nil +} + +// localFailure says what the app's door did, in the terms of the CLI's errors: a door +// that is closed is a connection that failed (6), with the way to open it; anything the +// app answered wrongly or refused is an API failure (7). +func localFailure(err error) error { + var ( + closed *localapp.DoorClosedError + connect *localapp.ConnectError + timeout *localapp.TimeoutError + server *localapp.ServerError + protocol *localapp.ProtocolError + ) + switch { + case err == nil: + return nil + case errors.As(err, &closed): + return &apierr.Error{ + Code: apierr.CodeNetwork, + Message: fmt.Sprintf("HEY isn't letting agents in: nothing answers at %s", closed.Path), + Hint: localDoorHint, + Cause: err, + } + case errors.As(err, &connect), errors.As(err, &timeout): + return &apierr.Error{Code: apierr.CodeNetwork, Message: terminal.SanitizeLine(err.Error()), Cause: err} + case errors.As(err, &protocol) && protocol.Closed && protocol.Method == "initialize": + return &apierr.Error{ + Code: apierr.CodeNetwork, + Message: "HEY closed the connection without answering", + Hint: "The HEY app answers only the user account it runs as", + Cause: err, + } + case errors.As(err, &server) && server.Method == "tools/call" && server.Code == -32602: + // The app's answer to a tool it has not got, or arguments that are not an object. + return &apierr.Error{ + Code: apierr.CodeUsage, + Message: terminal.SanitizeLine(err.Error()), + Hint: "Run: hey local tools, for what the app offers now and what each takes", + Cause: err, + } + case errors.As(err, &server), errors.As(err, &protocol): + return &apierr.Error{Code: apierr.CodeAPI, Message: terminal.SanitizeLine(err.Error()), Cause: err} + default: + return err + } +} + +// localRefusal is a command the app refused, with its reason in the error's meta. A +// refusal that names an argument is a usage error, with what the argument would take; the +// app signed out of HEY is an auth one, though not the CLI's sign-in; anything else, a list +// the app could not load included, is the app's to explain. +func localRefusal(name string, arguments map[string]any, result *localapp.Result) error { + // Numbers kept as written, so an id past a million is hinted as an id, not 1.7e+12. + var reason map[string]any + _ = decodeJSONExactly(string(result.Answer()), &reason) + why, _ := reason["error"].(string) + if why == "" { + why = result.Text() + } + + refusal := &apierr.Error{ + Code: apierr.CodeAPI, + Message: terminal.SanitizeLine(fmt.Sprintf("HEY refused %s: %s", name, why)), + Meta: map[string]any{"refusal": reason}, + } + if why == "session_ended" { + // A sign-out or account switch cut the call off while it ran: unlike every refusal, + // what it asked may already be done, so it is never worded as refused. + refusal.Message = terminal.SanitizeLine(fmt.Sprintf("HEY's session ended while it ran %s: it may have been done", name)) + refusal.Hint = "Check in HEY whether it was done before running it again" + } + arg, _ := reason["arg"].(string) + switch { + case why == "not_an_address": + // The pieces of --to that read as no address; nothing was written. + refusal.Code = apierr.CodeUsage + refusal.Message = terminal.SanitizeLine("HEY can't read these as addresses: " + localJoined(reason["given"])) + refusal.Hint = "Give the To line as addresses, or Name
, separated by commas" + case why == "not_saved": + localNotSaved(refusal, name, reason) + case why == "not_a_sender": + // No confirmation waits and no sheet shows: HEY would refuse it at Send. + refusal.Message = terminal.SanitizeLine(fmt.Sprintf("HEY won't send draft %v: it goes from %v, which this account no longer sends from", reason["draft"], reason["from"])) + refusal.Hint = "Change From in HEY, or write a new draft with hey local write" + // The app answers unavailable both for a thread it couldn't read and for a draft it saved + // that went (sent, deleted or refused elsewhere) before HEY listed it: neither is said + // as nothing having happened. + case why == "unavailable" && arg == "" && name == "draft.reply": + refusal.Message = terminal.SanitizeLine(fmt.Sprintf("HEY couldn't read thread %v, or the draft it saved went before HEY listed it", arguments["thread"])) + refusal.Hint = "Check hey local drafts before writing it again; hey local imbox lists the threads a reply takes" + case why == "unavailable" && arg == "" && name == "draft.write": + refusal.Message = "HEY couldn't show the draft it saved: it may have gone before HEY listed it" + refusal.Hint = "Check hey local drafts before writing it again" + case why == "disabled" && name == "message.send": + // The draft is there, but not one HEY would ask to send now; available lists those it would. + refusal.Message = terminal.SanitizeLine(fmt.Sprintf("HEY won't send draft %v now: it is scheduled, goes to nobody, has a file still uploading, or is a forward", arguments["draft"])) + refusal.Code = apierr.CodeUsage + refusal.Hint = terminal.SanitizeLine(localArgumentHint(arg, reason)) + if localOffersNothing(reason) { + refusal.Code = apierr.CodeAPI + refusal.Hint = "No draft can be sent from here now: hey local drafts shows which are sendable" + } + case arg != "" && localOffersNothing(reason): + // Nothing would do, so nothing the caller sent was wrong: the app holds none now, + // or could not load what it would choose from. + refusal.Hint = "The app holds nothing this takes now: hey local tools lists what it offers" + case arg != "": + refusal.Code = apierr.CodeUsage + refusal.Hint = terminal.SanitizeLine(localArgumentHint(arg, reason)) + case why == "bad_request" || why == "not_text": + refusal.Code = apierr.CodeUsage + refusal.Hint = "Run: hey local tools, for what each command takes" + case why == "not_offered": + refusal.Hint = "Run: hey local tools, for what the app offers now" + case why == "signed_out": + refusal.Code = apierr.CodeAuth + refusal.Hint = "Sign in to HEY in the app" + } + // The app's own words, after whichever plain ones lead. + if detail, _ := reason["detail"].(string); detail != "" { + refusal.Message += terminal.SanitizeLine(" (" + detail + ")") + } + return refusal +} + +// localOneOf is an answer of one thing with an id, a draft saved, as a list of one for +// --ids-only and --count: a draft written is never followed by a failure that reads as +// nothing having happened, and a retry that writes another. +func localOneOf(answer any) any { + format := writer.EffectiveFormat() + if format != output.FormatIDs && format != output.FormatCount { + return answer + } + switch one := answer.(type) { + case map[string]any: + if _, ok := one["id"]; ok { + return []any{one} + } + case localDraft: + return []localDraft{one} + } + return answer +} + +// localNotSaved says why HEY saved no draft: past what one message takes. draft.write's +// words and recipients are the caller's to change (1); a reply's recipients, files and +// quote are the thread's, so a reply past a limit is the person's to write in HEY (7). +func localNotSaved(refusal *apierr.Error, name string, reason map[string]any) { + why, _ := reason["refusal"].(string) + past := map[string]string{ + "too_many_recipients": "it goes to more people than HEY sends one message to", + "too_many_files": "it holds more files than HEY sends in one message", + "too_large": "it is larger than HEY sends in one message", + }[why] + if past == "" { + past = "HEY says " + why + } + refusal.Message = terminal.SanitizeLine("HEY didn't save the draft: " + past) + switch { + case why == "too_large" && name == "draft.write": + refusal.Code = apierr.CodeUsage + refusal.Hint = "Shorten the message" + case why == "too_many_recipients" && name == "draft.write": + refusal.Code = apierr.CodeUsage + refusal.Hint = "Write it to fewer people" + case why == "too_large" && name == "draft.reply": + refusal.Hint = "Shorten the message, or reply in HEY: a reply's quote counts too" + case name == "draft.reply": + refusal.Hint = "Reply to this thread in HEY" + } +} + +// localJoined is a refusal's list as words. +func localJoined(values any) string { + list, _ := values.([]any) + words := make([]string, 0, len(list)) + for _, value := range list { + words = append(words, fmt.Sprint(value)) + } + return strings.Join(words, ", ") +} + +// localOffersNothing reports a refusal whose list of what the argument would take, its +// ids or its cases, is there and empty. +func localOffersNothing(reason map[string]any) bool { + for _, key := range []string{"available", "cases"} { + if values, ok := reason[key].([]any); ok && len(values) == 0 { + return true + } + } + return false +} + +// localArgumentHint says what a refused argument would take: the ids the app offers it +// for now, or its cases. +func localArgumentHint(arg string, reason map[string]any) string { + for _, key := range []string{"available", "cases"} { + if values, ok := reason[key].([]any); ok { + taken := make([]string, 0, len(values)) + for _, value := range values { + taken = append(taken, fmt.Sprint(value)) + } + return fmt.Sprintf("%s takes one of: %s", arg, strings.Join(taken, ", ")) + } + } + return "Run: hey local tools, for what each command takes" +} + +// writeLocalAnswer writes what a command answered: pretty JSON for a person, the envelope +// for everyone else. +func writeLocalAnswer(cmd *cobra.Command, name string, result *localapp.Result) error { + answer, err := decodeLocalAnswer(name, result) + if err != nil { + return err + } + if writer.IsStyled() { + indented, err := json.MarshalIndent(answer, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), terminal.Sanitize(string(indented))) + return nil + } + return writeOK(localOneOf(answer), output.WithSummary("HEY ran "+name)) +} + +// writeLocalPending says a command waits in HEY's window, and returns ErrPending so the +// run exits as neither done nor failed, whatever the output format makes of the answer. +func writeLocalPending(cmd *cobra.Command, pending *localapp.Pending) error { + const summary = "Waiting in HEY: nothing is sent until the person presses Send in the HEY window" + var snapshot localSendSnapshot + _ = json.Unmarshal(pending.Snapshot, &snapshot) + confirmation := localConfirmationID(pending.Confirmation) + + switch writer.EffectiveFormat() { + case output.FormatStyled: + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Waiting in HEY: press Send in the HEY window to send it. Nothing has been sent.") + if len(snapshot.To) > 0 { + fmt.Fprintln(out, " To: "+terminal.SanitizeLine(strings.Join(snapshot.To, ", "))) + } + if snapshot.Subject != "" { + fmt.Fprintln(out, " Subject: "+terminal.SanitizeLine(snapshot.Subject)) + } + case output.FormatIDs, output.FormatCount: + // Neither has a list to show: the confirmation's id is the one id there is, and + // the words go to stderr so stdout stays what the flag asked for. + if writer.EffectiveFormat() == output.FormatIDs && confirmation != "" { + fmt.Fprintln(cmd.OutOrStdout(), confirmation) + } + fmt.Fprintln(cmd.ErrOrStderr(), summary) + default: + // Decoded to values, since --markdown prints a map's values as they are and would + // show raw JSON as bytes. + data := map[string]any{"status": "pending"} + for key, raw := range map[string]json.RawMessage{"confirmation": pending.Confirmation, "snapshot": pending.Snapshot} { + var value any + if decodeJSONExactly(string(raw), &value) == nil { + data[key] = value + } + } + if err := writeOK(data, output.WithSummary(summary), output.WithMeta("status", "pending")); err != nil { + // A filter that failed on the answer does not make the send any less pending. + fmt.Fprintln(cmd.ErrOrStderr(), terminal.SanitizeLine(err.Error())) + fmt.Fprintln(cmd.ErrOrStderr(), summary) + } + } + return output.ErrPending +} + +// localConfirmationID is a confirmation's id as a line: a number as written, a word +// without its quotes. +func localConfirmationID(raw json.RawMessage) string { + var word string + if json.Unmarshal(raw, &word) == nil { + return terminal.SanitizeLine(word) + } + return terminal.SanitizeLine(string(raw)) +} + +// decodeLocalAnswer reads a command's answer as JSON values, numbers kept exact, so an id +// the app gives reaches the output as the app wrote it. +func decodeLocalAnswer(name string, result *localapp.Result) (any, error) { + raw := result.Answer() + if raw == nil { + return map[string]any{"text": result.Text()}, nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var answer any + if err := decoder.Decode(&answer); err != nil { + return nil, localUnreadable(name, err) + } + return answer, nil +} + +// localOtherwiseError is what a complete func returns for an answer whose shape is right +// but whose content says the app did something else. +type localOtherwiseError struct{ said string } + +func (e *localOtherwiseError) Error() string { return e.said } + +// decodeLocalAnswerAs reads a command's answer into the shape the command's sugar knows, +// and refuses one that is not that shape, or that complete says lacks what the command +// must have, rather than show it as empty or as done. +func decodeLocalAnswerAs[T any](name string, result *localapp.Result, complete func(T) error) (T, error) { + var answer T + raw := result.Answer() + if raw == nil { + return answer, localUnreadable(name, errors.New("it carries no structured answer")) + } + if err := json.Unmarshal(raw, &answer); err != nil { + return answer, localUnreadable(name, err) + } + if err := complete(answer); err != nil { + var otherwise *localOtherwiseError + if errors.As(err, &otherwise) { + return answer, localAnsweredOtherwise(name, err) + } + return answer, localUnreadable(name, err) + } + return answer, nil +} + +// localAnsweredOtherwise is an answer the app gave in the right shape that says it did +// something other than what was asked. +func localAnsweredOtherwise(name string, cause error) error { + return &apierr.Error{ + Code: apierr.CodeAPI, + Message: terminal.SanitizeLine(fmt.Sprintf("HEY's answer to %s is not what was asked: %v", name, cause)), + Hint: "Check in the HEY app what it did", + Cause: cause, + } +} + +func localUnreadable(name string, cause error) error { + return &apierr.Error{ + Code: apierr.CodeAPI, + Message: terminal.SanitizeLine(fmt.Sprintf("HEY's answer to %s could not be read: %v", name, cause)), + Hint: "This hey may be older than the HEY app: run hey upgrade", + Cause: cause, + } +} diff --git a/internal/cmd/local_call.go b/internal/cmd/local_call.go new file mode 100644 index 000000000..8f941ca19 --- /dev/null +++ b/internal/cmd/local_call.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +type localCallCommand struct { + cmd *cobra.Command + args []string +} + +func newLocalCallCommand() *localCallCommand { + callCommand := &localCallCommand{} + callCommand.cmd = &cobra.Command{ + Use: "call [arguments-json]", + Short: "Run one of the HEY app's commands", + Long: `Run one of the commands hey local tools lists, with its arguments as a JSON object, +as --arg name=value pairs, or both, the pairs over the object. + +A value is read as JSON where it is JSON, so sender=42 is the number 42 and +decision=out the word out. Quote a word that would read as something else: +--arg 'subject="2026"'. + +A command that reaches people waits in the HEY window for you to press Send: +it exits 9, pending, and nothing has been sent.`, + Annotations: map[string]string{ + "agent_notes": "Prefer the sugar (hey local imbox, hey local screener, hey local send) where it covers the command. Exit 9 is pending: the command waits for the person in the HEY window and nothing has happened yet. A refusal carries the app's reason in the JSON error's meta.refusal, with the ids it would take under available.", + }, + Example: ` hey local call imbox.list + hey local call screener.decide --arg sender=42 --arg decision=out + hey local call screener.decide '{"sender": 42, "decision": "in"}' + hey local call message.send --arg draft=1731280000123 --json`, + RunE: callCommand.run, + Args: cobra.MatchAll(usageMinOneArg(), cobra.MaximumNArgs(2)), + } + callCommand.cmd.Flags().StringArrayVar(&callCommand.args, "arg", nil, "An argument as name=value (repeatable)") + return callCommand +} + +func (c *localCallCommand) run(cmd *cobra.Command, args []string) error { + arguments, err := localArguments(args[1:], c.args) + if err != nil { + return err + } + result, err := callLocal(cmd, args[0], arguments) + if err != nil { + return err + } + return writeLocalAnswer(cmd, args[0], result) +} + +// localArguments builds a call's arguments: the JSON object, if one was given, then each +// name=value pair over it. +func localArguments(object []string, pairs []string) (map[string]any, error) { + arguments := map[string]any{} + if len(object) == 1 { + if err := decodeJSONExactly(object[0], &arguments); err != nil || arguments == nil { + return nil, apierr.ErrUsageHint("the arguments are not a JSON object", + `Pass an object, like '{"sender": 42}', or --arg sender=42`) + } + } + given := map[string]bool{} + for _, pair := range pairs { + name, value, ok := strings.Cut(pair, "=") + if !ok || name == "" { + return nil, apierr.ErrUsage(fmt.Sprintf("--arg %s is not name=value", pair)) + } + if given[name] { + return nil, apierr.ErrUsage(fmt.Sprintf("--arg %s is given twice", name)) + } + given[name] = true + arguments[name] = localArgumentValue(value) + } + return arguments, nil +} + +// localArgumentValue reads value as JSON where it is JSON, and as a word otherwise. +func localArgumentValue(value string) any { + var parsed any + if err := decodeJSONExactly(value, &parsed); err != nil { + return value + } + return parsed +} + +// decodeJSONExactly reads one JSON value and nothing after it, numbers kept as written, so +// an id past what a float holds reaches the app unrounded. +func decodeJSONExactly(text string, into any) error { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + if err := decoder.Decode(into); err != nil { + return err + } + // Token reads past whatever follows, a stray bracket included, which More would not. + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return fmt.Errorf("more after the JSON value") + } + return nil +} diff --git a/internal/cmd/local_config_trust.go b/internal/cmd/local_config_trust.go index 7ed38245b..3daa43e7d 100644 --- a/internal/cmd/local_config_trust.go +++ b/internal/cmd/local_config_trust.go @@ -60,6 +60,9 @@ func commandIgnoresLocalConfig(cmd *cobra.Command) bool { switch parts[1] { case "skill": return true + case "local": + // hey local talks to the app on this computer, never to a server or an account. + return true case "setup": // The wizard itself uses the effective server; its subcommands // (agents, claude, codex, omarchy) touch only local files and must diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go new file mode 100644 index 000000000..a4eb9c4a3 --- /dev/null +++ b/internal/cmd/local_drafts.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/localapp" + "github.com/basecamp/hey-cli/internal/output" +) + +type localDraftsCommand struct { + cmd *cobra.Command +} + +// localDraft is a draft as the HEY app holds it. id is the one a send takes; made_as, the id +// it was written as before HEY named it, is taken too. sendable says whether a send would +// ask the person about it now. +type localDraft struct { + ID int64 `json:"id"` + MadeAs *int64 `json:"made_as,omitempty"` + Kind string `json:"kind"` + Thread *int64 `json:"thread,omitempty"` + Account string `json:"account"` + From string `json:"from"` + To []string `json:"to"` + Cc []string `json:"cc"` + Bcc []string `json:"bcc"` + Subject string `json:"subject"` + Summary string `json:"summary"` + SavedAt string `json:"saved_at"` + SendsAt *string `json:"sends_at,omitempty"` + Sendable *bool `json:"sendable"` +} + +// complete holds what a draft's zero would claim: draft 0, or one HEY would not send. +func (d localDraft) complete() error { + if d.ID <= 0 { + return errors.New("it gives a draft without an ID") + } + if d.Sendable == nil { + return fmt.Errorf("it does not say whether draft %d can be sent", d.ID) + } + return nil +} + +type localDrafts struct { + Drafts []localDraft `json:"drafts"` +} + +func newLocalDraftsCommand() *localDraftsCommand { + draftsCommand := &localDraftsCommand{} + draftsCommand.cmd = &cobra.Command{ + Use: "drafts", + Short: "List the drafts the HEY app holds", + Long: `List every draft the HEY app on this computer holds, the last saved first. + +A draft marked "sendable" is one hey local send would ask you to send now. One +that is scheduled goes on its own at its time; any other is yours to finish in HEY.`, + Annotations: map[string]string{ + "agent_notes": "Runs the app's drafts.list. id is what hey local send takes, and so is made_as, the id a draft was written as before HEY named it. sendable is whether hey local send would ask the person about it now: not scheduled (sends_at), to somebody, every file uploaded, not a forward, from a sender the account still has. These ids are the app's own, not hey draft list's.", + }, + Example: ` hey local drafts + hey local drafts --json`, + RunE: draftsCommand.run, + Args: cobra.NoArgs, + } + return draftsCommand +} + +func (c *localDraftsCommand) run(cmd *cobra.Command, _ []string) error { + result, err := callLocal(cmd, "drafts.list", nil) + if err != nil { + return err + } + drafts, err := decodeLocalAnswerAs("drafts.list", result, func(drafts localDrafts) error { + if drafts.Drafts == nil { + return errors.New("it lists no drafts") + } + for _, draft := range drafts.Drafts { + if incomplete := draft.complete(); incomplete != nil { + return incomplete + } + } + return nil + }) + if err != nil { + return err + } + + if writer.IsStyled() { + if len(drafts.Drafts) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No drafts in the HEY app") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "To", "Subject", "Saved", ""}) + for _, draft := range drafts.Drafts { + table.addRow([]string{fmt.Sprintf("%d", draft.ID), truncate(strings.Join(draft.To, ", "), 32), truncate(draft.Subject, 40), draft.SavedAt, localDraftState(draft)}) + } + table.print() + return nil + } + return writeOK(drafts.Drafts, + output.WithSummary(fmt.Sprintf("%d %s in the HEY app", len(drafts.Drafts), localDraftNoun(len(drafts.Drafts)))), + ) +} + +func localDraftState(draft localDraft) string { + switch { + case draft.SendsAt != nil: + return "scheduled " + *draft.SendsAt + case *draft.Sendable: + return "sendable" + default: + return "" + } +} + +func localDraftNoun(count int) string { + if count == 1 { + return "draft" + } + return "drafts" +} + +// writeLocalDraft answers a draft an agent wrote: saved, nothing sent, and how to ask HEY +// to send it, where HEY would. +func writeLocalDraft(cmd *cobra.Command, name string, result *localapp.Result, thread int64) error { + kind := "new" + if thread != 0 { + kind = "reply" + } + draft, err := decodeLocalAnswerAs(name, result, func(draft localDraft) error { + if err := draft.complete(); err != nil { + return err + } + if draft.Kind != kind { + return &localOtherwiseError{fmt.Sprintf("it saved draft %d as %s, not %s", draft.ID, draft.Kind, kind)} + } + if thread != 0 && (draft.Thread == nil || *draft.Thread != thread) { + return &localOtherwiseError{fmt.Sprintf("it saved draft %d, not a reply on thread %d", draft.ID, thread)} + } + return nil + }) + if err != nil { + return err + } + + to := "nobody" + if len(draft.To) > 0 { + to = strings.Join(draft.To, ", ") + } + saved := fmt.Sprintf("Draft %d saved, to %s. Nothing has been sent.", draft.ID, to) + if !*draft.Sendable { + summary := fmt.Sprintf("Draft %d saved; HEY would not send it from here now", draft.ID) + // --ids-only and --count carry no summary; this one goes where a waiting send's does. + if format := writer.EffectiveFormat(); format == output.FormatIDs || format == output.FormatCount { + fmt.Fprintln(cmd.ErrOrStderr(), summary) + } + return writeMutationLine(cmd, + saved+" HEY can't send it from here: finish it in HEY.", + summary, + localOneOf(draft), + ) + } + send := fmt.Sprintf("hey local %ssend %d", localSocketArgument(), draft.ID) + return writeMutationLine(cmd, + saved+" Ask HEY to send it with: "+send, + fmt.Sprintf("Draft %d saved; nothing has been sent", draft.ID), + localOneOf(draft), + output.WithBreadcrumbs(output.Breadcrumb{Action: "send", Command: send, Description: "Ask HEY to send it: the person presses Send in the HEY window"}), + ) +} + +// localMessageBody is -m's words, else stdin's when it is not a terminal. +func localMessageBody(message string) (string, error) { + if message == "" && !stdinIsTerminal() { + read, err := readStdin() + if err != nil { + return "", err + } + message = read + } + if message == "" { + return "", apierr.ErrUsage("no message provided (use -m or --message, or pipe it to stdin)") + } + return message, nil +} diff --git a/internal/cmd/local_drafts_test.go b/internal/cmd/local_drafts_test.go new file mode 100644 index 000000000..33b929555 --- /dev/null +++ b/internal/cmd/local_drafts_test.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/localapp/localapptest" + "github.com/basecamp/hey-cli/internal/output" +) + +func localDraftAnswer(id int64, kind string, sendable bool) map[string]any { + draft := map[string]any{ + "id": id, "kind": kind, "account": "ada@hey.example", "from": "ada@hey.example", + "to": []string{"marcus@example.com"}, "cc": []string{}, "bcc": []string{}, + "subject": "Stint plan", "summary": "Saturday works", "saved_at": "2026-11-11T21:00:00Z", + "sendable": sendable, + } + if kind == "reply" { + draft["thread"] = 801 + draft["subject"] = "Re: Stint plan" + } + return draft +} + +func TestLocalDraftsListsEveryDraftAndWhetherHEYWouldAskToSendIt(t *testing.T) { + door := localapptest.Start(t) + scheduled := localDraftAnswer(1731280000999, "new", false) + scheduled["sends_at"] = "2026-11-12T08:00:00Z" + door.Answer("drafts.list", map[string]any{"drafts": []any{ + localDraftAnswer(1731280000123, "reply", true), scheduled, + }}) + + stdout, _, err := runLocal(t, door.Path, "drafts", "--json") + if err != nil { + t.Fatal(err) + } + var envelope struct { + Data []localDraft `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil || len(envelope.Data) != 2 || + envelope.Data[0].ID != 1731280000123 || !*envelope.Data[0].Sendable || *envelope.Data[0].Thread != 801 || + envelope.Data[1].SendsAt == nil || *envelope.Data[1].Sendable { + t.Errorf("drafts = %s (%v)", stdout, err) + } + + styled, _, err := runLocal(t, door.Path, "drafts", "--styled") + if err != nil || !strings.Contains(styled, "1731280000123") || !strings.Contains(styled, "marcus@example.com") || + !strings.Contains(styled, "scheduled") { + t.Errorf("styled = %q (%v)", styled, err) + } + ids, _, err := runLocal(t, door.Path, "drafts", "--ids-only") + if err != nil || ids != "1731280000123\n1731280000999\n" { + t.Errorf("--ids-only = %q (%v)", ids, err) + } +} + +func TestLocalWriteSavesADraftAndSaysHowToSendIt(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", true)) + + stdout, _, err := runLocal(t, door.Path, "write", "--to", "Marcus ", "--subject", "Stint plan", "-m", "Saturday works", "--json") + if err != nil { + t.Fatal(err) + } + var call struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments"` + } + if calls := door.Calls(); len(calls) != 1 || json.Unmarshal(calls[0], &call) != nil || call.Name != "draft.write" || + len(call.Arguments) != 3 || call.Arguments["to"] != "Marcus " || + call.Arguments["subject"] != "Stint plan" || call.Arguments["body"] != "Saturday works" { + t.Errorf("calls = %s", calls) + } + if !strings.Contains(stdout, `"id": 1731280000123`) || !strings.Contains(stdout, "hey local send 1731280000123") { + t.Errorf("stdout = %s", stdout) + } + styled, _, err := runLocal(t, door.Path, "write", "--to", "marcus@example.com", "--subject", "Stint plan", "-m", "Saturday works", "--styled") + if err != nil || !strings.Contains(styled, "Draft 1731280000123 saved") || !strings.Contains(styled, "hey local send 1731280000123") || + !strings.Contains(styled, "Nothing has been sent") { + t.Errorf("styled = %q (%v)", styled, err) + } +} + +func TestLocalWriteWithNoMessageAsksTheAppNothing(t *testing.T) { + door := localapptest.Start(t) + previous := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = previous }) + + _, _, err := runLocal(t, door.Path, "write", "--to", "marcus@example.com", "--subject", "Stint plan") + if output.ExitCodeFor(err) != output.ExitUsage || !strings.Contains(apierr.AsError(err).Message, "no message") { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if calls := door.Calls(); len(calls) != 0 { + t.Errorf("calls = %s", calls) + } +} + +// A draft HEY could not send from here now says so, so nobody asks for a send that waits on nothing. +func TestLocalWriteOfADraftHEYWouldNotSendSaysSo(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", false)) + + styled, _, err := runLocal(t, door.Path, "write", "--to", "", "--subject", "Notes", "-m", "Later", "--styled") + if err != nil || strings.Contains(styled, "hey local send") || !strings.Contains(styled, "can't send it from here") { + t.Errorf("styled = %q (%v)", styled, err) + } +} + +func TestLocalReplySavesADraftOnTheThread(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.reply", localDraftAnswer(1731280000123, "reply", true)) + + styled, _, err := runLocal(t, door.Path, "reply", "801", "-m", "Saturday works", "--styled") + if err != nil || !strings.Contains(styled, "Draft 1731280000123 saved") || !strings.Contains(styled, "hey local send 1731280000123") { + t.Errorf("styled = %q (%v)", styled, err) + } + if calls := door.Calls(); len(calls) != 1 || + string(calls[0]) != `{"arguments":{"body":"Saturday works","thread":801},"name":"draft.reply"}` { + t.Errorf("calls = %s", calls) + } +} + +func TestLocalAReplyAnsweredOnAnotherThreadIsTheAppsToExplain(t *testing.T) { + door := localapptest.Start(t) + elsewhere := localDraftAnswer(1731280000123, "reply", true) + elsewhere["thread"] = 802 + door.Answer("draft.reply", elsewhere) + + _, _, err := runLocal(t, door.Path, "reply", "801", "-m", "Saturday works") + if output.ExitCodeFor(err) != output.ExitAPI || apierr.AsError(err).Hint != "Check in the HEY app what it did" { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } +} + +// The whole of it: an agent writes a draft, asks to send it by the id it was given, and it +// waits for the person in HEY. +func TestLocalAnAgentWritesADraftThenItsSendWaitsInHEY(t *testing.T) { + door := pendingDoor(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", true)) + + stdout, _, err := runLocal(t, door.Path, "write", "--to", "marcus@example.com", "--subject", "Stint plan", "-m", "Saturday works", "--json") + var written struct { + Data localDraft `json:"data"` + } + if err != nil || json.Unmarshal([]byte(stdout), &written) != nil { + t.Fatalf("write = %s (%v)", stdout, err) + } + _, _, err = runLocal(t, door.Path, "send", fmt.Sprint(written.Data.ID)) + if output.ExitCodeFor(err) != output.ExitPending { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if calls := door.Calls(); len(calls) != 2 || string(calls[1]) != `{"arguments":{"draft":1731280000123},"name":"message.send"}` { + t.Errorf("calls = %s", calls) + } +} + +// Each refusal of the draft commands says plainly what happened, with its exit code; +// nothing was saved or waits. +func TestLocalDraftRefusalsSayWhatHappened(t *testing.T) { + cases := []struct { + name, tool string + refusal map[string]any + args []string + exit int + message string + hint string + }{ + {"not an address", "draft.write", map[string]any{"error": "not_an_address", "arg": "to", "given": []string{"marcus", "@@"}}, + []string{"write", "--to", "marcus, @@", "--subject", "s", "-m", "b"}, output.ExitUsage, + "HEY can't read these as addresses: marcus, @@", "Give the To line as addresses, or Name
, separated by commas"}, + {"too many recipients", "draft.write", map[string]any{"error": "not_saved", "refusal": "too_many_recipients"}, + []string{"write", "--to", "a@example.com", "--subject", "s", "-m", "b"}, output.ExitUsage, + "HEY didn't save the draft: it goes to more people than HEY sends one message to", "Write it to fewer people"}, + {"too large", "draft.reply", map[string]any{"error": "not_saved", "refusal": "too_large"}, + []string{"reply", "801", "-m", "b"}, output.ExitAPI, + "HEY didn't save the draft: it is larger than HEY sends in one message", "Shorten the message, or reply in HEY: a reply's quote counts too"}, + {"write too large", "draft.write", map[string]any{"error": "not_saved", "refusal": "too_large"}, + []string{"write", "--to", "a@example.com", "--subject", "s", "-m", "b"}, output.ExitUsage, + "HEY didn't save the draft: it is larger than HEY sends in one message", "Shorten the message"}, + {"reply to too many", "draft.reply", map[string]any{"error": "not_saved", "refusal": "too_many_recipients"}, + []string{"reply", "801", "-m", "b"}, output.ExitAPI, + "HEY didn't save the draft: it goes to more people than HEY sends one message to", "Reply to this thread in HEY"}, + {"too many files", "draft.reply", map[string]any{"error": "not_saved", "refusal": "too_many_files"}, + []string{"reply", "801", "-m", "b"}, output.ExitAPI, + "HEY didn't save the draft: it holds more files than HEY sends in one message", "Reply to this thread in HEY"}, + {"not a sender", "message.send", map[string]any{"error": "not_a_sender", "draft": 1731280000123, "from": "old@hey.example"}, + []string{"send", "1731280000123"}, output.ExitAPI, + "HEY won't send draft 1731280000123: it goes from old@hey.example, which this account no longer sends from", "Change From in HEY, or write a new draft with hey local write"}, + {"thread unreadable", "draft.reply", map[string]any{"error": "unavailable"}, + []string{"reply", "801", "-m", "b"}, output.ExitAPI, + "HEY couldn't read thread 801, or the draft it saved went before HEY listed it", "Check hey local drafts before writing it again; hey local imbox lists the threads a reply takes"}, + {"written draft gone", "draft.write", map[string]any{"error": "unavailable"}, + []string{"write", "--to", "a@example.com", "--subject", "s", "-m", "b"}, output.ExitAPI, + "HEY couldn't show the draft it saved: it may have gone before HEY listed it", "Check hey local drafts before writing it again"}, + {"no draft sendable now", "message.send", map[string]any{"error": "disabled", "arg": "draft", "of": "draft", "available": []int64{}}, + []string{"send", "1731280000999"}, output.ExitAPI, + "HEY won't send draft 1731280000999 now: it is scheduled, goes to nobody, has a file still uploading, or is a forward", "No draft can be sent from here now: hey local drafts shows which are sendable"}, + {"draft not sendable now", "message.send", map[string]any{"error": "disabled", "arg": "draft", "of": "draft", "available": []int64{1731280000123}}, + []string{"send", "1731280000999"}, output.ExitUsage, + "HEY won't send draft 1731280000999 now: it is scheduled, goes to nobody, has a file still uploading, or is a forward", "draft takes one of: 1731280000123"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + door := localapptest.Start(t) + door.Refuse(tc.tool, tc.refusal) + + _, _, err := runLocal(t, door.Path, tc.args...) + failure := apierr.AsError(err) + if output.ExitCodeFor(err) != tc.exit || failure.Message != tc.message || failure.Hint != tc.hint { + t.Errorf("exit = %d, message = %q, hint = %q", output.ExitCodeFor(err), failure.Message, failure.Hint) + } + if refusal, _ := failure.Meta["refusal"].(map[string]any); refusal["error"] != tc.refusal["error"] { + t.Errorf("meta.refusal = %v", failure.Meta["refusal"]) + } + }) + } +} + +// A draft saved is never followed by a failure: --ids-only prints its id and --count 1, so +// nobody retries a run that wrote one, and writes another. +func TestLocalADraftSavedGivesItsIDToIDsOnly(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", true)) + door.Answer("draft.reply", localDraftAnswer(1731280000123, "reply", true)) + + for _, args := range [][]string{ + {"write", "--to", "a@example.com", "--subject", "s", "-m", "b", "--ids-only"}, + {"reply", "801", "-m", "b", "--ids-only"}, + {"call", "draft.write", `{"to":"a@example.com","subject":"s","body":"b"}`, "--ids-only"}, + } { + stdout, _, err := runLocal(t, door.Path, args...) + if err != nil || stdout != "1731280000123\n" { + t.Errorf("%v: stdout = %q (%v)", args, stdout, err) + } + } + count, _, err := runLocal(t, door.Path, "write", "--to", "a@example.com", "--subject", "s", "-m", "b", "--count") + if err != nil || strings.TrimSpace(count) != "1" { + t.Errorf("--count = %q (%v)", count, err) + } +} + +// Leaving out --to would write a draft to nobody that HEY won't send; --to "" still can. +func TestLocalWriteAsksForTheToLine(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", false)) + + _, _, err := runLocal(t, door.Path, "write", "--subject", "s", "-m", "b") + if output.ExitCodeFor(err) != output.ExitUsage || !strings.Contains(apierr.AsError(err).Message, "--to") { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if calls := door.Calls(); len(calls) != 0 { + t.Errorf("calls = %s", calls) + } + if _, _, err := runLocal(t, door.Path, "write", "--to", "", "--subject", "s", "-m", "b"); err != nil { + t.Errorf("--to \"\": %v", err) + } +} + +// --ids-only keeps the id alone on stdout, but a draft HEY won't send says so on stderr, +// so nobody asks to send it and meets a refusal. +func TestLocalADraftHEYWontSendSaysSoUnderIDsOnly(t *testing.T) { + door := localapptest.Start(t) + door.Answer("draft.write", localDraftAnswer(1731280000123, "new", false)) + + stdout, stderr, err := runLocal(t, door.Path, "write", "--to", "", "--subject", "s", "-m", "b", "--ids-only") + if err != nil || stdout != "1731280000123\n" || !strings.Contains(stderr, "HEY would not send it from here") { + t.Errorf("stdout = %q, stderr = %q (%v)", stdout, stderr, err) + } +} + +// A draft saved as some other kind than asked is the app's to explain, as one on another thread. +func TestLocalADraftOfAnotherKindIsTheAppsToExplain(t *testing.T) { + door := localapptest.Start(t) + onTheThread := localDraftAnswer(1731280000123, "new", true) + onTheThread["thread"] = 801 + door.Answer("draft.reply", onTheThread) + asReply := localDraftAnswer(1731280000123, "reply", true) + door.Answer("draft.write", asReply) + + for _, args := range [][]string{{"reply", "801", "-m", "b"}, {"write", "--to", "a@example.com", "--subject", "s", "-m", "b"}} { + _, _, err := runLocal(t, door.Path, args...) + if output.ExitCodeFor(err) != output.ExitAPI || apierr.AsError(err).Hint != "Check in the HEY app what it did" { + t.Errorf("%v: exit = %d (%v)", args, output.ExitCodeFor(err), err) + } + } +} + +// The app's own words on a refusal stay in the message, whichever plain words lead it. +func TestLocalARefusalKeepsTheAppsDetail(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("message.send", map[string]any{"error": "not_a_sender", "draft": 1731280000123, "from": "old@hey.example", "detail": "Address removed"}) + + _, _, err := runLocal(t, door.Path, "send", "1731280000123") + if message := apierr.AsError(err).Message; !strings.HasPrefix(message, "HEY won't send draft 1731280000123") || !strings.HasSuffix(message, "(Address removed)") { + t.Errorf("message = %q", message) + } +} diff --git a/internal/cmd/local_imbox.go b/internal/cmd/local_imbox.go new file mode 100644 index 000000000..571f87464 --- /dev/null +++ b/internal/cmd/local_imbox.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +type localImboxCommand struct { + cmd *cobra.Command +} + +// localThread is one Imbox row as the app holds it: id is the row's, topic the thread's. +type localThread struct { + ID int64 `json:"id"` + Topic int64 `json:"topic"` + Subject string `json:"subject"` + Summary string `json:"summary"` + Sender string `json:"sender"` + SenderID int64 `json:"sender_id"` + Seen bool `json:"seen"` + BubbledUp bool `json:"bubbled_up"` +} + +type localImbox struct { + Threads []localThread `json:"threads"` + HasMore *bool `json:"has_more"` +} + +func newLocalImboxCommand() *localImboxCommand { + imboxCommand := &localImboxCommand{} + imboxCommand.cmd = &cobra.Command{ + Use: "imbox", + Short: "List the Imbox as the HEY app holds it", + Long: "List the threads in the Imbox, from the HEY app on this computer. Reading them marks nothing seen.", + Annotations: map[string]string{ + "agent_notes": "Runs the app's imbox.list. Each row's seen says whether the person has seen it, and stays as it was: listing marks nothing seen. has_more in meta means the Imbox holds more than the app has loaded.", + }, + Example: ` hey local imbox + hey local imbox --json`, + RunE: imboxCommand.run, + Args: cobra.NoArgs, + } + return imboxCommand +} + +func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { + result, err := callLocal(cmd, "imbox.list", nil) + if err != nil { + return err + } + imbox, err := decodeLocalAnswerAs("imbox.list", result, func(imbox localImbox) error { + if imbox.Threads == nil { + return errors.New("it lists no threads") + } + // Missing, has_more would read as a complete Imbox, and an id as thread 0. + if imbox.HasMore == nil { + return errors.New("it does not say whether the Imbox holds more") + } + for _, thread := range imbox.Threads { + if thread.ID <= 0 { + return errors.New("it lists a thread without an ID") + } + } + return nil + }) + if err != nil { + return err + } + notice := "" + if *imbox.HasMore { + notice = "The Imbox holds more threads than the app has loaded." + } + + if writer.IsStyled() { + if len(imbox.Threads) == 0 { + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), "Nothing in the Imbox") + return nil + } + table := newTable(cmd.OutOrStdout()) + // Both ids, as hey box view shows them: the row's is what --ids-only prints. + table.addRow([]string{"ID", "Thread", "Sender", "Subject", ""}) + for _, thread := range imbox.Threads { + unseen := "" + if !thread.Seen { + unseen = "new" + } + table.addRow([]string{fmt.Sprintf("%d", thread.ID), fmt.Sprintf("%d", thread.Topic), truncate(thread.Sender, 24), truncate(thread.Subject, 50), unseen}) + } + table.print() + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } + return nil + } + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + return writeOK(imbox.Threads, + output.WithSummary(fmt.Sprintf("%d %s in the Imbox", len(imbox.Threads), threadNoun(len(imbox.Threads)))), + output.WithNotice(notice), + output.WithMeta("has_more", *imbox.HasMore), + ) +} diff --git a/internal/cmd/local_reply.go b/internal/cmd/local_reply.go new file mode 100644 index 000000000..feb889ddb --- /dev/null +++ b/internal/cmd/local_reply.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +type localReplyCommand struct { + cmd *cobra.Command + message string +} + +func newLocalReplyCommand() *localReplyCommand { + replyCommand := &localReplyCommand{} + replyCommand.cmd = &cobra.Command{ + Use: "reply ", + Short: "Write a reply as a draft in the HEY app", + Long: `Write a reply to an Imbox thread as a draft in the HEY app on this computer. +Nothing is sent: hey local send asks HEY to send it, and it waits for you to press +Send in HEY. + + is a thread hey local imbox shows, from its Thread column. The reply +answers the thread's last message, to everyone it went to, as HEY's Reply does, +with the message above your name tag and the quote. It is a new draft beside any +you have on the thread, never written over yours.`, + Annotations: map[string]string{ + "agent_notes": "Runs the app's draft.reply, which saves a draft and sends nothing. is a topic from hey local imbox (its topic field, the Thread column), not the row's id; only Imbox threads are reachable. It answers the draft, whose id hey local send takes. Not idempotent: each run writes one more draft. The message is plain text, read from -m or stdin.", + }, + Example: ` hey local reply 801 -m "Saturday works" + hey local reply 801 -m "Saturday works" --json`, + RunE: replyCommand.run, + Args: usageExactOneArg(), + } + replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Message as plain text (or pipe it to stdin)") + return replyCommand +} + +func (c *localReplyCommand) run(cmd *cobra.Command, args []string) error { + thread, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || thread <= 0 { + return apierr.ErrUsage(fmt.Sprintf("invalid thread ID: %s", args[0])) + } + body, err := localMessageBody(c.message) + if err != nil { + return err + } + result, err := callLocal(cmd, "draft.reply", map[string]any{"thread": thread, "body": body}) + if err != nil { + return err + } + return writeLocalDraft(cmd, "draft.reply", result, thread) +} diff --git a/internal/cmd/local_screener.go b/internal/cmd/local_screener.go new file mode 100644 index 000000000..ad98f4238 --- /dev/null +++ b/internal/cmd/local_screener.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" +) + +type localScreenerCommand struct { + cmd *cobra.Command +} + +func newLocalScreenerCommand() *localScreenerCommand { + screenerCommand := &localScreenerCommand{} + screenerCommand.cmd = &cobra.Command{ + Use: "screener", + Short: "Screen senders in or out through the HEY app", + Long: "List the senders waiting in The Screener and the ones already decided, and screen a sender in or out, through the HEY app on this computer.", + Annotations: map[string]string{ + "agent_notes": "Sender IDs come from `hey local screener list`: they are the app's contact IDs, not the clearance IDs `hey screener` takes. A decision runs at once, contacts nobody, and is undone by the opposite one; screening out a sender already screened in is how HEY stops a newsletter. Say which senders you screened out and how to undo it.", + }, + } + screenerCommand.cmd.AddCommand(newLocalScreenerListCommand().cmd) + screenerCommand.cmd.AddCommand(newLocalScreenerApproveCommand().cmd) + screenerCommand.cmd.AddCommand(newLocalScreenerDenyCommand().cmd) + return screenerCommand +} + +// localDecision is what screener.decide answers. +type localDecision struct { + Sender int64 `json:"sender"` + Screened string `json:"screened"` +} + +// decideLocally screens one sender in or out, and says how to undo it. +func decideLocally(cmd *cobra.Command, value, decision string) error { + sender, err := strconv.ParseInt(value, 10, 64) + if err != nil || sender <= 0 { + return apierr.ErrUsageHint(fmt.Sprintf("invalid sender ID: %s", value), + "Sender IDs come from: hey local screener list") + } + result, err := callLocal(cmd, "screener.decide", map[string]any{"sender": sender, "decision": decision}) + if err != nil { + return err + } + // Said back by the app, so a decision it took any other way is never reported as this one. + decided, err := decodeLocalAnswerAs("screener.decide", result, func(decided localDecision) error { + if decided.Screened != "in" && decided.Screened != "out" { + return fmt.Errorf("it says sender %d was screened %q", decided.Sender, decided.Screened) + } + if decided.Sender != sender || decided.Screened != decision { + return &localOtherwiseError{fmt.Sprintf("it says sender %d was screened %s, not %d screened %s", decided.Sender, decided.Screened, sender, decision)} + } + return nil + }) + if err != nil { + return err + } + + opposite, undo := "approve", "Screen them back in" + verb := "screened out" + if decided.Screened == "in" { + opposite, undo, verb = "deny", "Screen them out again", "screened in" + } + reverse := fmt.Sprintf("hey local %sscreener %s %d", localSocketArgument(), opposite, sender) + return writeMutationLine(cmd, + fmt.Sprintf("Sender %d %s. Undo with: %s", sender, verb, reverse), + fmt.Sprintf("Sender %d %s", sender, verb), + decided, + output.WithBreadcrumbs(output.Breadcrumb{Action: opposite, Command: reverse, Description: undo}), + ) +} + +// localSocketArgument is the --socket this run was given, as a command a person can paste, +// so an undo reaches the same app. HEY_LOCAL_SOCKET stays in the shell that set it. +func localSocketArgument() string { + if localSocketFlag == "" { + return "" + } + return "--socket " + shellQuoted(localSocketFlag) + " " +} diff --git a/internal/cmd/local_screener_approve.go b/internal/cmd/local_screener_approve.go new file mode 100644 index 000000000..9a2faab87 --- /dev/null +++ b/internal/cmd/local_screener_approve.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type localScreenerApproveCommand struct { + cmd *cobra.Command +} + +func newLocalScreenerApproveCommand() *localScreenerApproveCommand { + approveCommand := &localScreenerApproveCommand{} + approveCommand.cmd = &cobra.Command{ + Use: "approve ", + Short: "Screen a sender in", + Long: "Screen a sender in through the HEY app, so their email reaches you. Undo with hey local screener deny.", + Example: ` hey local screener approve 42 + hey local screener approve 42 --json`, + RunE: approveCommand.run, + Args: usageExactOneArg(), + } + return approveCommand +} + +func (c *localScreenerApproveCommand) run(cmd *cobra.Command, args []string) error { + return decideLocally(cmd, args[0], "in") +} diff --git a/internal/cmd/local_screener_deny.go b/internal/cmd/local_screener_deny.go new file mode 100644 index 000000000..97fcbf995 --- /dev/null +++ b/internal/cmd/local_screener_deny.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +type localScreenerDenyCommand struct { + cmd *cobra.Command +} + +func newLocalScreenerDenyCommand() *localScreenerDenyCommand { + denyCommand := &localScreenerDenyCommand{} + denyCommand.cmd = &cobra.Command{ + Use: "deny ", + Short: "Screen a sender out", + Long: "Screen a sender out through the HEY app, so their email goes to Screened Out, kept there for 90 days, and contacts nobody. Undo with hey local screener approve.", + Example: ` hey local screener deny 42 + hey local screener deny 42 --json`, + RunE: denyCommand.run, + Args: usageExactOneArg(), + } + return denyCommand +} + +func (c *localScreenerDenyCommand) run(cmd *cobra.Command, args []string) error { + return decideLocally(cmd, args[0], "out") +} diff --git a/internal/cmd/local_screener_list.go b/internal/cmd/local_screener_list.go new file mode 100644 index 000000000..aa7c0cacc --- /dev/null +++ b/internal/cmd/local_screener_list.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +type localScreenerListCommand struct { + cmd *cobra.Command +} + +type localSender struct { + ID int64 `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + ScreenedIn *bool `json:"screened_in,omitempty"` +} + +type localScreener struct { + Waiting []localSender `json:"waiting"` + WaitingCount *int `json:"waiting_count"` + Decided []localSender `json:"decided"` +} + +func newLocalScreenerListCommand() *localScreenerListCommand { + listCommand := &localScreenerListCommand{} + listCommand.cmd = &cobra.Command{ + Use: "list", + Short: "List who waits in The Screener, and who was decided", + Annotations: map[string]string{ + "agent_notes": "waiting are first-time senders; decided are senders already screened in (screened_in true) or out. Every id is a sender ID for `hey local screener approve|deny`. waiting_count counts everyone waiting, which may be more than the app lists.", + }, + Example: ` hey local screener list + hey local screener list --json`, + RunE: listCommand.run, + Args: cobra.NoArgs, + } + return listCommand +} + +func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) error { + result, err := callLocal(cmd, "screener.list", nil) + if err != nil { + return err + } + screener, err := decodeLocalAnswerAs("screener.list", result, func(screener localScreener) error { + if screener.Waiting == nil || screener.Decided == nil { + return errors.New("it lists neither who waits nor who was decided") + } + // Missing, these would read as nobody waiting, sender 0 or a sender screened out. + if screener.WaitingCount == nil { + return errors.New("it does not count who waits") + } + for _, sender := range append(append([]localSender{}, screener.Waiting...), screener.Decided...) { + if sender.ID <= 0 { + return errors.New("it lists a sender without an ID") + } + } + for _, sender := range screener.Decided { + if sender.ScreenedIn == nil { + return fmt.Errorf("it does not say whether sender %d was screened in or out", sender.ID) + } + } + return nil + }) + if err != nil { + return err + } + + notice := "" + waitingCount := *screener.WaitingCount + if unlisted := waitingCount - len(screener.Waiting); unlisted > 0 { + notice = fmt.Sprintf("%d more %s waiting than the app lists.", unlisted, senderNoun(unlisted)) + } + + if writer.IsStyled() { + return c.writeStyled(cmd, screener, notice) + } + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + switch writer.EffectiveFormat() { + case output.FormatIDs, output.FormatCount: + // The one list a sender ID or a count reads: everyone the app holds, waiting first. + return writeOK(append(append([]localSender{}, screener.Waiting...), screener.Decided...)) + default: + return writeOK(screener, + output.WithSummary(fmt.Sprintf("%d %s waiting", waitingCount, senderNoun(waitingCount))), + output.WithNotice(notice), + output.WithBreadcrumbs( + output.Breadcrumb{Action: "approve", Command: "hey local screener approve ", Description: "Screen a sender in"}, + output.Breadcrumb{Action: "deny", Command: "hey local screener deny ", Description: "Screen a sender out"}, + ), + ) + } +} + +func (c *localScreenerListCommand) writeStyled(cmd *cobra.Command, screener localScreener, notice string) error { + if len(screener.Waiting) == 0 && len(screener.Decided) == 0 { + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), "Nobody waits in The Screener, and nobody was decided") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Sender", "Name", "Email", "Status"}) + for _, sender := range screener.Waiting { + table.addRow([]string{fmt.Sprintf("%d", sender.ID), truncate(sender.Name, 24), truncate(sender.Address, 32), "waiting"}) + } + for _, sender := range screener.Decided { + status := "screened out" + if *sender.ScreenedIn { + status = "screened in" + } + table.addRow([]string{fmt.Sprintf("%d", sender.ID), truncate(sender.Name, 24), truncate(sender.Address, 32), status}) + } + table.print() + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } + return nil +} diff --git a/internal/cmd/local_send.go b/internal/cmd/local_send.go new file mode 100644 index 000000000..137f05bb0 --- /dev/null +++ b/internal/cmd/local_send.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" +) + +type localSendCommand struct { + cmd *cobra.Command +} + +// localSendSnapshot is the part of a waiting send's snapshot hey local shows: what the +// person will see in the HEY window before they press Send. +type localSendSnapshot struct { + To []string `json:"to"` + Subject string `json:"subject"` +} + +func newLocalSendCommand() *localSendCommand { + sendCommand := &localSendCommand{} + sendCommand.cmd = &cobra.Command{ + Use: "send ", + Short: "Ask to send a saved draft, which you confirm in HEY", + Long: `Ask the HEY app to send a saved draft. The app shows you exactly what would go, +from whom, to whom, and its words, and sends it only when you press Send in its +window. + +hey local send never sends anything itself: it exits 9 and says "Waiting in HEY". +Asking again while the same draft waits answers the same confirmation.`, + Annotations: map[string]string{ + "agent_notes": "Always exits 9 (pending) when the app takes the draft: nothing is sent until the person presses Send in the HEY window, and no command or flag here can press it. Tell the person it waits for them in HEY; never say it was sent. The draft ID is the HEY app's own, not hey draft list's: the id hey local write or reply answered, or a draft's id or made_as from hey local drafts.", + }, + Example: ` hey local send 1731280000123 + hey local send 1731280000123 --json`, + RunE: sendCommand.run, + Args: usageExactOneArg(), + } + return sendCommand +} + +func (c *localSendCommand) run(cmd *cobra.Command, args []string) error { + draft, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || draft <= 0 { + return apierr.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) + } + // A send the app took comes back pending, and callLocal has written it; anything else + // it answered is not a send this command knows, and never reads as one. It may still + // have done something in the app, so the person is sent there to look. + result, err := callLocal(cmd, "message.send", map[string]any{"draft": draft}) + if err != nil { + return err + } + answer := string(result.Answer()) + if answer == "" { + answer = result.Text() + } + return localAnsweredOtherwise("message.send", fmt.Errorf("it is not pending: %s", terminal.SanitizeLine(answer))) +} diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go new file mode 100644 index 000000000..76d816b6a --- /dev/null +++ b/internal/cmd/local_test.go @@ -0,0 +1,736 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/localapp/localapptest" + "github.com/basecamp/hey-cli/internal/output" +) + +// runLocal runs hey local against socket, signed out of HEY and with HEY's server one +// that fails the test if anything reaches it: hey local talks to the app alone. +func runLocal(t *testing.T, socket string, args ...string) (string, string, error) { + t.Helper() + heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + t.Errorf("hey local reached HEY's server: %s %s", req.Method, req.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(heyServer.Close) + + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", heyServer.URL) + t.Setenv("HEY_LOCAL_SOCKET", socket) + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"local"}, args...)) + err := root.Execute() + return stdout.String(), stderr.String(), err +} + +func localEnvelope(t *testing.T, stdout string) output.Response { + t.Helper() + var resp output.Response + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("not an envelope: %v\n%s", err, stdout) + } + return resp +} + +func imboxDoor(t *testing.T) *localapptest.Door { + t.Helper() + door := localapptest.Start(t) + door.Answer("imbox.list", map[string]any{ + "threads": []map[string]any{ + {"id": 301, "topic": 801, "subject": "Stint plan for Saturday", "summary": "Here's the order", + "sender": "Marcus Ortiz", "sender_id": 41, "seen": false, "bubbled_up": false}, + {"id": 302, "topic": 802, "subject": "Invoice 2026-114", "summary": "Your invoice", + "sender": "Hollis Heimboch", "sender_id": 42, "seen": true, "bubbled_up": false}, + }, + "has_more": false, + }) + return door +} + +func TestLocalToolsListsWhatTheAppOffersAndWhatEachDoes(t *testing.T) { + door := localapptest.Start(t) + door.Offer("imbox.list", "Lists the threads in the Imbox", "read") + door.Offer("screener.decide", "Screens a sender in or out", "reversible", "sender", "decision") + door.Offer("message.send", "Sends a saved draft once you confirm it in HEY", "reaches_people") + + stdout, _, err := runLocal(t, door.Path, "tools", "--json") + if err != nil { + t.Fatal(err) + } + tools := decodeScreenerData[[]localTool](t, localEnvelope(t, stdout).Data) + if len(tools) != 3 || tools[0].Name != "imbox.list" || tools[2].Effect != "reaches_people" { + t.Fatalf("tools = %+v", tools) + } + + styled, _, err := runLocal(t, door.Path, "tools", "--styled") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "message.send") || !strings.Contains(styled, "waits for you in HEY") || !strings.Contains(styled, "decision, sender") { + t.Errorf("styled = %s", styled) + } +} + +func TestLocalCallTypesEachArgumentAsJSONWouldRead(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + stdout, _, err := runLocal(t, door.Path, "call", "screener.decide", "--arg", "sender=42", "--arg", "decision=out", "--json") + if err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"decision":"out","sender":42},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } + answer := decodeScreenerData[map[string]any](t, localEnvelope(t, stdout).Data) + if answer["screened"] != "out" { + t.Errorf("answer = %+v", answer) + } +} + +func TestLocalCallTakesAJSONObjectAndArgsOverIt(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "in"}) + + _, _, err := runLocal(t, door.Path, "call", "screener.decide", `{"sender":42,"decision":"out"}`, "--arg", "decision=in", "--json") + if err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"decision":"in","sender":42},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } +} + +func TestLocalCallRefusesArgumentsThatAreNotAnObject(t *testing.T) { + door := localapptest.Start(t) + + for _, args := range [][]string{ + {"call", "imbox.list", `[1,2]`}, + {"call", "imbox.list", "--arg", "sender"}, + } { + _, _, err := runLocal(t, door.Path, args...) + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("%v: err = %v", args, err) + } + } + if calls := door.Calls(); len(calls) != 0 { + t.Errorf("calls = %s", calls) + } +} + +func TestLocalImboxListsWhatTheAppHolds(t *testing.T) { + door := imboxDoor(t) + + stdout, _, err := runLocal(t, door.Path, "imbox", "--json") + if err != nil { + t.Fatal(err) + } + resp := localEnvelope(t, stdout) + threads := decodeScreenerData[[]localThread](t, resp.Data) + if len(threads) != 2 || threads[0].Subject != "Stint plan for Saturday" || threads[0].Topic != 801 { + t.Fatalf("threads = %+v", threads) + } + if resp.Summary != "2 threads in the Imbox" { + t.Errorf("summary = %q", resp.Summary) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{},"name":"imbox.list"}` { + t.Errorf("calls = %s", calls) + } + + styled, _, err := runLocal(t, door.Path, "imbox", "--styled") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "Stint plan for Saturday") || !strings.Contains(styled, "Marcus Ortiz") || !strings.Contains(styled, "301") || !strings.Contains(styled, "801") { + t.Errorf("styled = %s", styled) + } +} + +func TestLocalScreenerListNamesTheSendersADecisionTakes(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.list", map[string]any{ + "waiting": []map[string]any{{"id": 51, "name": "Priya Natarajan", "address": "priya@example.com"}}, + "waiting_count": 1, + "decided": []map[string]any{{"id": 52, "name": "Glenn Hoffman", "address": "glenn@example.org", "screened_in": true}}, + }) + + styled, _, err := runLocal(t, door.Path, "screener", "list", "--styled") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"51", "Priya Natarajan", "waiting", "52", "screened in"} { + if !strings.Contains(styled, want) { + t.Errorf("styled lacks %q:\n%s", want, styled) + } + } +} + +func TestLocalScreenerDenyScreensTheSenderOutAndSaysHowToUndoIt(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + stdout, _, err := runLocal(t, door.Path, "screener", "deny", "42", "--json") + if err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"decision":"out","sender":42},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } + resp := localEnvelope(t, stdout) + if len(resp.Breadcrumbs) != 1 || resp.Breadcrumbs[0].Command != "hey local screener approve 42" { + t.Errorf("breadcrumbs = %+v", resp.Breadcrumbs) + } + + styled, _, err := runLocal(t, door.Path, "screener", "deny", "42", "--styled") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "hey local screener approve 42") { + t.Errorf("styled = %s", styled) + } +} + +func TestLocalScreenerApproveScreensTheSenderIn(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "in"}) + + if _, _, err := runLocal(t, door.Path, "screener", "approve", "42", "--json"); err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"decision":"in","sender":42},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } +} + +func TestLocalScreenerRefusesASenderThatIsNotAnID(t *testing.T) { + door := localapptest.Start(t) + + _, _, err := runLocal(t, door.Path, "screener", "deny", "glenn@example.org") + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("err = %v", err) + } + if calls := door.Calls(); len(calls) != 0 { + t.Errorf("calls = %s", calls) + } +} + +func pendingDoor(t *testing.T) *localapptest.Door { + t.Helper() + door := localapptest.Start(t) + door.Answer("message.send", map[string]any{ + "status": "pending", + "confirmation": 7, + "snapshot": map[string]any{ + "draft": 1731280000123, "account": "jason@example.com", "from": "jason@example.com", + "to": []string{"marcus@example.com"}, "cc": []string{}, "bcc": []string{}, + "subject": "Re: Stint plan for Saturday", "body": "Works for me.", "rich": false, + "attachments": []any{}, "thread": 801, + }, + }) + return door +} + +// A send waits in HEY's window for the person, so it is neither done nor failed: it says +// so and exits with its own code. +func TestLocalSendWaitingInHEYIsPendingNotSentNorFailed(t *testing.T) { + door := pendingDoor(t) + + stdout, stderr, err := runLocal(t, door.Path, "send", "1731280000123", "--json") + if !errors.Is(err, output.ErrPending) || output.ExitCodeFor(err) != output.ExitPending { + t.Fatalf("err = %v (exit %d)", err, output.ExitCodeFor(err)) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"draft":1731280000123},"name":"message.send"}` { + t.Errorf("calls = %s", calls) + } + resp := localEnvelope(t, stdout) + if !strings.Contains(resp.Summary, "Waiting in HEY") || !strings.Contains(resp.Summary, "nothing is sent") { + t.Errorf("summary = %q", resp.Summary) + } + if resp.Meta["status"] != "pending" { + t.Errorf("meta = %+v", resp.Meta) + } + pending := decodeScreenerData[map[string]any](t, resp.Data) + if pending["confirmation"] != float64(7) { + t.Errorf("data = %+v", pending) + } + if stderr != "" { + t.Errorf("stderr = %q", stderr) + } + + styled, _, err := runLocal(t, door.Path, "send", "1731280000123", "--styled") + if !errors.Is(err, output.ErrPending) { + t.Fatalf("styled err = %v", err) + } + for _, want := range []string{"Waiting in HEY", "Nothing has been sent", "marcus@example.com", "Re: Stint plan for Saturday"} { + if !strings.Contains(styled, want) { + t.Errorf("styled lacks %q:\n%s", want, styled) + } + } +} + +func TestLocalCallOfASendIsPendingToo(t *testing.T) { + door := pendingDoor(t) + + _, _, err := runLocal(t, door.Path, "call", "message.send", "--arg", "draft=1731280000123", "--json") + if output.ExitCodeFor(err) != output.ExitPending { + t.Fatalf("err = %v (exit %d)", err, output.ExitCodeFor(err)) + } +} + +func TestLocalWithNoSocketSaysToTurnTheDoorOn(t *testing.T) { + _, _, err := runLocal(t, filepath.Join(t.TempDir(), "agent.sock"), "imbox") + + failure := apierr.AsError(err) + if output.ExitCodeFor(err) != output.ExitNetwork { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if !strings.Contains(failure.Hint, `Turn on "Let agents use HEY"`) { + t.Errorf("hint = %q", failure.Hint) + } +} + +func TestLocalWithNoRuntimeFolderSaysWhereToLook(t *testing.T) { + for _, runtime := range []string{"", "run/user/1000"} { + t.Setenv("XDG_RUNTIME_DIR", runtime) + + _, _, err := runLocal(t, "", "imbox") + + failure := apierr.AsError(err) + if !strings.Contains(failure.Message, "XDG_RUNTIME_DIR is not set to an absolute path") || !strings.Contains(failure.Hint, "--socket") { + t.Errorf("XDG_RUNTIME_DIR=%q: err = %q, hint = %q", runtime, failure.Message, failure.Hint) + } + } +} + +func TestLocalASocketFlagOutranksTheEnvironment(t *testing.T) { + door := imboxDoor(t) + + _, _, err := runLocal(t, filepath.Join(t.TempDir(), "elsewhere.sock"), "imbox", "--socket", door.Path, "--json") + if err != nil { + t.Fatal(err) + } +} + +// The app answers a tool it has not got with -32602, which is the caller's mistake. +func TestLocalAnUnknownToolIsAUsageErrorThatPointsAtTheList(t *testing.T) { + door := localapptest.Start(t) + + _, _, err := runLocal(t, door.Path, "call", "drafts.empty", "--json") + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if !strings.Contains(err.Error(), "drafts.empty") || !strings.Contains(apierr.AsError(err).Hint, "hey local tools") { + t.Errorf("err = %v, hint = %q", err, apierr.AsError(err).Hint) + } +} + +func TestLocalAServerErrorIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + door.Fail("tools/list", -32603, "internal error") + + _, _, err := runLocal(t, door.Path, "tools", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } +} + +func TestLocalAMalformedReplyIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + door.Reply("tools/call", `{"jsonrpc":"2.0","id":2,"result":`) + + _, _, err := runLocal(t, door.Path, "imbox", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if !strings.Contains(err.Error(), "could not be read") { + t.Errorf("err = %v", err) + } +} + +// An answer the app gives in a shape the command does not know is not shown as an empty +// Imbox. +func TestLocalAnImboxInAnotherShapeIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + door.Answer("imbox.list", map[string]any{"threads": "none"}) + + _, _, err := runLocal(t, door.Path, "imbox", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } +} + +// A sender the app does not hold is refused as the app's Arg.Ref refuses it: unavailable, +// naming the argument and the ids it would take, or disabled where the sender is there but +// the decision is off for them. Either is the caller's to fix. +func TestLocalARefusedArgumentIsAUsageErrorNamingWhatItTakes(t *testing.T) { + for _, why := range []string{"unavailable", "disabled"} { + t.Run(why, func(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("screener.decide", map[string]any{ + "error": why, "arg": "sender", "of": "sender", "given": 99, "available": []int64{51, 1731280000123}, + }) + + _, _, err := runLocal(t, door.Path, "screener", "deny", "99", "--json") + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + failure := apierr.AsError(err) + if !strings.Contains(failure.Message, why) || failure.Hint != "sender takes one of: 51, 1731280000123" { + t.Errorf("message = %q, hint = %q", failure.Message, failure.Hint) + } + if strings.Contains(failure.Hint, "Let agents use HEY") { + t.Errorf("hint = %q: the door is open, it answered", failure.Hint) + } + refusal, _ := failure.Meta["refusal"].(map[string]any) + if refusal["arg"] != "sender" { + t.Errorf("meta = %+v", failure.Meta) + } + }) + } +} + +// A list the app could not load is nothing the caller sent wrong: a read's refusal names +// no argument, and a decision's names one with nothing available. +func TestLocalAListTheAppCouldNotLoadIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("imbox.list", map[string]any{"error": "unavailable"}) + door.Refuse("screener.decide", map[string]any{"error": "unavailable", "arg": "sender", "of": "sender", "given": 42, "available": []int{}}) + door.Refuse("screener.list", map[string]any{"error": "no_such_case", "arg": "box", "cases": []string{}}) + + for _, args := range [][]string{{"imbox"}, {"screener", "deny", "42"}, {"screener", "list"}} { + _, _, err := runLocal(t, door.Path, append(args, "--json")...) + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("%v: exit = %d (%v)", args, output.ExitCodeFor(err), err) + } + } +} + +// Pending reaches every format as pending: a format with no list to show writes what it +// can and still exits 9. +func TestLocalAPendingSendIsPendingInEveryFormat(t *testing.T) { + door := pendingDoor(t) + + for _, format := range [][]string{{"--ids-only"}, {"--count"}, {"--quiet"}, {"--markdown"}, {"--jq", ".data.confirmation"}, {"--jq", ".nope | error"}} { + stdout, stderr, err := runLocal(t, door.Path, append([]string{"send", "1731280000123"}, format...)...) + if output.ExitCodeFor(err) != output.ExitPending { + t.Errorf("%v: exit = %d (%v)", format, output.ExitCodeFor(err), err) + } + if format[0] == "--ids-only" && strings.TrimSpace(stdout) != "7" { + t.Errorf("--ids-only: stdout = %q", stdout) + } + if format[0] == "--markdown" && (!strings.Contains(stdout, "**confirmation:** 7") || strings.Contains(stdout, "[55]")) { + t.Errorf("--markdown: stdout = %q", stdout) + } + if (format[0] == "--ids-only" || format[0] == "--count") && !strings.Contains(stderr, "Waiting in HEY") { + t.Errorf("%v: stderr = %q", format, stderr) + } + } +} + +// Whatever the command, an answer that waits for the person is not read on as done. +func TestLocalSugarAnsweredPendingIsPendingNotDone(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"status": "pending", "confirmation": 8}) + door.Answer("imbox.list", map[string]any{"status": "pending", "confirmation": 9}) + + for _, args := range [][]string{{"screener", "approve", "42"}, {"imbox"}} { + stdout, _, err := runLocal(t, door.Path, append(args, "--styled")...) + if output.ExitCodeFor(err) != output.ExitPending { + t.Errorf("%v: exit = %d (%v)", args, output.ExitCodeFor(err), err) + } + if strings.Contains(stdout, "screened") || strings.Contains(stdout, "Nothing in the Imbox") { + t.Errorf("%v: stdout = %q", args, stdout) + } + } +} + +// A decision the app says it took some other way is not reported as the one asked for. +func TestLocalADecisionAnsweredOtherwiseIsAnAPIFailure(t *testing.T) { + for name, answer := range map[string]map[string]any{ + "empty": {}, + "the other way": {"sender": 42, "screened": "out"}, + "another sender": {"sender": 43, "screened": "in"}, + } { + t.Run(name, func(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", answer) + + stdout, _, err := runLocal(t, door.Path, "screener", "approve", "42", "--styled") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if strings.Contains(stdout, "screened") { + t.Errorf("stdout = %q", stdout) + } + }) + } +} + +func TestLocalAnArgumentWithTrailingJunkIsAWord(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + if _, _, err := runLocal(t, door.Path, "call", "screener.decide", "--arg", "sender=42}", "--json"); err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{"sender":"42}"},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } + + _, _, err := runLocal(t, door.Path, "call", "screener.decide", `{"sender":42}]`) + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } +} + +// The app closes a connection from another user account without a word. +func TestLocalAConnectionClosedUnansweredSaysWhoTheAppAnswers(t *testing.T) { + path, listener := localapptest.Listen(t) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + + _, _, err := runLocal(t, path, "imbox", "--json") + if output.ExitCodeFor(err) != output.ExitNetwork { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if !strings.Contains(apierr.AsError(err).Hint, "user account") { + t.Errorf("hint = %q", apierr.AsError(err).Hint) + } +} + +func TestLocalSignedOutOfTheAppIsAnAuthFailureThatNamesTheApp(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("imbox.list", map[string]any{"error": "signed_out"}) + + _, _, err := runLocal(t, door.Path, "imbox", "--json") + if output.ExitCodeFor(err) != output.ExitAuth { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if hint := apierr.AsError(err).Hint; hint != "Sign in to HEY in the app" { + t.Errorf("hint = %q: the app's sign-in is not the CLI's", hint) + } +} + +// A call the app's sign-out or account switch cut off may still have been done: it never +// reads as a refusal, and sends the person to HEY before anything is run again. +func TestLocalACallCutOffBySessionEndSaysItMayHaveHappened(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("screener.decide", map[string]any{"error": "session_ended", "detail": "Signed out"}) + + for _, args := range [][]string{{"screener", "deny", "42"}, {"call", "screener.decide", `{"sender":42,"decision":"out"}`}} { + _, _, err := runLocal(t, door.Path, args...) + failure := apierr.AsError(err) + if output.ExitCodeFor(err) != output.ExitAPI || strings.Contains(failure.Message, "refused") || + !strings.Contains(failure.Message, "may have been done") || + failure.Hint != "Check in HEY whether it was done before running it again" { + t.Errorf("%v: exit = %d, message = %q, hint = %q", args, output.ExitCodeFor(err), failure.Message, failure.Hint) + } + } +} + +// An undo reaches the app the decision did: a --socket given goes into it, quoted. +func TestLocalAnUndoCarriesTheSocketItWasGiven(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + stdout, _, err := runLocal(t, filepath.Join(t.TempDir(), "elsewhere.sock"), "--socket", door.Path, "screener", "deny", "42", "--json") + if err != nil { + t.Fatal(err) + } + resp := localEnvelope(t, stdout) + if want := "hey local --socket '" + door.Path + "' screener approve 42"; len(resp.Breadcrumbs) != 1 || resp.Breadcrumbs[0].Command != want { + t.Errorf("breadcrumbs = %+v, want %q", resp.Breadcrumbs, want) + } +} + +func screenerListDoor(t *testing.T, waitingCount int) *localapptest.Door { + t.Helper() + door := localapptest.Start(t) + door.Answer("screener.list", map[string]any{ + "waiting": []map[string]any{{"id": 51, "name": "Priya Natarajan", "address": "priya@example.com"}}, + "waiting_count": waitingCount, + "decided": []map[string]any{{"id": 52, "name": "Glenn Hoffman", "address": "glenn@example.org", "screened_in": false}}, + }) + return door +} + +func TestLocalScreenerListGivesIDsAndACountOfEveryoneItHolds(t *testing.T) { + door := screenerListDoor(t, 1) + + ids, _, err := runLocal(t, door.Path, "screener", "list", "--ids-only") + if err != nil || ids != "51\n52\n" { + t.Errorf("--ids-only = %q (%v)", ids, err) + } + count, _, err := runLocal(t, door.Path, "screener", "list", "--count") + if err != nil || strings.TrimSpace(count) != "2" { + t.Errorf("--count = %q (%v)", count, err) + } +} + +// waiting_count counts everyone waiting, which may be more than the app lists. +func TestLocalScreenerListSaysWhenMoreWaitThanItLists(t *testing.T) { + door := screenerListDoor(t, 6) + + styled, _, err := runLocal(t, door.Path, "screener", "list", "--styled") + if err != nil || !strings.Contains(styled, "5 more senders waiting than the app lists.") { + t.Errorf("styled = %q (%v)", styled, err) + } + _, stderr, err := runLocal(t, door.Path, "screener", "list", "--ids-only") + if err != nil || !strings.Contains(stderr, "5 more senders waiting") { + t.Errorf("--ids-only stderr = %q (%v)", stderr, err) + } + + empty := localapptest.Start(t) + empty.Answer("screener.list", map[string]any{"waiting": []any{}, "waiting_count": 5, "decided": []any{}}) + styled, _, _ = runLocal(t, empty.Path, "screener", "list", "--styled") + if strings.Contains(styled, "Nobody waits") || !strings.Contains(styled, "5 more senders waiting") { + t.Errorf("empty styled = %q", styled) + } +} + +// The app may hold more of the Imbox than it has loaded, and a list that stops short says +// so in every format, never only in the envelope. +func TestLocalImboxSaysWhenTheAppHoldsMoreInEveryFormat(t *testing.T) { + door := localapptest.Start(t) + door.Answer("imbox.list", map[string]any{"threads": []any{}, "has_more": true}) + + styled, _, _ := runLocal(t, door.Path, "imbox", "--styled") + if strings.Contains(styled, "Nothing in the Imbox") || !strings.Contains(styled, "more threads than the app has loaded") { + t.Errorf("styled = %q", styled) + } + for _, format := range []string{"--ids-only", "--count", "--quiet"} { + _, stderr, err := runLocal(t, door.Path, "imbox", format) + if err != nil || !strings.Contains(stderr, "more threads than the app has loaded") { + t.Errorf("%s stderr = %q (%v)", format, stderr, err) + } + } +} + +// A send the app took is pending; any other answer to it is not a send this knows. +func TestLocalASendNotAnsweredPendingIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + door.Answer("message.send", map[string]any{}) + + stdout, _, err := runLocal(t, door.Path, "send", "1731280000123", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if strings.Contains(stdout, "HEY ran") { + t.Errorf("stdout = %q", stdout) + } +} + +// A send answered otherwise may have done something in the app: the hint points there, +// and a plain-text answer still reaches the person. +func TestLocalASendAnsweredOtherwisePointsAtTheApp(t *testing.T) { + door := localapptest.Start(t) + door.AnswerRaw("message.send", map[string]any{ + "content": []map[string]any{{"type": "text", "text": "Sent to Ada"}}, + }) + + _, _, err := runLocal(t, door.Path, "send", "1731280000123") + failure := apierr.AsError(err) + if output.ExitCodeFor(err) != output.ExitAPI || failure.Hint != "Check in the HEY app what it did" { + t.Errorf("exit = %d, hint = %q (%v)", output.ExitCodeFor(err), failure.Hint, err) + } + if !strings.Contains(failure.Message, "Sent to Ada") { + t.Errorf("message = %q", failure.Message) + } +} + +func TestLocalCallRefusesAnArgumentGivenTwice(t *testing.T) { + door := localapptest.Start(t) + + _, _, err := runLocal(t, door.Path, "call", "screener.decide", "--arg", "sender=42", "--arg", "sender=43") + if output.ExitCodeFor(err) != output.ExitUsage { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if calls := door.Calls(); len(calls) != 0 { + t.Errorf("calls = %s", calls) + } +} + +// A decision the app says it took otherwise is the app's to explain, not an old hey. +func TestLocalADecisionAnsweredOtherwiseDoesNotBlameTheCLIsVersion(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + _, _, err := runLocal(t, door.Path, "screener", "approve", "42") + if hint := apierr.AsError(err).Hint; hint != "Check in the HEY app what it did" { + t.Errorf("hint = %q", hint) + } +} + +// A field whose zero value would say something (complete, screened out, sender 0) is the +// app's to give: missing, the listing is unreadable, never read as that value. +func TestLocalListingsRefuseAnswersMissingWhatTheirZeroWouldClaim(t *testing.T) { + waiting := map[string]any{"id": 51, "name": "Priya Natarajan", "address": "priya@example.com"} + decided := map[string]any{"id": 52, "name": "Glenn Hoffman", "address": "glenn@example.org", "screened_in": false} + thread := map[string]any{"id": 301, "topic": 801, "subject": "Stint plan", "sender": "Marcus Ortiz", "sender_id": 41} + without := func(row map[string]any, key string) map[string]any { + copied := map[string]any{} + for k, v := range row { + if k != key { + copied[k] = v + } + } + return copied + } + cases := []struct { + name, tool string + answer map[string]any + args []string + }{ + {"no has_more", "imbox.list", map[string]any{"threads": []any{}}, []string{"imbox"}}, + {"has_more null", "imbox.list", map[string]any{"threads": []any{}, "has_more": nil}, []string{"imbox"}}, + {"thread without id", "imbox.list", map[string]any{"threads": []any{without(thread, "id")}, "has_more": false}, []string{"imbox", "--ids-only"}}, + {"no waiting_count", "screener.list", map[string]any{"waiting": []any{waiting}, "decided": []any{decided}}, []string{"screener", "list"}}, + {"waiting sender null", "screener.list", map[string]any{"waiting": []any{nil}, "waiting_count": 1, "decided": []any{}}, []string{"screener", "list", "--ids-only"}}, + {"decided without id", "screener.list", map[string]any{"waiting": []any{}, "waiting_count": 0, "decided": []any{without(decided, "id")}}, []string{"screener", "list", "--ids-only"}}, + {"draft without id", "drafts.list", map[string]any{"drafts": []any{map[string]any{"kind": "new", "sendable": true}}}, []string{"drafts", "--ids-only"}}, + {"draft without sendable", "drafts.list", map[string]any{"drafts": []any{map[string]any{"id": 7, "kind": "new"}}}, []string{"drafts", "--styled"}}, + {"no drafts", "drafts.list", map[string]any{}, []string{"drafts"}}, + {"decided without screened_in", "screener.list", map[string]any{"waiting": []any{}, "waiting_count": 0, "decided": []any{without(decided, "screened_in")}}, []string{"screener", "list", "--styled"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + door := localapptest.Start(t) + door.Answer(tc.tool, tc.answer) + + stdout, _, err := runLocal(t, door.Path, tc.args...) + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v), stdout = %q", output.ExitCodeFor(err), err, stdout) + } + if strings.Contains(stdout, "screened out") || strings.Contains(stdout, "Nothing in the Imbox") || strings.TrimSpace(stdout) == "0" { + t.Errorf("stdout = %q", stdout) + } + }) + } +} diff --git a/internal/cmd/local_tools.go b/internal/cmd/local_tools.go new file mode 100644 index 000000000..a71f32196 --- /dev/null +++ b/internal/cmd/local_tools.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/localapp" + "github.com/basecamp/hey-cli/internal/output" +) + +type localToolsCommand struct { + cmd *cobra.Command +} + +// localTool is a command the app offers, as hey local tools lists it. +type localTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Effect string `json:"effect,omitempty"` + InputSchema any `json:"input_schema,omitempty"` +} + +// localEffects is how a person reads each effect the app names. +var localEffects = map[string]string{ + "read": "reads, changes nothing", + "reversible": "changes something you can undo", + "reaches_people": "waits for you in HEY", +} + +func newLocalToolsCommand() *localToolsCommand { + toolsCommand := &localToolsCommand{} + toolsCommand.cmd = &cobra.Command{ + Use: "tools", + Short: "List what the HEY app offers", + Long: "List the commands the HEY app offers agents now, with what each does and the arguments it takes.", + Annotations: map[string]string{ + "agent_notes": "Each tool has a name for `hey local call`, its description in the app's language, its effect (read, reversible, or reaches_people, which waits for the person in the HEY window) and its JSON Schema. An empty list means nobody is signed in to the app.", + }, + Example: ` hey local tools + hey local tools --json`, + RunE: toolsCommand.run, + Args: cobra.NoArgs, + } + return toolsCommand +} + +func (c *localToolsCommand) run(cmd *cobra.Command, _ []string) error { + var offered []localapp.Tool + err := withLocalApp(cmd, func(ctx context.Context, client *localapp.Client) error { + var err error + offered, err = client.Tools(ctx) + return err + }) + if err != nil { + return err + } + + tools := make([]localTool, 0, len(offered)) + for _, tool := range offered { + listed := localTool{Name: tool.Name, Description: tool.Description, Effect: tool.Effect()} + if len(tool.InputSchema) > 0 { + listed.InputSchema = tool.InputSchema + } + tools = append(tools, listed) + } + + if writer.IsStyled() { + if len(tools) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "HEY offers nothing now: sign in to HEY in the app") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Command", "Effect", "Arguments", "Description"}) + for _, tool := range tools { + effect := localEffects[tool.Effect] + if effect == "" { + effect = tool.Effect + } + table.addRow([]string{tool.Name, effect, localArgumentNames(tool.InputSchema), truncate(tool.Description, 60)}) + } + table.print() + return nil + } + return writeOK(tools, + output.WithSummary(fmt.Sprintf("HEY offers %d %s", len(tools), localCommandNoun(len(tools)))), + output.WithBreadcrumbs(output.Breadcrumb{Action: "call", Command: "hey local call --arg name=value", Description: "Run one"}), + ) +} + +// localArgumentNames names the arguments a tool's schema declares, in order of name, for +// a person to read; --json carries the whole schema. +func localArgumentNames(schema any) string { + raw, ok := schema.(json.RawMessage) + if !ok { + return "" + } + var declared struct { + Properties map[string]any `json:"properties"` + } + if json.Unmarshal(raw, &declared) != nil { + return "" + } + names := make([]string, 0, len(declared.Properties)) + for name := range declared.Properties { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +func localCommandNoun(count int) string { + if count == 1 { + return "command" + } + return "commands" +} diff --git a/internal/cmd/local_write.go b/internal/cmd/local_write.go new file mode 100644 index 000000000..5a2e4cd55 --- /dev/null +++ b/internal/cmd/local_write.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +type localWriteCommand struct { + cmd *cobra.Command + to string + subject string + message string +} + +func newLocalWriteCommand() *localWriteCommand { + writeCommand := &localWriteCommand{} + writeCommand.cmd = &cobra.Command{ + Use: "write", + Short: "Write a new message as a draft in the HEY app", + Long: `Write a new message as a draft in the HEY app on this computer. Nothing is sent: +hey local send asks HEY to send it, and it waits for you to press Send in HEY. + +--to reads as HEY's To line does: addresses, or Name
, separated by +commas. The message is plain text and goes above your name tag. Cc, Bcc, files +and a schedule are yours to add in HEY. Each run writes one more draft; delete +one you don't want in HEY.`, + Annotations: map[string]string{ + "agent_notes": "Runs the app's draft.write, which saves a draft and sends nothing. It answers the draft, whose id hey local send takes; when sendable is false (to nobody, say) HEY would not send it from here. Not idempotent: each run writes one more draft, and nothing here edits or deletes one, so write again rather than retrying a run that answered, and leave the wrong one for the person to delete in HEY. The message is plain text, read from -m or stdin.", + }, + Example: ` hey local write --to "Marcus " --subject "Stint plan" -m "Saturday works" + echo "Saturday works" | hey local write --to marcus@example.com --subject "Stint plan" --json`, + RunE: writeCommand.run, + Args: cobra.NoArgs, + } + writeCommand.cmd.Flags().StringVar(&writeCommand.to, "to", "", "Recipients, as HEY's To line reads them (required; --to \"\" for a draft to nobody)") + writeCommand.cmd.Flags().StringVar(&writeCommand.subject, "subject", "", "Subject") + writeCommand.cmd.Flags().StringVarP(&writeCommand.message, "message", "m", "", "Message as plain text (or pipe it to stdin)") + return writeCommand +} + +func (c *localWriteCommand) run(cmd *cobra.Command, _ []string) error { + // Left out, the draft would go to nobody, which HEY won't send; --to "" still writes one. + if !cmd.Flags().Changed("to") { + return apierr.ErrUsage("--to is required: the To line, or --to \"\" for a draft to nobody") + } + body, err := localMessageBody(c.message) + if err != nil { + return err + } + result, err := callLocal(cmd, "draft.write", map[string]any{"to": c.to, "subject": c.subject, "body": body}) + if err != nil { + return err + } + return writeLocalDraft(cmd, "draft.write", result, 0) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ab0cbba9e..8d86df352 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -239,6 +239,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newHeyCommand().cmd) root.AddCommand(newSkillCommand().cmd) root.AddCommand(newMCPCommand().cmd) + root.AddCommand(newLocalCommand().cmd) root.AddCommand(newHelpTopicCommands()...) root.AddCommand(newCommandsCommand()) root.AddCommand(newShellCompletionCommand()) @@ -262,6 +263,8 @@ func commandSkipsCredentialMigration(cmd *cobra.Command) bool { switch parts[1] { case "skill": return true + case "local": + return true // hey local talks to the app on this computer and holds no credential case "setup": return len(parts) > 2 // the wizard itself persists onboarded; subcommands touch only agent files default: @@ -275,7 +278,7 @@ func commandUsesAccountScope(cmd *cobra.Command) bool { return true } switch parts[1] { - case "account", "auth", "commands", "config", "doctor", "login", "logout", "setup", "shell-completion", "skill", "upgrade", "version": + case "account", "auth", "commands", "config", "doctor", "local", "login", "logout", "setup", "shell-completion", "skill", "upgrade", "version": return false case "mcp": // hey mcp scopes its own clients, on its own credential (mcp.go): the shared @@ -290,6 +293,10 @@ func Execute() { root := newRootCmd() err := root.Execute() + if errors.Is(err, output.ErrPending) { + // The command already wrote its answer: pending is not a failure to report. + os.Exit(output.ExitPending) + } if err != nil { err = normalizeCobraError(err) if writer == nil { diff --git a/internal/localapp/client.go b/internal/localapp/client.go new file mode 100644 index 000000000..8f191e7d6 --- /dev/null +++ b/internal/localapp/client.go @@ -0,0 +1,376 @@ +// Package localapp speaks MCP to the HEY desktop app's own socket: the door the app opens +// while "Let agents use HEY" is on in its Settings. It is plain MCP, JSON-RPC 2.0 with one +// message per line, and the client asks only for what hey local needs: initialize, +// tools/list and tools/call. +// +// What the door serves is the app's, not HEY's: it runs the app's own commands over the +// mail the app holds, and a command that reaches people (a send) answers pending, waiting +// in the app's window for the person to press Send. Nothing here can confirm one. +package localapp + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "strconv" + "syscall" + "time" + + "github.com/basecamp/hey-cli/internal/version" +) + +// ProtocolVersion is the MCP revision the client asks for. initialize, tools/list and +// tools/call read the same in every revision so far, so whichever one the app answers +// with is accepted. +const ProtocolVersion = "2025-06-18" + +// EffectMetaKey is where a tool's _meta names its effect: read, reversible or +// reaches_people. +const EffectMetaKey = "com.hey/effect" + +// MaxLine bounds one message from the app. An Imbox listing is a few hundred kilobytes +// at most; a line past this is not an answer worth waiting for. +const MaxLine = 16 << 20 + +// Client is one connection to the app's door, initialized and ready for calls. It sends +// one request at a time and reads its answer before the next. +type Client struct { + conn net.Conn + lines *bufio.Scanner + lastID int64 + + // Server is what the app said about itself in its initialize answer. + Server ServerInfo +} + +type ServerInfo struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Version string `json:"version,omitempty"` +} + +// Tool is one command the app offers now. +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"inputSchema,omitempty"` + Annotations json.RawMessage `json:"annotations,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` +} + +// Effect is what the tool's _meta says running it does, or "" where it says nothing. +func (t Tool) Effect() string { + effect, _ := t.Meta[EffectMetaKey].(string) + return effect +} + +// Result is the app's answer to a tools/call. +type Result struct { + Content []Content `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` +} + +type Content struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// Pending is a call the app took but did not carry out: it waits in the app's window for +// the person, who sees Snapshot and sends it or not. +type Pending struct { + Confirmation json.RawMessage `json:"confirmation,omitempty"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` +} + +// Dial connects to the door at path and initializes the session. ctx bounds the dial and +// the initialize; each later call is bounded by its own. +func Dial(ctx context.Context, path string) (*Client, error) { + // Asked first, since a path too long for a socket's address fails the dial with + // EINVAL whether or not there is anything there. + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil, &DoorClosedError{Path: path, Cause: err} + } + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", path) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) { + return nil, &DoorClosedError{Path: path, Cause: err} + } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, &TimeoutError{Method: "connect", Cause: ctxErr} + } + return nil, &ConnectError{Path: path, Cause: err} + } + + client := newClient(conn) + if err := client.initialize(ctx); err != nil { + _ = client.Close() + return nil, err + } + return client, nil +} + +// Tools lists every tool the app offers now, following pagination should the app page. +func (c *Client) Tools(ctx context.Context) ([]Tool, error) { + var tools []Tool + cursor := "" + for range 100 { + params := map[string]any{} + if cursor != "" { + params["cursor"] = cursor + } + var page struct { + Tools []Tool `json:"tools"` + NextCursor string `json:"nextCursor,omitempty"` + } + if err := c.request(ctx, "tools/list", params, &page); err != nil { + return nil, err + } + // An empty list is the app with nobody signed in; no list at all is no answer. + if page.Tools == nil { + return nil, &ProtocolError{Method: "tools/list", Reason: "the result lists no tools"} + } + tools = append(tools, page.Tools...) + if page.NextCursor == "" { + return tools, nil + } + cursor = page.NextCursor + } + return nil, &ProtocolError{Reason: "tools/list was still paging after 100 pages"} +} + +// Call runs one tool. A refusal comes back as a Result with IsError set, as MCP has it; +// only a failure of the exchange itself is an error. +func (c *Client) Call(ctx context.Context, name string, arguments map[string]any) (*Result, error) { + if arguments == nil { + arguments = map[string]any{} + } + var result Result + if err := c.request(ctx, "tools/call", map[string]any{"name": name, "arguments": arguments}, &result); err != nil { + return nil, err + } + // MCP's result always carries content. One that does not is no answer, unless it says + // it is pending, which is never read as anything less. + if _, pending := result.Pending(); result.Content == nil && !pending { + return nil, &ProtocolError{Method: "tools/call", Reason: "the result carries no content"} + } + return &result, nil +} + +func (c *Client) Close() error { + return c.conn.Close() +} + +// Text is the result's text content, every text block joined by a newline. +func (r *Result) Text() string { + text := "" + for _, content := range r.Content { + if content.Type != "text" { + continue + } + if text != "" { + text += "\n" + } + text += content.Text + } + return text +} + +// Answer is the result's structured answer: structuredContent, or the text content where +// that is JSON and there is no structuredContent. nil where the result carries neither. +func (r *Result) Answer() json.RawMessage { + if len(r.StructuredContent) > 0 && string(r.StructuredContent) != "null" { + return r.StructuredContent + } + if text := r.Text(); json.Valid([]byte(text)) { + return json.RawMessage(text) + } + return nil +} + +// Pending reports a call the app answered "pending, waiting in HEY": a structured answer +// whose status is pending, however the rest of it reads, since reading a waiting send as +// done is the one mistake this must not make. A refusal is never pending. +func (r *Result) Pending() (*Pending, bool) { + answer := r.Answer() + if r.IsError || answer == nil { + return nil, false + } + var status struct { + Status string `json:"status"` + } + if err := json.Unmarshal(answer, &status); err != nil || status.Status != "pending" { + return nil, false + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(answer, &fields) + return &Pending{Confirmation: fields["confirmation"], Snapshot: fields["snapshot"]}, true +} + +func newClient(conn net.Conn) *Client { + lines := bufio.NewScanner(conn) + lines.Buffer(make([]byte, 0, 64<<10), MaxLine) + return &Client{conn: conn, lines: lines} +} + +func (c *Client) initialize(ctx context.Context) error { + params := map[string]any{ + "protocolVersion": ProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "hey-cli", "title": "hey local", "version": version.Version}, + } + var answer struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo ServerInfo `json:"serverInfo"` + } + if err := c.request(ctx, "initialize", params, &answer); err != nil { + return err + } + if answer.ProtocolVersion == "" { + return &ProtocolError{Reason: "initialize answered with no protocolVersion"} + } + c.Server = answer.ServerInfo + if err := c.notify("notifications/initialized"); err != nil { + return c.exchangeError(ctx, "initialize", err) + } + return nil +} + +func (c *Client) notify(method string) error { + return c.send(map[string]any{"jsonrpc": "2.0", "method": method}) +} + +// request sends one request and reads lines until its answer, answering what the app asks +// of the client on the way (a ping) and passing over its notifications. +func (c *Client) request(ctx context.Context, method string, params any, into any) error { + defer c.bound(ctx)() + c.lastID++ + id := c.lastID + if err := c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil { + return c.exchangeError(ctx, method, err) + } + + for { + message, err := c.receive(ctx, method) + if err != nil { + return err + } + if message.Method != "" { + if err := c.answerServer(message); err != nil { + return c.exchangeError(ctx, method, err) + } + continue + } + if message.JSONRPC != "2.0" { + return &ProtocolError{Method: method, Reason: "the answer is not JSON-RPC 2.0"} + } + if message.Error != nil && isNull(message.ID) { + // The app could not read the request at all, so it could not name it. + return &ServerError{Method: method, Code: message.Error.Code, Message: message.Error.Message} + } + if string(message.ID) != strconv.FormatInt(id, 10) { + return &ProtocolError{Method: method, Reason: fmt.Sprintf("the answer is to request %s, not %d", message.ID, id)} + } + if message.Error != nil { + return &ServerError{Method: method, Code: message.Error.Code, Message: message.Error.Message} + } + if isNull(message.Result) { + return &ProtocolError{Method: method, Reason: "the answer has neither a result nor an error"} + } + if err := json.Unmarshal(message.Result, into); err != nil { + return &ProtocolError{Method: method, Reason: "the result does not read as " + method + "'s: " + err.Error()} + } + return nil + } +} + +// bound holds the connection to ctx for one exchange: its deadline, or none, and its +// cancellation, which ends whatever read or write waits rather than leave the caller on a +// connection the app holds open. The returned func lets go of ctx. +func (c *Client) bound(ctx context.Context) func() { + deadline, _ := ctx.Deadline() + _ = c.conn.SetDeadline(deadline) + stop := context.AfterFunc(ctx, func() { _ = c.conn.SetDeadline(time.Unix(1, 0)) }) + return func() { stop() } +} + +type message struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (c *Client) send(value any) error { + line, err := json.Marshal(value) + if err != nil { + return err + } + _, err = c.conn.Write(append(line, '\n')) + return err +} + +func (c *Client) receive(ctx context.Context, method string) (message, error) { + if !c.lines.Scan() { + err := c.lines.Err() + if err == nil { + return message{}, &ProtocolError{Method: method, Reason: "the app closed the connection without answering", Closed: true} + } + if errors.Is(err, bufio.ErrTooLong) { + return message{}, &ProtocolError{Method: method, Reason: fmt.Sprintf("the answer is longer than %d bytes", MaxLine)} + } + return message{}, c.exchangeError(ctx, method, err) + } + var received message + if err := json.Unmarshal(c.lines.Bytes(), &received); err != nil { + return message{}, &ProtocolError{Method: method, Reason: "the answer is not JSON: " + err.Error()} + } + return received, nil +} + +// answerServer answers a request the app makes of the client. A ping is the one a client +// with no capabilities is asked; anything else is a method it does not have. +func (c *Client) answerServer(request message) error { + if isNull(request.ID) { + return nil + } + if request.Method == "ping" { + return c.send(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) + } + return c.send(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "error": map[string]any{"code": -32601, "message": "hey local does not answer " + request.Method}, + }) +} + +// exchangeError names a failed read or write: the deadline, a cancelled context, or the +// connection itself. +func (c *Client) exchangeError(ctx context.Context, method string, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return &TimeoutError{Method: method, Cause: ctxErr} + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return &TimeoutError{Method: method, Cause: err} + } + // On Linux a close with the request unread reaches the client as a reset, not an end. + if errors.Is(err, io.EOF) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) { + return &ProtocolError{Method: method, Reason: "the app closed the connection without answering", Closed: true} + } + return &ConnectError{Path: c.conn.RemoteAddr().String(), Cause: err} +} + +func isNull(raw json.RawMessage) bool { + return len(raw) == 0 || string(raw) == "null" +} diff --git a/internal/localapp/client_test.go b/internal/localapp/client_test.go new file mode 100644 index 000000000..3ad88f5cd --- /dev/null +++ b/internal/localapp/client_test.go @@ -0,0 +1,388 @@ +package localapp_test + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/basecamp/hey-cli/internal/localapp" + "github.com/basecamp/hey-cli/internal/localapp/localapptest" +) + +func dial(t *testing.T, path string) *localapp.Client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + client, err := localapp.Dial(ctx, path) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestDialInitializesBeforeAnythingElse(t *testing.T) { + door := localapptest.Start(t) + client := dial(t, door.Path) + + if _, err := client.Tools(context.Background()); err != nil { + t.Fatal(err) + } + requests := door.Requests() + methods := make([]string, 0, len(requests)) + for _, request := range requests { + methods = append(methods, request.Method) + } + if got := strings.Join(methods, " "); got != "initialize notifications/initialized tools/list" { + t.Errorf("methods = %s", got) + } + var initialize struct { + ProtocolVersion string `json:"protocolVersion"` + ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"clientInfo"` + } + _ = json.Unmarshal(door.Requests()[0].Params, &initialize) + if initialize.ProtocolVersion != localapp.ProtocolVersion { + t.Errorf("protocolVersion = %q", initialize.ProtocolVersion) + } + // MCP requires both of clientInfo's name and version. + if initialize.ClientInfo.Name != "hey-cli" || initialize.ClientInfo.Version == "" { + t.Errorf("clientInfo = %+v", initialize.ClientInfo) + } + if client.Server.Name != "hey" { + t.Errorf("server = %+v", client.Server) + } +} + +func TestToolsListsWhatTheAppOffersWithItsEffect(t *testing.T) { + door := localapptest.Start(t) + door.Offer("imbox.list", "Lists the Imbox", "read") + door.Offer("message.send", "Sends a saved draft once you confirm it", "reaches_people") + + tools, err := dial(t, door.Path).Tools(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(tools) != 2 || tools[0].Name != "imbox.list" || tools[1].Name != "message.send" { + t.Fatalf("tools = %+v", tools) + } + if tools[0].Effect() != "read" || tools[1].Effect() != "reaches_people" { + t.Errorf("effects = %q, %q", tools[0].Effect(), tools[1].Effect()) + } +} + +func TestCallSendsTheArgumentsAndReadsTheAnswer(t *testing.T) { + door := localapptest.Start(t) + door.Answer("screener.decide", map[string]any{"sender": 42, "screened": "out"}) + + result, err := dial(t, door.Path).Call(context.Background(), "screener.decide", map[string]any{"sender": 42, "decision": "out"}) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("refused: %s", result.Text()) + } + if string(result.Answer()) != `{"screened":"out","sender":42}` { + t.Errorf("answer = %s", result.Answer()) + } + if _, pending := result.Pending(); pending { + t.Error("a decision is not pending") + } + + calls := door.Calls() + if len(calls) != 1 || string(calls[0]) != `{"arguments":{"decision":"out","sender":42},"name":"screener.decide"}` { + t.Errorf("calls = %s", calls) + } +} + +func TestCallWithNoArgumentsSendsAnEmptyObject(t *testing.T) { + door := localapptest.Start(t) + door.Answer("imbox.list", map[string]any{"threads": []any{}, "has_more": false}) + + if _, err := dial(t, door.Path).Call(context.Background(), "imbox.list", nil); err != nil { + t.Fatal(err) + } + if calls := door.Calls(); string(calls[0]) != `{"arguments":{},"name":"imbox.list"}` { + t.Errorf("calls = %s", calls) + } +} + +func TestASendWaitingInHEYIsPending(t *testing.T) { + door := localapptest.Start(t) + door.Answer("message.send", map[string]any{ + "status": "pending", + "confirmation": 7, + "snapshot": map[string]any{"draft": 99, "to": []string{"marcus@example.com"}, "subject": "Stint plan"}, + }) + + result, err := dial(t, door.Path).Call(context.Background(), "message.send", map[string]any{"draft": 99}) + if err != nil { + t.Fatal(err) + } + pending, ok := result.Pending() + if !ok { + t.Fatalf("not pending: %s", result.Answer()) + } + if string(pending.Confirmation) != "7" || !strings.Contains(string(pending.Snapshot), "marcus@example.com") { + t.Errorf("pending = %+v", pending) + } +} + +// Pending is the status alone: a confirmation in a shape this client does not expect +// still waits for the person, and must not read as done. +func TestAPendingAnswerOfAnotherShapeIsStillPending(t *testing.T) { + door := localapptest.Start(t) + door.Answer("message.send", map[string]any{"status": "pending", "confirmation": "c-7"}) + + result, err := dial(t, door.Path).Call(context.Background(), "message.send", map[string]any{"draft": 99}) + if err != nil { + t.Fatal(err) + } + pending, ok := result.Pending() + if !ok || string(pending.Confirmation) != `"c-7"` { + t.Errorf("pending = %+v, %v", pending, ok) + } +} + +func TestARefusalIsNeverPending(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("message.send", map[string]any{"status": "pending", "error": "not_offered"}) + + result, err := dial(t, door.Path).Call(context.Background(), "message.send", map[string]any{"draft": 99}) + if err != nil { + t.Fatal(err) + } + if !result.IsError { + t.Error("the refusal reads as done") + } + if _, pending := result.Pending(); pending { + t.Error("a refusal reads as pending") + } +} + +func TestNoSocketIsADoorClosed(t *testing.T) { + _, err := localapp.Dial(context.Background(), filepath.Join(t.TempDir(), "agent.sock")) + + var closed *localapp.DoorClosedError + if !errors.As(err, &closed) { + t.Fatalf("err = %v (%T)", err, err) + } +} + +// A crashed app leaves its socket file behind with nothing listening on it, which is as +// closed as no file at all. +func TestASocketNothingAnswersOnIsADoorClosed(t *testing.T) { + path, listener := localapptest.Listen(t) + listener.SetUnlinkOnClose(false) + _ = listener.Close() + + _, err := localapp.Dial(context.Background(), path) + + var closed *localapp.DoorClosedError + if !errors.As(err, &closed) { + t.Fatalf("err = %v (%T)", err, err) + } +} + +func TestAJSONRPCErrorIsAServerError(t *testing.T) { + door := localapptest.Start(t) + + _, err := dial(t, door.Path).Call(context.Background(), "drafts.empty", nil) + + var server *localapp.ServerError + if !errors.As(err, &server) { + t.Fatalf("err = %v (%T)", err, err) + } + if server.Code != -32602 || server.Method != "tools/call" || !strings.Contains(server.Message, "drafts.empty") { + t.Errorf("server error = %+v", server) + } +} + +func TestAMalformedAnswerIsAProtocolError(t *testing.T) { + for name, line := range map[string]string{ + "not JSON": `{"jsonrpc":"2.0","id":2,"result":`, + "not JSON-RPC 2.0": `{"jsonrpc":"1.0","id":2,"result":{"tools":[]}}`, + "another request": `{"jsonrpc":"2.0","id":41,"result":{"tools":[]}}`, + "no result": `{"jsonrpc":"2.0","id":2}`, + "the wrong shape": `{"jsonrpc":"2.0","id":2,"result":{"tools":"imbox.list"}}`, + } { + t.Run(name, func(t *testing.T) { + door := localapptest.Start(t) + door.Reply("tools/list", line) + + _, err := dial(t, door.Path).Tools(context.Background()) + + var protocol *localapp.ProtocolError + if !errors.As(err, &protocol) { + t.Fatalf("err = %v (%T)", err, err) + } + }) + } +} + +func TestAnInitializeWithNoVersionIsAProtocolError(t *testing.T) { + door := localapptest.Start(t) + door.Reply("initialize", `{"jsonrpc":"2.0","id":1,"result":{}}`) + + _, err := localapp.Dial(context.Background(), door.Path) + + var protocol *localapp.ProtocolError + if !errors.As(err, &protocol) { + t.Fatalf("err = %v (%T)", err, err) + } +} + +// The app may ask the client something, or tell it something, before it answers; the +// client answers a ping, passes over a notification and still reads its own answer. +func TestTheAppsOwnMessagesBeforeAnAnswerArePassedOver(t *testing.T) { + path, listener := localapptest.Listen(t) + answered := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + // Held open until the test ends, as the app holds it, so the client's + // notifications/initialized never meets a closed socket. + t.Cleanup(func() { _ = conn.Close() }) + buffer := make([]byte, 4096) + _, _ = conn.Read(buffer) // initialize + _, _ = conn.Write([]byte(`{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info"}}` + "\n")) + _, _ = conn.Write([]byte(`{"jsonrpc":"2.0","id":"s1","method":"ping"}` + "\n")) + _, _ = conn.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","serverInfo":{"name":"hey"}}}` + "\n")) + n, _ := conn.Read(buffer) + answered <- string(buffer[:n]) + }() + + client := dial(t, path) + if client.Server.Name != "hey" { + t.Errorf("server = %+v", client.Server) + } + select { + case got := <-answered: + if !strings.Contains(got, `"id":"s1","jsonrpc":"2.0","result":{}`) { + t.Errorf("the ping was answered with %s", got) + } + case <-time.After(5 * time.Second): + t.Fatal("the ping went unanswered") + } +} + +func TestAnAppThatNeverAnswersTimesOut(t *testing.T) { + path, listener := localapptest.Listen(t) + go func() { + conn, err := listener.Accept() + if err == nil { + t.Cleanup(func() { _ = conn.Close() }) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := localapp.Dial(ctx, path) + + var timeout *localapp.TimeoutError + if !errors.As(err, &timeout) { + t.Fatalf("err = %v (%T)", err, err) + } +} + +func TestTheSocketIsTheFlagsThenTheEnvironmentsThenTheRuntimeFolders(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv(localapp.SocketEnv, "") + if path, _ := localapp.SocketPath(""); path != "/run/user/1000/hey/agent.sock" { + t.Errorf("default = %q", path) + } + t.Setenv(localapp.SocketEnv, "/tmp/elsewhere.sock") + if path, _ := localapp.SocketPath(""); path != "/tmp/elsewhere.sock" { + t.Errorf("environment = %q", path) + } + if path, _ := localapp.SocketPath("/tmp/flag.sock"); path != "/tmp/flag.sock" { + t.Errorf("flag = %q", path) + } +} + +func TestNoRuntimeFolderIsNoGuess(t *testing.T) { + t.Setenv(localapp.SocketEnv, "") + for _, runtime := range []string{"", "run/user/1000"} { + t.Setenv("XDG_RUNTIME_DIR", runtime) + if _, err := localapp.SocketPath(""); !errors.Is(err, localapp.ErrNoRuntimeDir) { + t.Errorf("XDG_RUNTIME_DIR=%q: err = %v", runtime, err) + } + } +} + +func TestAResultWithNoListOrNoContentIsAProtocolError(t *testing.T) { + door := localapptest.Start(t) + door.Reply("tools/list", `{"jsonrpc":"2.0","id":2,"result":{}}`) + if _, err := dial(t, door.Path).Tools(context.Background()); !errors.As(err, new(*localapp.ProtocolError)) { + t.Errorf("tools/list with no tools: err = %v (%T)", err, err) + } + + door = localapptest.Start(t) + door.AnswerRaw("imbox.list", map[string]any{}) + if _, err := dial(t, door.Path).Call(context.Background(), "imbox.list", nil); !errors.As(err, new(*localapp.ProtocolError)) { + t.Errorf("tools/call with no content: err = %v (%T)", err, err) + } +} + +// A pending answer is never read as less, whatever else it lacks. +func TestAPendingAnswerWithNoContentIsStillPending(t *testing.T) { + door := localapptest.Start(t) + door.AnswerRaw("message.send", map[string]any{"structuredContent": map[string]any{"status": "pending", "confirmation": 7}}) + + result, err := dial(t, door.Path).Call(context.Background(), "message.send", map[string]any{"draft": 99}) + if err != nil { + t.Fatal(err) + } + if _, ok := result.Pending(); !ok { + t.Errorf("not pending: %+v", result) + } +} + +// Each call is bounded by its own context, not by the one the dial was given, which here +// has no deadline at all. +func TestACallThatGoesUnansweredTimesOutOnItsOwnContext(t *testing.T) { + door := localapptest.Start(t) + door.Hang("tools/call") + client, err := localapp.Dial(context.Background(), door.Path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + + answered := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := client.Call(ctx, "imbox.list", nil) + answered <- err + }() + + select { + case err := <-answered: + if !errors.As(err, new(*localapp.TimeoutError)) { + t.Fatalf("err = %v (%T)", err, err) + } + case <-time.After(3 * time.Second): + t.Fatal("the call outlived its own context") + } +} + +func TestADialWhoseContextEndedIsATimeoutNotAConnectError(t *testing.T) { + door := localapptest.Start(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := localapp.Dial(ctx, door.Path) + + if !errors.As(err, new(*localapp.TimeoutError)) { + t.Errorf("err = %v (%T)", err, err) + } +} diff --git a/internal/localapp/errors.go b/internal/localapp/errors.go new file mode 100644 index 000000000..0ec6bddd8 --- /dev/null +++ b/internal/localapp/errors.go @@ -0,0 +1,68 @@ +package localapp + +import "fmt" + +// DoorClosedError is a socket that is not there, or that nothing answers on: the door is +// off in the app's Settings, or the app is not running. +type DoorClosedError struct { + Path string + Cause error +} + +func (e *DoorClosedError) Error() string { + return fmt.Sprintf("nothing answers at %s: %v", e.Path, e.Cause) +} + +func (e *DoorClosedError) Unwrap() error { return e.Cause } + +// ConnectError is a socket that is there but could not be used: refused by permissions, +// not a socket, or a connection that broke. +type ConnectError struct { + Path string + Cause error +} + +func (e *ConnectError) Error() string { + return fmt.Sprintf("could not talk to HEY at %s: %v", e.Path, e.Cause) +} + +func (e *ConnectError) Unwrap() error { return e.Cause } + +// ServerError is a JSON-RPC error the app answered with: a method or a tool it does not +// have, or arguments it could not read. +type ServerError struct { + Method string + Code int + Message string +} + +func (e *ServerError) Error() string { + return fmt.Sprintf("HEY refused %s: %s (%d)", e.Method, e.Message, e.Code) +} + +// ProtocolError is an answer the client cannot read as MCP. Closed is a connection the +// app closed before it answered, as it does a caller of another user account. +type ProtocolError struct { + Method string + Reason string + Closed bool +} + +func (e *ProtocolError) Error() string { + if e.Method == "" { + return "HEY's answer could not be read: " + e.Reason + } + return fmt.Sprintf("HEY's answer to %s could not be read: %s", e.Method, e.Reason) +} + +// TimeoutError is an answer that did not come in time. +type TimeoutError struct { + Method string + Cause error +} + +func (e *TimeoutError) Error() string { + return fmt.Sprintf("HEY did not answer %s in time: %v", e.Method, e.Cause) +} + +func (e *TimeoutError) Unwrap() error { return e.Cause } diff --git a/internal/localapp/localapptest/door.go b/internal/localapp/localapptest/door.go new file mode 100644 index 000000000..d01b48d1f --- /dev/null +++ b/internal/localapp/localapptest/door.go @@ -0,0 +1,261 @@ +// Package localapptest is a fake of the HEY app's door for tests: a Unix socket that +// answers MCP as the door's contract does, from what a test gives it. +package localapptest + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/basecamp/hey-cli/internal/localapp" +) + +// Door answers initialize, tools/list and tools/call. A test offers tools (Offer) and +// sets what a call answers (Answer, Refuse, AnswerRaw), and may make a method answer with +// a JSON-RPC error (Fail), with a line of its own (Reply) or not at all (Hang), to see what +// a client makes of an app that misbehaves. +type Door struct { + Path string + + mu sync.Mutex + tools []map[string]any + results map[string]map[string]any + errors map[string]RPCError + raw map[string]string + hang map[string]bool + requests []Request +} + +type RPCError struct { + Code int + Message string +} + +// Request is one message the door read: its method and its params. +type Request struct { + Method string + Params json.RawMessage +} + +// Start opens a door, closed when the test ends. +func Start(t testing.TB) *Door { + t.Helper() + path, listener := Listen(t) + door := &Door{ + Path: path, + results: map[string]map[string]any{}, + errors: map[string]RPCError{}, + raw: map[string]string{}, + hang: map[string]bool{}, + } + go door.serve(listener) + return door +} + +// Listen opens a Unix socket for a test that answers by hand, closed when the test ends. +// Its folder is short-named under the system's temporary folder, since a socket's path is +// capped near a hundred bytes and a test's own temporary folder on macOS is longer. +func Listen(t testing.TB) (string, *net.UnixListener) { + t.Helper() + folder, err := os.MkdirTemp("", "hl") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(folder) }) + + path := filepath.Join(folder, "agent.sock") + var config net.ListenConfig + listener, err := config.Listen(context.Background(), "unix", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + unix, ok := listener.(*net.UnixListener) + if !ok { + t.Fatalf("a unix listen gave a %T", listener) + } + return path, unix +} + +// Offer lists a tool, with the effect the door names in its _meta and the arguments its +// schema declares, each required, as the door's are. +func (d *Door) Offer(name, description, effect string, arguments ...string) { + properties := map[string]any{} + for _, argument := range arguments { + properties[argument] = map[string]any{"type": "string"} + } + if arguments == nil { + arguments = []string{} + } + d.mu.Lock() + defer d.mu.Unlock() + d.tools = append(d.tools, map[string]any{ + "name": name, + "description": description, + "inputSchema": map[string]any{"type": "object", "properties": properties, "required": arguments, "additionalProperties": false}, + "_meta": map[string]any{"com.hey/effect": effect}, + }) +} + +// AnswerRaw makes a tools/call of name answer with result, as written, for a result that +// is not what Answer builds. +func (d *Door) AnswerRaw(name string, result map[string]any) { + d.mu.Lock() + defer d.mu.Unlock() + d.results[name] = result +} + +// Answer makes a tools/call of name answer structured, done. +func (d *Door) Answer(name string, structured any) { + d.answer(name, structured, false) +} + +// Refuse makes a tools/call of name answer structured as a refusal (isError). +func (d *Door) Refuse(name string, structured any) { + d.answer(name, structured, true) +} + +// Fail makes method answer with a JSON-RPC error. +func (d *Door) Fail(method string, code int, message string) { + d.mu.Lock() + defer d.mu.Unlock() + d.errors[method] = RPCError{Code: code, Message: message} +} + +// Reply makes method answer with line, as written, in place of an answer. +func (d *Door) Reply(method, line string) { + d.mu.Lock() + defer d.mu.Unlock() + d.raw[method] = line +} + +// Hang makes method go unanswered, the connection left open. +func (d *Door) Hang(method string) { + d.mu.Lock() + defer d.mu.Unlock() + d.hang[method] = true +} + +// Requests is every message the door has read, notifications included, in order. +func (d *Door) Requests() []Request { + d.mu.Lock() + defer d.mu.Unlock() + return append([]Request(nil), d.requests...) +} + +// Calls is the params of every tools/call the door read. +func (d *Door) Calls() []json.RawMessage { + var calls []json.RawMessage + for _, request := range d.Requests() { + if request.Method == "tools/call" { + calls = append(calls, request.Params) + } + } + return calls +} + +func (d *Door) answer(name string, structured any, refused bool) { + text, _ := json.Marshal(structured) + d.mu.Lock() + defer d.mu.Unlock() + d.results[name] = map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(text)}}, + "structuredContent": structured, + "isError": refused, + } +} + +func (d *Door) serve(listener net.Listener) { + for { + conn, err := listener.Accept() + if errors.Is(err, net.ErrClosed) { + return + } + if err != nil { + continue + } + go d.converse(conn) + } +} + +func (d *Door) converse(conn net.Conn) { + defer conn.Close() + lines := bufio.NewScanner(conn) + lines.Buffer(make([]byte, 0, 64<<10), localapp.MaxLine) + for lines.Scan() { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(lines.Bytes(), &request); err != nil { + d.write(conn, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "parse error"}}) + continue + } + d.mu.Lock() + d.requests = append(d.requests, Request{Method: request.Method, Params: request.Params}) + raw, isRaw := d.raw[request.Method] + failure, fails := d.errors[request.Method] + hangs := d.hang[request.Method] + d.mu.Unlock() + + if len(request.ID) == 0 || hangs { + continue + } + switch { + case isRaw: + _, _ = conn.Write([]byte(raw + "\n")) + case fails: + d.write(conn, map[string]any{"jsonrpc": "2.0", "id": request.ID, "error": map[string]any{"code": failure.Code, "message": failure.Message}}) + default: + d.write(conn, d.respond(request.ID, request.Method, request.Params)) + } + } +} + +func (d *Door) respond(id json.RawMessage, method string, params json.RawMessage) map[string]any { + d.mu.Lock() + defer d.mu.Unlock() + switch method { + case "initialize": + return result(id, map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "hey", "title": "HEY", "version": "0.0.0-test"}, + }) + case "ping": + return result(id, map[string]any{}) + case "tools/list": + tools := d.tools + if tools == nil { + tools = []map[string]any{} + } + return result(id, map[string]any{"tools": tools}) + case "tools/call": + var call struct { + Name string `json:"name"` + } + _ = json.Unmarshal(params, &call) + if answer, ok := d.results[call.Name]; ok { + return result(id, answer) + } + return map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32602, "message": "unknown tool: " + call.Name}} + default: + return map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "method not found"}} + } +} + +func (d *Door) write(conn net.Conn, value any) { + line, _ := json.Marshal(value) + _, _ = conn.Write(append(line, '\n')) +} + +func result(id json.RawMessage, value any) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": id, "result": value} +} diff --git a/internal/localapp/socket.go b/internal/localapp/socket.go new file mode 100644 index 000000000..822c0e2a5 --- /dev/null +++ b/internal/localapp/socket.go @@ -0,0 +1,30 @@ +package localapp + +import ( + "errors" + "os" + "path/filepath" +) + +// SocketEnv overrides where the door is looked for, as --socket does for one command. +const SocketEnv = "HEY_LOCAL_SOCKET" + +// ErrNoRuntimeDir is a session with no XDG_RUNTIME_DIR, the one folder the app opens its +// door in. The app refuses to open one without it, so there is nothing to guess at. +var ErrNoRuntimeDir = errors.New("XDG_RUNTIME_DIR is not set to an absolute path") + +// SocketPath is where the door is: the flag's path, else HEY_LOCAL_SOCKET's, else +// $XDG_RUNTIME_DIR/hey/agent.sock, where the app opens it. +func SocketPath(flag string) (string, error) { + if flag != "" { + return flag, nil + } + if env := os.Getenv(SocketEnv); env != "" { + return env, nil + } + runtime := os.Getenv("XDG_RUNTIME_DIR") + if !filepath.IsAbs(runtime) { + return "", ErrNoRuntimeDir + } + return filepath.Join(runtime, "hey", "agent.sock"), nil +} diff --git a/internal/output/codes.go b/internal/output/codes.go index 3a995fbb4..0ee393be0 100644 --- a/internal/output/codes.go +++ b/internal/output/codes.go @@ -15,9 +15,18 @@ const ( ExitNetwork = 6 ExitAPI = 7 ExitAmbiguous = 8 + ExitPending = 9 ) +// ErrPending is what a command returns once it has written an answer that is neither done +// nor failed: a send the HEY app holds in its window until the person confirms it. Execute +// writes nothing more for it and exits ExitPending. +var ErrPending = errors.New("waiting in HEY for the person to confirm") + func ExitCodeFor(err error) int { + if errors.Is(err, ErrPending) { + return ExitPending + } var e *apierr.Error if !errors.As(err, &e) { return ExitUsage diff --git a/internal/output/codes_test.go b/internal/output/codes_test.go index e1d34d6cf..6dee58c6f 100644 --- a/internal/output/codes_test.go +++ b/internal/output/codes_test.go @@ -88,6 +88,9 @@ func TestExitCodeForEveryCodeAnErrorCanCarry(t *testing.T) { // standard library or a dependency still has to exit with something. {errors.New("a plain error nobody wrapped"), ExitUsage}, {fmt.Errorf("wrapped: %w", apierr.ErrNotFound("topic", "3011")), ExitNotFound}, + // A send waiting in HEY's window is neither done nor failed, and exits as neither. + {ErrPending, ExitPending}, + {fmt.Errorf("sent to the app: %w", ErrPending), ExitPending}, } for _, tt := range tests {