From 9757ae06fb18bdc08fc5be4d0d477e1091b5b59d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 10 Nov 2026 23:19:49 -0800 Subject: [PATCH 01/15] hey local: ask the HEY app on this computer to act for you The HEY desktop app is opening a door for agents: while "Let agents use HEY" is on in its Settings, it serves its own commands as MCP over a Unix socket, and a send waits in its window for the person to press Send. Nothing in hey-cli spoke to it, and the eval's hey-local capability probes hey local --help, which failed. hey local now does: - hey local tools lists what the app offers, with each command's effect (read, reversible, reaches_people). - hey local call [json] [--arg k=v] runs any of them. - hey local imbox, hey local screener list|approve|deny and hey local send are sugar for imbox.list, screener.list, screener.decide and message.send. - A call the app answers pending (a send waiting in HEY) prints "Waiting in HEY", says nothing has been sent, and exits 9, a new documented code (hey help exit-codes): neither done nor failed. - No socket, or a stale one nothing answers on, fails with exit 6 and says to turn on "Let agents use HEY" in the app's Settings. The client (internal/localapp) is plain MCP written out: JSON-RPC 2.0, one message per line, initialize and notifications/initialized first, then tools/list and tools/call, answering a ping and passing over notifications. The socket is --socket, else HEY_LOCAL_SOCKET, else $XDG_RUNTIME_DIR/hey/agent.sock, matching the door's contract; with no absolute XDG_RUNTIME_DIR it asks for --socket rather than guess. hey local reaches HEY's servers never: it skips the credential migration, the account scope and the local config's trust gate. Verified by Go tests against a fake door (localapptest), in the client and in the commands: list, call, typed arguments, pending, a refusal never pending, no socket, a stale socket, no runtime folder, a JSON-RPC error, malformed replies (not JSON, not 2.0, another id, no result, the wrong shape), a ping before the answer and a timeout. The command tests fail if anything reaches HEY's server. The pending tests were run red first, with the pending branch unwired: the send read as success. Mutations that drop the refusal check, the id check or ECONNREFUSED each turned a client test red. Rejected: the go-sdk's MCP client, already a dependency. Its newest protocol tries server/discover before initialize and negotiates versions, which is more than three methods on a local socket need, and its handling of a malformed line is the SDK's to decide, not ours to report plainly. --arg values are typed as JSON rather than by the tool's schema, so a call needs no extra tools/list; a numeric-looking word is quoted. A Mac door is not built: there a socket would let any program skip the Keychain prompt guarding HEY's sign-in, so the app has to pair each agent and check its caller's signature first. --- .surface | 11 + docs/agents.md | 55 +++- docs/cli.md | 9 + internal/cmd/help_topics.go | 3 + internal/cmd/local.go | 265 ++++++++++++++++ internal/cmd/local_call.go | 99 ++++++ internal/cmd/local_config_trust.go | 3 + internal/cmd/local_imbox.go | 95 ++++++ internal/cmd/local_screener.go | 67 +++++ internal/cmd/local_screener_approve.go | 27 ++ internal/cmd/local_screener_deny.go | 27 ++ internal/cmd/local_screener_list.go | 81 +++++ internal/cmd/local_send.go | 55 ++++ internal/cmd/local_test.go | 400 +++++++++++++++++++++++++ internal/cmd/local_tools.go | 97 ++++++ internal/cmd/root.go | 9 +- internal/localapp/client.go | 353 ++++++++++++++++++++++ internal/localapp/client_test.go | 323 ++++++++++++++++++++ internal/localapp/errors.go | 66 ++++ internal/localapp/localapptest/door.go | 217 ++++++++++++++ internal/localapp/socket.go | 30 ++ internal/output/codes.go | 9 + internal/output/codes_test.go | 3 + 23 files changed, 2301 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/local.go create mode 100644 internal/cmd/local_call.go create mode 100644 internal/cmd/local_imbox.go create mode 100644 internal/cmd/local_screener.go create mode 100644 internal/cmd/local_screener_approve.go create mode 100644 internal/cmd/local_screener_deny.go create mode 100644 internal/cmd/local_screener_list.go create mode 100644 internal/cmd/local_send.go create mode 100644 internal/cmd/local_test.go create mode 100644 internal/cmd/local_tools.go create mode 100644 internal/localapp/client.go create mode 100644 internal/localapp/client_test.go create mode 100644 internal/localapp/errors.go create mode 100644 internal/localapp/localapptest/door.go create mode 100644 internal/localapp/socket.go diff --git a/.surface b/.surface index 3100de6c6..5afd21777 100644 --- a/.surface +++ b/.surface @@ -281,6 +281,17 @@ 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 imbox +hey local screener +hey local screener approve +hey local screener deny +hey local screener list +hey local send +hey local tools hey login hey login --cookie hey login --mcp diff --git a/docs/agents.md b/docs/agents.md index 2230797f9..35e56449d 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,53 @@ 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 send 1731280000123 # ask to send a saved 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` | +| `reversible` | Runs at once, and can be undone | `screener.decide` | +| `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. Its +JSON envelope carries `meta.status: "pending"`, the confirmation's id and what you were +shown. No command or flag here can confirm it. + +`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. + +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..79f975418 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -362,6 +362,15 @@ 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|approve|deny`, `hey local send`, and `hey local call` for any +command `hey local tools` lists. It reaches the app alone, never HEY's servers, and needs +no sign-in. 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/help_topics.go b/internal/cmd/help_topics.go index 198d89e27..0186ba17a 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -46,6 +46,8 @@ 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). Nothing has happened yet. JSON failures also carry a machine-readable error code and an actionable hint when one is available.`, }, @@ -59,6 +61,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..adae86fe7 --- /dev/null +++ b/internal/cmd/local.go @@ -0,0 +1,265 @@ +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. 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(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, 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. A refusal is an error; an answer, done or +// pending, is the result. +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, result) + } + 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, &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 +// request the app could not take is a usage error; the app signed out of HEY is an auth +// one, though not the CLI's sign-in; anything else is the app's to explain. +func localRefusal(name string, result *localapp.Result) error { + var reason map[string]any + _ = json.Unmarshal(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 detail, _ := reason["detail"].(string); detail != "" { + refusal.Message += terminal.SanitizeLine(" (" + detail + ")") + } + switch why { + case "bad_request", "no_such_case", "not_text": + refusal.Code = apierr.CodeUsage + refusal.Hint = "Run: hey local tools, for what each command takes" + case "not_offered": + refusal.Hint = "Run: hey local tools, for what the app offers now" + case "signed_out": + refusal.Code = apierr.CodeAuth + refusal.Hint = "Sign in to HEY in the app" + case "disabled": + refusal.Hint = localDoorHint + } + return refusal +} + +// writeLocalAnswer writes what a command answered: pretty JSON for a person, the envelope +// for everyone else. A pending answer is written as pending. +func writeLocalAnswer(cmd *cobra.Command, name string, result *localapp.Result) error { + if pending, ok := result.Pending(); ok { + return writeLocalPending(cmd, pending) + } + + 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(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. +func writeLocalPending(cmd *cobra.Command, pending *localapp.Pending) error { + var snapshot localSendSnapshot + _ = json.Unmarshal(pending.Snapshot, &snapshot) + + if writer.IsStyled() { + 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)) + } + return output.ErrPending + } + + data := map[string]any{"status": "pending", "confirmation": pending.Confirmation} + if len(pending.Snapshot) > 0 { + data["snapshot"] = pending.Snapshot + } + if err := writeOK(data, + output.WithSummary("Waiting in HEY: nothing is sent until the person presses Send in the HEY window"), + output.WithMeta("status", "pending"), + ); err != nil { + return err + } + return output.ErrPending +} + +// 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 +} + +// decodeLocalAnswerAs reads a command's answer into the shape the command's sugar knows, +// and refuses one in any other shape rather than show it as empty. +func decodeLocalAnswerAs[T any](name string, result *localapp.Result) (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) + } + return answer, nil +} + +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..c11ca07a1 --- /dev/null +++ b/internal/cmd/local_call.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "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`) + } + } + 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)) + } + 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 + } + if decoder.More() { + return fmt.Errorf("more than one 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_imbox.go b/internal/cmd/local_imbox.go new file mode 100644 index 000000000..12ab1b0b4 --- /dev/null +++ b/internal/cmd/local_imbox.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "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[localImbox]("imbox.list", result) + 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 { + fmt.Fprintln(cmd.OutOrStdout(), "Nothing in the Imbox") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Thread", "Sender", "Subject", ""}) + for _, thread := range imbox.Threads { + unseen := "" + if !thread.Seen { + unseen = "new" + } + table.addRow([]string{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 + } + return writeOK(imbox.Threads, + output.WithSummary(fmt.Sprintf("%d %s in the Imbox", len(imbox.Threads), localThreadNoun(len(imbox.Threads)))), + output.WithNotice(notice), + output.WithMeta("has_more", imbox.HasMore), + ) +} + +func localThreadNoun(count int) string { + if count == 1 { + return "thread" + } + return "threads" +} diff --git a/internal/cmd/local_screener.go b/internal/cmd/local_screener.go new file mode 100644 index 000000000..5f07386e8 --- /dev/null +++ b/internal/cmd/local_screener.go @@ -0,0 +1,67 @@ +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 + } + decided, err := decodeLocalAnswerAs[localDecision]("screener.decide", result) + 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 screener %s %d", 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}), + ) +} 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..417812c96 --- /dev/null +++ b/internal/cmd/local_screener_list.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "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[localScreener]("screener.list", result) + if err != nil { + return err + } + + if writer.IsStyled() { + if len(screener.Waiting) == 0 && len(screener.Decided) == 0 { + 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 != nil && *sender.ScreenedIn { + status = "screened in" + } + table.addRow([]string{fmt.Sprintf("%d", sender.ID), truncate(sender.Name, 24), truncate(sender.Address, 32), status}) + } + table.print() + return nil + } + return writeOK(screener, + output.WithSummary(fmt.Sprintf("%d %s waiting", screener.WaitingCount, senderNoun(screener.WaitingCount))), + 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"}, + ), + ) +} diff --git a/internal/cmd/local_send.go b/internal/cmd/local_send.go new file mode 100644 index 000000000..cf7b00208 --- /dev/null +++ b/internal/cmd/local_send.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +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.", + }, + 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])) + } + result, err := callLocal(cmd, "message.send", map[string]any{"draft": draft}) + if err != nil { + return err + } + return writeLocalAnswer(cmd, "message.send", result) +} diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go new file mode 100644 index 000000000..c28e2f634 --- /dev/null +++ b/internal/cmd/local_test.go @@ -0,0 +1,400 @@ +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") + 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") { + 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") { + 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) { + t.Setenv("XDG_RUNTIME_DIR", "") + + _, _, err := runLocal(t, "", "imbox") + + failure := apierr.AsError(err) + if !strings.Contains(failure.Message, "XDG_RUNTIME_DIR") || !strings.Contains(failure.Hint, "--socket") { + t.Errorf("err = %q, hint = %q", 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) + } +} + +func TestLocalAServerErrorIsAnAPIFailure(t *testing.T) { + door := localapptest.Start(t) + + _, _, err := runLocal(t, door.Path, "call", "drafts.empty", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + } + if !strings.Contains(err.Error(), "drafts.empty") { + t.Errorf("err = %v", 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) + } +} + +func TestLocalARefusalSaysWhyAndCarriesTheAppsReason(t *testing.T) { + door := localapptest.Start(t) + door.Refuse("screener.decide", map[string]any{ + "error": "bad_request", "arg": "sender", "of": "sender", "given": 99, "available": []int{51, 52}, + }) + + _, _, 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, "bad_request") { + t.Errorf("message = %q", failure.Message) + } + refusal, _ := failure.Meta["refusal"].(map[string]any) + if refusal["arg"] != "sender" { + t.Errorf("meta = %+v", failure.Meta) + } +} + +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; strings.Contains(hint, "hey auth login") { + t.Errorf("hint = %q: the app's sign-in is not the CLI's", hint) + } +} diff --git a/internal/cmd/local_tools.go b/internal/cmd/local_tools.go new file mode 100644 index 000000000..48ce1b8a8 --- /dev/null +++ b/internal/cmd/local_tools.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "context" + "fmt" + + "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", "Description"}) + for _, tool := range tools { + effect := localEffects[tool.Effect] + if effect == "" { + effect = tool.Effect + } + table.addRow([]string{tool.Name, effect, 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"}), + ) +} + +func localCommandNoun(count int) string { + if count == 1 { + return "command" + } + return "commands" +} 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..7b3b2918f --- /dev/null +++ b/internal/localapp/client.go @@ -0,0 +1,353 @@ +// 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" +) + +// 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 + stopped func() bool + + // 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 int64 `json:"confirmation"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` +} + +// Dial connects to the door at path and initializes the session. ctx bounds the dial and, +// through its deadline, every exchange on the connection after it. +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} + } + return nil, &ConnectError{Path: path, Cause: err} + } + + client := newClient(ctx, 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 + } + 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 + } + return &result, nil +} + +func (c *Client) Close() error { + c.stopped() + 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. A refusal is never pending. +func (r *Result) Pending() (*Pending, bool) { + answer := r.Answer() + if r.IsError || answer == nil { + return nil, false + } + var pending struct { + Status string `json:"status"` + Pending + } + if err := json.Unmarshal(answer, &pending); err != nil || pending.Status != "pending" { + return nil, false + } + return &pending.Pending, true +} + +func newClient(ctx context.Context, conn net.Conn) *Client { + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + lines := bufio.NewScanner(conn) + lines.Buffer(make([]byte, 0, 64<<10), maxLine) + // A cancelled context ends whatever read or write is waiting, rather than leaving the + // caller on a connection the app holds open. + stopped := context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Unix(1, 0)) }) + return &Client{conn: conn, lines: lines, stopped: stopped} +} + +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"}, + } + 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 + return c.notify("notifications/initialized") +} + +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 { + 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 + } +} + +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"} + } + 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} + } + if errors.Is(err, io.EOF) { + return &ProtocolError{Method: method, Reason: "the app closed the connection without answering"} + } + 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..5aa4febe4 --- /dev/null +++ b/internal/localapp/client_test.go @@ -0,0 +1,323 @@ +package localapp_test + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "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) + } + var methods []string + for _, request := range door.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"` + } + _ = json.Unmarshal(door.Requests()[0].Params, &initialize) + if initialize.ProtocolVersion != localapp.ProtocolVersion { + t.Errorf("protocolVersion = %q", initialize.ProtocolVersion) + } + 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 pending.Confirmation != 7 || !strings.Contains(string(pending.Snapshot), "marcus@example.com") { + t.Errorf("pending = %+v", pending) + } +} + +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) { + folder, err := os.MkdirTemp("", "hl") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(folder) }) + path := filepath.Join(folder, "agent.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + listener.(*net.UnixListener).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) { + folder, err := os.MkdirTemp("", "hl") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(folder) }) + path := filepath.Join(folder, "agent.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + answered := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer 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) { + folder, err := os.MkdirTemp("", "hl") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(folder) }) + path := filepath.Join(folder, "agent.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + 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) + } + } +} diff --git a/internal/localapp/errors.go b/internal/localapp/errors.go new file mode 100644 index 000000000..41633cefc --- /dev/null +++ b/internal/localapp/errors.go @@ -0,0 +1,66 @@ +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. +type ProtocolError struct { + Method string + Reason string +} + +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..231640823 --- /dev/null +++ b/internal/localapp/localapptest/door.go @@ -0,0 +1,217 @@ +// 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" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "sync" + "testing" +) + +// Door answers initialize, tools/list and tools/call. A test fills Tools and Results, and +// may make a method answer with a JSON-RPC error (Errors) or with a line of its own +// (Raw), 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 + 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 in a folder of its own, closed when the test ends. The folder is +// short-named under the system's temporary folder, since a Unix socket's path is capped +// near a hundred bytes and a test's own temporary folder on macOS is longer. +func Start(t testing.TB) *Door { + t.Helper() + folder, err := os.MkdirTemp("", "hl") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(folder) }) + + door := &Door{ + Path: filepath.Join(folder, "agent.sock"), + results: map[string]map[string]any{}, + errors: map[string]RPCError{}, + raw: map[string]string{}, + } + listener, err := net.Listen("unix", door.Path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go door.serve(listener) + return door +} + +// Offer lists a tool, with the effect the door names in its _meta. +func (d *Door) Offer(name, description, effect 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", "additionalProperties": false}, + "_meta": map[string]any{"com.hey/effect": effect}, + }) +} + +// 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 +} + +// 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) + 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] + d.mu.Unlock() + + if len(request.ID) == 0 { + 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 { From 571f9b68554f69593d48bfa6d7f1396abebde714 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 10 Nov 2026 23:34:16 -0800 Subject: [PATCH 02/15] hey local: pending stays pending in every format and on every command A fresh review of the first commit found: - A pending send under --ids-only or --count exited 1: writeLocalPending passed back the writer's "requires list data" error. It now always returns ErrPending; --ids-only prints the confirmation id, --count prints nothing, and both say "Waiting in HEY" on stderr. A --jq filter that fails on the answer no longer turns pending into a failure. - Only call and send looked for pending. callLocal now does, so the sugar can't read a waiting answer as done ("Sender 42 screened in" or "Nothing in the Imbox", exit 0). - The sugar took any shape: {} decoded as an empty Imbox, or as a decision, reported done. Each now requires what it shows: the Imbox's threads, both of the Screener's lists, and a decision said back for the sender and the way it was asked. - A refused id exited 7 with no hint, and "disabled" pointed at the setting when the door had answered. The app's Arg.Ref refuses with unavailable or disabled and names arg and available, so a refusal naming an argument is now a usage error (1) whose hint lists what it takes. unavailable with no arg (a list the app could not load) stays 7. - An unknown tool (-32602 from the door) is a usage error pointing at hey local tools. - Pending is the status alone: a confirmation id the client doesn't expect (a string) read as done. It is kept as the app wrote it. - --arg took trailing junk: sender=42} sent 42. The value must be the whole JSON value, or it is a word. - A connection the app closes unanswered, as it does a caller of another user account, exits 6 and says so, not 7. Verified: a test per finding, 10 cases across 7 tests, run red against the first commit's code with the new tests, then green. The lint findings from make check (net.Listen with no context, one prealloc) are fixed with one localapptest.Listen for every hand-rolled socket. - hey local imbox's table showed the thread (topic) as its only id while --ids-only prints the row's. It shows both, as hey box view does. Not changed: hey local stays out of the curated root help until the app ships its door. --- internal/cmd/local.go | 121 ++++++++++++++----- internal/cmd/local_call.go | 7 +- internal/cmd/local_imbox.go | 13 +- internal/cmd/local_screener.go | 8 +- internal/cmd/local_screener_list.go | 8 +- internal/cmd/local_test.go | 161 ++++++++++++++++++++++--- internal/localapp/client.go | 18 +-- internal/localapp/client_test.go | 66 ++++------ internal/localapp/errors.go | 4 +- internal/localapp/localapptest/door.go | 38 ++++-- 10 files changed, 330 insertions(+), 114 deletions(-) diff --git a/internal/cmd/local.go b/internal/cmd/local.go index adae86fe7..e6277d219 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -90,8 +90,9 @@ func withLocalApp(cmd *cobra.Command, talk func(context.Context, *localapp.Clien return localFailure(talk(ctx, client)) } -// callLocal runs one of the app's commands. A refusal is an error; an answer, done or -// pending, is the result. +// 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 { @@ -105,6 +106,9 @@ func callLocal(cmd *cobra.Command, name string, arguments map[string]any) (*loca if result.IsError { return nil, localRefusal(name, result) } + if pending, ok := result.Pending(); ok { + return nil, writeLocalPending(cmd, pending) + } return result, nil } @@ -131,6 +135,21 @@ func localFailure(err error) error { } 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: @@ -139,8 +158,9 @@ func localFailure(err error) error { } // localRefusal is a command the app refused, with its reason in the error's meta. A -// request the app could not take is a usage error; the app signed out of HEY is an auth -// one, though not the CLI's sign-in; anything else is the app's to explain. +// 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, result *localapp.Result) error { var reason map[string]any _ = json.Unmarshal(result.Answer(), &reason) @@ -157,28 +177,44 @@ func localRefusal(name string, result *localapp.Result) error { if detail, _ := reason["detail"].(string); detail != "" { refusal.Message += terminal.SanitizeLine(" (" + detail + ")") } - switch why { - case "bad_request", "no_such_case", "not_text": + arg, _ := reason["arg"].(string) + switch { + 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 "not_offered": + case why == "not_offered": refusal.Hint = "Run: hey local tools, for what the app offers now" - case "signed_out": + case why == "signed_out": refusal.Code = apierr.CodeAuth refusal.Hint = "Sign in to HEY in the app" - case "disabled": - refusal.Hint = localDoorHint } return refusal } -// writeLocalAnswer writes what a command answered: pretty JSON for a person, the envelope -// for everyone else. A pending answer is written as pending. -func writeLocalAnswer(cmd *cobra.Command, name string, result *localapp.Result) error { - if pending, ok := result.Pending(); ok { - return writeLocalPending(cmd, pending) +// 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 { + if len(values) == 0 { + return fmt.Sprintf("%s takes nothing the app holds now", arg) + } + 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 @@ -195,12 +231,15 @@ func writeLocalAnswer(cmd *cobra.Command, name string, result *localapp.Result) } // writeLocalPending says a command waits in HEY's window, and returns ErrPending so the -// run exits as neither done nor failed. +// 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) - if writer.IsStyled() { + 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 { @@ -209,20 +248,38 @@ func writeLocalPending(cmd *cobra.Command, pending *localapp.Pending) error { if snapshot.Subject != "" { fmt.Fprintln(out, " Subject: "+terminal.SanitizeLine(snapshot.Subject)) } - return output.ErrPending + 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: + data := map[string]any{"status": "pending"} + if len(pending.Confirmation) > 0 { + data["confirmation"] = pending.Confirmation + } + if len(pending.Snapshot) > 0 { + data["snapshot"] = pending.Snapshot + } + 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 +} - data := map[string]any{"status": "pending", "confirmation": pending.Confirmation} - if len(pending.Snapshot) > 0 { - data["snapshot"] = pending.Snapshot - } - if err := writeOK(data, - output.WithSummary("Waiting in HEY: nothing is sent until the person presses Send in the HEY window"), - output.WithMeta("status", "pending"), - ); err != nil { - return err +// 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 output.ErrPending + return terminal.SanitizeLine(string(raw)) } // decodeLocalAnswer reads a command's answer as JSON values, numbers kept exact, so an id @@ -242,8 +299,9 @@ func decodeLocalAnswer(name string, result *localapp.Result) (any, error) { } // decodeLocalAnswerAs reads a command's answer into the shape the command's sugar knows, -// and refuses one in any other shape rather than show it as empty. -func decodeLocalAnswerAs[T any](name string, result *localapp.Result) (T, error) { +// 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 { @@ -252,6 +310,9 @@ func decodeLocalAnswerAs[T any](name string, result *localapp.Result) (T, error) if err := json.Unmarshal(raw, &answer); err != nil { return answer, localUnreadable(name, err) } + if err := complete(answer); err != nil { + return answer, localUnreadable(name, err) + } return answer, nil } diff --git a/internal/cmd/local_call.go b/internal/cmd/local_call.go index c11ca07a1..e0d754fdb 100644 --- a/internal/cmd/local_call.go +++ b/internal/cmd/local_call.go @@ -2,7 +2,9 @@ package cmd import ( "encoding/json" + "errors" "fmt" + "io" "strings" "github.com/spf13/cobra" @@ -92,8 +94,9 @@ func decodeJSONExactly(text string, into any) error { if err := decoder.Decode(into); err != nil { return err } - if decoder.More() { - return fmt.Errorf("more than one JSON value") + // 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_imbox.go b/internal/cmd/local_imbox.go index 12ab1b0b4..c668a19d5 100644 --- a/internal/cmd/local_imbox.go +++ b/internal/cmd/local_imbox.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -51,7 +52,12 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { if err != nil { return err } - imbox, err := decodeLocalAnswerAs[localImbox]("imbox.list", result) + imbox, err := decodeLocalAnswerAs("imbox.list", result, func(imbox localImbox) error { + if imbox.Threads == nil { + return errors.New("it lists no threads") + } + return nil + }) if err != nil { return err } @@ -66,13 +72,14 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { return nil } table := newTable(cmd.OutOrStdout()) - table.addRow([]string{"Thread", "Sender", "Subject", ""}) + // 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.Topic), truncate(thread.Sender, 24), truncate(thread.Subject, 50), unseen}) + 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 != "" { diff --git a/internal/cmd/local_screener.go b/internal/cmd/local_screener.go index 5f07386e8..e65876a60 100644 --- a/internal/cmd/local_screener.go +++ b/internal/cmd/local_screener.go @@ -47,7 +47,13 @@ func decideLocally(cmd *cobra.Command, value, decision string) error { if err != nil { return err } - decided, err := decodeLocalAnswerAs[localDecision]("screener.decide", result) + // 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.Sender != sender || decided.Screened != decision { + return fmt.Errorf("it answered sender %d screened %q, not %d screened %q", decided.Sender, decided.Screened, sender, decision) + } + return nil + }) if err != nil { return err } diff --git a/internal/cmd/local_screener_list.go b/internal/cmd/local_screener_list.go index 417812c96..349ce5d44 100644 --- a/internal/cmd/local_screener_list.go +++ b/internal/cmd/local_screener_list.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -46,7 +47,12 @@ func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) error { if err != nil { return err } - screener, err := decodeLocalAnswerAs[localScreener]("screener.list", result) + 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") + } + return nil + }) if err != nil { return err } diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index c28e2f634..a56654f0f 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -161,7 +161,7 @@ func TestLocalImboxListsWhatTheAppHolds(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(styled, "Stint plan for Saturday") || !strings.Contains(styled, "Marcus Ortiz") { + 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) } } @@ -329,15 +329,26 @@ func TestLocalASocketFlagOutranksTheEnvironment(t *testing.T) { } } -func TestLocalAServerErrorIsAnAPIFailure(t *testing.T) { +// 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.ExitAPI { + if output.ExitCodeFor(err) != output.ExitUsage { t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) } - if !strings.Contains(err.Error(), "drafts.empty") { - t.Errorf("err = %v", 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) } } @@ -366,23 +377,141 @@ func TestLocalAnImboxInAnotherShapeIsAnAPIFailure(t *testing.T) { } } -func TestLocalARefusalSaysWhyAndCarriesTheAppsReason(t *testing.T) { +// 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": []int{51, 52}, + }) + + _, _, 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, 52" { + 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 names no argument: nothing the caller sent was wrong. +func TestLocalAListTheAppCouldNotLoadIsAnAPIFailure(t *testing.T) { door := localapptest.Start(t) - door.Refuse("screener.decide", map[string]any{ - "error": "bad_request", "arg": "sender", "of": "sender", "given": 99, "available": []int{51, 52}, - }) + door.Refuse("imbox.list", map[string]any{"error": "unavailable"}) + + _, _, err := runLocal(t, door.Path, "imbox", "--json") + if output.ExitCodeFor(err) != output.ExitAPI { + t.Errorf("exit = %d (%v)", 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] == "--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, "screener", "deny", "99", "--json") + _, _, 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) } - failure := apierr.AsError(err) - if !strings.Contains(failure.Message, "bad_request") { - t.Errorf("message = %q", failure.Message) +} + +// 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) } - refusal, _ := failure.Meta["refusal"].(map[string]any) - if refusal["arg"] != "sender" { - t.Errorf("meta = %+v", failure.Meta) + if !strings.Contains(apierr.AsError(err).Hint, "user account") { + t.Errorf("hint = %q", apierr.AsError(err).Hint) } } diff --git a/internal/localapp/client.go b/internal/localapp/client.go index 7b3b2918f..79f04e161 100644 --- a/internal/localapp/client.go +++ b/internal/localapp/client.go @@ -83,7 +83,7 @@ type Content struct { // 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 int64 `json:"confirmation"` + Confirmation json.RawMessage `json:"confirmation,omitempty"` Snapshot json.RawMessage `json:"snapshot,omitempty"` } @@ -183,20 +183,22 @@ func (r *Result) Answer() json.RawMessage { } // Pending reports a call the app answered "pending, waiting in HEY": a structured answer -// whose status is pending. A refusal is never pending. +// 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 pending struct { + var status struct { Status string `json:"status"` - Pending } - if err := json.Unmarshal(answer, &pending); err != nil || pending.Status != "pending" { + if err := json.Unmarshal(answer, &status); err != nil || status.Status != "pending" { return nil, false } - return &pending.Pending, true + var fields map[string]json.RawMessage + _ = json.Unmarshal(answer, &fields) + return &Pending{Confirmation: fields["confirmation"], Snapshot: fields["snapshot"]}, true } func newClient(ctx context.Context, conn net.Conn) *Client { @@ -302,7 +304,7 @@ 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"} + 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)} @@ -343,7 +345,7 @@ func (c *Client) exchangeError(ctx context.Context, method string, err error) er return &TimeoutError{Method: method, Cause: err} } if errors.Is(err, io.EOF) { - return &ProtocolError{Method: method, Reason: "the app closed the connection without answering"} + return &ProtocolError{Method: method, Reason: "the app closed the connection without answering", Closed: true} } return &ConnectError{Path: c.conn.RemoteAddr().String(), Cause: err} } diff --git a/internal/localapp/client_test.go b/internal/localapp/client_test.go index 5aa4febe4..4c347d28f 100644 --- a/internal/localapp/client_test.go +++ b/internal/localapp/client_test.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "errors" - "net" - "os" "path/filepath" "strings" "testing" @@ -34,8 +32,9 @@ func TestDialInitializesBeforeAnythingElse(t *testing.T) { if _, err := client.Tools(context.Background()); err != nil { t.Fatal(err) } - var methods []string - for _, request := range door.Requests() { + 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" { @@ -122,11 +121,27 @@ func TestASendWaitingInHEYIsPending(t *testing.T) { if !ok { t.Fatalf("not pending: %s", result.Answer()) } - if pending.Confirmation != 7 || !strings.Contains(string(pending.Snapshot), "marcus@example.com") { + 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"}) @@ -155,20 +170,11 @@ func TestNoSocketIsADoorClosed(t *testing.T) { // 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) { - folder, err := os.MkdirTemp("", "hl") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(folder) }) - path := filepath.Join(folder, "agent.sock") - listener, err := net.Listen("unix", path) - if err != nil { - t.Fatal(err) - } - listener.(*net.UnixListener).SetUnlinkOnClose(false) + path, listener := localapptest.Listen(t) + listener.SetUnlinkOnClose(false) _ = listener.Close() - _, err = localapp.Dial(context.Background(), path) + _, err := localapp.Dial(context.Background(), path) var closed *localapp.DoorClosedError if !errors.As(err, &closed) { @@ -227,17 +233,7 @@ func TestAnInitializeWithNoVersionIsAProtocolError(t *testing.T) { // 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) { - folder, err := os.MkdirTemp("", "hl") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(folder) }) - path := filepath.Join(folder, "agent.sock") - listener, err := net.Listen("unix", path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = listener.Close() }) + path, listener := localapptest.Listen(t) answered := make(chan string, 1) go func() { conn, err := listener.Accept() @@ -269,17 +265,7 @@ func TestTheAppsOwnMessagesBeforeAnAnswerArePassedOver(t *testing.T) { } func TestAnAppThatNeverAnswersTimesOut(t *testing.T) { - folder, err := os.MkdirTemp("", "hl") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(folder) }) - path := filepath.Join(folder, "agent.sock") - listener, err := net.Listen("unix", path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = listener.Close() }) + path, listener := localapptest.Listen(t) go func() { conn, err := listener.Accept() if err == nil { @@ -289,7 +275,7 @@ func TestAnAppThatNeverAnswersTimesOut(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - _, err = localapp.Dial(ctx, path) + _, err := localapp.Dial(ctx, path) var timeout *localapp.TimeoutError if !errors.As(err, &timeout) { diff --git a/internal/localapp/errors.go b/internal/localapp/errors.go index 41633cefc..0ec6bddd8 100644 --- a/internal/localapp/errors.go +++ b/internal/localapp/errors.go @@ -40,10 +40,12 @@ 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. +// 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 { diff --git a/internal/localapp/localapptest/door.go b/internal/localapp/localapptest/door.go index 231640823..2834f4be4 100644 --- a/internal/localapp/localapptest/door.go +++ b/internal/localapp/localapptest/door.go @@ -4,6 +4,7 @@ package localapptest import ( "bufio" + "context" "encoding/json" "errors" "net" @@ -38,10 +39,24 @@ type Request struct { Params json.RawMessage } -// Start opens a door in a folder of its own, closed when the test ends. The folder is -// short-named under the system's temporary folder, since a Unix socket's path is capped -// near a hundred bytes and a test's own temporary folder on macOS is longer. +// 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{}, + } + 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 { @@ -49,19 +64,18 @@ func Start(t testing.TB) *Door { } t.Cleanup(func() { _ = os.RemoveAll(folder) }) - door := &Door{ - Path: filepath.Join(folder, "agent.sock"), - results: map[string]map[string]any{}, - errors: map[string]RPCError{}, - raw: map[string]string{}, - } - listener, err := net.Listen("unix", door.Path) + 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() }) - go door.serve(listener) - return door + 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. From 0882207471ac5eec29d44cdff11daa1af378c9cd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 10 Nov 2026 23:40:16 -0800 Subject: [PATCH 03/15] hey local: a reset before the app answers is the app closing on the caller On Linux, the app closing a connection with the request unread reaches the client as ECONNRESET or EPIPE, not EOF, so a caller of another user account, which the door closes without a word, got exit 6 with no hint. TestLocalAConnectionClosedUnansweredSaysWhoTheAppAnswers failed on thelio (Linux) and passed on macOS; both now read the reset as a close. --- internal/localapp/client.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/localapp/client.go b/internal/localapp/client.go index 79f04e161..66b21ac64 100644 --- a/internal/localapp/client.go +++ b/internal/localapp/client.go @@ -344,7 +344,8 @@ func (c *Client) exchangeError(ctx context.Context, method string, err error) er if errors.As(err, &netErr) && netErr.Timeout() { return &TimeoutError{Method: method, Cause: err} } - if errors.Is(err, io.EOF) { + // 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} From aa0698dcce6c1c3b0c40e4abf1f03f76b65afb2f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 10 Nov 2026 23:44:15 -0800 Subject: [PATCH 04/15] hey local: a refused id is hinted as written, and a refusal with nothing to take is the app's A second review round, of the fixes alone, found: - The refusal was decoded as floats, so an id of a million or more was hinted as 1.731280000123e+12, which hey local send then refuses. The refusal is the one place an agent learns the app's draft ids. It is now decoded with numbers as written, in the hint and in meta.refusal. - A refusal naming an argument with nothing available (the app holds no senders now, or could not load them) exited 1 as the caller's mistake. It stays 7, saying the app holds nothing the command takes. - A pending answer's confirmation and snapshot are handed to the writer as values rather than raw JSON. The reviewer expected --markdown to print raw JSON as bytes; the assertion added for it passed on the old code too, so that one was not reproduced, and the decode stays as the shape every other writer path gets. Also: TestTheAppsOwnMessagesBeforeAnAnswerArePassedOver closed its hand-rolled server's connection after reading the ping's answer, so under -race on Linux the client's notifications/initialized met a closed socket (broken pipe). The server holds it open until the test ends, as the app does; 20 race runs pass. Verified: the refusal and nothing-available tests were run red against the previous head's local.go (hint "sender takes one of: 51, 1.731280000123e+12"; deny exit 1), then green. --- internal/cmd/local.go | 23 ++++++++++++++--------- internal/cmd/local_test.go | 19 +++++++++++++------ internal/localapp/client_test.go | 4 +++- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/internal/cmd/local.go b/internal/cmd/local.go index e6277d219..4e53302f3 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -162,8 +162,9 @@ func localFailure(err error) error { // 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, 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 - _ = json.Unmarshal(result.Answer(), &reason) + _ = decodeJSONExactly(string(result.Answer()), &reason) why, _ := reason["error"].(string) if why == "" { why = result.Text() @@ -178,7 +179,12 @@ func localRefusal(name string, result *localapp.Result) error { refusal.Message += terminal.SanitizeLine(" (" + detail + ")") } arg, _ := reason["arg"].(string) + available, listsAvailable := reason["available"].([]any) switch { + case arg != "" && listsAvailable && len(available) == 0: + // 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)) @@ -199,9 +205,6 @@ func localRefusal(name string, result *localapp.Result) error { func localArgumentHint(arg string, reason map[string]any) string { for _, key := range []string{"available", "cases"} { if values, ok := reason[key].([]any); ok { - if len(values) == 0 { - return fmt.Sprintf("%s takes nothing the app holds now", arg) - } taken := make([]string, 0, len(values)) for _, value := range values { taken = append(taken, fmt.Sprint(value)) @@ -256,12 +259,14 @@ func writeLocalPending(cmd *cobra.Command, pending *localapp.Pending) error { } 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"} - if len(pending.Confirmation) > 0 { - data["confirmation"] = pending.Confirmation - } - if len(pending.Snapshot) > 0 { - data["snapshot"] = pending.Snapshot + 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. diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index a56654f0f..cb531a48e 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -385,7 +385,7 @@ func TestLocalARefusedArgumentIsAUsageErrorNamingWhatItTakes(t *testing.T) { 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": []int{51, 52}, + "error": why, "arg": "sender", "of": "sender", "given": 99, "available": []int64{51, 1731280000123}, }) _, _, err := runLocal(t, door.Path, "screener", "deny", "99", "--json") @@ -393,7 +393,7 @@ func TestLocalARefusedArgumentIsAUsageErrorNamingWhatItTakes(t *testing.T) { 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, 52" { + 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") { @@ -407,14 +407,18 @@ func TestLocalARefusedArgumentIsAUsageErrorNamingWhatItTakes(t *testing.T) { } } -// A list the app could not load names no argument: nothing the caller sent was wrong. +// 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{}}) - _, _, err := runLocal(t, door.Path, "imbox", "--json") - if output.ExitCodeFor(err) != output.ExitAPI { - t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) + for _, args := range [][]string{{"imbox"}, {"screener", "deny", "42"}} { + _, _, 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) + } } } @@ -431,6 +435,9 @@ func TestLocalAPendingSendIsPendingInEveryFormat(t *testing.T) { 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) } diff --git a/internal/localapp/client_test.go b/internal/localapp/client_test.go index 4c347d28f..ef74cb884 100644 --- a/internal/localapp/client_test.go +++ b/internal/localapp/client_test.go @@ -240,7 +240,9 @@ func TestTheAppsOwnMessagesBeforeAnAnswerArePassedOver(t *testing.T) { if err != nil { return } - defer conn.Close() + // 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")) From 523912907e749ef5c9f03a06c423d4cf1031718b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 10 Nov 2026 23:48:07 -0800 Subject: [PATCH 05/15] hey local: an empty list of cases is the app's too, and a failed initialized names the close Round 3 of the review, two Lows: - A refused choice whose live cases are empty hinted " takes one of: " and exited 1. Empty cases now read as empty ids do: the app holds nothing the command takes, exit 7. No shipped command has a choice that can empty; the test covers it with a made-up refusal. - notifications/initialized's write error came back unmapped, so an app closing right after initialize gave a bare broken pipe. It now goes through the same mapping as every other exchange. --- internal/cmd/local.go | 14 ++++++++++++-- internal/cmd/local_test.go | 3 ++- internal/localapp/client.go | 5 ++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/cmd/local.go b/internal/cmd/local.go index 4e53302f3..360b7da83 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -179,9 +179,8 @@ func localRefusal(name string, result *localapp.Result) error { refusal.Message += terminal.SanitizeLine(" (" + detail + ")") } arg, _ := reason["arg"].(string) - available, listsAvailable := reason["available"].([]any) switch { - case arg != "" && listsAvailable && len(available) == 0: + 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" @@ -200,6 +199,17 @@ func localRefusal(name string, result *localapp.Result) error { return refusal } +// 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 { diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index cb531a48e..618bb3c95 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -413,8 +413,9 @@ 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"}} { + 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) diff --git a/internal/localapp/client.go b/internal/localapp/client.go index 66b21ac64..ec5888291 100644 --- a/internal/localapp/client.go +++ b/internal/localapp/client.go @@ -230,7 +230,10 @@ func (c *Client) initialize(ctx context.Context) error { return &ProtocolError{Reason: "initialize answered with no protocolVersion"} } c.Server = answer.ServerInfo - return c.notify("notifications/initialized") + if err := c.notify("notifications/initialized"); err != nil { + return c.exchangeError(ctx, "initialize", err) + } + return nil } func (c *Client) notify(method string) error { From 828511cf3c70bc93629915488ce0a66354ad2f2c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 00:08:22 -0800 Subject: [PATCH 06/15] hey local: answer the bots' review of #640 Copilot, cubic and Greptile reviewed the head. Fixed here: - initialize sends clientInfo.version, which MCP requires. - Each call is bounded by its own context. Only Dial's was applied, so a call made with a shorter deadline could wait on an app that holds the socket open; a call now times out on its own context even after a dial with none. A dial whose context ended is a timeout, not a connection failure. - A tools/list with no tools, or a tools/call with no content, is no answer rather than an empty list or a done call. A pending answer is still pending without content. - hey local send reported an answer that was not pending as "HEY ran message.send", exit 0. A send the app took is pending; anything else exits 7. - An undo hint drops the --socket it ran with, so pasting it could reach another app. The breadcrumb and line now carry it, quoted. - hey local screener list: --ids-only and --count failed (no list), and "Nobody waits" showed while waiting_count said otherwise. Both lists feed the ids and count, waiting first, and senders waiting beyond what the app lists are named in every format. - hey local imbox: has_more reached only the envelope. --ids-only, --count, --quiet and --markdown say so on stderr, and the styled empty Imbox says so rather than "Nothing in the Imbox". - --arg given twice under one name is a usage error, not the last one. - hey local tools --styled lists each command's arguments. - A decision the app says it took otherwise is "not what was asked", pointing at the app, not at an old hey. - XDG_RUNTIME_DIR relative is said as "not set to an absolute path". - Wording: exit 9 names hey local call too; docs/cli.md says Linux only and spells out the screener subcommands; docs/agents.md says hey local send takes the app's own draft id and where to learn one. - Tests: hey local's exemptions from account scope, credential migration and the local-config gate are held; the signed-out hint is asserted exactly; the fake door's comment names its real methods, its Offer declares arguments, and it reads lines up to the client's cap. Verified: each fix's test run red against the previous head's code, then green (12 tests). The per-call timeout test first passed on the old client for the wrong reason, the dial helper's own deadline; it now dials with none and was red ("the call outlived its own context"). Not changed, with reasons in the threads: the fake door's one-call writes (net.Conn.Write writes all or errors), the styled pretty-JSON branch of hey local call (there is no shared writer for arbitrary JSON to call), and hey local's place in root help. --- docs/agents.md | 5 +- docs/cli.md | 5 +- internal/cmd/accounts_test.go | 19 ++++ internal/cmd/help_topics.go | 3 +- internal/cmd/local.go | 23 ++++- internal/cmd/local_call.go | 5 + internal/cmd/local_imbox.go | 16 +-- internal/cmd/local_screener.go | 16 ++- internal/cmd/local_screener_list.go | 70 +++++++++---- internal/cmd/local_send.go | 5 +- internal/cmd/local_test.go | 133 +++++++++++++++++++++++-- internal/cmd/local_tools.go | 28 +++++- internal/localapp/client.go | 59 +++++++---- internal/localapp/client_test.go | 77 ++++++++++++++ internal/localapp/localapptest/door.go | 44 ++++++-- 15 files changed, 432 insertions(+), 76 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 35e56449d..0c812ad1c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -192,7 +192,10 @@ Each command the app offers names its effect, and the effect decides what happen 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. Its +`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 already saved in the app, not the id `hey draft +list` shows, and no command lists the app's drafts yet: a send refused for its draft names +the ids the app would take, in `meta.refusal.available`. Its JSON envelope carries `meta.status: "pending"`, the confirmation's id and what you were shown. No command or flag here can confirm it. diff --git a/docs/cli.md b/docs/cli.md index 79f975418..f770a72e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -366,8 +366,9 @@ Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. M `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|approve|deny`, `hey local send`, and `hey local call` for any -command `hey local tools` lists. It reaches the app alone, never HEY's servers, and needs +`hey local screener list`, `hey local screener approve`, `hey local screener deny`, +`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. 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). 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 0186ba17a..757f9ba54 100644 --- a/internal/cmd/help_topics.go +++ b/internal/cmd/help_topics.go @@ -47,7 +47,8 @@ EXIT CODES 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). Nothing has happened yet. + 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.`, }, diff --git a/internal/cmd/local.go b/internal/cmd/local.go index 360b7da83..223aad831 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -75,7 +75,7 @@ func withLocalApp(cmd *cobra.Command, talk func(context.Context, *localapp.Clien path, err := localapp.SocketPath(localSocketFlag) if err != nil { return apierr.ErrUsageHint( - "XDG_RUNTIME_DIR is not set, so there is no HEY socket to look for", + "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 ", ) } @@ -313,6 +313,12 @@ func decodeLocalAnswer(name string, result *localapp.Result) (any, error) { 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. @@ -326,11 +332,26 @@ func decodeLocalAnswerAs[T any](name string, result *localapp.Result, complete f 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, diff --git a/internal/cmd/local_call.go b/internal/cmd/local_call.go index e0d754fdb..8f941ca19 100644 --- a/internal/cmd/local_call.go +++ b/internal/cmd/local_call.go @@ -67,11 +67,16 @@ func localArguments(object []string, pairs []string) (map[string]any, error) { `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 diff --git a/internal/cmd/local_imbox.go b/internal/cmd/local_imbox.go index c668a19d5..8ff8002f0 100644 --- a/internal/cmd/local_imbox.go +++ b/internal/cmd/local_imbox.go @@ -68,6 +68,10 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { 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 } @@ -87,16 +91,12 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { } 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), localThreadNoun(len(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), ) } - -func localThreadNoun(count int) string { - if count == 1 { - return "thread" - } - return "threads" -} diff --git a/internal/cmd/local_screener.go b/internal/cmd/local_screener.go index e65876a60..ad98f4238 100644 --- a/internal/cmd/local_screener.go +++ b/internal/cmd/local_screener.go @@ -49,8 +49,11 @@ func decideLocally(cmd *cobra.Command, value, decision string) error { } // 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 fmt.Errorf("it answered sender %d screened %q, not %d screened %q", decided.Sender, decided.Screened, sender, decision) + return &localOtherwiseError{fmt.Sprintf("it says sender %d was screened %s, not %d screened %s", decided.Sender, decided.Screened, sender, decision)} } return nil }) @@ -63,7 +66,7 @@ func decideLocally(cmd *cobra.Command, value, decision string) error { if decided.Screened == "in" { opposite, undo, verb = "deny", "Screen them out again", "screened in" } - reverse := fmt.Sprintf("hey local screener %s %d", opposite, sender) + 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), @@ -71,3 +74,12 @@ func decideLocally(cmd *cobra.Command, value, decision string) error { 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_list.go b/internal/cmd/local_screener_list.go index 349ce5d44..21ee69261 100644 --- a/internal/cmd/local_screener_list.go +++ b/internal/cmd/local_screener_list.go @@ -57,31 +57,57 @@ func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) error { return err } + notice := "" + if unlisted := screener.WaitingCount - len(screener.Waiting); unlisted > 0 { + notice = fmt.Sprintf("%d more %s waiting than the app lists.", unlisted, senderNoun(unlisted)) + } + if writer.IsStyled() { - if len(screener.Waiting) == 0 && len(screener.Decided) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "Nobody waits in The Screener, and nobody was decided") + 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", screener.WaitingCount, senderNoun(screener.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 } - 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 != nil && *sender.ScreenedIn { - status = "screened in" - } - table.addRow([]string{fmt.Sprintf("%d", sender.ID), truncate(sender.Name, 24), truncate(sender.Address, 32), status}) - } - table.print() + fmt.Fprintln(cmd.OutOrStdout(), "Nobody waits in The Screener, and nobody was decided") return nil } - return writeOK(screener, - output.WithSummary(fmt.Sprintf("%d %s waiting", screener.WaitingCount, senderNoun(screener.WaitingCount))), - 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"}, - ), - ) + 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 != nil && *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 index cf7b00208..3b56df33c 100644 --- a/internal/cmd/local_send.go +++ b/internal/cmd/local_send.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" ) type localSendCommand struct { @@ -47,9 +48,11 @@ func (c *localSendCommand) run(cmd *cobra.Command, args []string) error { 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. result, err := callLocal(cmd, "message.send", map[string]any{"draft": draft}) if err != nil { return err } - return writeLocalAnswer(cmd, "message.send", result) + return localUnreadable("message.send", fmt.Errorf("it is not pending: %s", terminal.SanitizeLine(string(result.Answer())))) } diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index 618bb3c95..587a8a2e3 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -70,7 +70,7 @@ func imboxDoor(t *testing.T) *localapptest.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") + 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") @@ -86,7 +86,7 @@ func TestLocalToolsListsWhatTheAppOffersAndWhatEachDoes(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(styled, "message.send") || !strings.Contains(styled, "waits for you in HEY") { + if !strings.Contains(styled, "message.send") || !strings.Contains(styled, "waits for you in HEY") || !strings.Contains(styled, "decision, sender") { t.Errorf("styled = %s", styled) } } @@ -310,13 +310,15 @@ func TestLocalWithNoSocketSaysToTurnTheDoorOn(t *testing.T) { } func TestLocalWithNoRuntimeFolderSaysWhereToLook(t *testing.T) { - t.Setenv("XDG_RUNTIME_DIR", "") + for _, runtime := range []string{"", "run/user/1000"} { + t.Setenv("XDG_RUNTIME_DIR", runtime) - _, _, err := runLocal(t, "", "imbox") + _, _, err := runLocal(t, "", "imbox") - failure := apierr.AsError(err) - if !strings.Contains(failure.Message, "XDG_RUNTIME_DIR") || !strings.Contains(failure.Hint, "--socket") { - t.Errorf("err = %q, hint = %q", failure.Message, failure.Hint) + 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) + } } } @@ -531,7 +533,122 @@ func TestLocalSignedOutOfTheAppIsAnAuthFailureThatNamesTheApp(t *testing.T) { if output.ExitCodeFor(err) != output.ExitAuth { t.Errorf("exit = %d (%v)", output.ExitCodeFor(err), err) } - if hint := apierr.AsError(err).Hint; strings.Contains(hint, "hey auth login") { + 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) } } + +// 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) + } +} + +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; strings.Contains(hint, "upgrade") { + t.Errorf("hint = %q", hint) + } +} diff --git a/internal/cmd/local_tools.go b/internal/cmd/local_tools.go index 48ce1b8a8..a71f32196 100644 --- a/internal/cmd/local_tools.go +++ b/internal/cmd/local_tools.go @@ -2,7 +2,10 @@ package cmd import ( "context" + "encoding/json" "fmt" + "sort" + "strings" "github.com/spf13/cobra" @@ -72,13 +75,13 @@ func (c *localToolsCommand) run(cmd *cobra.Command, _ []string) error { return nil } table := newTable(cmd.OutOrStdout()) - table.addRow([]string{"Command", "Effect", "Description"}) + 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, truncate(tool.Description, 60)}) + table.addRow([]string{tool.Name, effect, localArgumentNames(tool.InputSchema), truncate(tool.Description, 60)}) } table.print() return nil @@ -89,6 +92,27 @@ func (c *localToolsCommand) run(cmd *cobra.Command, _ []string) error { ) } +// 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" diff --git a/internal/localapp/client.go b/internal/localapp/client.go index ec5888291..8f191e7d6 100644 --- a/internal/localapp/client.go +++ b/internal/localapp/client.go @@ -20,6 +20,8 @@ import ( "strconv" "syscall" "time" + + "github.com/basecamp/hey-cli/internal/version" ) // ProtocolVersion is the MCP revision the client asks for. initialize, tools/list and @@ -31,17 +33,16 @@ const ProtocolVersion = "2025-06-18" // reaches_people. const EffectMetaKey = "com.hey/effect" -// maxLine bounds one message from the app. An Imbox listing is a few hundred kilobytes +// 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 +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 - stopped func() bool + conn net.Conn + lines *bufio.Scanner + lastID int64 // Server is what the app said about itself in its initialize answer. Server ServerInfo @@ -87,8 +88,8 @@ type Pending struct { Snapshot json.RawMessage `json:"snapshot,omitempty"` } -// Dial connects to the door at path and initializes the session. ctx bounds the dial and, -// through its deadline, every exchange on the connection after it. +// 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. @@ -101,10 +102,13 @@ func Dial(ctx context.Context, path string) (*Client, error) { 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(ctx, conn) + client := newClient(conn) if err := client.initialize(ctx); err != nil { _ = client.Close() return nil, err @@ -128,6 +132,10 @@ func (c *Client) Tools(ctx context.Context) ([]Tool, error) { 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 @@ -147,11 +155,15 @@ func (c *Client) Call(ctx context.Context, name string, arguments map[string]any 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 { - c.stopped() return c.conn.Close() } @@ -201,23 +213,17 @@ func (r *Result) Pending() (*Pending, bool) { return &Pending{Confirmation: fields["confirmation"], Snapshot: fields["snapshot"]}, true } -func newClient(ctx context.Context, conn net.Conn) *Client { - if deadline, ok := ctx.Deadline(); ok { - _ = conn.SetDeadline(deadline) - } +func newClient(conn net.Conn) *Client { lines := bufio.NewScanner(conn) - lines.Buffer(make([]byte, 0, 64<<10), maxLine) - // A cancelled context ends whatever read or write is waiting, rather than leaving the - // caller on a connection the app holds open. - stopped := context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Unix(1, 0)) }) - return &Client{conn: conn, lines: lines, stopped: stopped} + 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"}, + "clientInfo": map[string]any{"name": "hey-cli", "title": "hey local", "version": version.Version}, } var answer struct { ProtocolVersion string `json:"protocolVersion"` @@ -243,6 +249,7 @@ func (c *Client) notify(method string) error { // 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 { @@ -283,6 +290,16 @@ func (c *Client) request(ctx context.Context, method string, params any, into an } } +// 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"` @@ -310,7 +327,7 @@ func (c *Client) receive(ctx context.Context, method string) (message, error) { 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{}, &ProtocolError{Method: method, Reason: fmt.Sprintf("the answer is longer than %d bytes", MaxLine)} } return message{}, c.exchangeError(ctx, method, err) } diff --git a/internal/localapp/client_test.go b/internal/localapp/client_test.go index ef74cb884..3ad88f5cd 100644 --- a/internal/localapp/client_test.go +++ b/internal/localapp/client_test.go @@ -42,11 +42,19 @@ func TestDialInitializesBeforeAnythingElse(t *testing.T) { } 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) } @@ -309,3 +317,72 @@ func TestNoRuntimeFolderIsNoGuess(t *testing.T) { } } } + +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/localapptest/door.go b/internal/localapp/localapptest/door.go index 2834f4be4..d01b48d1f 100644 --- a/internal/localapp/localapptest/door.go +++ b/internal/localapp/localapptest/door.go @@ -12,11 +12,14 @@ import ( "path/filepath" "sync" "testing" + + "github.com/basecamp/hey-cli/internal/localapp" ) -// Door answers initialize, tools/list and tools/call. A test fills Tools and Results, and -// may make a method answer with a JSON-RPC error (Errors) or with a line of its own -// (Raw), to see what a client makes of an app that misbehaves. +// 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 @@ -25,6 +28,7 @@ type Door struct { results map[string]map[string]any errors map[string]RPCError raw map[string]string + hang map[string]bool requests []Request } @@ -48,6 +52,7 @@ func Start(t testing.TB) *Door { results: map[string]map[string]any{}, errors: map[string]RPCError{}, raw: map[string]string{}, + hang: map[string]bool{}, } go door.serve(listener) return door @@ -78,18 +83,34 @@ func Listen(t testing.TB) (string, *net.UnixListener) { return path, unix } -// Offer lists a tool, with the effect the door names in its _meta. -func (d *Door) Offer(name, description, effect string) { +// 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", "additionalProperties": false}, + "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) @@ -114,6 +135,13 @@ func (d *Door) Reply(method, line string) { 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() @@ -159,6 +187,7 @@ func (d *Door) serve(listener net.Listener) { 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"` @@ -173,9 +202,10 @@ func (d *Door) converse(conn net.Conn) { 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 { + if len(request.ID) == 0 || hangs { continue } switch { From e27c34f566193b0987165b1cd579914a6a4d75a1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 00:17:56 -0800 Subject: [PATCH 07/15] hey local send points at the app when it answers otherwise A send the app answered without pending was reported as an answer this hey could not read, with the hint to run hey upgrade, and a plain-text answer was dropped, leaving "it is not pending: " with nothing after it. An answer that is not pending may still have done something in the app. Now it is the app's answer, with the hint to check in the HEY app what it did, and a text answer is quoted when there is no structured one. TestLocalASendAnsweredOtherwisePointsAtTheApp failed on 828511c on both the hint and the dropped text, and passes now. --- internal/cmd/local_send.go | 9 +++++++-- internal/cmd/local_test.go | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/cmd/local_send.go b/internal/cmd/local_send.go index 3b56df33c..7c7d2ff40 100644 --- a/internal/cmd/local_send.go +++ b/internal/cmd/local_send.go @@ -49,10 +49,15 @@ func (c *localSendCommand) run(cmd *cobra.Command, args []string) error { 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 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 } - return localUnreadable("message.send", fmt.Errorf("it is not pending: %s", terminal.SanitizeLine(string(result.Answer())))) + 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 index 587a8a2e3..560f205e4 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -630,6 +630,24 @@ func TestLocalASendNotAnsweredPendingIsAnAPIFailure(t *testing.T) { } } +// 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 || strings.Contains(failure.Hint, "upgrade") { + 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) From a67ec14fa8b03f7fedbc41293687a3e188a8e42e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 00:22:04 -0800 Subject: [PATCH 08/15] hey local's answered-otherwise tests assert the hint they name The send and screener-decision tests checked only that the hint did not say upgrade, so an empty or unrelated hint would have passed them. Both now require "Check in the HEY app what it did". With that hint text changed in local.go, both failed; restored, both pass. --- internal/cmd/local_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index 560f205e4..5317ed2d5 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -640,7 +640,7 @@ func TestLocalASendAnsweredOtherwisePointsAtTheApp(t *testing.T) { _, _, err := runLocal(t, door.Path, "send", "1731280000123") failure := apierr.AsError(err) - if output.ExitCodeFor(err) != output.ExitAPI || strings.Contains(failure.Hint, "upgrade") { + 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") { @@ -666,7 +666,7 @@ func TestLocalADecisionAnsweredOtherwiseDoesNotBlameTheCLIsVersion(t *testing.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; strings.Contains(hint, "upgrade") { + if hint := apierr.AsError(err).Hint; hint != "Check in the HEY app what it did" { t.Errorf("hint = %q", hint) } } From a111a6050835a35e8b313cbea903331cce878d8d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 00:33:16 -0800 Subject: [PATCH 09/15] hey local's listings refuse an answer missing what its zero would claim A decided sender without screened_in was listed as screened out, a null or id-less sender as sender 0, which the decision commands then reject, an Imbox answer without has_more as complete ("Nothing in the Imbox", meta has_more false), and a screener answer without waiting_count as nobody waiting. Each read an app's missing field as a fact it never gave. Now each field whose zero value would say something is required, as the door contract has the app always give it: has_more, waiting_count, every row's id, and each decided sender's screened_in. Missing, the listing is an answer that could not be read, exit 7, and prints nothing. TestLocalListingsRefuseAnswersMissingWhatTheirZeroWouldClaim's seven cases each failed on a67ec14 and pass now. Not done: keeping a pending marker when the rest of a tools/call reply fails to decode. A reply with a broken content array is a broken reply; exit 7 never says anything was sent, and asking again while the draft waits answers the same confirmation. --- internal/cmd/local_imbox.go | 15 ++++++++-- internal/cmd/local_screener_list.go | 23 ++++++++++++--- internal/cmd/local_test.go | 44 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/internal/cmd/local_imbox.go b/internal/cmd/local_imbox.go index 8ff8002f0..571f87464 100644 --- a/internal/cmd/local_imbox.go +++ b/internal/cmd/local_imbox.go @@ -27,7 +27,7 @@ type localThread struct { type localImbox struct { Threads []localThread `json:"threads"` - HasMore bool `json:"has_more"` + HasMore *bool `json:"has_more"` } func newLocalImboxCommand() *localImboxCommand { @@ -56,13 +56,22 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) 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 { + if *imbox.HasMore { notice = "The Imbox holds more threads than the app has loaded." } @@ -97,6 +106,6 @@ func (c *localImboxCommand) run(cmd *cobra.Command, _ []string) error { 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), + output.WithMeta("has_more", *imbox.HasMore), ) } diff --git a/internal/cmd/local_screener_list.go b/internal/cmd/local_screener_list.go index 21ee69261..aa7c0cacc 100644 --- a/internal/cmd/local_screener_list.go +++ b/internal/cmd/local_screener_list.go @@ -22,7 +22,7 @@ type localSender struct { type localScreener struct { Waiting []localSender `json:"waiting"` - WaitingCount int `json:"waiting_count"` + WaitingCount *int `json:"waiting_count"` Decided []localSender `json:"decided"` } @@ -51,6 +51,20 @@ func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) 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 { @@ -58,7 +72,8 @@ func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) error { } notice := "" - if unlisted := screener.WaitingCount - len(screener.Waiting); unlisted > 0 { + 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)) } @@ -74,7 +89,7 @@ func (c *localScreenerListCommand) run(cmd *cobra.Command, _ []string) error { return writeOK(append(append([]localSender{}, screener.Waiting...), screener.Decided...)) default: return writeOK(screener, - output.WithSummary(fmt.Sprintf("%d %s waiting", screener.WaitingCount, senderNoun(screener.WaitingCount))), + 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"}, @@ -100,7 +115,7 @@ func (c *localScreenerListCommand) writeStyled(cmd *cobra.Command, screener loca } for _, sender := range screener.Decided { status := "screened out" - if sender.ScreenedIn != nil && *sender.ScreenedIn { + if *sender.ScreenedIn { status = "screened in" } table.addRow([]string{fmt.Sprintf("%d", sender.ID), truncate(sender.Name, 24), truncate(sender.Address, 32), status}) diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index 5317ed2d5..f5bdf501f 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -670,3 +670,47 @@ func TestLocalADecisionAnsweredOtherwiseDoesNotBlameTheCLIsVersion(t *testing.T) 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"}}, + {"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) + } + }) + } +} From 8875d2189f3e9e87022e1857ecbbe84df2fac90d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 04:04:56 -0800 Subject: [PATCH 10/15] hey local says a call cut off by HEY's session ending may have been done The door now answers session_ended when a sign-out or account switch cuts off a call while it runs, and what the call asked may already be done. hey local read it as any refusal: "HEY refused screener.decide: session_ended", which says nothing happened, with no hint. Now it says HEY's session ended while it ran the command and that it may have been done, with the hint to check in HEY before running it again. It still exits 7 with the reason in meta.refusal; docs/agents.md says so. TestLocalACallCutOffBySessionEndSaysItMayHaveHappened, through screener deny and call, failed on a111a60 and passes now. --- docs/agents.md | 3 +++ internal/cmd/local.go | 6 ++++++ internal/cmd/local_test.go | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/docs/agents.md b/docs/agents.md index 0c812ad1c..324014d45 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -204,6 +204,9 @@ JSON object, as `--arg name=value` pairs, or both, the pairs over the object; a 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, diff --git a/internal/cmd/local.go b/internal/cmd/local.go index 223aad831..1d72c07cd 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -175,6 +175,12 @@ func localRefusal(name string, result *localapp.Result) error { 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" + } if detail, _ := reason["detail"].(string); detail != "" { refusal.Message += terminal.SanitizeLine(" (" + detail + ")") } diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index f5bdf501f..d0abe1836 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -538,6 +538,23 @@ func TestLocalSignedOutOfTheAppIsAnAuthFailureThatNamesTheApp(t *testing.T) { } } +// 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) From 599899b3a59c235f4f7fd5515a610c33ccdea5f9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 16:21:44 -0800 Subject: [PATCH 11/15] hey local writes a message or reply as a draft, lists drafts and sends one An agent could ask the HEY app to send a draft, but had no way to make one or learn its id: no command listed or wrote the app's drafts, so hey local send could not work end to end. The door now offers drafts.list, draft.write and draft.reply (hey-native-apps#11400). Now: - hey local drafts lists the app's drafts, the last saved first, with which HEY would send now (sendable) and which are scheduled. - hey local write --to --subject -m, and hey local reply -m, save a draft and send nothing. They answer the draft with the command that asks HEY to send it, or say HEY can't send it from here. - hey local send takes that id, or a draft's made_as, and still exits 9: it waits for the person to press Send in HEY. - The new refusals read plainly, with their exit codes: not_an_address names the pieces (1); not_saved says which limit, 1 where the caller's words or --to passed it and 7 where a reply's thread did; not_a_sender names the address the account lost (7); a reply's unreadable thread (7) and a send of a draft HEY won't send now (1, or 7 with none) too. - A draft missing its id or sendable is unreadable, as other listings. docs/agents.md carries the flow and a table of the refusals. Each new test failed first against the commands as they were, with a type-only stub so they compiled: drafts, write, reply, a reply answered on another thread, write then send waiting in HEY, write with no message, eight refusal cases and three draft-shape cases. Not done: --ids-only on write and reply (a mutation answers one object, as hey local screener's decisions do); editing or deleting a draft, which the door does not offer. --- .surface | 7 + docs/agents.md | 36 ++++- docs/cli.md | 4 +- internal/cmd/local.go | 68 +++++++++- internal/cmd/local_drafts.go | 180 +++++++++++++++++++++++++ internal/cmd/local_drafts_test.go | 212 ++++++++++++++++++++++++++++++ internal/cmd/local_reply.go | 56 ++++++++ internal/cmd/local_send.go | 2 +- internal/cmd/local_test.go | 3 + internal/cmd/local_write.go | 50 +++++++ 10 files changed, 605 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/local_drafts.go create mode 100644 internal/cmd/local_drafts_test.go create mode 100644 internal/cmd/local_reply.go create mode 100644 internal/cmd/local_write.go diff --git a/.surface b/.surface index 5afd21777..274fb64bf 100644 --- a/.surface +++ b/.surface @@ -285,13 +285,20 @@ 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 324014d45..2b397aa44 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -177,7 +177,10 @@ hey local tools # what the app offers now, and what each 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 send 1731280000123 # ask to send a saved draft: you confirm it in HEY +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 ``` @@ -185,20 +188,39 @@ Each command the app offers names its effect, and the effect decides what happen | Effect | What happens | Commands | |--------|--------------|----------| -| `read` | Runs, and changes nothing | `imbox.list`, `screener.list` | -| `reversible` | Runs at once, and can be undone | `screener.decide` | +| `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 already saved in the app, not the id `hey draft -list` shows, and no command lists the app's drafts yet: a send refused for its draft names -the ids the app would take, in `meta.refusal.available`. Its -JSON envelope carries `meta.status: "pending"`, the confirmation's id and what you were +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. 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 goes to everyone the thread's last message went +to, as HEY's Reply does, beside any draft you have on the thread, never over 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 where the words or `--to` were yours to change; 7 where a reply's recipients or files 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 | `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 | + `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. diff --git a/docs/cli.md b/docs/cli.md index f770a72e5..596fd4924 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -367,9 +367,9 @@ Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. M `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 send`, and `hey local call` for any command `hey local tools` lists. The app +`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. A send waits in the HEY window for you to press Send and exits 9, pending. +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 diff --git a/internal/cmd/local.go b/internal/cmd/local.go index 1d72c07cd..b08ff9cc9 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -55,7 +55,7 @@ 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. 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.", + "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.", }, } @@ -65,6 +65,9 @@ Linux; on other systems there is nothing for hey local to talk to yet.`, 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 } @@ -104,7 +107,7 @@ func callLocal(cmd *cobra.Command, name string, arguments map[string]any) (*loca return nil, err } if result.IsError { - return nil, localRefusal(name, result) + return nil, localRefusal(name, arguments, result) } if pending, ok := result.Pending(); ok { return nil, writeLocalPending(cmd, pending) @@ -161,7 +164,7 @@ func localFailure(err error) error { // 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, result *localapp.Result) error { +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) @@ -186,6 +189,29 @@ func localRefusal(name string, result *localapp.Result) error { } 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 --to 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" + case why == "unavailable" && arg == "" && name == "draft.reply": + refusal.Message = terminal.SanitizeLine(fmt.Sprintf("HEY couldn't read thread %v", arguments["thread"])) + refusal.Hint = "hey local imbox lists the threads a reply takes, in its Thread column" + 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. @@ -205,6 +231,42 @@ func localRefusal(name string, result *localapp.Result) error { return refusal } +// localNotSaved says why HEY saved no draft: past what one message takes. The words and +// recipients of draft.write, and the words of a reply, are the caller's to change (1); a +// reply's recipients and files are the thread's, for the person to deal with 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": + 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 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 { diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go new file mode 100644 index 000000000..aee27e2c7 --- /dev/null +++ b/internal/cmd/local_drafts.go @@ -0,0 +1,180 @@ +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 err := draft.complete(); err != nil { + return err + } + } + 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 { + draft, err := decodeLocalAnswerAs(name, result, func(draft localDraft) error { + if err := draft.complete(); err != nil { + return err + } + 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 { + return writeMutationLine(cmd, + saved+" HEY can't send it from here: finish it in HEY.", + fmt.Sprintf("Draft %d saved; HEY would not send it from here now", draft.ID), + 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), + 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..2224a5274 --- /dev/null +++ b/internal/cmd/local_drafts_test.go @@ -0,0 +1,212 @@ +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 --to 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.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", "hey local imbox lists the threads a reply takes, in its Thread column"}, + {"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"]) + } + }) + } +} 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_send.go b/internal/cmd/local_send.go index 7c7d2ff40..137f05bb0 100644 --- a/internal/cmd/local_send.go +++ b/internal/cmd/local_send.go @@ -33,7 +33,7 @@ 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.", + "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`, diff --git a/internal/cmd/local_test.go b/internal/cmd/local_test.go index d0abe1836..76d816b6a 100644 --- a/internal/cmd/local_test.go +++ b/internal/cmd/local_test.go @@ -714,6 +714,9 @@ func TestLocalListingsRefuseAnswersMissingWhatTheirZeroWouldClaim(t *testing.T) {"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 { diff --git a/internal/cmd/local_write.go b/internal/cmd/local_write.go new file mode 100644 index 000000000..93df08427 --- /dev/null +++ b/internal/cmd/local_write.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +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") + 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 { + 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) +} From 3e90dad7a791dcef42a4a1a66a4840556575c545 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 16:22:12 -0800 Subject: [PATCH 12/15] hey local drafts names its incomplete-draft error apart from the call's govet's shadow check flagged the loop's err shadowing the call's. No behaviour changes; make check on thelio found it. --- internal/cmd/local_drafts.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go index aee27e2c7..b86cba72f 100644 --- a/internal/cmd/local_drafts.go +++ b/internal/cmd/local_drafts.go @@ -81,8 +81,8 @@ func (c *localDraftsCommand) run(cmd *cobra.Command, _ []string) error { return errors.New("it lists no drafts") } for _, draft := range drafts.Drafts { - if err := draft.complete(); err != nil { - return err + if incomplete := draft.complete(); incomplete != nil { + return incomplete } } return nil From 887c69f80bee24ad990e88707a8beec0362fc89c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 16:28:36 -0800 Subject: [PATCH 13/15] hey local never fails after saving a draft, and words "gone" as maybe saved A fresh-context review of the drafts commands found: - --ids-only or --count on write, reply or a call of draft.write saved the draft, then failed with exit 1, which reads as nothing happened; a retry wrote a second draft. (Medium) - The app answers unavailable for a draft it saved that went before HEY listed it, not only for a thread it couldn't read; both read as nothing having happened. - A reply too large exited 1 with "Shorten the message", though its quote, not the caller's words, may be what passed the limit. - docs/agents.md gave disabled as exit 1 only; it is 7 with none sendable. - write without --to silently wrote a draft to nobody. - not_an_address's hint named --to through hey local call too. Now --ids-only prints a saved draft's id and --count 1, through write, reply and call alike; unavailable from write or reply says the draft may have gone and to check hey local drafts before writing again; a reply past a limit is 7, its hint naming the quote; --to is required (--to "" still writes a draft to nobody); the docs match. TestLocalADraftSavedGivesItsIDToIDsOnly, TestLocalWriteAsksForTheToLine and four refusal cases failed on 3e90dad and pass now; two more cases (a written draft gone, no draft sendable) cover paths that had no test. --- docs/agents.md | 9 ++--- internal/cmd/local.go | 43 +++++++++++++++++++----- internal/cmd/local_drafts.go | 4 +-- internal/cmd/local_drafts_test.go | 55 +++++++++++++++++++++++++++++-- internal/cmd/local_write.go | 6 ++++ 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 2b397aa44..04efb9181 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -203,7 +203,8 @@ 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. Writing sends nothing, 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 goes to everyone the thread's last message went @@ -216,10 +217,10 @@ 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 where the words or `--to` were yours to change; 7 where a reply's recipients or files come from the thread | +| `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 | `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 | +| `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 diff --git a/internal/cmd/local.go b/internal/cmd/local.go index b08ff9cc9..4428599f9 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -193,16 +193,22 @@ func localRefusal(name string, arguments map[string]any, result *localapp.Result // 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 --to as addresses, or Name
, separated by commas" + 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", arguments["thread"])) - refusal.Hint = "hey local imbox lists the threads a reply takes, in its Thread column" + 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"])) @@ -231,9 +237,28 @@ func localRefusal(name string, arguments map[string]any, result *localapp.Result return refusal } -// localNotSaved says why HEY saved no draft: past what one message takes. The words and -// recipients of draft.write, and the words of a reply, are the caller's to change (1); a -// reply's recipients and files are the thread's, for the person to deal with in HEY (7). +// 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{ @@ -246,12 +271,14 @@ func localNotSaved(refusal *apierr.Error, name string, reason map[string]any) { } refusal.Message = terminal.SanitizeLine("HEY didn't save the draft: " + past) switch { - case why == "too_large": + 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" } @@ -308,7 +335,7 @@ func writeLocalAnswer(cmd *cobra.Command, name string, result *localapp.Result) fmt.Fprintln(cmd.OutOrStdout(), terminal.Sanitize(string(indented))) return nil } - return writeOK(answer, output.WithSummary("HEY ran "+name)) + return writeOK(localOneOf(answer), output.WithSummary("HEY ran "+name)) } // writeLocalPending says a command waits in HEY's window, and returns ErrPending so the diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go index b86cba72f..e2e5bc560 100644 --- a/internal/cmd/local_drafts.go +++ b/internal/cmd/local_drafts.go @@ -152,14 +152,14 @@ func writeLocalDraft(cmd *cobra.Command, name string, result *localapp.Result, t return writeMutationLine(cmd, saved+" HEY can't send it from here: finish it in HEY.", fmt.Sprintf("Draft %d saved; HEY would not send it from here now", draft.ID), - draft, + 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), - draft, + localOneOf(draft), output.WithBreadcrumbs(output.Breadcrumb{Action: "send", Command: send, Description: "Ask HEY to send it: the person presses Send in the HEY window"}), ) } diff --git a/internal/cmd/local_drafts_test.go b/internal/cmd/local_drafts_test.go index 2224a5274..bcdfc6def 100644 --- a/internal/cmd/local_drafts_test.go +++ b/internal/cmd/local_drafts_test.go @@ -171,12 +171,15 @@ func TestLocalDraftRefusalsSayWhatHappened(t *testing.T) { }{ {"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 --to as addresses, or Name
, separated by commas"}, + "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.ExitUsage, + []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, @@ -189,7 +192,13 @@ func TestLocalDraftRefusalsSayWhatHappened(t *testing.T) { "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", "hey local imbox lists the threads a reply takes, in its Thread column"}, + "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"}, @@ -210,3 +219,43 @@ func TestLocalDraftRefusalsSayWhatHappened(t *testing.T) { }) } } + +// 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) + } +} diff --git a/internal/cmd/local_write.go b/internal/cmd/local_write.go index 93df08427..3687cc258 100644 --- a/internal/cmd/local_write.go +++ b/internal/cmd/local_write.go @@ -2,6 +2,8 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" ) type localWriteCommand struct { @@ -38,6 +40,10 @@ one you don't want in HEY.`, } 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 From 530498f95205b4f20f1486d22cad3fd6ebdc4fc6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 16:32:20 -0800 Subject: [PATCH 14/15] hey local says on stderr when --ids-only saved a draft HEY won't send The second review round of the drafts fixes found no High or Medium; of its Lows, two are cheap. Under --ids-only or --count, a draft HEY would not send from here printed its id alone, so an agent asked to send it and met a refusal; and write's --help did not say --to is required. Now the summary goes to stderr in those modes, as a waiting send's does, and --to's usage says it is required. TestLocalADraftHEYWontSendSaysSo- UnderIDsOnly failed on 887c69f and passes now. Left: more --count cases on the paths --ids-only already covers. --- internal/cmd/local_drafts.go | 7 ++++++- internal/cmd/local_drafts_test.go | 12 ++++++++++++ internal/cmd/local_write.go | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go index e2e5bc560..1d220ecf4 100644 --- a/internal/cmd/local_drafts.go +++ b/internal/cmd/local_drafts.go @@ -149,9 +149,14 @@ func writeLocalDraft(cmd *cobra.Command, name string, result *localapp.Result, t } 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.", - fmt.Sprintf("Draft %d saved; HEY would not send it from here now", draft.ID), + summary, localOneOf(draft), ) } diff --git a/internal/cmd/local_drafts_test.go b/internal/cmd/local_drafts_test.go index bcdfc6def..c6f1a1163 100644 --- a/internal/cmd/local_drafts_test.go +++ b/internal/cmd/local_drafts_test.go @@ -259,3 +259,15 @@ func TestLocalWriteAsksForTheToLine(t *testing.T) { 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) + } +} diff --git a/internal/cmd/local_write.go b/internal/cmd/local_write.go index 3687cc258..5a2e4cd55 100644 --- a/internal/cmd/local_write.go +++ b/internal/cmd/local_write.go @@ -33,7 +33,7 @@ one you don't want in HEY.`, RunE: writeCommand.run, Args: cobra.NoArgs, } - writeCommand.cmd.Flags().StringVar(&writeCommand.to, "to", "", "Recipients, as HEY's To line reads them") + 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 From fcf918b2dfddd23e32db882c4cdf1ae23f0a482d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 11 Nov 2026 17:05:06 -0800 Subject: [PATCH 15/15] hey local checks a saved draft's kind and keeps the app's refusal detail cubic's review of 3e90dad found: a reply the app saved as a new draft, or a message saved as a reply, read as done; the plain messages of the new refusals dropped any detail the app gave; and the docs described the reply's recipients by a rule of their own. Now a draft saved as another kind than asked is the app's answer to explain (exit 7, check in HEY), as one on another thread already was; the app's detail follows every refusal's message; and the docs say a reply is addressed as HEY's own Reply addresses one, with hey local drafts showing whom it goes to. TestLocalADraftOfAnotherKindIsTheAppsToExplain (its reply case given the asked thread, so only the kind differs) and TestLocalARefusalKeepsTheAppsDetail failed on 530498f and pass now. --- docs/agents.md | 5 +++-- internal/cmd/local.go | 7 ++++--- internal/cmd/local_drafts.go | 7 +++++++ internal/cmd/local_drafts_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 04efb9181..7a1300a72 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -207,8 +207,9 @@ thread from the Thread column of `hey local imbox`) saves a draft and answers it 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 goes to everyone the thread's last message went -to, as HEY's Reply does, beside any draft you have on the thread, never over it. +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. diff --git a/internal/cmd/local.go b/internal/cmd/local.go index 4428599f9..e43c45b3d 100644 --- a/internal/cmd/local.go +++ b/internal/cmd/local.go @@ -184,9 +184,6 @@ func localRefusal(name string, arguments map[string]any, result *localapp.Result 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" } - if detail, _ := reason["detail"].(string); detail != "" { - refusal.Message += terminal.SanitizeLine(" (" + detail + ")") - } arg, _ := reason["arg"].(string) switch { case why == "not_an_address": @@ -234,6 +231,10 @@ func localRefusal(name string, arguments map[string]any, result *localapp.Result 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 } diff --git a/internal/cmd/local_drafts.go b/internal/cmd/local_drafts.go index 1d220ecf4..a4eb9c4a3 100644 --- a/internal/cmd/local_drafts.go +++ b/internal/cmd/local_drafts.go @@ -130,10 +130,17 @@ func localDraftNoun(count int) string { // 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)} } diff --git a/internal/cmd/local_drafts_test.go b/internal/cmd/local_drafts_test.go index c6f1a1163..33b929555 100644 --- a/internal/cmd/local_drafts_test.go +++ b/internal/cmd/local_drafts_test.go @@ -271,3 +271,31 @@ func TestLocalADraftHEYWontSendSaysSoUnderIDsOnly(t *testing.T) { 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) + } +}