hey-axi 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # hey-axi
2
2
 
3
+ [![skills.sh](https://skills.sh/b/TangentialSolutions/hey-axi)](https://skills.sh/TangentialSolutions/hey-axi) [![npm](https://img.shields.io/npm/v/hey-axi.svg)](https://www.npmjs.com/package/hey-axi) [![test](https://github.com/TangentialSolutions/hey-axi/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/TangentialSolutions/hey-axi/actions/workflows/test.yml)
4
+
3
5
  An agent-friendly wrapper for [HEY CLI](https://github.com/basecamp/hey-cli) (`hey`), Basecamp's command line for HEY email, calendars, todos, habits, time tracking and journals.
4
6
 
5
- hey-axi runs `hey`, asks for its JSON response envelope, and prints it as [TOON](https://github.com/toon-format/toon). It follows the [AXI](https://axi.md) principles: a live home view, minimal default fields, truncated long text, definitive empty states, and unknown flags rejected up front. On our synthetic benchmark that's **~89% fewer tokens than `hey --json`** ([docs/benchmarks.md](docs/benchmarks.md)). It also adds a few safety rails. It covers **every command in HEY CLI v1.7.0** (154 runnable command paths, including the `login`/`logout` aliases, plus the `box <id>`-style shortcuts) by routing from a snapshot of HEY's own `hey commands --json` catalog. See [CHANGELOG.md](CHANGELOG.md) for what changed in each release.
7
+ hey-axi runs `hey`, asks for its JSON response envelope, and prints it as [TOON](https://github.com/toon-format/toon). It follows the [AXI](https://axi.md) principles: a live home view, minimal default fields, truncated long text, definitive empty states, and unknown flags rejected up front. On our synthetic benchmark that's **~89% fewer tokens than `hey --json`** ([docs/benchmarks.md](docs/benchmarks.md)). It also adds a few safety rails. It covers **every command in HEY CLI v1.7.0 plus the commands on HEY's unreleased `main` branch as of 2026-10-06** (commit [`9dfe00f`](https://github.com/basecamp/hey-cli/commit/9dfe00fb6594a4da5272b8ed0692f494229f14d2)): 155 runnable command paths, including the `login`/`logout` aliases, plus the `box <id>`-style shortcuts. It routes from a snapshot of HEY's own `hey commands --json` catalog. The main-only additions (`contact deliver`, `event delete --occurrence/--apply-to`) need a HEY built from main, or the next HEY release; with HEY v1.7.0 they fail with `kind: hey_outdated`. See [CHANGELOG.md](CHANGELOG.md) for what changed in each release.
6
8
 
7
9
  ## Install
8
10
 
@@ -70,9 +72,13 @@ hey-axi move --help # usage, flags, examples, notes (no H
70
72
  - `compose` and `reply` are **saved as drafts**. hey-axi adds `--draft`, and the output starts with `sent: false`, `saved_as: draft` and an `axi_notice` (also printed to stderr).
71
73
  - `forward`, `draft send` and `bulk-reply send` have no draft mode in HEY, so they're refused (exit 2) with the safe alternative (`reply … --to`, `draft show`, `bulk-reply preview`).
72
74
  - If you pass `--draft` or `--dry-run` yourself, hey-axi adds and marks nothing.
75
+ - With `--allow-send`, a send HEY confirms is marked `sent: true` (and names the delivered message's `id`/`topic_id` when HEY reports them, as HEY main does). If HEY holds it for Undo Send (`delayed: true`), a `held` line says it hasn't gone out yet and the thread won't show it until it does.
76
+ - If HEY refuses to send and keeps the message as a draft (HEY main's `not_delivered`, usually the sending limit), the result is an error with `kind: not_delivered`, `sent: false` and the draft id, and says not to repeat the send. HEY v1.7.0 reports such a send as sent, so it can't be detected there.
77
+ - Recipients HEY would silently drop (no domain or top-level domain, like `bob` or `bob@example`) are refused (exit 2, `sent: false`) before HEY runs, for `compose`, `reply`, `forward` and `draft edit`. HEY main's own "not a valid email address" refusal is reported the same way.
73
78
  - **Credentials stay out of agent context.** `auth token` needs `--allow-secret` or `HEY_AXI_ALLOW_SECRETS=1`.
79
+ - **Destructive commands need an opt-in.** `screener clear` (HEY main) moves everything waiting in the Screener to Trash, for every sender, and HEY asks for no confirmation. hey-axi refuses it (exit 2, HEY isn't run, nothing changes) unless you pass `--allow-destructive` or set `HEY_AXI_ALLOW_DESTRUCTIVE=1`. The refusal says what it would do and suggests `screener list` and per-sender `screener deny` first.
74
80
  - **Content-first.** `compose`, `reply`, `draft edit`, `journal write`, `contact note set` and `bulk-reply send` must be given their content (`--message`, `--message -` for stdin, or a positional) and are refused (exit 2) otherwise, so HEY never opens an editor.
75
- - hey-axi's own flags (`--allow-send`, `--allow-secret`, `--interactive`, `--fields`, `--full`) are never forwarded to HEY. Switches take no value: `--draft=false` or `--allow-send=false` is refused (exit 2) rather than guessed at.
81
+ - hey-axi's own flags (`--allow-send`, `--allow-secret`, `--allow-destructive`, `--interactive`, `--fields`, `--full`) are never forwarded to HEY. Switches take no value: `--draft=false` or `--allow-send=false` is refused (exit 2) rather than guessed at.
76
82
 
77
83
  ### Errors and exit codes
78
84
 
@@ -81,8 +87,8 @@ Errors are printed as structured TOON on stdout: `ok: false`, `error`, a `kind`,
81
87
  | Exit | Meaning |
82
88
  |---|---|
83
89
  | 0 | success (including no-op mutations and empty results) |
84
- | 1 | the command failed; `kind` says why: `not_found`, `auth`, `forbidden`, `rate_limited`, `network`, `api_error`, `ambiguous`, `hey_missing`, `command_error` |
85
- | 2 | usage error (`kind: usage`): unknown command/flag/field, bad flag value, missing or extra arguments, missing required flag or content, a switch given a value (`--draft=false`), blocked send, a person-only command without `--interactive`, or a usage error reported by HEY |
90
+ | 1 | the command failed; `kind` says why: `not_found`, `auth`, `forbidden`, `rate_limited`, `network`, `api_error`, `ambiguous`, `hey_missing`, `hey_outdated` (the installed HEY is older than the command needs), `not_delivered` (HEY kept a send as a draft), `command_error` |
91
+ | 2 | usage error (`kind: usage`): unknown command/flag/field, bad flag value, missing or extra arguments, missing required flag or content, a switch given a value (`--draft=false`), blocked send, a destructive command without `--allow-destructive`, a person-only command without `--interactive`, or a usage error reported by HEY |
86
92
 
87
93
  ## Agent integrations: session hook or skill
88
94
 
@@ -118,13 +124,16 @@ See [docs/listing.md](docs/listing.md) for how hey-axi gets listed on skills.sh
118
124
 
119
125
  ## Keeping up with HEY releases
120
126
 
121
- `src/manifest.json` is a snapshot of `hey commands --json`. To regenerate it from whichever `hey` you have installed:
127
+ `src/manifest.json` is a snapshot of `hey commands --json` plus usage parsed from `hey <command> --help`. The current snapshot was built from HEY's unreleased `main` branch (commit `9dfe00f`, 2026-10-06), so its `hey_version` reads `1.7.0+main.9dfe00f`. To regenerate it:
122
128
 
123
129
  ```bash
124
- npm run refresh-manifest # runs only `hey version` and `hey commands`
130
+ npm run refresh-manifest # from whichever `hey` you have installed (runs only `hey version`, `hey commands`, `hey <command> --help`)
131
+ npm run manifest:main # clone basecamp/hey-cli main, build it (needs Go and git), and rewrite the manifest from it
132
+ npm run check-drift # same build, but only compare: exits 1 with a readable diff when commands or flags differ
133
+ node scripts/check-drift.js --ref v1.8.0 --write # a tagged release instead of main
125
134
  ```
126
135
 
127
- hey-axi routes only from the snapshot, so an unknown command is rejected immediately (exit 2, with suggestions) without running HEY. New HEY commands become available after a refresh and a hey-axi release. A weekly GitHub Action (`manifest-drift`) fails when the snapshot falls behind the latest HEY release.
136
+ hey-axi routes only from the snapshot, so an unknown command is rejected immediately (exit 2, with suggestions) without running HEY. New HEY commands become available after a refresh and a hey-axi release. The `upstream drift` GitHub Action builds HEY from basecamp/hey-cli `main` daily (and on changes to the manifest or scripts) and fails when its commands or flags differ from the snapshot; help-text-only changes are listed in the job summary without failing. It also warns when HEY publishes a release newer than the one the snapshot builds on.
128
137
 
129
138
  ## Development
130
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hey-axi",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Agent-ergonomic AXI wrapper for Basecamp's HEY CLI: TOON output, send-to-draft safety, and a shareable agent skill",
5
5
  "keywords": [
6
6
  "axi",
@@ -41,6 +41,8 @@
41
41
  "scripts": {
42
42
  "test": "node --test test/*.test.js",
43
43
  "refresh-manifest": "node scripts/refresh-manifest.js",
44
+ "check-drift": "node scripts/check-drift.js",
45
+ "manifest:main": "node scripts/check-drift.js --write",
44
46
  "bench:tokens": "node bench/tokens.mjs",
45
47
  "bench:capture": "node bench/capture.mjs",
46
48
  "bench:fixtures": "node bench/generate-api-fixtures.mjs",
@@ -49,7 +51,7 @@
49
51
  },
50
52
  "dependencies": {
51
53
  "@toon-format/toon": "^4.1.1",
52
- "axi-sdk-js": "^0.1.12"
54
+ "axi-sdk-js": "^0.1.13"
53
55
  },
54
56
  "engines": {
55
57
  "node": ">=20"
@@ -2,7 +2,7 @@
2
2
  name: hey-axi
3
3
  description: Read, triage and draft HEY email (and HEY calendars, todos, habits, journal, time tracking) from the shell through hey-axi, a token-efficient TOON wrapper around Basecamp's HEY CLI that saves outgoing mail as drafts unless sending is explicitly allowed. Use when the user asks you to check, search, read, summarize, sort, label, screen, archive or reply to their HEY (hey.com) email, watch for new mail, or manage HEY todos, events, habits, journal entries or time tracks.
4
4
  license: MIT
5
- compatibility: Requires Node.js 20+ (commands run as `npx -y hey-axi`), the HEY CLI (`hey`, v1.7.0 or newer) signed in to a HEY account, and network access to app.hey.com.
5
+ compatibility: Requires Node.js 20+ (commands run as `npx -y hey-axi`), the HEY CLI (`hey`, v1.7.0 or newer; `contact deliver` and `event delete --occurrence` need a HEY built from basecamp/hey-cli main until HEY's next release) signed in to a HEY account, and network access to app.hey.com.
6
6
  metadata:
7
7
  author: TangentialSolutions
8
8
  repository: https://github.com/TangentialSolutions/hey-axi
@@ -62,7 +62,11 @@ npx -y hey-axi screener approve <clearance-id>
62
62
  npx -y hey-axi trash <id>... npx -y hey-axi spam <id>... npx -y hey-axi ignore <id>...
63
63
  ```
64
64
 
65
- `trash`, `spam`, `ignore`, `screener deny --spam` and `screener clear` (which clears the whole queue) run without any confirmation. Confirm with the user before you run them in bulk.
65
+ `trash`, `spam`, `ignore` and `screener deny --spam` run without any confirmation. Confirm with the user before you run them in bulk.
66
+
67
+ `screener clear` moves **everything** waiting in the Screener to Trash, for every sender, so hey-axi refuses it (exit 2, nothing changes) unless `--allow-destructive` is passed. Prefer `screener deny <clearance-id>` per sender. Only add `--allow-destructive` after the user has explicitly confirmed clearing the whole Screener.
68
+
69
+ `npx -y hey-axi contact deliver <contact-id> --to imbox|feed|papertrail|screened-out` chooses where a contact's future mail arrives (a contact id from `contact list`). `npx -y hey-axi event delete <series-id> --occurrence <occurrence_id> --apply-to current|future` deletes one day (or that day onward) of a repeating event; the occurrence_id comes from `event day`/`event week`.
66
70
 
67
71
  Mutations are idempotent: when the desired state already holds (already seen, already deleted), the answer is `noop: true` with exit 0, not an error.
68
72
 
@@ -80,6 +84,9 @@ Output includes HEY's breadcrumb hints as `help` lines (with your `--account` ca
80
84
  - The message is required (`--message`, `--message-html`, `--attach`, or `--message -` to read stdin): HEY never opens an editor under hey-axi. The same goes for `journal write --content`, `contact note set --note` and `draft edit` (pass the fields to change).
81
85
  - `forward`, `draft send` and `bulk-reply send` are **refused** (exit 2) because HEY has no draft mode for them. The error names the safe alternative.
82
86
  - To actually send, the user must explicitly ask you to, and you add `--allow-send` (or they set `HEY_AXI_ALLOW_SEND=1`). Don't add it on your own initiative.
87
+ - A real send answers `sent: true`; newer HEY versions also name the message (`id`) and its thread (`topic_id`). `held:` means Undo Send is holding it back: it is sent, but `thread read` won't show it (or a thread it starts) until HEY releases it.
88
+ - `kind: not_delivered` (`sent: false`) means HEY refused the send (usually the account's sending limit) and kept it as draft `draft_id`. **Don't repeat the send**, which only makes another draft; tell the user, and send the draft later with `draft send <draft_id> --allow-send` when they ask.
89
+ - A recipient without a full address (`bob`, `bob@localhost`) is refused before HEY runs (exit 2, `sent: false`): fix or remove it.
83
90
 
84
91
  ## 4. Output: fields, truncation, counts, modes
85
92
 
@@ -110,7 +117,7 @@ Lines also include `ready`, `disconnected` and `resync`. On `resync`, re-read th
110
117
 
111
118
  ## 6. Errors and exit codes
112
119
 
113
- Errors are TOON on stdout: `ok: false`, `error`, `kind` (`usage`, `not_found`, `auth`, `forbidden`, `rate_limited`, `network`, `api_error`, `ambiguous`, `hey_missing`, `command_error`), HEY's `code`/`hint`/`meta` when it gave them, and a `help` line with the next step. Exit codes: **0** success (including no-ops) · **1** error · **2** usage error or refusal (unknown command/flag/field, bad argument, blocked send, needs the user). Read `hint`/`help` before you retry.
120
+ Errors are TOON on stdout: `ok: false`, `error`, `kind` (`usage`, `not_found`, `auth`, `forbidden`, `rate_limited`, `network`, `api_error`, `ambiguous`, `not_delivered`, `hey_missing`, `hey_outdated`, `command_error`), HEY's `code`/`hint`/`meta` when it gave them, and a `help` line with the next step. Exit codes: **0** success (including no-ops) · **1** error · **2** usage error or refusal (unknown command/flag/field, bad argument, blocked send, `screener clear` without `--allow-destructive`, needs the user). Read `hint`/`help` before you retry. `hey_outdated` means the installed `hey` is older than hey-axi's command catalog (`npx -y hey-axi version` shows it).
114
121
 
115
122
  ## Install
116
123
 
@@ -1,8 +1,8 @@
1
1
  # hey-axi command reference
2
2
 
3
- <!-- Generated by scripts/gen-skill.js from src/manifest.json (hey 1.7.0). Do not edit by hand. -->
3
+ <!-- Generated by scripts/gen-skill.js from src/manifest.json (hey 1.7.0+main.9dfe00f). Do not edit by hand. -->
4
4
 
5
- Every command from HEY CLI 1.7.0 (154 runnable paths, including the `login`/`logout` aliases and shortcut forms such as `box <name>`). Run each as `npx -y hey-axi <command> [args] [flags]` (just `hey-axi …` when it is installed globally).
5
+ Every command from HEY CLI v1.7.0 plus the commands on HEY main as of 2026-10-06 (commit 9dfe00f, unreleased): 155 runnable paths, including the `login`/`logout` aliases and shortcut forms such as `box <name>`. Run each as `npx -y hey-axi <command> [args] [flags]` (just `hey-axi …` when it is installed globally).
6
6
  `npx -y hey-axi <command> --help` shows usage, arguments, every flag with its default, 2-3 examples and notes, offline.
7
7
  Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--fields <a,b|all>`, `--full`, `--ids-only`, `--count`, `--markdown`, `--html`, `--styled`, `--jq <expr>`. Unknown commands, flags and arguments, flags without a value and values of the wrong type are rejected before HEY runs (exit 2).
8
8
 
@@ -32,7 +32,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
32
32
  | `calendar list` | List calendars | | fields: id, name, kind, owned |
33
33
  | `clip create` | Save text from an email entry | `--content <v>` | |
34
34
  | `clip delete` | Delete a saved clip | | |
35
- | `clip list` | List the newest page of passages clipped from email | | fields: id, content, topic_id, at |
35
+ | `clip list` | List the newest page of passages clipped from email | | fields: id, topic_id, at |
36
36
  | `collection` | List and manage email collections | `--all` `--limit <v>` `--page <v>` | fields: id, topic_id, from, subject |
37
37
  | `collection add` | Add email threads to a collection | `--to <v>` | |
38
38
  | `collection create` | Create a collection | `--summary <v>` | |
@@ -49,6 +49,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
49
49
  | `config untrust-local` | Remove trust for this repository's local HEY settings | | |
50
50
  | `contact add` | Add a contact | `--account-user-id <v>` `--alias <v>` `--email <v>` `--name <v>` | |
51
51
  | `contact bundle` | Bundle a contact's mail | | |
52
+ | `contact deliver` | Choose where a contact's email arrives | `--to <v>` | |
52
53
  | `contact hide` | Hide a contact | | |
53
54
  | `contact list` | List contacts | `--all` `--page <v>` | fields: id, name, email_address |
54
55
  | `contact note delete` | Delete a private contact note | | |
@@ -67,7 +68,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
67
68
  | `draft show` | Read a draft back | | |
68
69
  | `event add` | Create an event | `--all-day` `--calendar <v>` `--circle` `--countdown <v>` `--countdown-unit <v>` `--end-time <v>` `--ends-on <v>` `--invite <v>` `--link <v>` `--location <v>` `--notes <v>` `--remind <v>` `--repeat <v>` `--repeat-times <v>` `--repeat-until <v>` `--start-time <v>` `--starts-on <v>` `--time-zone <v>` `--title <v>` | |
69
70
  | `event day` | List the events of one day, as HEY draws it | `--all` `--limit <v>` | fields: id, title, starts_at, ends_at |
70
- | `event delete` | Delete an event | | |
71
+ | `event delete` | Delete an event | `--apply-to <v>` `--occurrence <v>` | |
71
72
  | `event edit` | Change an event | `--all-day` `--allow-plain-notes` `--apply-to <v>` `--calendar <v>` `--circle` `--countdown <v>` `--countdown-unit <v>` `--end-time <v>` `--ends-on <v>` `--invite <v>` `--link <v>` `--location <v>` `--notes <v>` `--occurrence <v>` `--remind <v>` `--repeat <v>` `--repeat-times <v>` `--repeat-until <v>` `--start-time <v>` `--starts-on <v>` `--time-zone <v>` `--title <v>` | |
72
73
  | `event list` | List calendar events | `--all` `--calendar <v>` `--ends-on <v>` `--limit <v>` `--starts-on <v>` | fields: id, title, starts_at, ends_at |
73
74
  | `event week` | List the events of one week, as HEY draws it | `--all` `--limit <v>` | fields: id, title, starts_at, ends_at |
@@ -79,7 +80,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
79
80
  | `habit list` | List habits | `--all` `--date <v>` `--limit <v>` | fields: id, title, days |
80
81
  | `habit uncomplete` | Remove a habit completion for a date | `--date <v>` | |
81
82
  | `ignore` | Ignore email threads | | |
82
- | `journal list` | List journal entries | `--all` `--calendar <v>` `--ends-on <v>` `--limit <v>` `--starts-on <v>` | fields: id, starts_at, content |
83
+ | `journal list` | List journal entries | `--all` `--calendar <v>` `--ends-on <v>` `--limit <v>` `--starts-on <v>` | fields: id, date |
83
84
  | `journal read` | Read a journal entry (default: today) | | |
84
85
  | `journal write` | Write or edit a journal entry (default: today) | `--content <v>` `--content-html <v>` | |
85
86
  | `label` | List and manage email labels | `--all` `--limit <v>` `--page <v>` | fields: id, topic_id, from, subject |
@@ -94,7 +95,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
94
95
  | `move` | Move email threads to another box | `--to <v>` | |
95
96
  | `reply` | Reply to a thread | `--attach <v>` `--bcc <v>` `--cc <v>` `--draft` `--dry-run` `--message <v>` `--message-html <v>` `--replace-recipients` `--to <v>` | saved as a draft unless `--allow-send` |
96
97
  | `screener approve` | Let a sender through | `--box <v>` `--seen` | |
97
- | `screener clear` | Empty the Screener without deciding | | |
98
+ | `screener clear` | Trash everything waiting in the Screener | | refused unless `--allow-destructive` |
98
99
  | `screener deny` | Turn a sender away | `--spam` | |
99
100
  | `screener history` | List the senders already screened | `--all` `--page <v>` | fields: id, status, email_address, decided |
100
101
  | `screener list` | List the senders waiting to be screened | `--all` `--page <v>` | fields: id, email_address, subject, topic_id |
@@ -122,7 +123,7 @@ Flags every command accepts: `--account <id|all>`, `--json`, `--quiet`, `--field
122
123
  | `skill install` | Install the hey skill globally for your coding agents | | |
123
124
  | `snippet create` | Create a reusable email snippet | `--content <v>` `--content-html <v>` `--name <v>` | |
124
125
  | `snippet delete` | Delete a reusable email snippet | | |
125
- | `snippet list` | List reusable email snippets | | fields: id, name, content |
126
+ | `snippet list` | List reusable email snippets | | fields: id, name |
126
127
  | `snippet update` | Update a reusable email snippet | `--content <v>` `--content-html <v>` `--name <v>` | |
127
128
  | `spam` | Mark email threads as spam | | |
128
129
  | `stop-ignoring` | Stop ignoring email threads | | |
package/src/args.js CHANGED
@@ -8,7 +8,7 @@ export const GLOBAL_BOOLEAN_FLAGS = ["json", "quiet", "ids-only", "count", "mark
8
8
  export const GLOBAL_SHORTHANDS = ["-v", "-h"];
9
9
 
10
10
  // hey-axi's own flags. Never forwarded to HEY.
11
- export const AXI_BOOLEAN_FLAGS = ["allow-send", "allow-secret", "full", "interactive"];
11
+ export const AXI_BOOLEAN_FLAGS = ["allow-send", "allow-secret", "allow-destructive", "full", "interactive"];
12
12
  export const AXI_VALUE_FLAGS = ["fields"];
13
13
 
14
14
  // Output selectors that make HEY print something other than a JSON envelope. When any is
@@ -149,6 +149,23 @@ function typeError(name, type, value) {
149
149
  return null;
150
150
  }
151
151
 
152
+ // Rules HEY enforces itself but its catalog doesn't mark (no "(required)" in the flag's
153
+ // description, no list of values), so hey-axi can fail loud before HEY runs:
154
+ // required: flags the command can't run without
155
+ // choices: the only values a flag takes
156
+ // together: flags that are only meaningful with each other
157
+ export const FLAG_RULES = {
158
+ "contact deliver": { required: ["--to"], choices: { "--to": ["imbox", "feed", "papertrail", "screened-out"] } },
159
+ "event delete": { choices: { "--apply-to": ["current", "future"] }, together: [["--occurrence", "--apply-to"]] },
160
+ "event edit": { choices: { "--apply-to": ["current", "future"] }, together: [["--occurrence", "--apply-to"]] },
161
+ };
162
+
163
+ // Every required flag of a command: marked "(required)" by HEY, or listed in FLAG_RULES.
164
+ export function requiredFlags(node) {
165
+ const marked = (node.flags || []).filter((flag) => /\(required\)$/.test(flag.desc || "")).map((flag) => `--${flag.name}`);
166
+ return [...new Set([...marked, ...(FLAG_RULES[node.path]?.required || [])])];
167
+ }
168
+
152
169
  // AXI: reject unknown flags, flags without their value, values of the wrong type and
153
170
  // missing required flags before HEY is ever called.
154
171
  // Returns null, or { unknown } / { missingValue } / { badValue } / { missing }.
@@ -174,9 +191,21 @@ export function validateFlags(node, flags, { values = [], missingValues = [] } =
174
191
  const problem = typeError(name, types.get(name), value);
175
192
  if (problem) return { badValue: problem };
176
193
  }
177
- const missing = (node.flags || [])
178
- .filter((flag) => /\(required\)$/.test(flag.desc || ""))
179
- .filter((flag) => !flags.has(`--${flag.name}`) && !(flag.shorthand && flags.has(`-${flag.shorthand}`)))
180
- .map((flag) => `--${flag.name}`);
181
- return missing.length ? { missing } : null;
194
+ const rules = FLAG_RULES[node.path] || {};
195
+ for (const [name, value] of values) {
196
+ const choices = rules.choices?.[name];
197
+ if (choices && !choices.includes(value)) return { badValue: `${name} takes one of ${choices.join(", ")}, got "${value}"` };
198
+ }
199
+ const shorthandOf = (name) => (node.flags || []).find((flag) => `--${flag.name}` === name)?.shorthand;
200
+ const given = (name) => flags.has(name) || (shorthandOf(name) && flags.has(`-${shorthandOf(name)}`));
201
+ const missing = requiredFlags(node).filter((name) => !given(name));
202
+ if (missing.length) return { missing };
203
+ for (const group of rules.together || []) {
204
+ const present = group.filter(given);
205
+ if (present.length && present.length < group.length) {
206
+ const absent = group.filter((name) => !given(name));
207
+ return { badValue: `${present.join(" and ")} needs ${absent.join(" and ")} for \`${node.path}\` (pass ${group.join(" and ")} together)` };
208
+ }
209
+ }
210
+ return null;
182
211
  }
package/src/cli.js CHANGED
@@ -9,10 +9,11 @@ import { spawn } from "node:child_process";
9
9
  import { readFileSync } from "node:fs";
10
10
  import { createInterface } from "node:readline";
11
11
  import { encode } from "@toon-format/toon";
12
- import { closestNames, loadBundledManifest, resolveCommand } from "./router.js";
13
- import { STDIN_VALUE_FLAGS, checkPolicy, contentProblem, noopFor, runMode, sendCommands, sendStaging, userOnlyPaths } from "./policy.js";
12
+ import { closestNames, coverageLabel, loadBundledManifest, resolveCommand } from "./router.js";
13
+ import { STDIN_VALUE_FLAGS, checkPolicy, contentProblem, destructiveCommands, noopFor, runMode, sendCommands, sendStaging, userOnlyPaths } from "./policy.js";
14
14
  import { translateFailure, cleanLine, warningLines } from "./errors.js";
15
- import { RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, nodeValueFlags, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js";
15
+ import { FLAG_RULES, RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, nodeValueFlags, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js";
16
+ import { markSent, recipientProblem } from "./send.js";
16
17
  import { argumentHelp, checkArity, oneOfHelp } from "./arity.js";
17
18
  import { FieldError, LIST_FIELDS, DETAIL_FIELDS, carrySelectors, withSelectors, shapeEnvelope } from "./shape.js";
18
19
  import { homeView } from "./home.js";
@@ -29,7 +30,7 @@ const HEY = process.env.HEY_BIN || "hey";
29
30
  const QUIET_ENV = { HEY_NONINTERACTIVE: "1", EDITOR: "false", VISUAL: "false" };
30
31
  const heyEnv = () => ({ ...process.env, ...QUIET_ENV });
31
32
 
32
- const ALWAYS_ALLOWED = "--help, --json, --quiet, --fields, --full, --account, --ids-only, --count, --markdown, --html, --styled, --jq, --stats, --verbose, --base-url, --allow-send, --allow-secret, --interactive";
33
+ const ALWAYS_ALLOWED = "--help, --json, --quiet, --fields, --full, --account, --ids-only, --count, --markdown, --html, --styled, --jq, --stats, --verbose, --base-url, --allow-send, --allow-secret, --allow-destructive, --interactive";
33
34
 
34
35
  const GLOBAL_HELP = [
35
36
  "--account <id|all> linked account (default: HEY's default account)",
@@ -44,6 +45,7 @@ const GLOBAL_HELP = [
44
45
  const OWN_FLAG_HELP = [
45
46
  [(path) => sendCommands().includes(path), "--allow-send really send (default false: saved as a draft, or refused when there is no draft mode)"],
46
47
  [(path) => path === "auth token", "--allow-secret print the token (default false: refused)"],
48
+ [(path) => destructiveCommands().includes(path), "--allow-destructive really run it (default false: refused, nothing is changed)"],
47
49
  [(path) => userOnlyPaths().includes(path), "--interactive hand HEY the terminal; for a person at a terminal (default false: refused)"],
48
50
  ];
49
51
 
@@ -53,7 +55,7 @@ function usage(manifest) {
53
55
  "",
54
56
  "Run `hey-axi` with no arguments for a live home view (mail for this directory's scope + next commands).",
55
57
  "",
56
- `commands (from hey ${manifest.hey_version}; run \`hey-axi <command> --help\` for details):`,
58
+ `commands (from HEY CLI ${coverageLabel(manifest)}; run \`hey-axi <command> --help\` for details):`,
57
59
  ];
58
60
  for (const node of manifest.commands) {
59
61
  const subs = node.subcommands?.length ? ` ${node.subcommands.map((child) => child.name).join("|")}` : "";
@@ -85,6 +87,8 @@ function usage(manifest) {
85
87
  " Nothing is sent without --allow-send (or HEY_AXI_ALLOW_SEND=1): compose and reply are saved",
86
88
  " as drafts (hey-axi adds --draft and says so); forward, draft send and bulk-reply send are refused.",
87
89
  " auth token needs --allow-secret / HEY_AXI_ALLOW_SECRETS=1.",
90
+ " screener clear (trashes everything waiting in the Screener) needs --allow-destructive /",
91
+ " HEY_AXI_ALLOW_DESTRUCTIVE=1; without it, it is refused and nothing is changed.",
88
92
  );
89
93
  return lines.join("\n");
90
94
  }
@@ -103,11 +107,16 @@ export function commandHelp(node) {
103
107
  for (const child of node.subcommands) lines.push(` ${child.path} — ${child.short}`);
104
108
  }
105
109
  if (node.flags?.length) {
110
+ const rules = FLAG_RULES[node.path] || {};
106
111
  lines.push("", "flags:");
107
112
  for (const flag of node.flags) {
108
- const extra = [flag.desc, `(${flagDefault(flag)})`].filter(Boolean).join(" ");
113
+ const name = `--${flag.name}`;
114
+ const required = rules.required?.includes(name) && !/\(required\)$/.test(flag.desc || "") ? "(required)" : "";
115
+ const choices = rules.choices?.[name] ? `one of: ${rules.choices[name].join(", ")}` : "";
116
+ const extra = [flag.desc, required, choices, `(${flagDefault(flag)})`].filter(Boolean).join(" ");
109
117
  lines.push(` ${flagLabel(flag)} ${extra}`);
110
118
  }
119
+ for (const group of rules.together || []) lines.push(` ${group.join(" and ")} go together: pass both or neither`);
111
120
  }
112
121
  lines.push("", "global flags (allowed on every command):", ...GLOBAL_HELP.map((line) => ` ${line}`));
113
122
  const own = OWN_FLAG_HELP.filter(([applies]) => applies(node.path)).map(([, line]) => ` ${line}`);
@@ -412,6 +421,10 @@ if (refusal) fail({ ok: false, ...refusal }, 2);
412
421
  const missingContent = contentProblem(node, flags, positionals);
413
422
  if (missingContent) fail({ ok: false, kind: "usage", ...missingContent }, 2);
414
423
 
424
+ // A recipient HEY would drop (no domain): refused here, so no HEY version can lose it.
425
+ const badRecipient = recipientProblem(path, scan.values);
426
+ if (badRecipient) fail({ ok: false, kind: "usage", ...badRecipient }, 2);
427
+
415
428
  let heyArgs = stripAxiFlags(args);
416
429
  // `--message -` (and --note -, --content -): read the content from stdin.
417
430
  const fromStdin = scan.values.filter(([name, value]) => STDIN_VALUE_FLAGS.includes(name) && value === "-").map(([name]) => name);
@@ -461,20 +474,29 @@ function markStaged(parsed) {
461
474
  return { ...marker, data: parsed };
462
475
  }
463
476
 
464
- function reportFailure(result) {
465
- const { failure, exitCode, warnings } = translateFailure(result, { path });
477
+ // A real delivery: a send command, not staged as a draft, not --draft or --dry-run.
478
+ const delivering = sendCommands().includes(path) && !staging && !flags.has("--draft") && !flags.has("--dry-run");
479
+
480
+ async function reportFailure(result) {
481
+ const { failure, exitCode, warnings } = translateFailure(result, { path, usage: usageLines, example: examplesFor(node)[0], coverage: coverageLabel(manifest) });
466
482
  // The fix-it commands keep the invocation's --account/--base-url.
467
483
  for (const key of ["hint", "help"]) {
468
484
  if (Array.isArray(failure[key])) failure[key] = failure[key].map((line) => carrySelectors(line, carry));
469
485
  else if (failure[key]) failure[key] = carrySelectors(failure[key], carry);
470
486
  }
471
487
  for (const line of warnings) process.stderr.write(`${line}\n`);
472
- const noop = exitCode !== 0 && noopFor(path, failure, positionals, { to: flagValue(args, "to"), from: flagValue(args, "from") });
488
+ const noop = exitCode !== 0 && noopFor(path, failure, positionals, { to: flagValue(args, "to"), from: flagValue(args, "from"), occurrence: flagValue(args, "occurrence") });
473
489
  if (noop) {
474
490
  if (json) process.stdout.write(`${JSON.stringify(noop)}\n`);
475
491
  else output(noop);
476
492
  process.exit(0);
477
493
  }
494
+ if (failure.kind === "not_delivered") {
495
+ // HEY kept the message as a draft: remember it as one, so the next home view says so.
496
+ const { recordActivity } = await import("./activity.js");
497
+ const { findScope } = await import("./scope.js");
498
+ recordActivity({ path, positionals: failure.draft_id ? [failure.draft_id] : [], staged: true, scopeDir: scopeDirFor(process.cwd(), findScope) });
499
+ }
478
500
  if (json) process.stdout.write(`${JSON.stringify(failure)}\n`);
479
501
  else output(failure);
480
502
  process.exit(exitCode);
@@ -484,7 +506,7 @@ const mode = runMode(path, flags);
484
506
  if (mode === "interactive") {
485
507
  // Only reached with the explicit --interactive opt-in (and a terminal, except for mcp).
486
508
  const result = await runHeyInteractive(heyArgs);
487
- if (result.error) reportFailure(result);
509
+ if (result.error) await reportFailure(result);
488
510
  process.exit(result.status === 0 ? 0 : 1);
489
511
  }
490
512
  if (raw || mode !== "json") {
@@ -493,7 +515,7 @@ if (raw || mode !== "json") {
493
515
  // A watch stopped by a signal ended the way the agent asked: not an error.
494
516
  if (mode === "stream" && (result.signal || result.status === 130 || result.status === 143)) process.exit(0);
495
517
  // HEY's stdout here is data, not an error message: it never goes into the failure.
496
- if (result.error || result.status !== 0) reportFailure({ ...result, stdout: "" });
518
+ if (result.error || result.status !== 0) await reportFailure({ ...result, stdout: "" });
497
519
  writeWarnings(result.stderr);
498
520
  if (mode !== "stream" && result.stdout?.length) process.stdout.write(result.stdout);
499
521
  process.exit(0);
@@ -522,7 +544,7 @@ if (path === "version") {
522
544
  }
523
545
 
524
546
  const result = await runHey(command, ["--json"]);
525
- if (result.status !== 0) reportFailure(result);
547
+ if (result.status !== 0) await reportFailure(result);
526
548
  writeWarnings(result.stderr);
527
549
 
528
550
  let parsed;
@@ -551,6 +573,8 @@ try {
551
573
  }, 2);
552
574
  }
553
575
  shaped = markStaged(shaped);
576
+ // A delivered message: say so, and when Undo Send holds it, that the thread won't show it yet.
577
+ if (delivering && !full && !plainText) shaped = markSent(shaped);
554
578
 
555
579
  // A list whose HEY result carried no next steps still gets one (AXI principle 9): the
556
580
  // matching view/show command when there is one, else how to see every field.
@@ -568,14 +592,14 @@ if (!full && !quiet && !plainText && shaped && typeof shaped === "object" && sha
568
592
  ? `Run \`${withSelectors(`hey-axi ${sibling.path} --help`, carry)}\` to see how to add one`
569
593
  : sibling
570
594
  ? `Run \`${withSelectors(`hey-axi ${sibling.path} <id>`, carry)}\` to see one in full`
571
- : shaped.empty ? `Run \`hey-axi ${path} --help\` to check the filters` : `Run \`${commandLine} --fields all\` to see every field`;
595
+ : shaped.empty ? `Run \`${withSelectors(`hey-axi ${path} --help`, carry)}\` to check the filters` : `Run \`${commandLine} --fields all\` to see every field`;
572
596
  shaped.help = [next, ...help];
573
597
  }
574
598
  }
575
599
 
576
600
  const { recordActivity } = await import("./activity.js");
577
601
  const { findScope } = await import("./scope.js");
578
- recordActivity({ path, positionals, staged: Boolean(staging), sent: sendCommands().includes(path) && !staging && !flags.has("--draft") && !flags.has("--dry-run"), scopeDir: scopeDirFor(process.cwd(), findScope) });
602
+ recordActivity({ path, positionals, staged: Boolean(staging), sent: delivering, scopeDir: scopeDirFor(process.cwd(), findScope) });
579
603
 
580
604
  if (json) process.stdout.write(`${JSON.stringify(shaped)}\n`);
581
605
  else output(shaped);
package/src/errors.js CHANGED
@@ -8,10 +8,12 @@
8
8
  // A `help` line always says what to do next.
9
9
 
10
10
  import { heyToAxi } from "./text.js";
11
+ import { HEY_BAD_ADDRESS, notDelivered, recipientRefusal } from "./send.js";
11
12
 
12
13
  // HEY's exit codes (`hey help exit-codes`) → a kind, a message and the next step.
13
14
  const KINDS = {
14
- 1: { kind: "command_error", message: "the command failed", help: "Run `hey-axi <command> --help` to check the arguments" },
15
+ // No fixed help: the fix for an unrecognized failure is built from the command (fixFor).
16
+ 1: { kind: "command_error", message: "the command failed" },
15
17
  2: { kind: "not_found", message: "not found", help: "Check the id; list commands such as `hey-axi box view imbox` show valid ids (threads use topic_id)" },
16
18
  3: { kind: "auth", message: "not signed in to HEY", help: "Run `hey-axi auth login --token <token>` if you have a token; otherwise ask the user to run `hey-axi auth login --interactive` in their terminal. Check with `hey-axi auth status`" },
17
19
  4: { kind: "forbidden", message: "this account can't do that", help: "Check `hey-axi account list` and --account" },
@@ -24,6 +26,22 @@ const KINDS = {
24
26
 
25
27
  const USAGE_CODES = new Set(["usage", "invalid_argument", "invalid_flag", "validation"]);
26
28
 
29
+ const DOCTOR = "Run `hey-axi doctor` to check HEY's sign-in, configuration and connection";
30
+
31
+ // The specific next step for a failure that matched no known kind, or a usage error: the
32
+ // command's own USAGE line and an example inline (no "see --help" round trip), then the
33
+ // one command that diagnoses HEY itself.
34
+ // context: { path, usage: ["usage: hey-axi thread read <thread-id>"], example }
35
+ function fixFor(context = {}, { usageError = false } = {}) {
36
+ const { path, usage = [], example } = context;
37
+ if (!path) return [DOCTOR];
38
+ const lines = usage.length ? [...usage] : [];
39
+ if (example) lines.push(`example: ${example}`);
40
+ if (!lines.length) lines.push(`Run \`hey-axi ${path} --help\` for its arguments and flags`);
41
+ if (!usageError) lines.push("Run `hey-axi doctor` if the arguments are right (it checks HEY's sign-in, configuration and connection)");
42
+ return lines;
43
+ }
44
+
27
45
  // AXI exit code for a failed HEY run.
28
46
  export function axiExitCode(status, code) {
29
47
  if (status === 0) return 0;
@@ -38,11 +56,25 @@ const NOISE = /^(warning:|panic:|fatal error:|\[signal |runtime error|\s+at |gor
38
56
  // Programming-language errors from inside HEY or its libraries say nothing an agent can
39
57
  // act on: they are dropped, and the failure's own message (by kind) stands alone.
40
58
  const INTERNAL = /^(Uncaught )?(TypeError|ReferenceError|SyntaxError|RangeError|InternalError|AssertionError|panic|fatal|runtime error|invalid memory address|nil pointer|unexpected end of JSON|json: |SIGSEGV)\b/i;
59
+ // Any other "<Something>Error:" / "<Something>Exception:" line, a Ruby/Python-style
60
+ // class path (Foo::Bar, foo.bar.BazError) or a traceback is a dependency's own error
61
+ // (SqliteError, PG::ConnectionBad, sqlalchemy.exc.OperationalError): dropped the same way.
62
+ const DEPENDENCY = /^(Uncaught\s+)?([\w$]+(\.[\w$]+|::[\w$]+)*(Error|Exception|Fault)\b|[A-Z]\w*(::[A-Z]\w*)+\b|Traceback\b|Caused by\b|node:internal\b)/;
63
+ // Names of HTTP/database libraries; a short value that is just one of these says nothing.
64
+ const LIBRARY = /\b(undici|axios|node-fetch|got|superagent|sqlite3?|better-sqlite3|sequel|activerecord|faraday|net\/http|go-http-client|libcurl|openssl|electron|node(\.js)?|v8)\b/i;
65
+ // The ones that are never ordinary words: a line or meta value that still names one,
66
+ // even mid-sentence ("request via superagent failed"), is dependency detail and is dropped.
67
+ const LIBRARY_NAME = /(^|[^\w-])(undici|axios|node-fetch|superagent|better-sqlite3|sqlite3|activerecord|faraday|net\/http|go-http-client|libcurl|node\.js|nodejs)(?![\w-])/i;
68
+ // HEY's error codes are snake_case words (usage, not_found, not_delivered ...); anything
69
+ // else in `code` (SqliteError, ECONNRESET) is a dependency's and is left out.
70
+ const HEY_CODE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
41
71
  // Low-level network codes become plain words.
42
72
  const NETWORK = [
43
73
  [/\bECONNRESET\b/, "the connection was reset"], [/\bECONNREFUSED\b/, "the connection was refused"],
44
74
  [/\bETIMEDOUT\b|\bi\/o timeout\b|context deadline exceeded/i, "the request timed out"], [/\bENOTFOUND\b|no such host/i, "the server name could not be resolved"],
45
75
  [/\bEAI_AGAIN\b/, "DNS lookup failed"], [/\bcertificate\b|\bx509\b|\bTLS\b/i, "a TLS/certificate problem"],
76
+ // Local database codes (HEY's cache) become plain words too.
77
+ [/\bSQLITE_(BUSY|LOCKED)\b|database is locked/i, "the local database is busy; retry in a moment"], [/\bSQLITE_[A-Z]+\b/, "a local database error"],
46
78
  ];
47
79
 
48
80
  export function cleanLine(text) {
@@ -51,10 +83,14 @@ export function cleanLine(text) {
51
83
  let line = lines[0].trim().replace(/^(error|Error|ERROR):\s*/, "");
52
84
  const network = NETWORK.find(([pattern]) => pattern.test(line));
53
85
  if (network) return network[1];
54
- if (INTERNAL.test(line)) return "";
86
+ if (INTERNAL.test(line) || DEPENDENCY.test(line)) return "";
55
87
  // Library prefixes ("axios:", "fetch failed:", "Get \"https://…\":") and raw URLs add nothing.
56
- line = line.replace(/^(axios|undici|fetch failed|request failed|(get|post|put|patch|delete) "[^"]*"):?\s*/i, "").replace(/\bhttps?:\/\/\S+/g, "the HEY server").trim();
57
- if (!line) return "";
88
+ line = line.replace(/^((axios|undici|node-fetch|got|superagent|faraday|sqlite3?|better-sqlite3)(\s*error)?|fetch failed|request failed|(get|post|put|patch|delete) "[^"]*"):?\s*/i, "")
89
+ .replace(/\bhttps?:\/\/\S+/g, "the HEY server")
90
+ // File paths and source locations (/usr/lib/…/x.js:12:3, C:\\…) are internal detail.
91
+ .replace(/(^|\s)(\/|~\/|[A-Za-z]:\\)[^\s:]+(:\d+)*/g, "$1a local file").replace(/\s{2,}/g, " ").trim();
92
+ // A line that is still a dependency error after its prefix came off is dropped too.
93
+ if (!line || INTERNAL.test(line) || DEPENDENCY.test(line) || LIBRARY_NAME.test(line) || /^[\w./-]+$/.test(line) && LIBRARY.test(line)) return "";
58
94
  return heyToAxi(line.length > 200 ? `${line.slice(0, 200)}…` : line);
59
95
  }
60
96
 
@@ -72,7 +108,12 @@ const DEBUG_KEYS = /stack|trace|backtrace|panic|goroutine|debug|caller|frames?$/
72
108
  // HEY's error meta without debugging detail: stack-like keys are dropped and
73
109
  // multi-line strings are cut to their one meaningful line.
74
110
  export function cleanMeta(value, depth = 0) {
75
- if (typeof value === "string") return /[\r\n\u001b]/.test(value) || value.length > 200 ? cleanLine(value) : value;
111
+ if (typeof value === "string") {
112
+ if (/[\r\n\u001b]/.test(value) || value.length > 200) return cleanLine(value);
113
+ // Short values are kept as they are, unless they are a dependency's error or name.
114
+ const bare = value.trim().replace(/^(error|Error|ERROR):\s*/, "");
115
+ return INTERNAL.test(bare) || DEPENDENCY.test(bare) || LIBRARY_NAME.test(bare) || (/^[\w./@ -]{1,40}$/.test(bare) && LIBRARY.test(bare) && !/\s\w+\s\w+/.test(bare)) ? undefined : value;
116
+ }
76
117
  if (!value || typeof value !== "object" || depth > 3) return depth > 3 ? undefined : value;
77
118
  if (Array.isArray(value)) return value.slice(0, 20).map((item) => cleanMeta(item, depth + 1)).filter((item) => item !== undefined && item !== "");
78
119
  const out = {};
@@ -107,31 +148,59 @@ function findEnvelope(text) {
107
148
  }
108
149
 
109
150
  // Returns { failure, exitCode, warnings }: the object to print, hey-axi's exit code,
110
- // and diagnostic lines for stderr.
111
- export function translateFailure(result, { path } = {}) {
112
- const raw = KINDS[result.status] || KINDS[1];
113
- const info = path ? { ...raw, help: raw.help.replace("<command>", path) } : raw;
151
+ // and diagnostic lines for stderr. `context` ({ path, usage, example }) names the command
152
+ // so the fix-it lines can be specific.
153
+ export function translateFailure(result, context = {}) {
154
+ const { path } = context;
155
+ const info = KINDS[result.status] || KINDS[1];
156
+ const help = (kind = info) => kind.help || fixFor(context);
114
157
  if (result.error) {
115
- return { failure: { ok: false, error: result.error, kind: info.kind, help: info.help }, exitCode: 1, warnings: [] };
158
+ return { failure: { ok: false, error: result.error, kind: info.kind, help: help() }, exitCode: 1, warnings: [] };
116
159
  }
117
160
  for (const text of [result.stderr, result.stdout]) {
118
161
  const found = findEnvelope(text);
119
162
  if (!found) continue;
120
163
  const { envelope } = found;
164
+ // A send HEY refused and kept as a draft: say it wasn't sent, and never "retry".
165
+ if (envelope.code === "not_delivered") return { failure: notDelivered(envelope), exitCode: 1, warnings: warnings(found.prefix) };
121
166
  // Even HEY's own envelope is translated: one clean line, no stack frames or escapes.
122
167
  const failure = { ok: false, error: cleanLine(String(envelope.error)) || info.message, kind: info.kind };
123
- if (envelope.code !== undefined && envelope.code !== "" && envelope.code !== "unknown") failure.code = String(envelope.code);
168
+ const code = envelope.code === undefined || envelope.code === null ? "" : String(envelope.code);
169
+ if (code && code !== "unknown" && HEY_CODE.test(code) && !LIBRARY.test(code)) failure.code = code;
124
170
  const meta = envelope.meta === undefined || envelope.meta === "" ? undefined : cleanMeta(envelope.meta);
125
171
  if (meta !== undefined) failure.meta = meta;
126
- const usage = USAGE_CODES.has(envelope.code) || /^usage: /i.test(String(envelope.error));
172
+ const usage = USAGE_CODES.has(code) || /^usage: /i.test(String(envelope.error));
127
173
  if (usage) failure.kind = "usage";
174
+ // hey-axi checked the command and flags against its catalog before HEY ran, so HEY
175
+ // not knowing one means the installed HEY is older than that catalog (for example
176
+ // v1.7.0 and `contact deliver`, which is only on HEY's main branch so far).
177
+ const unknown = usage && path && failure.error.match(/^unknown (flag|shorthand flag|command)[:\s]+["']?([^"'\s]+)["']?/i);
178
+ if (unknown) {
179
+ const what = /command/i.test(unknown[1]) ? `the command \`${path}\`` : `${unknown[2]} for \`${path}\``;
180
+ return {
181
+ failure: {
182
+ ok: false,
183
+ kind: "hey_outdated",
184
+ error: `the installed HEY CLI doesn't have ${what}${context.coverage ? `; hey-axi's catalog is HEY CLI ${context.coverage}` : ""}`,
185
+ help: ["Run `hey-axi version` to see which HEY is installed", "Upgrade HEY with `hey-axi upgrade`; commands not in a HEY release yet need a HEY built from basecamp/hey-cli main"],
186
+ },
187
+ exitCode: 1,
188
+ warnings: warnings(found.prefix),
189
+ };
190
+ }
191
+ // HEY refused a recipient it would otherwise drop: name it and the fix.
192
+ const badAddress = usage && failure.error.match(HEY_BAD_ADDRESS);
193
+ if (badAddress) {
194
+ const refusal = recipientRefusal(path, badAddress[1]);
195
+ return { failure: { ...failure, sent: false, reason: refusal.reason, hint: refusal.hint, help: `Fix or remove that address, then run the command again` }, exitCode: 2, warnings: warnings(found.prefix) };
196
+ }
128
197
  const hint = envelope.hint ? cleanLine(String(envelope.hint)) : "";
129
198
  if (hint && !/^Run 'hey(-axi)? --help'/.test(hint)) failure.hint = hint;
130
- failure.help = usage ? `Run \`hey-axi ${path || "<command>"} --help\` for its arguments and flags` : info.help;
131
- return { failure, exitCode: usage ? 2 : axiExitCode(result.status, envelope.code), warnings: warnings(found.prefix) };
199
+ failure.help = usage ? fixFor(context, { usageError: true }) : help();
200
+ return { failure, exitCode: usage ? 2 : axiExitCode(result.status, code), warnings: warnings(found.prefix) };
132
201
  }
133
202
  const detail = cleanLine(result.stderr) || cleanLine(result.stdout);
134
- const failure = { ok: false, error: detail ? `${info.message}: ${detail}` : info.message, kind: info.kind, help: info.help };
203
+ const failure = { ok: false, error: detail ? `${info.message}: ${detail}` : info.message, kind: info.kind, help: help() };
135
204
  return { failure, exitCode: axiExitCode(result.status), warnings: [...warnings(result.stderr), ...warnings(result.stdout)] };
136
205
  }
137
206