diff --git a/.github/workflows/manifest-drift.yml b/.github/workflows/manifest-drift.yml index 3c4cf9d..2ba66c5 100644 --- a/.github/workflows/manifest-drift.yml +++ b/.github/workflows/manifest-drift.yml @@ -1,11 +1,28 @@ -name: manifest drift +name: upstream drift -# Weekly check that src/manifest.json still matches the latest HEY CLI release. -# Only `hey version`, `hey commands` and `hey --help` are run; no login, no mailbox access. +# Compares the HEY command surface on basecamp/hey-cli main (built from source) against +# src/manifest.json and fails, with a readable diff, when commands or flags differ. +# Help-text-only changes are listed in the job summary but do not fail the job. +# Only `hey commands --json` and `hey --help` run; no login, no mailbox access. on: schedule: - - cron: "17 13 * * 1" + - cron: "17 13 * * *" workflow_dispatch: + inputs: + ref: + description: basecamp/hey-cli branch or tag to compare against + default: main + push: + branches: [main] + paths: + - src/manifest.json + - scripts/** + - .github/workflows/manifest-drift.yml + pull_request: + paths: + - src/manifest.json + - scripts/** + - .github/workflows/manifest-drift.yml permissions: contents: read @@ -20,25 +37,28 @@ jobs: node-version: 22 cache: npm - run: npm ci - - name: Install latest HEY CLI release + - name: Check out basecamp/hey-cli + uses: actions/checkout@v4 + with: + repository: basecamp/hey-cli + ref: ${{ github.event.inputs.ref || 'main' }} + path: hey-cli + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: hey-cli/go.mod + cache: false + - name: Compare HEY's command list with src/manifest.json env: - GH_TOKEN: ${{ github.token }} + HEY_REF: ${{ github.event.inputs.ref || 'main' }} + run: node scripts/check-drift.js --source hey-cli --ref "$HEY_REF" + - name: Note newer HEY releases + if: always() run: | set -euo pipefail - tag=$(gh release view -R basecamp/hey-cli --json tagName -q .tagName) - version=${tag#v} - mkdir -p "$RUNNER_TEMP/hey" - cd "$RUNNER_TEMP/hey" - gh release download "$tag" -R basecamp/hey-cli -p "hey_${version}_linux_amd64.tar.gz" -p checksums.txt - grep " hey_${version}_linux_amd64.tar.gz\$" checksums.txt | sha256sum -c - - tar xzf "hey_${version}_linux_amd64.tar.gz" hey - echo "HEY_BIN=$RUNNER_TEMP/hey/hey" >> "$GITHUB_ENV" - - run: npm run refresh-manifest - env: - HOME: ${{ runner.temp }}/hey-home - - name: Fail if the manifest is out of date - run: | - if ! git diff --exit-code -- src/manifest.json; then - echo "::error::src/manifest.json is stale. Run 'npm run refresh-manifest' with the latest hey and commit the result." - exit 1 + ours=$(node -p "require('./src/manifest.json').hey_version.split('+')[0]") + latest=$(git -C hey-cli tag --list 'v*' --sort=-v:refname | head -n1) + if [ -n "$latest" ] && [ "${latest#v}" != "$ours" ]; then + echo "::warning title=HEY release::basecamp/hey-cli has released $latest; src/manifest.json builds on v$ours. Re-run npm run manifest:main (or check-drift --ref $latest --write) and update the README/CHANGELOG coverage wording." fi diff --git a/AGENTS.md b/AGENTS.md index c673d83..4344fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,16 +19,18 @@ A thin Node (ESM, Node 20+) wrapper around the HEY CLI (`hey`, github.com/baseca - `src/hooks.js`: `hey-axi setup hooks`: session-start (via axi-sdk-js) and session-end hooks for Claude Code, Codex and OpenCode. - `src/text.js`: `heyToAxi()` (rewrites `hey ` suggestions to `hey-axi `) and `shellWord()`. - `src/router.js`: `normalizeCatalog`, `resolveCommand`, `listCommands`. Routing is **data-driven** from `src/manifest.json`; don't add per-command branches. -- `src/manifest.json`: generated snapshot of `hey commands --json` plus usage/examples parsed from `hey --help`. **Never hand-edit it**; run `npm run refresh-manifest`. +- `src/manifest.json`: generated snapshot of `hey commands --json` plus usage/examples parsed from `hey --help`. **Never hand-edit it**; run `npm run refresh-manifest` (installed `hey`) or `npm run manifest:main` (built from basecamp/hey-cli main; `npm run check-drift` compares without writing). - `src/args.js`: flag-aware scanning (which flags take a value), global and hey-axi flags, `stripAxiFlags`, `validateFlags`, raw output selectors. - `src/policy.js`: run modes (interactive / stream / raw / json), the send gate, the secret gate, person-only commands (refused unless `--interactive`), content requirements, no-op detection (`noopFor`). New special cases go here. - `src/errors.js`: `translateFailure()` maps a failed run to `{ok:false,error,kind,code,hint,meta,help}`, an exit code (0/1/2) and stderr warnings. -- `scripts/refresh-manifest.js`: regenerates the manifest. +- `scripts/refresh-manifest.js`: regenerates the manifest from the installed `hey`. +- `scripts/check-drift.js` / `scripts/manifest-lib.js`: build HEY from basecamp/hey-cli (main by default), compare its surface with the manifest (CI: `.github/workflows/manifest-drift.yml`), `--write` to update. +- `src/send.js`: send outcomes (`sent: true`, Undo Send `held`, `not_delivered`) and the recipient pre-check. - `skills/hey-axi/`: the shareable agent skill. `SKILL.md` is hand-written except its `generated:home` block. That block and `references/commands.md` are generated by `scripts/gen-skill.js` (`npm run skill:gen`); `npm run skill:check` runs in CI. `test/skill.test.js` checks the spec rules, that every example command resolves, and that the generated parts are current. - `test/helpers.js`: `makeFakeHey` / `makeFakeHeyScript` (fake binaries that log their arguments) and `runAxi`. ## Rules -1. **Never run the real HEY CLI against a mailbox in tests, and never send email.** Every test uses the fake `hey` through `HEY_BIN`. The only real-`hey` calls anywhere are `hey version`, `hey commands` and `hey --help` in `refresh-manifest`. Hook tests use a temporary `HOME`/cwd, never your real agent config. +1. **Never run the real HEY CLI against a mailbox in tests, and never send email.** Every test uses the fake `hey` through `HEY_BIN`. The only real-`hey` calls anywhere are `hey version`, `hey commands` and `hey --help` in `refresh-manifest` and `check-drift`. Hook tests use a temporary `HOME`/cwd, never your real agent config. 2. Commands that deliver email must never send without `--allow-send`/`HEY_AXI_ALLOW_SEND=1`. Without it, `compose`/`reply` are auto-staged with `--draft` (and marked `sent: false`, `saved_as: draft`), and `forward`/`draft send`/`bulk-reply send` are refused (`src/policy.js`). Don't loosen this without the owner's sign-off. 3. Forward argv to HEY **in its original order**. Strip only hey-axi's own flags (`--allow-send`, `--allow-secret`, `--interactive`, `--fields`, `--full`) and `--json`/`--quiet` (hey-axi adds `--json` itself and implements `--quiet`). 4. Output: TOON on stdout for both success and errors (`ok: false`). Default output is shaped (`src/shape.js`), and `--full` returns HEY's envelope untouched. Raw and stream modes relay HEY's stdout but report failures as structured errors. Never pass raw HEY stderr through. Suggestions name `hey-axi`, not `hey`, and HEY's breadcrumbs must stay. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0530b25..5add0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to hey-axi. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [semver](https://semver.org/). +## 0.3.2 (2026-10-05) + +Follows HEY CLI's unreleased `main` branch, adds an upstream-drift check, and closes three gaps from the catalog review of 0.3.1. The rules that were already in place stay as they were: nothing is sent without `--allow-send`, tests use a fake HEY, and HEY's next-command hints are still shown. + +**HEY version covered:** v1.7.0 plus the commands on basecamp/hey-cli `main` as of 2026-10-03 (commit `8bf9310`). That branch is not released yet. `src/manifest.json` was regenerated by building HEY from that commit and running `hey commands --json` / `hey --help` (its `hey_version` reads `1.7.0+main.8bf9310`). With HEY v1.7.0 installed, everything from 0.3.1 works as before, and the main-only commands below fail with `kind: hey_outdated` until HEY is upgraded. + +### Added +- **HEY main commands:** `contact deliver --to imbox|feed|papertrail|screened-out` (screens a contact in and routes their mail; `--to` is required and checked before HEY runs), and `event delete --occurrence --apply-to current|future` for recurring events (the two flags go together; `--apply-to` is checked for `event edit` too). Per-command help shows required flags, allowed values and flags that go together. 155 runnable command paths (was 154); help text for 33 commands follows HEY main's wording. +- **Send outcomes from HEY main:** with `--allow-send`, a confirmed send is marked `sent: true` and keeps the delivered message's `id`/`topic_id` when HEY reports them. A message held for Undo Send (`delayed: true`) gets a `held` line saying it hasn't gone out yet and the thread won't show it until it does. A send HEY refused and kept as a draft (`code: not_delivered`, usually the sending limit) is an error with `kind: not_delivered`, `sent: false`, the draft id, and a note not to repeat the send. HEY v1.7.0 output is still read as before (v1.7.0 reports a refused send as sent, so that case can't be detected there). +- **Recipient check:** `compose`, `reply`, `forward` and `draft edit` refuse (exit 2, `sent: false`) recipients HEY would drop without saying so (no domain or top-level domain), before HEY runs. HEY main's own "not a valid email address" refusal is reported the same way. +- **`kind: hey_outdated`:** when the installed HEY doesn't know a command or flag that's in the catalog, the error says so and suggests `hey-axi version` / `hey-axi upgrade` instead of a generic usage error. +- **Upstream drift check:** `npm run check-drift` builds HEY from basecamp/hey-cli `main` (or `--ref `, `--source `, `--hey `) and exits 1 with a readable diff when its commands or flags differ from `src/manifest.json`; help-text changes are listed (they fail only with `--strict`). `npm run manifest:main` rewrites the manifest from that build. The `upstream drift` GitHub Action runs it daily and on changes to the manifest or scripts, and warns when HEY publishes a newer release. + +### Changed +- **axi-sdk-js 0.1.13** (was 0.1.12): its generated OpenCode plugin loads on OpenCode 2. +- **OpenCode session-end plugin:** hey-axi's own `axi-hey-axi-session-end.js` plugin used the OpenCode 1 named export only. It now also has the OpenCode 2 default export (`id`, `setup`, `server`). Re-run `hey-axi setup hooks` (or `--project`) to update installed plugins; it replaces the old ones in place. +- `event delete --occurrence` failing with not found is no longer treated as "already deleted" (a missing occurrence isn't proof the event is gone). + +### Fixed +- **Home view "nothing" (catalog review):** an empty Imbox page is only called "nothing" when HEY says there are no more pages or a total of 0. Otherwise it says `0 threads on this page …; HEY reported no total and no further pages` and suggests `--all` to check every page. +- **Generic error fallback (catalog review):** errors without a specific fix no longer say "run --help". They show the command's usage line and an example (or `hey-axi doctor` when there's no command), and HEY usage errors include them inline. +- **"Already done" for single ids (catalog review):** `seen 2` is only a no-op when HEY's message is about id 2 (or every requested id); "1 already seen" for a single id, or a message naming a different id, stays an error. A bare count is only accepted as "all N" matching the number of ids. +- **Error cleanup:** a sentence naming a networking library (`superagent`, `undici`, `axios`, `node-fetch`, …) is dropped from the error and its `meta`, and HEY's `code` is kept only when it's a HEY-style code (snake_case, not a library name). + ## 0.3.1 (2026-10-03) Closes the remaining gaps from the independent catalog review of 0.3.0. The rules that were already in place stay as they were: nothing is sent without `--allow-send`, tests use a fake HEY, HEY's next-command hints are still shown, `--interactive` stays opt-in, and the home view stays account-wide unless `setup scope` narrows it. diff --git a/README.md b/README.md index 65b31f2..3cc3af3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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. -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 `-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. +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-03** (commit [`8bf9310`](https://github.com/basecamp/hey-cli/commit/8bf9310c0b1df7448e39ed1f81826ae795bd8b5c)): 155 runnable command paths, including the `login`/`logout` aliases, plus the `box `-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. ## Install @@ -72,6 +72,9 @@ hey-axi move --help # usage, flags, examples, notes (no H - `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). - `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`). - If you pass `--draft` or `--dry-run` yourself, hey-axi adds and marks nothing. + - 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. + - 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. + - 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. - **Credentials stay out of agent context.** `auth token` needs `--allow-secret` or `HEY_AXI_ALLOW_SECRETS=1`. - **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. - 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. @@ -83,7 +86,7 @@ Errors are printed as structured TOON on stdout: `ok: false`, `error`, a `kind`, | Exit | Meaning | |---|---| | 0 | success (including no-op mutations and empty results) | -| 1 | the command failed; `kind` says why: `not_found`, `auth`, `forbidden`, `rate_limited`, `network`, `api_error`, `ambiguous`, `hey_missing`, `command_error` | +| 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` | | 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 | ## Agent integrations: session hook or skill @@ -120,13 +123,16 @@ See [docs/listing.md](docs/listing.md) for how hey-axi gets listed on skills.sh ## Keeping up with HEY releases -`src/manifest.json` is a snapshot of `hey commands --json`. To regenerate it from whichever `hey` you have installed: +`src/manifest.json` is a snapshot of `hey commands --json` plus usage parsed from `hey --help`. The current snapshot was built from HEY's unreleased `main` branch (commit `8bf9310`, 2026-10-03), so its `hey_version` reads `1.7.0+main.8bf9310`. To regenerate it: ```bash -npm run refresh-manifest # runs only `hey version` and `hey commands` +npm run refresh-manifest # from whichever `hey` you have installed (runs only `hey version`, `hey commands`, `hey --help`) +npm run manifest:main # clone basecamp/hey-cli main, build it (needs Go and git), and rewrite the manifest from it +npm run check-drift # same build, but only compare: exits 1 with a readable diff when commands or flags differ +node scripts/check-drift.js --ref v1.8.0 --write # a tagged release instead of main ``` -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. +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. ## Development diff --git a/package-lock.json b/package-lock.json index b55c962..754082f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "hey-axi", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hey-axi", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@toon-format/toon": "^4.1.1", - "axi-sdk-js": "^0.1.12" + "axi-sdk-js": "^0.1.13" }, "bin": { "hey-axi": "src/hey-axi.js" @@ -29,9 +29,9 @@ "license": "MIT" }, "node_modules/axi-sdk-js": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/axi-sdk-js/-/axi-sdk-js-0.1.12.tgz", - "integrity": "sha512-qAt4T8tOWAFfehx26UbOyfnRcK2VJ36fI9rAKFgDwS0+PRuPB2wOSMDKlpaAKffy6LOzofjuxIoVGaU4dB0geA==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/axi-sdk-js/-/axi-sdk-js-0.1.13.tgz", + "integrity": "sha512-Ygh8qUN5kdwQ3ASw+BNzCKYpy5QYi18vfcEpqEOQOSrcyh6i4wChrPMtvVW0/3Sqho3xPH3CuFkP0QUma5oxxA==", "dependencies": { "@toon-format/toon": "^2.1.0" }, diff --git a/package.json b/package.json index f665339..d879206 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hey-axi", - "version": "0.3.1", + "version": "0.3.2", "description": "Agent-ergonomic AXI wrapper for Basecamp's HEY CLI: TOON output, send-to-draft safety, and a shareable agent skill", "keywords": [ "axi", @@ -41,6 +41,8 @@ "scripts": { "test": "node --test test/*.test.js", "refresh-manifest": "node scripts/refresh-manifest.js", + "check-drift": "node scripts/check-drift.js", + "manifest:main": "node scripts/check-drift.js --write", "bench:tokens": "node bench/tokens.mjs", "bench:capture": "node bench/capture.mjs", "bench:fixtures": "node bench/generate-api-fixtures.mjs", @@ -49,7 +51,7 @@ }, "dependencies": { "@toon-format/toon": "^4.1.1", - "axi-sdk-js": "^0.1.12" + "axi-sdk-js": "^0.1.13" }, "engines": { "node": ">=20" diff --git a/scripts/check-drift.js b/scripts/check-drift.js new file mode 100644 index 0000000..3095c86 --- /dev/null +++ b/scripts/check-drift.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Upstream drift check: does src/manifest.json still match the commands and flags HEY has +// on basecamp/hey-cli main (or another ref)? Exits 1 with a readable diff when it doesn't. +// +// npm run check-drift # clone basecamp/hey-cli main, build it (needs Go), compare +// node scripts/check-drift.js --ref v1.8.0 # any branch or tag +// node scripts/check-drift.js --source ../hey-cli # an existing checkout (built here) +// node scripts/check-drift.js --hey ./hey --commit # an already built binary +// node scripts/check-drift.js --write # also rewrite src/manifest.json from it +// node scripts/check-drift.js --strict # help-text changes fail too +// +// Commands, flags (name, shorthand, value/type, default), USAGE lines and shortcut forms +// are compared: what an agent can type. Help-text-only changes (descriptions, notes, +// examples) are listed but only fail with --strict. +// +// HEY is only asked for `hey version`, `hey commands` and `hey --help`, under a +// throwaway HOME: no login, no mailbox access. + +import { execFileSync } from "node:child_process"; +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateManifest, serialize, surfaceDiff, textDiff } from "./manifest-lib.js"; + +const REPO = "https://github.com/basecamp/hey-cli.git"; +const target = fileURLToPath(new URL("../src/manifest.json", import.meta.url)); + +function parseOptions(argv) { + const options = { ref: "main" }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const value = () => { + if (argv[i + 1] === undefined) throw new Error(`${arg} needs a value`); + i += 1; + return argv[i]; + }; + if (arg === "--ref") options.ref = value(); + else if (arg === "--source") options.source = value(); + else if (arg === "--hey") options.hey = value(); + else if (arg === "--commit") options.commit = value(); + else if (arg === "--commit-date") options.commitDate = value(); + else if (arg === "--base") options.base = value(); + else if (arg === "--manifest") options.manifest = value(); + else if (arg === "--write") options.write = true; + else if (arg === "--strict") options.strict = true; + else if (arg === "--help" || arg === "-h") options.help = true; + else throw new Error(`unknown option ${arg}`); + } + return options; +} + +const git = (dir, ...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + +// Commit, date and the release it builds on, for the manifest's label. +function describeCheckout(dir) { + const info = { commit: git(dir, "rev-parse", "HEAD"), commitDate: git(dir, "log", "-1", "--format=%cs") }; + try { + info.exactTag = git(dir, "describe", "--tags", "--exact-match", "--match", "v*"); + } catch { + // not a release commit + } + try { + info.base = git(dir, "describe", "--tags", "--abbrev=0", "--match", "v*"); + } catch { + // no tags fetched + } + return info; +} + +function build(dir, out) { + process.stderr.write(`building hey from ${dir} ...\n`); + execFileSync("go", ["build", "-o", out, "./cmd/hey"], { cwd: dir, stdio: ["ignore", "inherit", "inherit"] }); +} + +function label({ ref, commit, commitDate, base, exactTag }) { + const short = commit ? commit.slice(0, 7) : "unknown"; + if (exactTag) return { version: exactTag.replace(/^v/, ""), commit, commit_date: commitDate, source: `hey commands --json + hey --help (basecamp/hey-cli ${exactTag})` }; + const version = `${base ? base.replace(/^v/, "") : "0.0.0"}+${String(ref).replace(/[^\w.-]+/g, "-")}.${short}`; + return { version, commit, commit_date: commitDate, source: `hey commands --json + hey --help (basecamp/hey-cli ${ref} at ${short}${commitDate ? `, ${commitDate}` : ""}; unreleased, built from source)` }; +} + +function main() { + const options = parseOptions(process.argv.slice(2)); + if (options.help) { + process.stdout.write(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").filter((line) => line.startsWith("//")).map((line) => line.slice(3)).join("\n") + "\n"); + return 0; + } + const work = mkdtempSync(join(tmpdir(), "hey-axi-drift-")); + try { + let hey = options.hey && resolve(options.hey); + let info = { ref: options.ref, commit: options.commit, commitDate: options.commitDate, base: options.base }; + if (!hey) { + let dir = options.source && resolve(options.source); + if (!dir) { + dir = join(work, "hey-cli"); + process.stderr.write(`cloning ${REPO} (${options.ref}) ...\n`); + execFileSync("git", ["clone", "--quiet", "--filter=blob:none", "--branch", options.ref, REPO, dir], { stdio: ["ignore", "inherit", "inherit"] }); + } + info = { ...info, ...describeCheckout(dir), ...(options.base ? { base: options.base } : {}) }; + hey = join(work, "hey"); + build(dir, hey); + } + const home = join(work, "home"); + const env = { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), HEY_NONINTERACTIVE: "1", NO_COLOR: "1" }; + const theirs = generateManifest(hey, { env, label: label(info) }); + const oursPath = options.manifest ? resolve(options.manifest) : target; + const ours = JSON.parse(readFileSync(oursPath, "utf8")); + + const surface = surfaceDiff(ours, theirs); + const text = textDiff(ours, theirs); + const out = []; + out.push(`ours: ${ours.hey_version} (${ours.hey_commit || "no commit"})`); + out.push(`upstream: ${theirs.hey_version} (${theirs.hey_commit || "no commit"})`); + if (surface.length) { + out.push("", `commands/flags differ (${surface.length}):`, ...surface.map((line) => ` ${line}`)); + } else out.push("", "commands/flags: identical"); + if (text.length) out.push("", `help text differs for ${text.length} command(s):`, ...text.map((line) => ` ${line}`)); + const failed = surface.length > 0 || (options.strict && text.length > 0); + if (failed && !options.write) out.push("", "To update: npm run manifest:main (or node scripts/check-drift.js --write), review `git diff src/manifest.json`, then classify any new command in src/policy.js and add tests."); + process.stdout.write(`${out.join("\n")}\n`); + + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## HEY upstream drift\n\n\`\`\`\n${out.join("\n")}\n\`\`\`\n`); + if (failed && process.env.GITHUB_ACTIONS) process.stdout.write(`::error title=HEY drift::src/manifest.json differs from basecamp/hey-cli ${info.ref}: ${surface.length} command/flag change(s)${options.strict ? `, ${text.length} help-text change(s)` : ""}\n`); + if (options.write) { + writeFileSync(target, serialize(theirs)); + process.stdout.write(`wrote ${target} from hey ${theirs.hey_version}\n`); + return 0; + } + return failed ? 1 : 0; + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +try { + process.exitCode = main(); +} catch (error) { + process.stderr.write(`check-drift: ${error.message}\n`); + process.exitCode = 2; +} diff --git a/scripts/gen-skill.js b/scripts/gen-skill.js index 80e7e7e..77cb33a 100644 --- a/scripts/gen-skill.js +++ b/scripts/gen-skill.js @@ -11,7 +11,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { loadBundledManifest, listCommands } from "../src/router.js"; +import { coverageLabel, loadBundledManifest, listCommands } from "../src/router.js"; import { checkPolicy, runMode, sendStaging } from "../src/policy.js"; import { DETAIL_FIELDS, LIST_FIELDS, parseField } from "../src/shape.js"; import { DESCRIPTION, HOME_HELP } from "../src/guide.js"; @@ -48,7 +48,7 @@ export function renderReference(manifest = loadBundledManifest()) { "", ``, "", - `Every command from HEY CLI ${manifest.hey_version} (${commands.length} runnable paths, including the \`login\`/\`logout\` aliases and shortcut forms such as \`box \`). Run each as \`${NPX} [args] [flags]\` (just \`hey-axi …\` when it is installed globally).`, + `Every command from HEY CLI ${coverageLabel(manifest)}: ${commands.length} runnable paths, including the \`login\`/\`logout\` aliases and shortcut forms such as \`box \`. Run each as \`${NPX} [args] [flags]\` (just \`hey-axi …\` when it is installed globally).`, `\`${NPX} --help\` shows usage, arguments, every flag with its default, 2-3 examples and notes, offline.`, "Flags every command accepts: `--account `, `--json`, `--quiet`, `--fields `, `--full`, `--ids-only`, `--count`, `--markdown`, `--html`, `--styled`, `--jq `. Unknown commands, flags and arguments, flags without a value and values of the wrong type are rejected before HEY runs (exit 2).", "", diff --git a/scripts/manifest-lib.js b/scripts/manifest-lib.js new file mode 100644 index 0000000..8453bb2 --- /dev/null +++ b/scripts/manifest-lib.js @@ -0,0 +1,131 @@ +// Building src/manifest.json from a HEY binary, and comparing two manifests. +// Shared by scripts/refresh-manifest.js and scripts/check-drift.js. +// +// Only `hey version --json`, `hey commands --json` and `hey --help` are run. +// None of them needs a login or touches a mailbox. + +import { execFileSync } from "node:child_process"; +import { normalizeCatalog } from "../src/router.js"; + +function heyJSON(hey, args, env) { + const stdout = execFileSync(hey, [...args, "--json"], { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }); + const parsed = JSON.parse(stdout); + return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed; +} + +// USAGE, EXAMPLES and flag value types only exist in each command's --help text. +// `--help` is answered locally by HEY (no login, no network). +export function parseHelp(text) { + const section = (name) => { + const match = text.match(new RegExp(`^${name}\\n((?: .*\\n?)+)`, "m")); + return match ? match[1].split("\n").map((line) => line.trim()).filter(Boolean) : []; + }; + const types = {}; + for (const line of section("FLAGS")) { + const match = line.match(/^(?:-\w, )?--([\w-]+)(?: (\w+))?(?:\s{2,}|$)/); + if (match && match[2]) types[match[1]] = match[2]; + } + return { synopsis: section("USAGE"), examples: section("EXAMPLES").filter((line) => line.startsWith("hey ")).slice(0, 3), types }; +} + +// The manifest for one HEY binary. `label` overrides what HEY reports about itself +// ({ version, commit, commit_date, source }); a build from source reports "dev". +export function generateManifest(hey, { env = process.env, label = {} } = {}) { + const version = heyJSON(hey, ["version"], env); + const catalog = heyJSON(hey, ["commands"], env); + const help = new Map(); + const readHelp = (nodes) => { + for (const node of nodes || []) { + const path = node.path || node.name; + const text = execFileSync(hey, [...path.split(" "), "--help"], { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }); + help.set(path, parseHelp(text)); + readHelp(node.subcommands); + } + }; + readHelp(catalog); + const types = {}; + for (const [path, { types: flagTypes }] of help) for (const [flag, type] of Object.entries(flagTypes)) types[`${path} --${flag}`] = type; + const commands = normalizeCatalog(catalog, types); + const addHelp = (nodes) => { + for (const node of nodes) { + const { synopsis, examples } = help.get(node.path); + if (synopsis.length) node.synopsis = synopsis; + if (examples.length) node.examples = examples; + if (node.subcommands) addHelp(node.subcommands); + } + }; + addHelp(commands); + const manifest = { source: label.source || "hey commands --json + hey --help", hey_version: label.version || version.version, hey_commit: label.commit || version.commit }; + if (label.commit_date) manifest.hey_commit_date = label.commit_date; + manifest.commands = commands; + return manifest; +} + +export const serialize = (manifest) => `${JSON.stringify(manifest, null, 1)}\n`; + +function flatten(nodes, out = new Map()) { + for (const node of nodes || []) { + out.set(node.path, node); + flatten(node.subcommands, out); + } + return out; +} + +const FLAG_KEYS = ["shorthand", "value", "type", "default"]; +const show = (value) => (value === undefined ? "(none)" : JSON.stringify(value)); + +// What an agent can type: command paths, their flags (name, shorthand, whether it takes a +// value, its type and default), USAGE lines and shortcut forms. Returns readable lines, +// sorted by command: "+ command contact deliver", "- flag box --page", "~ flag ...". +export function surfaceDiff(ours, theirs) { + const a = flatten(ours.commands); + const b = flatten(theirs.commands); + const lines = []; + for (const path of [...new Set([...a.keys(), ...b.keys()])].sort()) { + const was = a.get(path); + const now = b.get(path); + if (!was) { + const flags = (now.flags || []).map((flag) => `--${flag.name}`).join(", "); + lines.push(`+ command ${path}${flags ? ` (flags: ${flags})` : ""}`); + continue; + } + if (!now) { + lines.push(`- command ${path}`); + continue; + } + const oldFlags = new Map((was.flags || []).map((flag) => [flag.name, flag])); + const newFlags = new Map((now.flags || []).map((flag) => [flag.name, flag])); + for (const [name, flag] of newFlags) { + if (!oldFlags.has(name)) lines.push(`+ flag ${path} --${name}${flag.value ? ` <${flag.type || "string"}>` : ""}${flag.desc ? ` ${flag.desc}` : ""}`); + } + for (const name of oldFlags.keys()) if (!newFlags.has(name)) lines.push(`- flag ${path} --${name}`); + for (const [name, flag] of newFlags) { + const old = oldFlags.get(name); + if (!old) continue; + for (const key of FLAG_KEYS) { + if (JSON.stringify(old[key]) !== JSON.stringify(flag[key])) lines.push(`~ flag ${path} --${name} ${key}: ${show(old[key])} -> ${show(flag[key])}`); + } + } + if (JSON.stringify(was.synopsis || []) !== JSON.stringify(now.synopsis || [])) lines.push(`~ usage ${path}: ${show(was.synopsis)} -> ${show(now.synopsis)}`); + if ((was.usage || "") !== (now.usage || "")) lines.push(`~ shortcut ${path}: ${show(was.usage)} -> ${show(now.usage)}`); + } + return lines; +} + +// Help-text-only differences (short description, notes, examples, flag descriptions): +// the command paths whose text changed. +export function textDiff(ours, theirs) { + const a = flatten(ours.commands); + const b = flatten(theirs.commands); + const changed = []; + for (const [path, now] of b) { + const was = a.get(path); + if (!was) continue; + const fields = []; + for (const key of ["short", "notes", "examples"]) if (JSON.stringify(was[key]) !== JSON.stringify(now[key])) fields.push(key); + const oldDesc = new Map((was.flags || []).map((flag) => [flag.name, flag.desc])); + if ((now.flags || []).some((flag) => oldDesc.has(flag.name) && oldDesc.get(flag.name) !== flag.desc)) fields.push("flag descriptions"); + if (fields.length) changed.push(`${path} (${fields.join(", ")})`); + } + return changed.sort(); +} diff --git a/scripts/refresh-manifest.js b/scripts/refresh-manifest.js index 2cbe6b6..c14e8ac 100644 --- a/scripts/refresh-manifest.js +++ b/scripts/refresh-manifest.js @@ -1,72 +1,30 @@ #!/usr/bin/env node -// Regenerate src/manifest.json from the installed HEY CLI's own command catalog. +// Regenerate src/manifest.json from a HEY CLI binary's own command catalog. // -// npm run refresh-manifest # uses HEY_BIN or `hey` on PATH +// npm run refresh-manifest # uses HEY_BIN or `hey` on PATH (a release) // HEY_BIN=/path/to/hey npm run refresh-manifest +// npm run manifest:main # build HEY from basecamp/hey-cli main and use that +// # (scripts/check-drift.js --write) +// +// A HEY built from source reports version "dev"; label it with +// HEY_VERSION_LABEL=1.7.0+main.8bf9310 HEY_COMMIT= HEY_COMMIT_DATE=2026-10-03 HEY_SOURCE="..." // // Only `hey version --json`, `hey commands --json` and `hey --help` are run. // None of them needs a login or touches a mailbox. -import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { normalizeCatalog } from "../src/router.js"; +import { generateManifest, serialize } from "./manifest-lib.js"; const HEY = process.env.HEY_BIN || "hey"; const target = fileURLToPath(new URL("../src/manifest.json", import.meta.url)); -function heyJSON(args) { - const stdout = execFileSync(HEY, [...args, "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); - const parsed = JSON.parse(stdout); - return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed; -} - -// USAGE, EXAMPLES and flag value types only exist in each command's --help text. -// `--help` is answered locally by HEY (no login, no network). -function parseHelp(text) { - const section = (name) => { - const match = text.match(new RegExp(`^${name}\\n((?: .*\\n?)+)`, "m")); - return match ? match[1].split("\n").map((line) => line.trim()).filter(Boolean) : []; - }; - const types = {}; - for (const line of section("FLAGS")) { - const match = line.match(/^(?:-\w, )?--([\w-]+)(?: (\w+))?(?:\s{2,}|$)/); - if (match && match[2]) types[match[1]] = match[2]; - } - return { synopsis: section("USAGE"), examples: section("EXAMPLES").filter((line) => line.startsWith("hey ")).slice(0, 3), types }; -} - -const help = new Map(); -function readHelp(nodes) { - for (const node of nodes || []) { - const path = node.path || node.name; - const text = execFileSync(HEY, [...path.split(" "), "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); - help.set(path, parseHelp(text)); - readHelp(node.subcommands); - } -} - -function addHelp(nodes) { - for (const node of nodes) { - const { synopsis, examples } = help.get(node.path); - if (synopsis.length) node.synopsis = synopsis; - if (examples.length) node.examples = examples; - if (node.subcommands) addHelp(node.subcommands); - } -} - -const version = heyJSON(["version"]); -const catalog = heyJSON(["commands"]); -readHelp(catalog); -const types = {}; -for (const [path, { types: flagTypes }] of help) for (const [flag, type] of Object.entries(flagTypes)) types[`${path} --${flag}`] = type; -const commands = normalizeCatalog(catalog, types); -addHelp(commands); -const manifest = { - source: "hey commands --json + hey --help", - hey_version: version.version, - hey_commit: version.commit, - commands, +const label = { + version: process.env.HEY_VERSION_LABEL, + commit: process.env.HEY_COMMIT, + commit_date: process.env.HEY_COMMIT_DATE, + source: process.env.HEY_SOURCE, }; -writeFileSync(target, `${JSON.stringify(manifest, null, 1)}\n`); -process.stdout.write(`wrote ${target} from hey ${version.version} (${manifest.commands.length} top-level commands)\n`); +const manifest = generateManifest(HEY, { label }); +writeFileSync(target, serialize(manifest)); +process.stdout.write(`wrote ${target} from hey ${manifest.hey_version} (${manifest.commands.length} top-level commands)\n`); diff --git a/skills/hey-axi/SKILL.md b/skills/hey-axi/SKILL.md index 1a7c6c4..400f957 100644 --- a/skills/hey-axi/SKILL.md +++ b/skills/hey-axi/SKILL.md @@ -2,7 +2,7 @@ name: hey-axi 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. license: MIT -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. +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. metadata: author: TangentialSolutions repository: https://github.com/TangentialSolutions/hey-axi @@ -62,7 +62,9 @@ npx -y hey-axi screener approve npx -y hey-axi trash ... npx -y hey-axi spam ... npx -y hey-axi ignore ... ``` -`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. +`trash`, `spam`, `ignore`, `screener deny --spam` and `screener clear` (which trashes everything waiting in the Screener) run without any confirmation. Confirm with the user before you run them in bulk. + +`npx -y hey-axi contact deliver --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 --occurrence --apply-to current|future` deletes one day (or that day onward) of a repeating event; the occurrence_id comes from `event day`/`event week`. Mutations are idempotent: when the desired state already holds (already seen, already deleted), the answer is `noop: true` with exit 0, not an error. @@ -80,6 +82,9 @@ Output includes HEY's breadcrumb hints as `help` lines (with your `--account` ca - 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). - `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. - 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. +- 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. +- `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 --allow-send` when they ask. +- A recipient without a full address (`bob`, `bob@localhost`) is refused before HEY runs (exit 2, `sent: false`): fix or remove it. ## 4. Output: fields, truncation, counts, modes @@ -110,7 +115,7 @@ Lines also include `ready`, `disconnected` and `resync`. On `resync`, re-read th ## 6. Errors and exit codes -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. +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, 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). ## Install diff --git a/skills/hey-axi/references/commands.md b/skills/hey-axi/references/commands.md index 427c1b8..46d3b9f 100644 --- a/skills/hey-axi/references/commands.md +++ b/skills/hey-axi/references/commands.md @@ -1,8 +1,8 @@ # hey-axi command reference - + -Every command from HEY CLI 1.7.0 (154 runnable paths, including the `login`/`logout` aliases and shortcut forms such as `box `). Run each as `npx -y hey-axi [args] [flags]` (just `hey-axi …` when it is installed globally). +Every command from HEY CLI v1.7.0 plus the commands on HEY main as of 2026-10-03 (commit 8bf9310, unreleased): 155 runnable paths, including the `login`/`logout` aliases and shortcut forms such as `box `. Run each as `npx -y hey-axi [args] [flags]` (just `hey-axi …` when it is installed globally). `npx -y hey-axi --help` shows usage, arguments, every flag with its default, 2-3 examples and notes, offline. Flags every command accepts: `--account `, `--json`, `--quiet`, `--fields `, `--full`, `--ids-only`, `--count`, `--markdown`, `--html`, `--styled`, `--jq `. Unknown commands, flags and arguments, flags without a value and values of the wrong type are rejected before HEY runs (exit 2). @@ -49,6 +49,7 @@ Flags every command accepts: `--account `, `--json`, `--quiet`, `--field | `config untrust-local` | Remove trust for this repository's local HEY settings | | | | `contact add` | Add a contact | `--account-user-id ` `--alias ` `--email ` `--name ` | | | `contact bundle` | Bundle a contact's mail | | | +| `contact deliver` | Choose where a contact's email arrives | `--to ` | | | `contact hide` | Hide a contact | | | | `contact list` | List contacts | `--all` `--page ` | fields: id, name, email_address | | `contact note delete` | Delete a private contact note | | | @@ -67,7 +68,7 @@ Flags every command accepts: `--account `, `--json`, `--quiet`, `--field | `draft show` | Read a draft back | | | | `event add` | Create an event | `--all-day` `--calendar ` `--circle` `--countdown ` `--countdown-unit ` `--end-time ` `--ends-on ` `--invite ` `--link ` `--location ` `--notes ` `--remind ` `--repeat ` `--repeat-times ` `--repeat-until ` `--start-time ` `--starts-on ` `--time-zone ` `--title ` | | | `event day` | List the events of one day, as HEY draws it | `--all` `--limit ` | fields: id, title, starts_at, ends_at | -| `event delete` | Delete an event | | | +| `event delete` | Delete an event | `--apply-to ` `--occurrence ` | | | `event edit` | Change an event | `--all-day` `--allow-plain-notes` `--apply-to ` `--calendar ` `--circle` `--countdown ` `--countdown-unit ` `--end-time ` `--ends-on ` `--invite ` `--link ` `--location ` `--notes ` `--occurrence ` `--remind ` `--repeat ` `--repeat-times ` `--repeat-until ` `--start-time ` `--starts-on ` `--time-zone ` `--title ` | | | `event list` | List calendar events | `--all` `--calendar ` `--ends-on ` `--limit ` `--starts-on ` | fields: id, title, starts_at, ends_at | | `event week` | List the events of one week, as HEY draws it | `--all` `--limit ` | fields: id, title, starts_at, ends_at | @@ -94,7 +95,7 @@ Flags every command accepts: `--account `, `--json`, `--quiet`, `--field | `move` | Move email threads to another box | `--to ` | | | `reply` | Reply to a thread | `--attach ` `--bcc ` `--cc ` `--draft` `--dry-run` `--message ` `--message-html ` `--replace-recipients` `--to ` | saved as a draft unless `--allow-send` | | `screener approve` | Let a sender through | `--box ` `--seen` | | -| `screener clear` | Empty the Screener without deciding | | | +| `screener clear` | Trash everything waiting in the Screener | | | | `screener deny` | Turn a sender away | `--spam` | | | `screener history` | List the senders already screened | `--all` `--page ` | fields: id, status, email_address, decided | | `screener list` | List the senders waiting to be screened | `--all` `--page ` | fields: id, email_address, subject, topic_id | diff --git a/src/args.js b/src/args.js index b44c454..4c6cf66 100644 --- a/src/args.js +++ b/src/args.js @@ -149,6 +149,23 @@ function typeError(name, type, value) { return null; } +// Rules HEY enforces itself but its catalog doesn't mark (no "(required)" in the flag's +// description, no list of values), so hey-axi can fail loud before HEY runs: +// required: flags the command can't run without +// choices: the only values a flag takes +// together: flags that are only meaningful with each other +export const FLAG_RULES = { + "contact deliver": { required: ["--to"], choices: { "--to": ["imbox", "feed", "papertrail", "screened-out"] } }, + "event delete": { choices: { "--apply-to": ["current", "future"] }, together: [["--occurrence", "--apply-to"]] }, + "event edit": { choices: { "--apply-to": ["current", "future"] }, together: [["--occurrence", "--apply-to"]] }, +}; + +// Every required flag of a command: marked "(required)" by HEY, or listed in FLAG_RULES. +export function requiredFlags(node) { + const marked = (node.flags || []).filter((flag) => /\(required\)$/.test(flag.desc || "")).map((flag) => `--${flag.name}`); + return [...new Set([...marked, ...(FLAG_RULES[node.path]?.required || [])])]; +} + // AXI: reject unknown flags, flags without their value, values of the wrong type and // missing required flags before HEY is ever called. // Returns null, or { unknown } / { missingValue } / { badValue } / { missing }. @@ -174,9 +191,21 @@ export function validateFlags(node, flags, { values = [], missingValues = [] } = const problem = typeError(name, types.get(name), value); if (problem) return { badValue: problem }; } - const missing = (node.flags || []) - .filter((flag) => /\(required\)$/.test(flag.desc || "")) - .filter((flag) => !flags.has(`--${flag.name}`) && !(flag.shorthand && flags.has(`-${flag.shorthand}`))) - .map((flag) => `--${flag.name}`); - return missing.length ? { missing } : null; + const rules = FLAG_RULES[node.path] || {}; + for (const [name, value] of values) { + const choices = rules.choices?.[name]; + if (choices && !choices.includes(value)) return { badValue: `${name} takes one of ${choices.join(", ")}, got "${value}"` }; + } + const shorthandOf = (name) => (node.flags || []).find((flag) => `--${flag.name}` === name)?.shorthand; + const given = (name) => flags.has(name) || (shorthandOf(name) && flags.has(`-${shorthandOf(name)}`)); + const missing = requiredFlags(node).filter((name) => !given(name)); + if (missing.length) return { missing }; + for (const group of rules.together || []) { + const present = group.filter(given); + if (present.length && present.length < group.length) { + const absent = group.filter((name) => !given(name)); + return { badValue: `${present.join(" and ")} needs ${absent.join(" and ")} for \`${node.path}\` (pass ${group.join(" and ")} together)` }; + } + } + return null; } diff --git a/src/cli.js b/src/cli.js index 5fe09f7..1e21113 100755 --- a/src/cli.js +++ b/src/cli.js @@ -9,10 +9,11 @@ import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { createInterface } from "node:readline"; import { encode } from "@toon-format/toon"; -import { closestNames, loadBundledManifest, resolveCommand } from "./router.js"; +import { closestNames, coverageLabel, loadBundledManifest, resolveCommand } from "./router.js"; import { STDIN_VALUE_FLAGS, checkPolicy, contentProblem, noopFor, runMode, sendCommands, sendStaging, userOnlyPaths } from "./policy.js"; import { translateFailure, cleanLine, warningLines } from "./errors.js"; -import { RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, nodeValueFlags, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js"; +import { FLAG_RULES, RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, nodeValueFlags, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js"; +import { markSent, recipientProblem } from "./send.js"; import { argumentHelp, checkArity, oneOfHelp } from "./arity.js"; import { FieldError, LIST_FIELDS, DETAIL_FIELDS, carrySelectors, withSelectors, shapeEnvelope } from "./shape.js"; import { homeView } from "./home.js"; @@ -53,7 +54,7 @@ function usage(manifest) { "", "Run `hey-axi` with no arguments for a live home view (mail for this directory's scope + next commands).", "", - `commands (from hey ${manifest.hey_version}; run \`hey-axi --help\` for details):`, + `commands (from HEY CLI ${coverageLabel(manifest)}; run \`hey-axi --help\` for details):`, ]; for (const node of manifest.commands) { const subs = node.subcommands?.length ? ` ${node.subcommands.map((child) => child.name).join("|")}` : ""; @@ -103,11 +104,16 @@ export function commandHelp(node) { for (const child of node.subcommands) lines.push(` ${child.path} — ${child.short}`); } if (node.flags?.length) { + const rules = FLAG_RULES[node.path] || {}; lines.push("", "flags:"); for (const flag of node.flags) { - const extra = [flag.desc, `(${flagDefault(flag)})`].filter(Boolean).join(" "); + const name = `--${flag.name}`; + const required = rules.required?.includes(name) && !/\(required\)$/.test(flag.desc || "") ? "(required)" : ""; + const choices = rules.choices?.[name] ? `one of: ${rules.choices[name].join(", ")}` : ""; + const extra = [flag.desc, required, choices, `(${flagDefault(flag)})`].filter(Boolean).join(" "); lines.push(` ${flagLabel(flag)} ${extra}`); } + for (const group of rules.together || []) lines.push(` ${group.join(" and ")} go together: pass both or neither`); } lines.push("", "global flags (allowed on every command):", ...GLOBAL_HELP.map((line) => ` ${line}`)); const own = OWN_FLAG_HELP.filter(([applies]) => applies(node.path)).map(([, line]) => ` ${line}`); @@ -412,6 +418,10 @@ if (refusal) fail({ ok: false, ...refusal }, 2); const missingContent = contentProblem(node, flags, positionals); if (missingContent) fail({ ok: false, kind: "usage", ...missingContent }, 2); +// A recipient HEY would drop (no domain): refused here, so no HEY version can lose it. +const badRecipient = recipientProblem(path, scan.values); +if (badRecipient) fail({ ok: false, kind: "usage", ...badRecipient }, 2); + let heyArgs = stripAxiFlags(args); // `--message -` (and --note -, --content -): read the content from stdin. const fromStdin = scan.values.filter(([name, value]) => STDIN_VALUE_FLAGS.includes(name) && value === "-").map(([name]) => name); @@ -461,20 +471,29 @@ function markStaged(parsed) { return { ...marker, data: parsed }; } -function reportFailure(result) { - const { failure, exitCode, warnings } = translateFailure(result, { path }); +// A real delivery: a send command, not staged as a draft, not --draft or --dry-run. +const delivering = sendCommands().includes(path) && !staging && !flags.has("--draft") && !flags.has("--dry-run"); + +async function reportFailure(result) { + const { failure, exitCode, warnings } = translateFailure(result, { path, usage: usageLines, example: examplesFor(node)[0], coverage: coverageLabel(manifest) }); // The fix-it commands keep the invocation's --account/--base-url. for (const key of ["hint", "help"]) { if (Array.isArray(failure[key])) failure[key] = failure[key].map((line) => carrySelectors(line, carry)); else if (failure[key]) failure[key] = carrySelectors(failure[key], carry); } for (const line of warnings) process.stderr.write(`${line}\n`); - const noop = exitCode !== 0 && noopFor(path, failure, positionals, { to: flagValue(args, "to"), from: flagValue(args, "from") }); + const noop = exitCode !== 0 && noopFor(path, failure, positionals, { to: flagValue(args, "to"), from: flagValue(args, "from"), occurrence: flagValue(args, "occurrence") }); if (noop) { if (json) process.stdout.write(`${JSON.stringify(noop)}\n`); else output(noop); process.exit(0); } + if (failure.kind === "not_delivered") { + // HEY kept the message as a draft: remember it as one, so the next home view says so. + const { recordActivity } = await import("./activity.js"); + const { findScope } = await import("./scope.js"); + recordActivity({ path, positionals: failure.draft_id ? [failure.draft_id] : [], staged: true, scopeDir: scopeDirFor(process.cwd(), findScope) }); + } if (json) process.stdout.write(`${JSON.stringify(failure)}\n`); else output(failure); process.exit(exitCode); @@ -484,7 +503,7 @@ const mode = runMode(path, flags); if (mode === "interactive") { // Only reached with the explicit --interactive opt-in (and a terminal, except for mcp). const result = await runHeyInteractive(heyArgs); - if (result.error) reportFailure(result); + if (result.error) await reportFailure(result); process.exit(result.status === 0 ? 0 : 1); } if (raw || mode !== "json") { @@ -493,7 +512,7 @@ if (raw || mode !== "json") { // A watch stopped by a signal ended the way the agent asked: not an error. if (mode === "stream" && (result.signal || result.status === 130 || result.status === 143)) process.exit(0); // HEY's stdout here is data, not an error message: it never goes into the failure. - if (result.error || result.status !== 0) reportFailure({ ...result, stdout: "" }); + if (result.error || result.status !== 0) await reportFailure({ ...result, stdout: "" }); writeWarnings(result.stderr); if (mode !== "stream" && result.stdout?.length) process.stdout.write(result.stdout); process.exit(0); @@ -522,7 +541,7 @@ if (path === "version") { } const result = await runHey(command, ["--json"]); -if (result.status !== 0) reportFailure(result); +if (result.status !== 0) await reportFailure(result); writeWarnings(result.stderr); let parsed; @@ -551,6 +570,8 @@ try { }, 2); } shaped = markStaged(shaped); +// A delivered message: say so, and when Undo Send holds it, that the thread won't show it yet. +if (delivering && !full && !plainText) shaped = markSent(shaped); // A list whose HEY result carried no next steps still gets one (AXI principle 9): the // matching view/show command when there is one, else how to see every field. @@ -575,7 +596,7 @@ if (!full && !quiet && !plainText && shaped && typeof shaped === "object" && sha const { recordActivity } = await import("./activity.js"); const { findScope } = await import("./scope.js"); -recordActivity({ path, positionals, staged: Boolean(staging), sent: sendCommands().includes(path) && !staging && !flags.has("--draft") && !flags.has("--dry-run"), scopeDir: scopeDirFor(process.cwd(), findScope) }); +recordActivity({ path, positionals, staged: Boolean(staging), sent: delivering, scopeDir: scopeDirFor(process.cwd(), findScope) }); if (json) process.stdout.write(`${JSON.stringify(shaped)}\n`); else output(shaped); diff --git a/src/errors.js b/src/errors.js index 9d91e2b..cc71d35 100644 --- a/src/errors.js +++ b/src/errors.js @@ -8,10 +8,12 @@ // A `help` line always says what to do next. import { heyToAxi } from "./text.js"; +import { HEY_BAD_ADDRESS, notDelivered, recipientRefusal } from "./send.js"; // HEY's exit codes (`hey help exit-codes`) → a kind, a message and the next step. const KINDS = { - 1: { kind: "command_error", message: "the command failed", help: "Run `hey-axi --help` to check the arguments" }, + // No fixed help: the fix for an unrecognized failure is built from the command (fixFor). + 1: { kind: "command_error", message: "the command failed" }, 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)" }, 3: { kind: "auth", message: "not signed in to HEY", help: "Run `hey-axi auth login --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`" }, 4: { kind: "forbidden", message: "this account can't do that", help: "Check `hey-axi account list` and --account" }, @@ -24,6 +26,22 @@ const KINDS = { const USAGE_CODES = new Set(["usage", "invalid_argument", "invalid_flag", "validation"]); +const DOCTOR = "Run `hey-axi doctor` to check HEY's sign-in, configuration and connection"; + +// The specific next step for a failure that matched no known kind, or a usage error: the +// command's own USAGE line and an example inline (no "see --help" round trip), then the +// one command that diagnoses HEY itself. +// context: { path, usage: ["usage: hey-axi thread read "], example } +function fixFor(context = {}, { usageError = false } = {}) { + const { path, usage = [], example } = context; + if (!path) return [DOCTOR]; + const lines = usage.length ? [...usage] : []; + if (example) lines.push(`example: ${example}`); + if (!lines.length) lines.push(`Run \`hey-axi ${path} --help\` for its arguments and flags`); + if (!usageError) lines.push("Run `hey-axi doctor` if the arguments are right (it checks HEY's sign-in, configuration and connection)"); + return lines; +} + // AXI exit code for a failed HEY run. export function axiExitCode(status, code) { if (status === 0) return 0; @@ -44,6 +62,12 @@ const INTERNAL = /^(Uncaught )?(TypeError|ReferenceError|SyntaxError|RangeError| 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)/; // Names of HTTP/database libraries; a short value that is just one of these says nothing. 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; +// The ones that are never ordinary words: a line or meta value that still names one, +// even mid-sentence ("request via superagent failed"), is dependency detail and is dropped. +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; +// HEY's error codes are snake_case words (usage, not_found, not_delivered ...); anything +// else in `code` (SqliteError, ECONNRESET) is a dependency's and is left out. +const HEY_CODE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/; // Low-level network codes become plain words. const NETWORK = [ [/\bECONNRESET\b/, "the connection was reset"], [/\bECONNREFUSED\b/, "the connection was refused"], @@ -61,12 +85,12 @@ export function cleanLine(text) { if (network) return network[1]; if (INTERNAL.test(line) || DEPENDENCY.test(line)) return ""; // Library prefixes ("axios:", "fetch failed:", "Get \"https://…\":") and raw URLs add nothing. - line = line.replace(/^((axios|undici|node-fetch|got|faraday|sqlite3?|better-sqlite3)(\s*error)?|fetch failed|request failed|(get|post|put|patch|delete) "[^"]*"):?\s*/i, "") + 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, "") .replace(/\bhttps?:\/\/\S+/g, "the HEY server") // File paths and source locations (/usr/lib/…/x.js:12:3, C:\\…) are internal detail. .replace(/(^|\s)(\/|~\/|[A-Za-z]:\\)[^\s:]+(:\d+)*/g, "$1a local file").replace(/\s{2,}/g, " ").trim(); // A line that is still a dependency error after its prefix came off is dropped too. - if (!line || INTERNAL.test(line) || DEPENDENCY.test(line) || /^[\w./-]+$/.test(line) && LIBRARY.test(line)) return ""; + if (!line || INTERNAL.test(line) || DEPENDENCY.test(line) || LIBRARY_NAME.test(line) || /^[\w./-]+$/.test(line) && LIBRARY.test(line)) return ""; return heyToAxi(line.length > 200 ? `${line.slice(0, 200)}…` : line); } @@ -88,7 +112,7 @@ export function cleanMeta(value, depth = 0) { if (/[\r\n\u001b]/.test(value) || value.length > 200) return cleanLine(value); // Short values are kept as they are, unless they are a dependency's error or name. const bare = value.trim().replace(/^(error|Error|ERROR):\s*/, ""); - return INTERNAL.test(bare) || DEPENDENCY.test(bare) || (/^[\w./@ -]{1,40}$/.test(bare) && LIBRARY.test(bare) && !/\s\w+\s\w+/.test(bare)) ? undefined : value; + 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; } if (!value || typeof value !== "object" || depth > 3) return depth > 3 ? undefined : value; if (Array.isArray(value)) return value.slice(0, 20).map((item) => cleanMeta(item, depth + 1)).filter((item) => item !== undefined && item !== ""); @@ -124,31 +148,59 @@ function findEnvelope(text) { } // Returns { failure, exitCode, warnings }: the object to print, hey-axi's exit code, -// and diagnostic lines for stderr. -export function translateFailure(result, { path } = {}) { - const raw = KINDS[result.status] || KINDS[1]; - const info = path ? { ...raw, help: raw.help.replace("", path) } : raw; +// and diagnostic lines for stderr. `context` ({ path, usage, example }) names the command +// so the fix-it lines can be specific. +export function translateFailure(result, context = {}) { + const { path } = context; + const info = KINDS[result.status] || KINDS[1]; + const help = (kind = info) => kind.help || fixFor(context); if (result.error) { - return { failure: { ok: false, error: result.error, kind: info.kind, help: info.help }, exitCode: 1, warnings: [] }; + return { failure: { ok: false, error: result.error, kind: info.kind, help: help() }, exitCode: 1, warnings: [] }; } for (const text of [result.stderr, result.stdout]) { const found = findEnvelope(text); if (!found) continue; const { envelope } = found; + // A send HEY refused and kept as a draft: say it wasn't sent, and never "retry". + if (envelope.code === "not_delivered") return { failure: notDelivered(envelope), exitCode: 1, warnings: warnings(found.prefix) }; // Even HEY's own envelope is translated: one clean line, no stack frames or escapes. const failure = { ok: false, error: cleanLine(String(envelope.error)) || info.message, kind: info.kind }; - if (envelope.code !== undefined && envelope.code !== "" && envelope.code !== "unknown") failure.code = String(envelope.code); + const code = envelope.code === undefined || envelope.code === null ? "" : String(envelope.code); + if (code && code !== "unknown" && HEY_CODE.test(code) && !LIBRARY.test(code)) failure.code = code; const meta = envelope.meta === undefined || envelope.meta === "" ? undefined : cleanMeta(envelope.meta); if (meta !== undefined) failure.meta = meta; - const usage = USAGE_CODES.has(envelope.code) || /^usage: /i.test(String(envelope.error)); + const usage = USAGE_CODES.has(code) || /^usage: /i.test(String(envelope.error)); if (usage) failure.kind = "usage"; + // hey-axi checked the command and flags against its catalog before HEY ran, so HEY + // not knowing one means the installed HEY is older than that catalog (for example + // v1.7.0 and `contact deliver`, which is only on HEY's main branch so far). + const unknown = usage && path && failure.error.match(/^unknown (flag|shorthand flag|command)[:\s]+["']?([^"'\s]+)["']?/i); + if (unknown) { + const what = /command/i.test(unknown[1]) ? `the command \`${path}\`` : `${unknown[2]} for \`${path}\``; + return { + failure: { + ok: false, + kind: "hey_outdated", + error: `the installed HEY CLI doesn't have ${what}${context.coverage ? `; hey-axi's catalog is HEY CLI ${context.coverage}` : ""}`, + 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"], + }, + exitCode: 1, + warnings: warnings(found.prefix), + }; + } + // HEY refused a recipient it would otherwise drop: name it and the fix. + const badAddress = usage && failure.error.match(HEY_BAD_ADDRESS); + if (badAddress) { + const refusal = recipientRefusal(path, badAddress[1]); + 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) }; + } const hint = envelope.hint ? cleanLine(String(envelope.hint)) : ""; if (hint && !/^Run 'hey(-axi)? --help'/.test(hint)) failure.hint = hint; - failure.help = usage ? `Run \`hey-axi ${path || ""} --help\` for its arguments and flags` : info.help; - return { failure, exitCode: usage ? 2 : axiExitCode(result.status, envelope.code), warnings: warnings(found.prefix) }; + failure.help = usage ? fixFor(context, { usageError: true }) : help(); + return { failure, exitCode: usage ? 2 : axiExitCode(result.status, code), warnings: warnings(found.prefix) }; } const detail = cleanLine(result.stderr) || cleanLine(result.stdout); - const failure = { ok: false, error: detail ? `${info.message}: ${detail}` : info.message, kind: info.kind, help: info.help }; + const failure = { ok: false, error: detail ? `${info.message}: ${detail}` : info.message, kind: info.kind, help: help() }; return { failure, exitCode: axiExitCode(result.status), warnings: [...warnings(result.stderr), ...warnings(result.stdout)] }; } diff --git a/src/examples.js b/src/examples.js index 71e25b3..142b98a 100644 --- a/src/examples.js +++ b/src/examples.js @@ -6,6 +6,7 @@ import { heyToAxi } from "./text.js"; import { patternsFor } from "./arity.js"; import { LIST_FIELDS, parseField } from "./shape.js"; import { userOnlyPaths } from "./policy.js"; +import { requiredFlags } from "./args.js"; // "(default …)" text for one flag, including zero, false and empty defaults. export function flagDefault(flag) { @@ -31,8 +32,9 @@ function synthesized(node) { const base = fromHey ? heyToAxi(fromHey) : ["hey-axi", node.path, ...args, ...(oneOf ? [oneOf] : [])].join(" "); const out = [base]; const own = (node.flags || []).filter((flag) => !(pattern?.oneOf || []).flat().includes(`--${flag.name}`)); - const valued = own.find((flag) => flag.value && !/\(required\)$/.test(flag.desc || "")); - const required = own.filter((flag) => /\(required\)$/.test(flag.desc || "")); + const needed = requiredFlags(node); + const valued = own.find((flag) => flag.value && !needed.includes(`--${flag.name}`)); + const required = own.filter((flag) => needed.includes(`--${flag.name}`)); if (required.length && !fromHey) { out[0] = `${base} ${required.map((flag) => `--${flag.name} ${placeholder(flag)}`).join(" ")}`; } diff --git a/src/guide.js b/src/guide.js index f096ca7..b399da6 100644 --- a/src/guide.js +++ b/src/guide.js @@ -20,7 +20,7 @@ export const HOME_HELP = [ // The live home view's help: the static lines, pointed at the scope's command, with the // scope's --account carried into every suggestion, plus "see the rest" / --full / // review-drafts hints when they apply. -export function homeHelp({ base = "hey-axi box view imbox", carry = [], more = false, total, truncated = false, drafts = false, search = false } = {}) { +export function homeHelp({ base = "hey-axi box view imbox", carry = [], more = false, total, truncated = false, drafts = false, search = false, unsure = false } = {}) { const lines = HOME_HELP.map((line) => { if (line.includes("box view imbox --limit 50")) { if (!more) return `Run \`${withSelectors("hey-axi box list", carry)}\` for other boxes (feed, papertrail, aside, later)`; @@ -30,6 +30,8 @@ export function homeHelp({ base = "hey-axi box view imbox", carry = [], more = f return line.replace(/`(hey-axi [^`]*)`/, (_, command) => (command === "hey-axi --help" ? `\`${command}\`` : `\`${withSelectors(command, carry)}\``)); }).filter(Boolean); if (truncated) lines.push(`Run \`${base} --full\` to see complete content`); + // An empty page HEY gave no paging signal for: how to check every page. + if (unsure && !search) lines.unshift(`Run \`${base} --all\` to check every page`); if (drafts) lines.unshift(`Run \`${withSelectors("hey-axi draft list", carry)}\` to review drafts saved last session (not sent)`); return lines; } diff --git a/src/home.js b/src/home.js index ba50fe0..4505efb 100644 --- a/src/home.js +++ b/src/home.js @@ -68,15 +68,24 @@ export async function homeView(runHey, { execPath = process.argv[1], cwd = proce let threads = Array.isArray(data) ? data : data?.[found?.key]; if (threads && threads.length > query.limit) threads = threads.slice(0, query.limit); size.shown = threads?.length ?? 0; + const where = query.label.split(" (")[0]; if (!threads?.length && (size.more || size.total > 0)) { // An empty page while HEY reports more: say exactly that, not "nothing". out.count = countLine(size); out.mail = `0 threads on this page; HEY reports ${size.total > 0 ? `${size.total} in total` : "more"}`; - } else if (!threads?.length) out.mail = `0 threads: nothing in ${query.label.split(" (")[0]}`; - else { + } else if (!threads?.length && size.complete) { + // "Nothing" only when HEY says nothing is left (a total of 0, has_more: false, no next page). + out.count = countLine(size); + out.mail = `0 threads: nothing in ${where}`; + } else if (!threads?.length) { + // An empty first page with no paging signal either way: report the page, claim no more. + out.count = countLine(size); + out.mail = `0 threads on this page of ${where}; HEY reported no total and no further pages`; + } else { out.count = countLine(size); out.threads = threads; } - out.help = homeHelp({ base: query.base, carry: query.carry, more: size.more, total: size.total, truncated, drafts: previous?.drafts > 0, search: query.path === "search" }); + const unsure = !threads?.length && !size.more && !(size.total > 0) && !size.complete; + out.help = homeHelp({ base: query.base, carry: query.carry, more: size.more, total: size.total, truncated, drafts: previous?.drafts > 0, search: query.path === "search", unsure }); return out; } diff --git a/src/hooks.js b/src/hooks.js index 2ab4420..2a5e5e9 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -124,7 +124,10 @@ function hasEndHook(path) { } } -function openCodeEndPlugin(command) { +// One default export that both OpenCode generations load (the same shape axi-sdk-js +// 0.1.13 uses for the session-start plugin): OpenCode 2 checks `id` and runs `setup(ctx)`, +// subscribing to its event stream; OpenCode 1 runs `server(input)` and uses its `event` hook. +export function openCodeEndPlugin(command) { return `// ${END_PLUGIN_MARKER} // Generated by \`hey-axi setup hooks\`. \`hey-axi setup hooks --remove\` deletes it. import { spawn } from "node:child_process"; @@ -141,12 +144,36 @@ function capture(directory, sessionID) { }); } -export const HeyAxiSessionEndPlugin = async ({ directory }) => ({ - event: async ({ event }) => { - const idle = event.type === "session.idle" || (event.type === "session.status" && event.properties?.status?.type === "idle"); - if (idle || event.type === "session.deleted") await capture(directory, event.properties?.sessionID ?? event.properties?.info?.id); +// A session went idle or was deleted: record what hey-axi did in it. +async function onEvent(event, directory) { + if (!event || typeof event !== "object") return; + const properties = event.properties ?? {}; + const idle = event.type === "session.idle" || (event.type === "session.status" && properties.status?.type === "idle"); + if (idle || event.type === "session.deleted") { + await capture(properties.info?.directory ?? directory, properties.sessionID ?? properties.info?.id ?? event.sessionID); + } +} + +export default { + id: "axi-hey-axi-session-end", + // OpenCode 2 entrypoint. + setup(ctx) { + const controller = new AbortController(); + const directory = ctx.location?.directory; + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) await onEvent(event, directory); + } catch { + // the stream ended (plugin unloaded): nothing to record + } + })(); + return () => controller.abort(); + }, + // OpenCode 1 entrypoint. + async server({ directory }) { + return { event: async ({ event }) => onEvent(event, directory) }; }, -}); +}; `; } diff --git a/src/manifest.json b/src/manifest.json index 555a2c0..f4a6139 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,8 @@ { - "source": "hey commands --json + hey --help", - "hey_version": "1.7.0", - "hey_commit": "924703c5d7e8a24bb46acba0fe27bbc27f9c1de9", + "source": "hey commands --json + hey --help (basecamp/hey-cli main at 8bf9310, 2026-10-03; unreleased, built from source)", + "hey_version": "1.7.0+main.8bf9310", + "hey_commit": "8bf9310c0b1df7448e39ed1f81826ae795bd8b5c", + "hey_commit_date": "2026-10-03", "commands": [ { "name": "account", @@ -199,7 +200,7 @@ "path": "box", "short": "List HEY boxes and their email threads", "usage": "box ", - "notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", + "notes": "Accepts a box's short name (imbox, feed, papertrail, setaside, replylater, bubbleup), kind (feedbox, trailbox, …) or display name (including one the user gave the box) in any case, or a numeric ID. hey search --in and hey move --to take the same spellings, but not every box: search narrows only to imbox, feed and papertrail (plus trash) and refuses anything else as a usage error, and move takes a box ID or any box but bubbleup (use hey bubble up). An unknown name is not_found here and in move. Trash is not a box: search it with hey search --in trash and move threads there with hey trash. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", "flags": [ { "name": "all", @@ -254,7 +255,7 @@ "name": "view", "path": "box view", "short": "List email threads in a box", - "notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", + "notes": "Accepts a box's short name (imbox, feed, papertrail, setaside, replylater, bubbleup), kind (feedbox, trailbox, …) or display name (including one the user gave the box) in any case, or a numeric ID. hey search --in and hey move --to take the same spellings, but not every box: search narrows only to imbox, feed and papertrail (plus trash) and refuses anything else as a usage error, and move takes a box ID or any box but bubbleup (use hey bubble up). An unknown name is not_found here and in move. Trash is not a box: search it with hey search --in trash and move threads there with hey trash. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", "flags": [ { "name": "all", @@ -281,8 +282,8 @@ ], "examples": [ "hey box view imbox", - "hey box view imbox --limit 10", - "hey box view imbox --page next-cursor" + "hey box view papertrail", + "hey box view imbox --limit 10" ] } ], @@ -292,7 +293,7 @@ "examples": [ "hey box list", "hey box view imbox", - "hey box view imbox --limit 10" + "hey box view papertrail" ] }, { @@ -331,7 +332,7 @@ "name": "pop", "path": "bubble pop", "short": "Cancel bubble-ups", - "notes": "Accepts one or more box item IDs from hey box view output. Cancels each thread's bubble-up.", + "notes": "Accepts one or more box item IDs from hey box view output. Cancels each thread's bubble-up; the thread moves to the Imbox and is marked seen.", "synopsis": [ "hey bubble pop ... [flags]" ], @@ -344,7 +345,7 @@ "name": "up", "path": "bubble up", "short": "Bubble email threads up", - "notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at its morning hour of that day, or at its evening hour (18:00) when the date is today. --tomorrow, --weekend and --next-week land at the morning hour of tomorrow, the coming Saturday, and next Monday.", + "notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at 08:00 UTC that day, or at 18:00 UTC when the date is today in UTC, HEY's clock for bubbling up, not the local one. --tomorrow, --weekend and --next-week land at 08:00 UTC tomorrow, the next Saturday, and next Monday.", "flags": [ { "name": "next-week", @@ -817,7 +818,7 @@ "name": "compose", "path": "compose", "short": "Write and send a new email", - "notes": "--from selects a configured sender email or ID from account senders; --account must agree. --from is only for new messages. Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out.", + "notes": "--from selects a configured sender email or ID from account senders; --account must agree. --from is only for new messages. Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not; --thread-id answers the thread's latest emailed message, never a note (kind \"comment\") or share notice (kind \"access_notice\"), and a thread with no emailed message is refused as not_found. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out. A send answers the new message's id and the topic_id of its thread (for hey thread read), with subject and delayed (true while Undo Send holds it back) — only the fields HEY's answer carries, never guessed. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send , not by sending again, which only makes another draft.", "flags": [ { "name": "attach", @@ -883,7 +884,7 @@ "name": "thread-id", "value": true, "type": "string", - "desc": "Reply to this thread instead of starting a new one", + "desc": "Reply to this thread's latest emailed message instead of starting a new one", "default": "" }, { @@ -961,7 +962,7 @@ "name": "contact", "path": "contact", "short": "Manage contacts", - "notes": "Contact IDs come from `hey contact list`. Hiding and bundling are reversible. Private notes are managed under `hey contact note`.", + "notes": "Contact IDs come from `hey contact list`. Delivery is managed with `hey contact deliver`; hiding and bundling are reversible. Private notes are managed under `hey contact note`.", "subcommands": [ { "name": "add", @@ -1010,7 +1011,7 @@ "name": "bundle", "path": "contact bundle", "short": "Bundle a contact's mail", - "notes": "Bundling is a per-contact preference. HEY applies it when the contact's delivery setting supports bundles. Reverse it with `hey contact unbundle `.", + "notes": "Bundling is a per-contact preference. HEY bundles only a contact with no box preference or one sent to the Paper Trail; for any other it answers success and changes nothing. Reverse it with `hey contact unbundle `.", "synopsis": [ "hey contact bundle [flags]" ], @@ -1018,6 +1019,28 @@ "hey contact bundle 12345" ] }, + { + "name": "deliver", + "path": "contact deliver", + "short": "Choose where a contact's email arrives", + "notes": "Use a contact ID from `hey contact list`, not a clearance ID, box item ID, box ID, or email address. Pass the exact --to token: imbox, feed, papertrail, or screened-out.", + "flags": [ + { + "name": "to", + "value": true, + "type": "string", + "desc": "Delivery destination: imbox, feed, papertrail, or screened-out", + "default": "" + } + ], + "synopsis": [ + "hey contact deliver [flags]" + ], + "examples": [ + "hey contact deliver 12345 --to feed", + "hey contact deliver 12345 --to screened-out" + ] + }, { "name": "hide", "path": "contact hide", @@ -1062,7 +1085,7 @@ "name": "note", "path": "contact note", "short": "Manage private contact notes", - "notes": "Private notes support show, set, and delete. Set accepts --note, positional content, stdin, or $EDITOR.", + "notes": "Private notes support show, set, and delete. Show answers note_markdown, the form set takes, and note_markdown_lossless; when that is false, change note_html and set it with --note-html instead. Set replaces the whole note and accepts --note, positional content, stdin, or $EDITOR.", "subcommands": [ { "name": "delete", @@ -1079,7 +1102,7 @@ "name": "set", "path": "contact note set", "short": "Write or edit a private contact note", - "notes": "Accepts --note, positional content, stdin, or opens $EDITOR with the existing note. The note is Markdown, or raw HTML via --note-html. Use the delete subcommand to clear a note.", + "notes": "Replaces the whole note. Accepts --note, positional content, stdin, or opens $EDITOR with the existing note as Markdown, which is refused when that Markdown would drop part of the note. The note is Markdown, or raw HTML via --note-html. To add to a note, read note_markdown from hey contact note show, change it, and set all of it; when note_markdown_lossless is false, change note_html and set it with --note-html instead. Use the delete subcommand to clear a note.", "flags": [ { "name": "note", @@ -1102,19 +1125,22 @@ ], "examples": [ "hey contact note set 12345 \"Prefers email\"", - "hey contact note set 12345 --note \"Prefers email\"" + "hey contact note set 12345 --note \"Prefers email\"", + "hey contact note set 12345 --note-html \"

Prefers email

\"" ] }, { "name": "show", "path": "contact note show", "short": "Read a private contact note", + "notes": "JSON answers note (HEY's plain text, formatting dropped), note_html (as HEY serves it), note_markdown (the note as Markdown, the form hey contact note set takes) and note_markdown_lossless. When note_markdown_lossless is false the note holds an attachment or other markup Markdown cannot carry: change note_html and write it back with --note-html instead.", "synopsis": [ "hey contact note show [flags]" ], "examples": [ "hey contact note show 12345", - "hey contact note show 12345 --json" + "hey contact note show 12345 --json", + "hey contact note show 12345 --jq '.data.note_markdown'" ] } ], @@ -1126,7 +1152,7 @@ "name": "show", "path": "contact show", "short": "View a contact", - "notes": "Returns contact details, aliases, screening status, and the private note. The embedded postings are one page of the contact's threads; hey contact threads pages through all of them.", + "notes": "Returns contact details, aliases, screening status, and the private note: note, note_html and note_markdown, empty for a contact with no note, and note_markdown_lossless, true then (note_markdown is the form hey contact note set takes when note_markdown_lossless is true). The embedded postings are one page of the contact's threads; hey contact threads pages through all of them.", "synopsis": [ "hey contact show [flags]" ], @@ -1367,7 +1393,7 @@ "name": "send", "path": "draft send", "short": "Deliver a draft", - "notes": "Sends the draft as it stands — recipients are required, added with `hey draft edit --to`. Delivery goes through HEY's undo window. Scheduling a delivery is done in a HEY app for now; the API cannot yet name an exact instant.", + "notes": "Sends the draft as it stands — recipients are required, added with `hey draft edit --to`. Delivery goes through HEY's undo window. The answer carries the delivered message's id and its thread's topic_id, with subject and delayed; when HEY's answer names no entry, the id stays the draft's and only the fields HEY served are added. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. Scheduling a delivery is done in a HEY app for now; the CLI has no flag for it, and HEY's API schedules only to a whole hour. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send , not by sending again, which only makes another draft.", "synopsis": [ "hey draft send [flags]" ], @@ -1397,7 +1423,7 @@ "name": "event", "path": "event", "short": "Read and manage calendar events", - "notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit changes a whole series; one day of it is edit --occurrence --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id.", + "notes": "Subcommands: list, day, week, add, edit, delete. \"What's on the schedule today?\" is answered by day, not list: day and week read the span as HEY draws it, with a repeating event expanded into the occurrences inside it, over the calendars switched on in HEY; without a date they, and list's window, start from today in the HEY account's time zone. list reads what calendars hold — every calendar unless --calendar names one — and a repeating event is one row, its series, on the day the series began. An edit is not a patch on HEY's side: it resends the notes, location, link, attached email, reminders and time zones the event already carries, so notes lose their formatting and a countdown is removed unless --countdown names one again. edit changes a whole series; one day of it is edit --occurrence --apply-to current|future, which keeps the countdown and refuses to flatten notes unless --allow-plain-notes or --notes is given. --apply-to future starts a new series and requires --repeat with its complete schedule; a preset alone means forever and custom copies an existing opaque schedule (a count-based rule can restart its full count). A virtual day of an opaque custom schedule is read from HEY's Day view and refused if that view no longer serves it. A realized custom day cannot be split safely; use a virtual occurrence, edit that day alone, or edit the whole series. A realized preset occurrence moved away from its series time must be moved back with --apply-to current before a future split. Read the day again afterward for the new series id. delete deletes the whole series; one day of it is delete --occurrence --apply-to current|future, whether or not HEY has written that day out. delete refuses a written-out day's own id, because HEY would draw the day again from the series. add and edit read clock times, and today when --starts-on is left out, in the HEY account's time zone unless --time-zone names one, and refuse when they need it and the account has none; an edit keeps a zoned event's zone and leaves a zoneless event zoneless.", "subcommands": [ { "name": "add", @@ -1523,7 +1549,7 @@ "name": "time-zone", "value": true, "type": "string", - "desc": "IANA zone the times are written in (defaults to this machine's)", + "desc": "IANA zone the times are written in, such as America/New_York (defaults to your HEY account's)", "default": "" }, { @@ -1575,12 +1601,30 @@ "name": "delete", "path": "event delete", "short": "Delete an event", - "notes": "Deletes the whole event, recurring series included. An event on a shared calendar is deleted for everybody on it.", + "notes": "delete deletes the whole event, recurring series included, for everybody on a shared calendar. One day of a series is delete --occurrence --apply-to current|future: current deletes that day alone; future deletes it and every day after, and from the series' first day that is the whole series. A day HEY wrote out on its own (own id in id/recording_id, series in parent_id) is refused by its own id, because deleting that id lets HEY draw the day again from the series; use the occurrence form. future is refused from a written-out day moved off its series time or on an opaque custom schedule.", + "flags": [ + { + "name": "apply-to", + "value": true, + "type": "string", + "desc": "How much of the series an --occurrence delete reaches: current (that day alone) or future (that day and every one after it)", + "default": "" + }, + { + "name": "occurrence", + "value": true, + "type": "string", + "desc": "One day of a repeating event, by the occurrence_id 'hey event day' serves (_)", + "default": "" + } + ], "synopsis": [ "hey event delete [flags]" ], "examples": [ - "hey event delete 4821" + "hey event delete 4821", + "hey event delete 4821 --occurrence 4821_2026-09-15 --apply-to current", + "hey event delete 4821 --occurrence 4821_2026-09-15 --apply-to future" ] }, { @@ -1609,7 +1653,7 @@ "name": "calendar", "value": true, "type": "int", - "desc": "Calendar ID to file the event on", + "desc": "Calendar ID to move the event to", "default": "0" }, { @@ -1726,7 +1770,7 @@ "name": "time-zone", "value": true, "type": "string", - "desc": "IANA zone the times are written in (defaults to this machine's)", + "desc": "IANA zone the times are written in, such as America/New_York (defaults to the event's own zone, else your HEY account's)", "default": "" }, { @@ -1831,7 +1875,7 @@ "name": "forward", "path": "forward", "short": "Forward the latest message in a thread", - "notes": "Forwards the latest entry in a thread with HEY's quoted content. Accepts comma-separated recipients and an optional note via -m.", + "notes": "Forwards the latest emailed message in a thread with HEY's quoted content — never an internal note or share notice posted after it. Accepts comma-separated recipients and an optional note via -m. The answer's thread_id and entry_id name what was forwarded; id and topic_id name the new message and the thread it started, as HEY's answer names them. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send , not by sending again, which only makes another draft.", "flags": [ { "name": "bcc", @@ -1882,7 +1926,7 @@ "name": "habit", "path": "habit", "short": "Create and manage habits", - "notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday).", + "notes": "Subcommands: list, create, edit, delete, complete, uncomplete. Use list --ids-only to pipe IDs to the rest. Days accept weekday names or 0 (Sunday) through 6 (Saturday). Without --date, list, complete and uncomplete use today in the HEY account's time zone; complete and uncomplete refuse when the account has none, so pass --date.", "subcommands": [ { "name": "complete", @@ -2065,7 +2109,7 @@ "name": "ignore", "path": "ignore", "short": "Ignore email threads", - "notes": "Accepts one or more box item IDs from hey box view output. Ignored threads remain in their box and can be restored with hey stop-ignoring.", + "notes": "Accepts one or more box item IDs from hey box view output. Ignoring marks a thread seen and leaves it in its box; while it is ignored hey unseen has no effect. hey stop-ignoring resumes notifications but leaves it seen.", "synopsis": [ "hey ignore ... [flags]" ], @@ -2078,7 +2122,7 @@ "name": "journal", "path": "journal", "short": "Read and write journal entries", - "notes": "Subcommands: list, read, write. Read defaults to today. Write accepts --content, stdin, or opens $EDITOR; content is Markdown, or raw HTML via --content-html.", + "notes": "Subcommands: list, read, write. Read and write default to today in the HEY account's time zone; write refuses without one, so name the date. Read JSON answers content (HTML as HEY serves it), content_markdown (the form write takes) and content_markdown_lossless; when that is false, change content and write it with --content-html instead. Write replaces the whole entry and accepts --content, stdin, or opens $EDITOR (refused for an entry whose Markdown is not lossless); content is Markdown, or raw HTML via --content-html.", "subcommands": [ { "name": "list", @@ -2138,7 +2182,7 @@ "examples": [ "hey journal read", "hey journal read 2026-03-15", - "hey journal read --html" + "hey journal read --html > entry.html" ] }, { @@ -2158,7 +2202,7 @@ "name": "content-html", "value": true, "type": "string", - "desc": "Journal content as raw HTML instead of Markdown", + "desc": "Journal content as raw HTML instead of Markdown; the trix-content wrapper HEY serves an entry in is taken off", "default": "" } ], @@ -2426,7 +2470,7 @@ "name": "reply", "path": "reply", "short": "Reply to a thread", - "notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete.", + "notes": "Replies to the latest emailed message in a thread — never a note (kind \"comment\") or share notice (kind \"access_notice\"), which are internal to the thread and never emailed; a thread with no emailed message is refused as not_found. The reply is addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete. A send answers the reply's id and the topic_id of the thread it landed on — a new thread when HEY breaks the reply out of this one — as HEY's answer names them. While delayed is true, HEY leaves the message out of its thread, so hey thread read shows the thread without it (a thread it started reads as not found) until Undo Send releases it. A send HEY refuses — usually the account's sending limit — is kept as a draft and fails with not_delivered (exit 7), naming the draft in meta.draft_id; send it later with hey draft send , not by sending again, which only makes another draft.", "flags": [ { "name": "attach", @@ -2517,7 +2561,7 @@ }, { "name": "seen", - "desc": "Mark what they already sent as seen", + "desc": "Mark what they already sent as seen (reliable only for a sender with one waiting thread)", "default": "false" } ], @@ -2533,8 +2577,8 @@ { "name": "clear", "path": "screener clear", - "short": "Empty the Screener without deciding", - "notes": "Clears the whole queue for the account, not one sender, and takes no confirmation. It decides nothing: senders reappear in The Screener on their next email, so what they already sent stays hidden until then. HEY queues the work, so `hey screener list` may still show the queue for a moment afterwards.", + "short": "Trash everything waiting in the Screener", + "notes": "Clears the whole queue for the account, not one sender, and takes no confirmation: everything waiting goes to Trash, or loses your access if it is a shared thread. It approves and denies no one, so senders reappear in The Screener on their next email. Confirm with the user first. HEY queues the work, so `hey screener list` may still show the queue for a moment afterwards.", "synopsis": [ "hey screener clear [flags]" ], @@ -2547,11 +2591,11 @@ "name": "deny", "path": "screener deny", "short": "Turn a sender away", - "notes": "Clearance IDs come from `hey screener list`, not contact IDs. --spam also marks what they already sent as spam and trains HEY's filter on it, which is harder to undo than denying. Reverse with `hey screener approve `.", + "notes": "Clearance IDs come from `hey screener list`, not contact IDs. --spam also marks what they already sent as spam and, for a single ID, trains HEY's filter on it, which is harder to undo than denying; several IDs go through HEY's bulk endpoint, which does not train. Reverse with `hey screener approve `.", "flags": [ { "name": "spam", - "desc": "Also mark what they sent as spam and train the filter", + "desc": "Also mark what they sent as spam (and train the filter, for a single ID)", "default": "false" } ], @@ -2629,7 +2673,7 @@ "name": "search", "path": "search", "short": "Search email threads and messages", - "notes": "Returns one result per thread. id is the box item ID for organization actions when the thread has an active box item, topic_id opens the thread, and messages contains the matching message summaries. Use `hey search filters` to discover box, date, label, and attachment values.", + "notes": "Returns one result per thread. id is the box item ID for organization actions, omitted when you have no box item for the thread; topic_id opens the thread, and messages contains the matching message summaries. Use `hey search filters` to discover box, date, label, and attachment values.", "flags": [ { "name": "all", @@ -2675,7 +2719,7 @@ "name": "in", "value": true, "type": "string", - "desc": "Box: imbox, feed, papertrail, or trash", + "desc": "Box: imbox, feed, papertrail, or trash (or the kind or name of one of those three boxes)", "default": "" }, { @@ -3122,7 +3166,7 @@ "name": "content-html", "value": true, "type": "string", - "desc": "Snippet content as raw HTML instead of Markdown", + "desc": "Snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off", "default": "" }, { @@ -3182,7 +3226,7 @@ "name": "content-html", "value": true, "type": "string", - "desc": "New snippet content as raw HTML instead of Markdown", + "desc": "New snippet content as raw HTML instead of Markdown; the trix-content wrapper HEY serves content_html in is taken off", "default": "" }, { @@ -3242,7 +3286,7 @@ "name": "read", "path": "thread read", "short": "Read a thread", - "notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; the creator is the account user, while a non-default send-as address is the optional sender on a hydrated entry. Each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one
per entry with a From/To/CC/BCC header and HEY's original body HTML. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.", + "notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; the creator is the entry's author (the external sender for inbound mail, your own contact for mail you sent), while a non-default send-as address you sent from is the optional sender on a hydrated entry. Each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one
per entry with a From/To/CC/BCC header and HEY's original body HTML. Each entry's kind says what it is: message for mail, while comment (a note) and access_notice (a share notice) are internal — visible to everyone with access to the thread, never emailed — and --markdown, styled and --html output head them as a note or share notice rather than with a From line. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.", "flags": [ { "name": "allow-partial", @@ -3498,7 +3542,7 @@ "name": "todo", "path": "todo", "short": "Create and manage to-dos", - "notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete.", + "notes": "Subcommands: list, add, complete, uncomplete, delete. Use list --ids-only to pipe IDs to complete/delete. add files on today in the HEY account's time zone without --date, and refuses when the account has none, so pass --date.", "subcommands": [ { "name": "add", @@ -3509,7 +3553,7 @@ "name": "date", "value": true, "type": "string", - "desc": "Due date (YYYY-MM-DD)", + "desc": "Due date (YYYY-MM-DD, defaults to today)", "default": "" }, { @@ -3725,7 +3769,7 @@ "name": "watch", "path": "watch", "short": "Follow email threads and calendars as they change", - "notes": "Long-running. Writes one JSON object per changed thread to stdout (NDJSON), not the usual envelope. Use --exit-on-first to block until one change lands and then exit.", + "notes": "Long-running. Piped or with --json, writes one JSON object per change to stdout (NDJSON), not the usual envelope, and does not apply --jq. Use --exit-on-first to block until one change lands and then exit; a ready line comes first. For new mail only, add --events new.", "flags": [ { "name": "box", diff --git a/src/policy.js b/src/policy.js index ea698a4..a157eb5 100644 --- a/src/policy.js +++ b/src/policy.js @@ -157,14 +157,27 @@ const NAMED_CONTAINER = /\b(?:label|collection|group|workflow|box)\s+["'“]?([^ export function noopFor(path, failure, positionals = [], values = {}) { const text = `${failure.error || ""} ${failure.hint || ""}`; const target = positionals.length ? ` ${positionals.join(" ")}` : ""; - if (DELETES.has(path) && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) { + // One day of a repeating event (--occurrence) is not "already gone" on a not-found: + // the day may simply not be in the series (a wrong date), so it stays an error. + const occurrence = path === "event delete" && values.occurrence; + if (DELETES.has(path) && !occurrence && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) { return { ok: true, noop: true, command: path, result: `nothing to delete:${target || " it"} is already gone (no-op)`, note: "if you expected it to exist, check the id with the matching list command" }; } const endState = END_STATES[path]; - // Several targets: if HEY names some of them, it must name all of them. + if (!endState) return null; + // The ids asked for must be the ones HEY's message is about. Every number the message + // names has to be one of them (or the --to/--from container), and when it names any, + // it has to name them all: "1 already seen" is not about `seen 2`, and "5 already + // seen" is not about `seen 5 6`. A bare count is only accepted for the whole batch + // ("all 2 already seen"). A message naming no number at all ("Already seen") stands. const ids = positionals.filter((word) => /^\d+$/.test(word)); - if (ids.length > 1) { - const named = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(text)); + if (ids.length) { + const allowed = new Set([...ids, ...["to", "from"].map((key) => values[key]).filter((value) => value !== undefined).map(String)]); + const numbers = [...text.matchAll(/(? match[0]); + const strangers = numbers.filter((number) => !allowed.has(number)); + const wholeBatch = strangers.length === 1 && Number(strangers[0]) === ids.length && new RegExp(`\\ball ${strangers[0]}\\b`, "i").test(text); + if (strangers.length && !wholeBatch) return null; + const named = ids.filter((id) => numbers.includes(id)); if (named.length && named.length < ids.length) return null; } if (CONTAINER[path]) { @@ -177,7 +190,7 @@ export function noopFor(path, failure, positionals = [], values = {}) { if (DESTINATION[path] && (!destination || !text.toLowerCase().includes(String(destination).toLowerCase()))) return null; // A failure that also reports a failed part ("1 already seen; 2 failed") is not a no-op. if (/\b(failed|cannot|can'?t|could ?n[o']t|unable|error)\b/i.test(failure.error || "")) return null; - if (endState && failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) { + if (failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) { return { ok: true, noop: true, command: path, result: `already done${target ? ` for${target}` : ""} (no-op)`, detail: failure.error }; } return null; diff --git a/src/router.js b/src/router.js index 33e732c..12f1b74 100644 --- a/src/router.js +++ b/src/router.js @@ -38,6 +38,17 @@ export function normalizeCatalog(nodes, types = {}) { }); } +// Which HEY the manifest covers, in words: "v1.7.0", or for a snapshot of an unreleased +// branch ("1.7.0+main.8bf9310") "v1.7.0 plus the commands on HEY main as of 2026-10-03 +// (8bf9310, unreleased)". +export function coverageLabel(manifest) { + const version = String(manifest.hey_version || "unknown"); + const match = version.match(/^(\d+\.\d+\.\d+)\+([\w.-]+)\.([0-9a-f]{7,})$/); + if (!match) return /^\d/.test(version) ? `v${version}` : version; + const date = manifest.hey_commit_date ? ` as of ${manifest.hey_commit_date}` : ""; + return `v${match[1]} plus the commands on HEY ${match[2]}${date} (commit ${match[3]}, unreleased)`; +} + export function loadBundledManifest() { return JSON.parse(readFileSync(new URL("./manifest.json", import.meta.url), "utf8")); } diff --git a/src/send.js b/src/send.js new file mode 100644 index 0000000..84e0ee5 --- /dev/null +++ b/src/send.js @@ -0,0 +1,121 @@ +// Reading what HEY says about outgoing mail. +// +// HEY CLI v1.7.0 answers a send with a summary only. HEY's main branch (unreleased as of +// 2026-10-03) also: +// - names what went out: data.id (the message), data.topic_id (its thread), subject, +// and delayed (true while Undo Send holds it back; the thread doesn't show it yet) +// - fails a send HEY refused (usually the sending limit) with code not_delivered (exit 7), +// naming the draft HEY kept in meta.draft_id; sending again only makes another draft +// - refuses a --to/--cc/--bcc address it would otherwise drop (code usage, +// "not a valid email address: X") before anything is sent +// hey-axi reads both shapes. It also refuses clearly broken addresses itself, before HEY +// runs, so a v1.7.0 HEY can't silently drop a recipient either. + +// Commands whose recipient flags HEY checks (compose, reply, forward, draft edit). +export const RECIPIENT_COMMANDS = new Set(["compose", "reply", "forward", "draft edit"]); +const RECIPIENT_FLAGS = new Set(["--to", "--cc", "--bcc"]); + +// Split a recipient list on commas outside quotes, comments and angle brackets +// ("Bryan, Annie" is one recipient). +export function splitAddresses(text) { + const out = []; + let current = ""; + let quoted = false; + let depth = 0; + let angled = false; + for (const char of String(text)) { + if (char === "\"" && depth === 0) quoted = !quoted; + else if (!quoted && char === "(") depth += 1; + else if (!quoted && char === ")" && depth > 0) depth -= 1; + else if (!quoted && depth === 0 && char === "<") angled = true; + else if (!quoted && depth === 0 && char === ">") angled = false; + if (char === "," && !quoted && depth === 0 && !angled) { + if (current.trim()) out.push(current.trim()); + current = ""; + } else current += char; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +// A deliberately loose check: a local part, an @, and a domain with a dot and a top-level +// part of two or more characters. Anything it refuses, HEY refuses too (or, before its +// main branch, silently drops); HEY's own, stricter check still runs after it. +const ADDRESS = /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;.]{2,}$/u; + +export function invalidAddress(entry) { + const angle = entry.match(/<([^<>]*)>\s*$/); + const address = (angle ? angle[1] : entry).trim(); + return ADDRESS.test(address) ? null : address || entry; +} + +// Returns null, or a refusal (exit 2) naming the first address HEY would drop. +export function recipientProblem(path, values = []) { + if (!RECIPIENT_COMMANDS.has(path)) return null; + for (const [name, value] of values) { + if (!RECIPIENT_FLAGS.has(name)) continue; + for (const entry of splitAddresses(value)) { + const bad = invalidAddress(entry); + if (bad) return recipientRefusal(path, bad); + } + } + return null; +} + +export function recipientRefusal(path, address) { + return { + error: `not a valid email address: ${address}`, + command: path, + sent: false, + reason: "HEY would drop this recipient without saying so (no domain, or no top-level domain); nothing was sent", + hint: `fix or remove "${address}": every recipient needs a full address such as name@example.com`, + }; +} + +// HEY's own refusal of a recipient, as its error line words it. +export const HEY_BAD_ADDRESS = /^not a valid email address: (.+)$/i; + +// The draft HEY kept when it refused a send: meta.draft_id, else the id in its hint or message. +export function keptDraftId(envelope) { + const fromMeta = envelope?.meta?.draft_id; + if (fromMeta !== undefined && fromMeta !== null && /^\d+$/.test(String(fromMeta))) return String(fromMeta); + const text = `${envelope?.hint || ""} ${envelope?.error || ""}`; + const match = text.match(/\bdraft(?: send)? (\d+)\b/i); + return match ? match[1] : null; +} + +// The failure for a send HEY refused and kept as a draft: never retry the send. +export function notDelivered(envelope) { + const draft = keptDraftId(envelope); + const failure = { + ok: false, + kind: "not_delivered", + sent: false, + error: draft + ? `not sent: HEY kept the message as draft ${draft} instead of sending it (usually the account's sending limit)` + : "not sent: HEY kept the message as a draft instead of sending it (usually the account's sending limit)", + }; + if (draft) failure.draft_id = draft; + failure.help = draft + ? [ + `Run \`hey-axi draft show ${draft}\` to review it`, + `Run \`hey-axi draft send ${draft} --allow-send\` later, once the account can send again; don't repeat the send, which only makes another draft`, + ] + : ["Run `hey-axi draft list` to find the draft HEY kept; don't repeat the send, which only makes another draft"]; + return failure; +} + +// Mark a delivered message's result. `data` is HEY's answer: v1.7.0 names nothing; main +// names id, topic_id, subject and delayed. Nothing is guessed when HEY leaves it out. +export function markSent(result) { + if (!result || typeof result !== "object" || Array.isArray(result)) return result; + const data = result.data && typeof result.data === "object" && !Array.isArray(result.data) ? result.data : {}; + const marker = { sent: true }; + if (data.delayed === true) { + // topic_id is the thread it is on (forward's thread_id names the thread forwarded). + const thread = data.topic_id; + marker.held = `Undo Send is holding it back: it goes out when HEY releases it, and until then ${thread !== undefined ? `\`hey-axi thread read ${thread}\`` : "the thread"} won't show it (a thread it starts reads as not found)`; + } + const { ok, ...rest } = result; + return ok === undefined ? { ...marker, ...rest } : { ok, ...marker, ...rest }; +} diff --git a/src/version.js b/src/version.js index c569146..53e96e9 100644 --- a/src/version.js +++ b/src/version.js @@ -1,3 +1,3 @@ // Leaf module: no imports, so `hey-axi --version` answers before the CLI graph loads. // test/version.test.js keeps this in sync with package.json. -export const VERSION = "0.3.1"; +export const VERSION = "0.3.2"; diff --git a/test/axi.test.js b/test/axi.test.js index 7e0ce8a..fcb7d7d 100644 --- a/test/axi.test.js +++ b/test/axi.test.js @@ -88,11 +88,24 @@ test("home view degrades gracefully: HEY missing, signed out, empty Imbox", asyn assert.match(auth.stdout, /hey-axi auth login/); await signedOut.cleanup(); + // An empty page with no paging signal: the page is reported, "nothing" is not claimed. const empty = await makeFakeHey({ stdout: imbox([]) }); - const none = await runAxi([], { fake: empty }); - assert.match(none.stdout, /mail: "?0 threads: nothing in account-wide Imbox"?/); - assert.doesNotMatch(none.stdout, /^count:/m); + const unsure = await runAxi([], { fake: empty }); + assert.match(unsure.stdout, /mail: "?0 threads on this page of account-wide Imbox; HEY reported no total and no further pages"?/); + assert.match(unsure.stdout, /^count: 0 shown; no more pages reported$/m); + assert.match(unsure.stdout, /Run `hey-axi box view imbox --all` to check every page/); + assert.doesNotMatch(unsure.stdout, /nothing in/); await empty.cleanup(); + + // "Nothing" only when HEY says nothing is left: has_more false, a null next page, or a total of 0. + for (const side of [{ has_more: false }, { next_page: null }, { total_count: 0 }]) { + const done = await makeFakeHey({ stdout: JSON.stringify({ ok: true, data: { id: 7, name: "Imbox", kind: "imbox", postings: [], ...side } }) }); + const none = await runAxi([], { fake: done }); + assert.match(none.stdout, /mail: "?0 threads: nothing in account-wide Imbox"?/, JSON.stringify(side)); + assert.match(none.stdout, /^count: 0 (of 0 )?total$/m, JSON.stringify(side)); + assert.doesNotMatch(none.stdout, /check every page/); + await done.cleanup(); + } }); test("lists default to a few fields; --fields picks, --fields all and --full keep everything", async () => { diff --git a/test/drift.test.js b/test/drift.test.js new file mode 100644 index 0000000..cff4294 --- /dev/null +++ b/test/drift.test.js @@ -0,0 +1,111 @@ +// The upstream drift check (scripts/check-drift.js) with a fake HEY: no network, no Go. +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { surfaceDiff, textDiff, parseHelp, serialize } from "../scripts/manifest-lib.js"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +// A HEY with two commands: `contact deliver --to` and `seen`. +function fakeHey(dir, { extraFlag = false } = {}) { + const catalog = [ + { name: "contact", path: "contact", short: "Manage contacts", subcommands: [ + { name: "deliver", path: "contact deliver", short: "Choose where a contact's email arrives", flags: [{ name: "to", usage: "Delivery destination", default: "" }, ...(extraFlag ? [{ name: "keep-bundle", usage: "Keep the bundle", default: false }] : [])] }, + ] }, + { name: "seen", path: "seen", short: "Mark threads seen" }, + ]; + const help = { + contact: "USAGE\n hey contact [flags]\n", + "contact deliver": `USAGE\n hey contact deliver [flags]\n\nFLAGS\n --to string Delivery destination\n${extraFlag ? " --keep-bundle Keep the bundle\n" : ""}\nEXAMPLES\n hey contact deliver 12345 --to feed\n`, + seen: "USAGE\n hey seen ... [flags]\n", + }; + const bin = join(dir, "hey"); + const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`; + const cases = Object.entries(help).map(([path, text]) => ` "${path} --help") printf '%s' ${quote(text)} ;;`).join("\n"); + writeFileSync(bin, `#!/bin/sh +case "$*" in + "version --json") printf '%s' '{"ok":true,"data":{"version":"dev","commit":"none"}}' ;; + "commands --json") printf '%s' ${quote(JSON.stringify({ ok: true, data: catalog }))} ;; +${cases} + *) echo "unexpected: $*" >&2; exit 9 ;; +esac +`); + chmodSync(bin, 0o755); + return bin; +} + +const run = (args) => new Promise((resolve) => execFile(process.execPath, [join(ROOT, "scripts/check-drift.js"), ...args], { env: { ...process.env, GITHUB_ACTIONS: "", GITHUB_STEP_SUMMARY: "" } }, + (error, stdout, stderr) => resolve({ code: error ? error.code : 0, stdout, stderr }))); + +const ours = { + hey_version: "1.7.0+main.abc1234", hey_commit: "abc1234", + commands: [ + { name: "contact", path: "contact", short: "Manage contacts", synopsis: ["hey contact [flags]"], subcommands: [ + { name: "deliver", path: "contact deliver", short: "Choose where a contact's email arrives", flags: [{ name: "to", value: true, type: "string", desc: "Delivery destination", default: "" }], synopsis: ["hey contact deliver [flags]"], examples: ["hey contact deliver 12345 --to feed"] }, + ] }, + { name: "seen", path: "seen", short: "Mark threads seen", synopsis: ["hey seen ... [flags]"] }, + ], +}; + +test("check-drift passes when HEY's commands and flags match the manifest", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-drift-test-")); + const manifest = join(dir, "manifest.json"); + writeFileSync(manifest, serialize(ours)); + const result = await run(["--hey", fakeHey(dir), "--commit", "abc1234", "--manifest", manifest]); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.match(result.stdout, /commands\/flags: identical/); + rmSync(dir, { recursive: true, force: true }); +}); + +test("check-drift fails with a readable diff when HEY gained or lost a command or flag", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-drift-test-")); + const manifest = join(dir, "manifest.json"); + const stale = structuredClone(ours); + stale.commands[0].subcommands = []; + stale.commands.push({ name: "gone", path: "gone", short: "Removed upstream" }); + writeFileSync(manifest, serialize(stale)); + const result = await run(["--hey", fakeHey(dir, { extraFlag: true }), "--commit", "def5678", "--base", "v1.7.0", "--manifest", manifest]); + assert.equal(result.code, 1); + assert.match(result.stdout, /^ours: {5}1\.7\.0\+main\.abc1234 \(abc1234\)$/m); + assert.match(result.stdout, /^upstream: 1\.7\.0\+main\.def5678 \(def5678\)$/m); + assert.match(result.stdout, /commands\/flags differ \(2\):\n {2}\+ command {2}contact deliver {2}\(flags: --to, --keep-bundle\)\n {2}- command {2}gone/); + assert.match(result.stdout, /To update: npm run manifest:main/); + rmSync(dir, { recursive: true, force: true }); +}); + +test("help-text-only changes are listed, and fail only with --strict", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-drift-test-")); + const manifest = join(dir, "manifest.json"); + const wordy = structuredClone(ours); + wordy.commands[1].short = "Mark email threads seen"; + writeFileSync(manifest, serialize(wordy)); + const bin = fakeHey(dir); + const loose = await run(["--hey", bin, "--manifest", manifest]); + assert.equal(loose.code, 0); + assert.match(loose.stdout, /help text differs for 1 command\(s\):\n {2}seen \(short\)/); + const strict = await run(["--hey", bin, "--manifest", manifest, "--strict"]); + assert.equal(strict.code, 1); + rmSync(dir, { recursive: true, force: true }); +}); + +test("surfaceDiff reports flag changes; parseHelp reads types, usage and examples", () => { + const changed = structuredClone(ours); + changed.commands[0].subcommands[0].flags[0].default = "imbox"; + changed.commands[0].subcommands[0].synopsis = ["hey contact deliver ... [flags]"]; + assert.deepEqual(surfaceDiff(ours, changed), [ + '~ flag contact deliver --to default: "" -> "imbox"', + '~ usage contact deliver: ["hey contact deliver [flags]"] -> ["hey contact deliver ... [flags]"]', + ]); + assert.deepEqual(textDiff(ours, ours), []); + assert.deepEqual(parseHelp("USAGE\n hey x [flags]\n\nFLAGS\n --limit int Max\n -m, --message string Body\n\nEXAMPLES\n hey x 1\n"), { synopsis: ["hey x [flags]"], examples: ["hey x 1"], types: { limit: "int", message: "string" } }); +}); + +test("an unknown option is a usage error (exit 2)", async () => { + const result = await run(["--bogus"]); + assert.equal(result.code, 2); + assert.match(result.stderr, /unknown option --bogus/); +}); diff --git a/test/fixtures/hey-main/README.md b/test/fixtures/hey-main/README.md new file mode 100644 index 0000000..dbe14db --- /dev/null +++ b/test/fixtures/hey-main/README.md @@ -0,0 +1,12 @@ +# Real HEY CLI output (synthetic data) + +Captured on 2026-10-05 by running real HEY CLI binaries against a local mock of HEY's API +(invented data; no account, no mailbox), so the tests read exactly what HEY prints: + +| File | Binary | Command | +|---|---|---| +| `compose-sent.json` | basecamp/hey-cli main at 8bf9310 (built from source) | `hey compose --to maria@example.com --subject "Board update" -m Numbers --json` (stdout) | +| `compose-delayed.json` | same | same, with the API answering `delayed: true` (Undo Send holding it) | +| `compose-refused.stderr.json` | same | same, with the API refusing the send (302 to the draft's edit page); exit 7, stderr | +| `compose-sent-v1.7.0.json` | HEY CLI v1.7.0 release | the "sent" case (v1.7.0 prints the same for a refused send) | +| `contact-deliver.json` | main at 8bf9310 | `hey contact deliver 12345 --to feed --json` (stdout) | diff --git a/test/fixtures/hey-main/compose-delayed.json b/test/fixtures/hey-main/compose-delayed.json new file mode 100644 index 0000000..7c670ba --- /dev/null +++ b/test/fixtures/hey-main/compose-delayed.json @@ -0,0 +1,17 @@ +{ + "ok": true, + "data": { + "delayed": true, + "id": 3301, + "subject": "Board update", + "topic_id": 4401 + }, + "summary": "Message sent", + "breadcrumbs": [ + { + "action": "view", + "command": "hey thread read 4401", + "description": "Read the thread once Undo Send releases the message" + } + ] +} diff --git a/test/fixtures/hey-main/compose-refused.stderr.json b/test/fixtures/hey-main/compose-refused.stderr.json new file mode 100644 index 0000000..4ee96f0 --- /dev/null +++ b/test/fixtures/hey-main/compose-refused.stderr.json @@ -0,0 +1,9 @@ +{ + "ok": false, + "error": "HEY kept this message as draft 2201 instead of sending it, usually because the account has reached its sending limit", + "code": "not_delivered", + "hint": "hey draft send 2201", + "meta": { + "draft_id": 2201 + } +} diff --git a/test/fixtures/hey-main/compose-sent-v1.7.0.json b/test/fixtures/hey-main/compose-sent-v1.7.0.json new file mode 100644 index 0000000..c87a179 --- /dev/null +++ b/test/fixtures/hey-main/compose-sent-v1.7.0.json @@ -0,0 +1,4 @@ +{ + "ok": true, + "summary": "Message sent" +} diff --git a/test/fixtures/hey-main/compose-sent.json b/test/fixtures/hey-main/compose-sent.json new file mode 100644 index 0000000..4d656b8 --- /dev/null +++ b/test/fixtures/hey-main/compose-sent.json @@ -0,0 +1,17 @@ +{ + "ok": true, + "data": { + "delayed": false, + "id": 3301, + "subject": "Board update", + "topic_id": 4401 + }, + "summary": "Message sent", + "breadcrumbs": [ + { + "action": "view", + "command": "hey thread read 4401", + "description": "Read the thread" + } + ] +} diff --git a/test/fixtures/hey-main/contact-deliver.json b/test/fixtures/hey-main/contact-deliver.json new file mode 100644 index 0000000..00050b7 --- /dev/null +++ b/test/fixtures/hey-main/contact-deliver.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "data": { + "id": 12345, + "destination": "feed" + }, + "summary": "Contact delivery changed" +} diff --git a/test/hey-main.test.js b/test/hey-main.test.js new file mode 100644 index 0000000..7e0315d --- /dev/null +++ b/test/hey-main.test.js @@ -0,0 +1,231 @@ +// Commands and send behavior from HEY's main branch after v1.7.0 (unreleased as of +// 2026-10-03), read with the same fake HEY as the rest of the catalog. The send fixtures +// are real HEY output (test/fixtures/hey-main/README.md). +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeFakeHey, runAxi } from "./helpers.js"; +import { loadBundledManifest, resolveCommand, coverageLabel } from "../src/router.js"; +import { runMode, checkPolicy, sendCommands } from "../src/policy.js"; +import { noopFor } from "../src/policy.js"; +import { markSent, recipientProblem, splitAddresses } from "../src/send.js"; +import { translateFailure } from "../src/errors.js"; + +const fixture = (name) => readFileSync(new URL(`./fixtures/hey-main/${name}`, import.meta.url), "utf8"); +const manifest = loadBundledManifest(); +const allowSend = { HEY_AXI_ALLOW_SEND: "1" }; +const noOptIn = { HEY_AXI_ALLOW_SEND: "", HEY_AXI_ALLOW_SECRETS: "" }; + +test("the manifest has the commands and flags HEY main added after v1.7.0", () => { + const deliver = resolveCommand(manifest.commands, ["contact", "deliver"]); + assert.equal(deliver.path, "contact deliver"); + assert.deepEqual(deliver.node.flags.map((flag) => [flag.name, flag.value, flag.type]), [["to", true, "string"]]); + assert.deepEqual(deliver.node.synopsis, ["hey contact deliver [flags]"]); + const del = resolveCommand(manifest.commands, ["event", "delete"]).node; + assert.deepEqual(del.flags.map((flag) => flag.name).sort(), ["apply-to", "occurrence"]); + assert.equal(coverageLabel(manifest), "v1.7.0 plus the commands on HEY main as of 2026-10-03 (commit 8bf9310, unreleased)"); + assert.equal(coverageLabel({ hey_version: "1.8.0" }), "v1.8.0"); +}); + +test("contact deliver and event delete get the same safety classification as their neighbours", () => { + for (const path of ["contact deliver", "event delete"]) { + assert.equal(runMode(path, new Set()), "json", path); + assert.equal(checkPolicy(path, new Set(), {}, { stdin: true, stdout: true }), null, path); + assert.ok(!sendCommands().includes(path), path); + } +}); + +test("contact deliver: --to is required and takes only HEY's four destinations; the rest is forwarded intact", async () => { + const fake = await makeFakeHey({ stdout: fixture("contact-deliver.json") }); + const ok = await runAxi(["contact", "deliver", "12345", "--to", "feed"], { fake }); + assert.equal(ok.code, 0, ok.stdout); + assert.match(ok.stdout, /summary: Contact delivery changed/); + assert.match(ok.stdout, /destination: feed/); + + const missing = await runAxi(["contact", "deliver", "12345"], { fake }); + assert.equal(missing.code, 2); + assert.match(missing.stdout, /missing required flag --to for `contact deliver`/); + const wrong = await runAxi(["contact", "deliver", "12345", "--to", "spam"], { fake }); + assert.equal(wrong.code, 2); + assert.match(wrong.stdout, /--to takes one of imbox, feed, papertrail, screened-out, got \\"spam\\"/); + const noId = await runAxi(["contact", "deliver", "--to", "feed"], { fake }); + assert.equal(noId.code, 2); + assert.match(noId.stdout, /missing argument for `contact deliver`/); + assert.deepEqual(await fake.calls(), ["contact deliver 12345 --to feed --json"]); + await fake.cleanup(); +}); + +test("event delete --occurrence/--apply-to: forwarded together, refused alone or with another scope", async () => { + const fake = await makeFakeHey({ stdout: '{"ok":true,"data":{"occurrence_id":"4821_2026-09-15","apply_to":"current"},"summary":"Occurrence deleted"}' }); + const ok = await runAxi(["event", "delete", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "current"], { fake }); + assert.equal(ok.code, 0, ok.stdout); + assert.match(ok.stdout, /occurrence_id: 4821_2026-09-15/); + const whole = await runAxi(["event", "delete", "4821"], { fake }); + assert.equal(whole.code, 0); + for (const [line, pattern] of [ + [["event", "delete", "4821", "--occurrence", "4821_2026-09-15"], /--occurrence needs --apply-to for `event delete`/], + [["event", "delete", "4821", "--apply-to", "future"], /--apply-to needs --occurrence for `event delete`/], + [["event", "delete", "4821", "--occurrence", "4821_2026-09-15", "--apply-to", "all"], /--apply-to takes one of current, future/], + [["event", "edit", "4821", "--apply-to", "current", "--title", "x"], /--apply-to needs --occurrence for `event edit`/], + ]) { + const result = await runAxi(line, { fake }); + assert.equal(result.code, 2, line.join(" ")); + assert.match(result.stdout, pattern, line.join(" ")); + } + assert.deepEqual(await fake.calls(), ["event delete 4821 --occurrence 4821_2026-09-15 --apply-to current --json", "event delete 4821 --json"]); + await fake.cleanup(); +}); + +test("--help for the new commands states the required flag, the allowed values and the pairing, offline", async () => { + const deliver = await runAxi(["contact", "deliver", "--help"]); + assert.equal(deliver.code, 0); + assert.match(deliver.stdout, /usage: hey-axi contact deliver \[flags\]/); + assert.match(deliver.stdout, /--to Delivery destination: .* \(required\) one of: imbox, feed, papertrail, screened-out \(default: none\)/); + assert.match(deliver.stdout, /examples:\n hey-axi contact deliver 12345 --to feed\n hey-axi contact deliver 12345 --to screened-out/); + assert.match(deliver.stdout, /notes: Use a contact ID from `hey-axi contact list`/); + const del = await runAxi(["event", "delete", "--help"]); + assert.match(del.stdout, /--occurrence /); + assert.match(del.stdout, /--apply-to .*one of: current, future/); + assert.match(del.stdout, /--occurrence and --apply-to go together: pass both or neither/); + assert.match(del.stdout, /hey-axi event delete 4821 --occurrence 4821_2026-09-15 --apply-to current/); +}); + +test("deleting one day of a series is never an 'already gone' no-op; deleting the series still is", () => { + const failure = { ok: false, error: "event not found", kind: "not_found" }; + assert.equal(noopFor("event delete", failure, ["4821"], { occurrence: "4821_2026-09-15" }), null); + assert.equal(noopFor("event delete", failure, ["4821"], {}).noop, true); +}); + +test("a HEY older than the catalog (v1.7.0 has no contact deliver) is named as such, not as a usage error", async () => { + // Real v1.7.0 output for `hey contact deliver 5 --to feed --json`. + const fake = await makeFakeHey({ exitCode: 1, stdout: "", stderr: '{\n "ok": false,\n "error": "unknown flag: --to",\n "code": "usage",\n "hint": "Run \'hey --help\' for usage information"\n}\n' }); + const result = await runAxi(["contact", "deliver", "5", "--to", "feed", "--account", "8"], { fake }); + assert.equal(result.code, 1); + assert.match(result.stdout, /kind: hey_outdated/); + assert.match(result.stdout, /the installed HEY CLI doesn't have --to for `contact deliver`; hey-axi's catalog is HEY CLI v1\.7\.0 plus the commands on HEY main/); + assert.match(result.stdout, /Run `hey-axi version --account 8` to see which HEY is installed/); + await fake.cleanup(); +}); + +test("a delivered message says sent: true and keeps the message and thread HEY names (HEY main)", async () => { + const fake = await makeFakeHey({ stdout: fixture("compose-sent.json") }); + const result = await runAxi(["compose", "--to", "maria@example.com", "--subject", "Board update", "-m", "Numbers", "--allow-send"], { fake }); + assert.equal(result.code, 0, result.stdout); + assert.match(result.stdout, /^ok: true\nsent: true\nsummary: Message sent\n/); + assert.match(result.stdout, /id: 3301/); + assert.match(result.stdout, /topic_id: 4401/); + assert.match(result.stdout, /delayed: false/); + assert.doesNotMatch(result.stdout, /held:/); + assert.match(result.stdout, /Run `hey-axi thread read 4401` to read the thread/); + assert.deepEqual(await fake.calls(), ["compose --to maria@example.com --subject Board update -m Numbers --json"]); + await fake.cleanup(); +}); + +test("a message Undo Send is holding back is sent, but the thread won't show it yet", async () => { + const fake = await makeFakeHey({ stdout: fixture("compose-delayed.json") }); + const result = await runAxi(["compose", "--to", "maria@example.com", "--subject", "Board update", "-m", "Numbers", "--json"], { fake, env: allowSend }); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.sent, true); + assert.equal(parsed.data.delayed, true); + assert.match(parsed.held, /^Undo Send is holding it back: it goes out when HEY releases it, and until then `hey-axi thread read 4401` won't show it/); + assert.ok(parsed.help.includes("Run `hey-axi thread read 4401` to read the thread once Undo Send releases the message")); + await fake.cleanup(); +}); + +test("v1.7.0's answer (no ids) is still read as a send, with nothing guessed", async () => { + const fake = await makeFakeHey({ stdout: fixture("compose-sent-v1.7.0.json") }); + const result = await runAxi(["reply", "4401", "-m", "Thanks", "--allow-send", "--json"], { fake }); + assert.equal(result.code, 0); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.sent, true); + assert.equal(parsed.summary, "Message sent"); + assert.equal(parsed.held, undefined); + assert.equal(parsed.data, undefined); + await fake.cleanup(); +}); + +test("a staged draft is still sent: false with HEY main's draft answer", async () => { + const fake = await makeFakeHey({ stdout: '{"ok":true,"data":{"id":2201},"summary":"Draft saved","breadcrumbs":[{"action":"show","command":"hey draft show 2201","description":"Read the draft back"}]}' }); + const result = await runAxi(["compose", "--to", "maria@example.com", "--subject", "Hi", "-m", "x"], { fake, env: noOptIn }); + assert.match(result.stdout, /^ok: true\nsent: false\nsaved_as: draft/); + assert.doesNotMatch(result.stdout, /sent: true/); + await fake.cleanup(); +}); + +test("a send HEY refused is reported as not sent, names the draft, and never says retry", async () => { + const state = mkdtempSync(join(tmpdir(), "hey-axi-nd-")); + writeFileSync(join(state, "capture-enabled"), "on\n"); + for (const line of [ + ["compose", "--to", "maria@example.com", "--subject", "Board update", "-m", "Numbers", "--allow-send"], + ["draft", "send", "2201", "--allow-send"], + ["forward", "7", "--to", "alice@example.com", "--allow-send"], + ]) { + const fake = await makeFakeHey({ exitCode: 7, stdout: "", stderr: fixture("compose-refused.stderr.json") }); + const result = await runAxi([...line, "--account", "8"], { fake, env: { HEY_AXI_STATE_DIR: state } }); + assert.equal(result.code, 1, line.join(" ")); + assert.match(result.stdout, /^ok: false\nkind: not_delivered\nsent: false\nerror: "not sent: HEY kept the message as draft 2201 instead of sending it \(usually the account's sending limit\)"\ndraft_id: "2201"/, line.join(" ")); + assert.match(result.stdout, /Run `hey-axi draft show 2201 --account 8` to review it/); + assert.match(result.stdout, /Run `hey-axi draft send 2201 --allow-send --account 8` later, once the account can send again; don't repeat the send, which only makes another draft/); + assert.doesNotMatch(result.stdout, /Retry|api_error|code: not_delivered/); + await fake.cleanup(); + } + // The kept draft is remembered as a draft (for the next home view), not as a send. + const journal = readFileSync(join(state, "activity.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(journal.map((entry) => [entry.path, entry.kind, entry.ids]), [["compose", "draft", ["2201"]], ["draft send", "draft", ["2201"]], ["forward", "draft", ["2201"]]]); + rmSync(state, { recursive: true, force: true }); +}); + +test("a recipient HEY would drop is refused before HEY runs; HEY main's own refusal reads the same", async () => { + const fake = await makeFakeHey(); + for (const line of [ + ["compose", "--to", "maria", "--subject", "Hi", "-m", "x"], + ["reply", "5", "--to", "ok@example.com", "--cc", "Bob ", "-m", "x"], + ["forward", "5", "--to", "a@example.com, b@", "--allow-send"], + ["draft", "edit", "5", "--bcc=nobody"], + ]) { + const result = await runAxi(line, { fake, env: noOptIn }); + assert.equal(result.code, 2, line.join(" ")); + assert.match(result.stdout, /error: "?not a valid email address: /, line.join(" ")); + assert.match(result.stdout, /sent: false/); + assert.match(result.stdout, /every recipient needs a full address such as name@example\.com/); + } + assert.deepEqual(await fake.calls(), []); + await fake.cleanup(); + + // HEY main refuses an unknown top-level domain hey-axi lets through (real output). + const hey = await makeFakeHey({ exitCode: 1, stdout: "", stderr: '{\n "ok": false,\n "error": "not a valid email address: bob@example.invalidtld",\n "code": "usage"\n}\n' }); + const refused = await runAxi(["reply", "5", "--to", "bob@example.invalidtld", "-m", "hi", "--allow-send"], { fake: hey }); + assert.equal(refused.code, 2); + assert.match(refused.stdout, /kind: usage/); + assert.match(refused.stdout, /sent: false/); + assert.match(refused.stdout, /fix or remove \\"bob@example\.invalidtld\\"/); + assert.doesNotMatch(refused.stdout, /--help/); + await hey.cleanup(); +}); + +test("well-formed recipients pass the check, including quoted names with commas and an emptied list", async () => { + assert.deepEqual(splitAddresses('"Bryan, Annie" , bob@example.co.uk'), ['"Bryan, Annie" ', "bob@example.co.uk"]); + assert.equal(recipientProblem("compose", [["--to", '"Bryan, Annie" , bob@example.co.uk'], ["--cc", "zoë@exämple.de"]]), null); + assert.equal(recipientProblem("draft edit", [["--to", ""]]), null); + assert.equal(recipientProblem("move", [["--to", "feed"]]), null); + assert.equal(recipientProblem("label add", [["--to", "Receipts"]]), null); + const fake = await makeFakeHey({ stdout: '{"ok":true,"data":{"id":9},"summary":"Draft saved"}' }); + const result = await runAxi(["compose", "--to", "Annie ", "--subject", "Hi", "-m", "x"], { fake, env: noOptIn }); + assert.equal(result.code, 0, result.stdout); + await fake.cleanup(); +}); + +test("markSent leaves non-objects alone and only claims what HEY said", () => { + assert.equal(markSent(null), null); + assert.deepEqual(markSent({ ok: true, summary: "Message forwarded", data: { thread_id: 7, entry_id: 8, id: 9, topic_id: 10, delayed: true } }).held.includes("thread read 10"), true); + assert.deepEqual(markSent({ summary: "x" }), { sent: true, summary: "x" }); +}); + +test("not_delivered without meta still finds the draft in HEY's hint", () => { + const { failure, exitCode } = translateFailure({ status: 7, stderr: '{"ok":false,"error":"HEY kept this message as draft 77 instead of sending it","code":"not_delivered","hint":"hey draft send 77"}' }, { path: "reply" }); + assert.equal(exitCode, 1); + assert.equal(failure.draft_id, "77"); + assert.equal(failure.kind, "not_delivered"); +}); diff --git a/test/modes.test.js b/test/modes.test.js index 3630b12..62415d9 100644 --- a/test/modes.test.js +++ b/test/modes.test.js @@ -164,12 +164,13 @@ test("translateFailure keeps HEY's envelope, maps exit codes to AXI's, and trans translateFailure({ status: 3, stdout: "", stderr: 'warning: system keyring unavailable\n{\n "ok": false,\n "error": "Not logged in",\n "code": "auth",\n "hint": "Run: hey auth login"\n}\n' }), { failure: { ok: false, error: "Not logged in", kind: "auth", code: "auth", hint: "Run: hey-axi auth login", help: "Run `hey-axi auth login --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`" }, exitCode: 1, warnings: ["warning: system keyring unavailable"] }, ); - // Real HEY 1.7.0 usage error: exit 2, HEY's generic hint dropped, help names the command. + // Real HEY 1.7.0 usage error: exit 2, HEY's generic hint dropped, help shows the usage inline. assert.deepEqual( - translateFailure({ status: 1, stderr: '{"ok":false,"error":"accepts at most 1 arg(s), received 2","code":"usage","hint":"Run \'hey --help\' for usage information"}' }, { path: "search" }), - { failure: { ok: false, error: "accepts at most 1 arg(s), received 2", kind: "usage", code: "usage", help: "Run `hey-axi search --help` for its arguments and flags" }, exitCode: 2, warnings: [] }, + translateFailure({ status: 1, stderr: '{"ok":false,"error":"accepts at most 1 arg(s), received 2","code":"usage","hint":"Run \'hey --help\' for usage information"}' }, { path: "search", usage: ["usage: hey-axi search [query] [flags]"], example: "hey-axi search \"invoice\"" }), + { failure: { ok: false, error: "accepts at most 1 arg(s), received 2", kind: "usage", code: "usage", help: ["usage: hey-axi search [query] [flags]", "example: hey-axi search \"invoice\""] }, exitCode: 2, warnings: [] }, ); - assert.deepEqual(heyFailure({ status: 1, stderr: "", stdout: "" }), { ok: false, error: "the command failed", kind: "command_error", help: "Run `hey-axi --help` to check the arguments" }); + // A failure of no known kind: a specific next step, never "see --help". + assert.deepEqual(heyFailure({ status: 1, stderr: "", stdout: "" }), { ok: false, error: "the command failed", kind: "command_error", help: ["Run `hey-axi doctor` to check HEY's sign-in, configuration and connection"] }); assert.deepEqual(heyFailure({ status: 7, stderr: "\u001b[31mError: upstream 502\u001b[0m\ngoroutine 1 [running]:\n main.go:3\n" }), { ok: false, error: "HEY's API returned an error: upstream 502", kind: "api_error", help: "Retry later, or run `hey-axi doctor`" }); assert.deepEqual(heyFailure({ status: 127, error: "HEY CLI not found on PATH" }), { ok: false, error: "HEY CLI not found on PATH", kind: "hey_missing", help: "Install the HEY CLI (curl -fsSL https://hey.com/install-cli | bash) or set HEY_BIN" }); }); diff --git a/test/opencode-plugins.test.js b/test/opencode-plugins.test.js new file mode 100644 index 0000000..6568353 --- /dev/null +++ b/test/opencode-plugins.test.js @@ -0,0 +1,106 @@ +// The OpenCode plugins `hey-axi setup hooks` installs must load on OpenCode 1 and 2: +// one default export with an `id`, `setup(ctx)` (OpenCode 2) and `server(input)` (OpenCode 1). +// The session-start plugin comes from axi-sdk-js (0.1.13 made it OpenCode 2-ready); the +// session-end plugin is hey-axi's own and follows the same shape. +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { openCodeEndPlugin } from "../src/hooks.js"; + +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + +async function waitForFile(path, ms = 3000) { + for (let waited = 0; waited < ms; waited += 50) { + if (existsSync(path) && readFileSync(path, "utf8").trim()) return readFileSync(path, "utf8"); + await sleep(50); + } + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +// The session-end plugin, pointed at a fake hey-axi that records its stdin. +async function loadEndPlugin(dir, name) { + const record = join(dir, `${name}.jsonl`); + const fake = join(dir, `fake-hey-axi-${name}`); + writeFileSync(fake, `#!/bin/sh\n[ "$1 $2" = "hook session-end" ] || exit 9\ncat >> '${record}'\necho >> '${record}'\n`); + chmodSync(fake, 0o755); + const file = join(dir, `${name}.mjs`); + writeFileSync(file, openCodeEndPlugin(fake)); + const plugin = (await import(pathToFileURL(file).href)).default; + return { plugin, record }; +} + +test("the session-end plugin is one default export both OpenCode generations load", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-oc-")); + const { plugin } = await loadEndPlugin(dir, "shape"); + assert.equal(plugin.id, "axi-hey-axi-session-end"); + assert.equal(typeof plugin.setup, "function"); + assert.equal(typeof plugin.server, "function"); + rmSync(dir, { recursive: true, force: true }); +}); + +test("OpenCode 1: the event hook records a session that went idle, and ignores other events", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-oc-")); + const { plugin, record } = await loadEndPlugin(dir, "v1"); + const hooks = await plugin.server({ directory: dir }); + await hooks.event({ event: { type: "message.updated", properties: { sessionID: "nope" } } }); + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "ses_1" } } }); + assert.deepEqual(JSON.parse(readFileSync(record, "utf8").trim()), { session_id: "ses_1", cwd: dir }); + rmSync(dir, { recursive: true, force: true }); +}); + +test("OpenCode 2: setup subscribes to the event stream and returns a cleanup that stops it", async () => { + const dir = mkdtempSync(join(tmpdir(), "hey-axi-oc-")); + const { plugin, record } = await loadEndPlugin(dir, "v2"); + let aborted = false; + const ctx = { + location: { directory: dir }, + event: { + async *subscribe({ signal }) { + signal.addEventListener("abort", () => { aborted = true; }); + yield { type: "session.status", properties: { sessionID: "ses_2", status: { type: "busy" } } }; + yield { type: "session.deleted", properties: { info: { id: "ses_2", directory: dir } } }; + while (!signal.aborted) await sleep(20); + }, + }, + }; + const cleanup = plugin.setup(ctx); + assert.equal(typeof cleanup, "function"); + const text = await waitForFile(record); + assert.deepEqual(JSON.parse(text.trim()), { session_id: "ses_2", cwd: dir }); + cleanup(); + await sleep(60); + assert.equal(aborted, true); + rmSync(dir, { recursive: true, force: true }); +}); + +test("setup hooks installs OpenCode plugins in the OpenCode 2 shape (axi-sdk-js 0.1.13 start plugin + hey-axi end plugin)", async () => { + const home = mkdtempSync(join(tmpdir(), "hey-axi-home-")); + const result = await new Promise((done) => execFile(process.execPath, [resolve("src/hey-axi.js"), "setup", "hooks"], { cwd: home, env: { ...process.env, HOME: home, HEY_AXI_STATE_DIR: join(home, "state") } }, + (error, stdout) => done({ code: error ? error.code : 0, stdout }))); + assert.equal(result.code, 0, result.stdout); + const start = readFileSync(join(home, ".config/opencode/plugins/axi-hey-axi.js"), "utf8"); + assert.match(start, /export default \{\n {2}id: "axi-hey-axi",/); + assert.match(start, /async setup\(ctx\)/); + assert.match(start, /async server\(\{ directory \}\)/); + const end = readFileSync(join(home, ".config/opencode/plugins/axi-hey-axi-session-end.js"), "utf8"); + assert.match(end, /export default \{\n {2}id: "axi-hey-axi-session-end",/); + assert.doesNotMatch(end, /export const HeyAxiSessionEndPlugin/); + rmSync(home, { recursive: true, force: true }); +}); + +test("re-running setup hooks upgrades a 0.3.1 (OpenCode 1-only) session-end plugin in place", async () => { + const home = mkdtempSync(join(tmpdir(), "hey-axi-home-")); + const run = () => new Promise((done) => execFile(process.execPath, [resolve("src/hey-axi.js"), "setup", "hooks"], { cwd: home, env: { ...process.env, HOME: home, HEY_AXI_STATE_DIR: join(home, "state") } }, + (error, stdout) => done({ code: error ? error.code : 0, stdout }))); + assert.equal((await run()).code, 0); + const path = join(home, ".config/opencode/plugins/axi-hey-axi-session-end.js"); + writeFileSync(path, "// hey-axi managed opencode session-end plugin\nexport const HeyAxiSessionEndPlugin = async () => ({});\n"); + const again = await run(); + assert.match(again.stdout, /installed or updated/); + assert.match(readFileSync(path, "utf8"), /export default \{/); + rmSync(home, { recursive: true, force: true }); +}); diff --git a/test/review-gaps.test.js b/test/review-gaps.test.js new file mode 100644 index 0000000..99f8219 --- /dev/null +++ b/test/review-gaps.test.js @@ -0,0 +1,78 @@ +// The gaps the catalog reviewer listed against 0.3.1 (home "nothing" without completeness +// evidence is covered in axi.test.js). +import test from "node:test"; +import assert from "node:assert/strict"; +import { makeFakeHey, runAxi } from "./helpers.js"; +import { noopFor } from "../src/policy.js"; +import { cleanLine, cleanMeta, translateFailure } from "../src/errors.js"; + +test("a failure of no known kind suggests a specific fix (usage inline, then doctor), never --help", async () => { + const fake = await makeFakeHey({ exitCode: 1, stdout: "", stderr: "something odd happened\n" }); + const result = await runAxi(["thread", "read", "5", "--account", "8"], { fake }); + assert.equal(result.code, 1); + assert.match(result.stdout, /kind: command_error/); + assert.match(result.stdout, /usage: hey-axi thread read \[flags\]/); + assert.match(result.stdout, /example: hey-axi thread read 12345/); + assert.match(result.stdout, /Run `hey-axi doctor --account 8` if the arguments are right/); + assert.doesNotMatch(result.stdout, /--help/); + await fake.cleanup(); + + // HEY's own usage error: the usage line and an example, inline. + const usage = await makeFakeHey({ exitCode: 1, stdout: "", stderr: '{"ok":false,"error":"accepts at most 1 arg(s), received 2","code":"usage","hint":"Run \'hey --help\' for usage information"}' }); + const bad = await runAxi(["contact", "show", "5"], { fake: usage }); + assert.equal(bad.code, 2); + assert.match(bad.stdout, /usage: hey-axi contact show \[flags\]/); + assert.doesNotMatch(bad.stdout, /--help/); + await usage.cleanup(); + + // No command known (the home view): doctor. + assert.deepEqual(translateFailure({ status: 1, stderr: "x", stdout: "" }).failure.help, ["Run `hey-axi doctor` to check HEY's sign-in, configuration and connection"]); +}); + +test("a single-id command is only 'already done' when HEY's message is about that id", async () => { + const failure = (error) => ({ ok: false, error, kind: "command_error" }); + // The reviewer's case: "1 already seen" is about id 1 (or a count), not about `seen 2`. + assert.equal(noopFor("seen", failure("1 already seen"), ["2"]), null); + assert.equal(noopFor("seen", failure("Thread 7 is already seen"), ["2"]), null); + assert.equal(noopFor("unseen", failure("3 already unseen"), ["2"]), null); + assert.equal(noopFor("trash", failure("Already in trash: 9"), ["4"]), null); + // About the id asked for, or naming no id at all: a no-op. + assert.equal(noopFor("seen", failure("2 already seen"), ["2"]).noop, true); + assert.equal(noopFor("seen", failure("Already seen"), ["2"]).noop, true); + // Batches: every id named, or "all N" for the whole batch. + assert.equal(noopFor("seen", failure("5 already seen"), ["5", "6"]), null); + assert.equal(noopFor("seen", failure("2 already seen"), ["5", "6"]), null); + assert.equal(noopFor("seen", failure("5, 6 already seen"), ["5", "6"]).noop, true); + assert.equal(noopFor("seen", failure("all 2 already seen"), ["5", "6"]).noop, true); + // A container named by number is the one asked for. + assert.equal(noopFor("label add", failure("Already in label 9"), ["5"], { to: "9" }).noop, true); + assert.equal(noopFor("label add", failure("Already in label 8"), ["5"], { to: "9" }), null); + + const fake = await makeFakeHey({ exitCode: 1, stdout: "", stderr: '{"ok":false,"error":"1 already seen","code":"conflict"}' }); + const result = await runAxi(["seen", "2"], { fake }); + assert.equal(result.code, 1); + assert.match(result.stdout, /ok: false/); + assert.doesNotMatch(result.stdout, /noop/); + await fake.cleanup(); +}); + +test("library names inside sentences and non-HEY error codes don't leak", () => { + assert.equal(cleanLine("request via superagent failed with 500"), ""); + assert.equal(cleanLine("Error: the undici pool is closed"), ""); + assert.equal(cleanLine("superagent: bad gateway"), "bad gateway"); + // Ordinary words that happen to be library names stay. + assert.equal(cleanLine("expected a number, got text"), "expected a number, got text"); + assert.equal(cleanLine("node 5 not found"), "node 5 not found"); + assert.deepEqual(cleanMeta({ client: "request made with undici v6", engine: "Node.js 22", box: "imbox", reason: "label is full" }), { box: "imbox", reason: "label is full" }); + + const sqlite = translateFailure({ status: 7, stdout: "", stderr: '{"ok":false,"error":"SqliteError: disk I/O error","code":"SqliteError","meta":{"client":"via undici"}}' }).failure; + assert.equal(sqlite.code, undefined); + assert.equal(sqlite.meta, undefined); + assert.equal(sqlite.error, "HEY's API returned an error"); + for (const code of ["SQLITE_BUSY", "ECONNRESET", "TypeError"]) { + assert.equal(translateFailure({ status: 7, stderr: JSON.stringify({ ok: false, error: "x", code }) }).failure.code, undefined, code); + } + for (const code of ["not_found", "rate_limit", "api", "validation"]) { + assert.equal(translateFailure({ status: 7, stderr: JSON.stringify({ ok: false, error: "x", code }) }).failure.code, code, code); + } +}); diff --git a/test/router.test.js b/test/router.test.js index 7643f37..a3f4321 100644 --- a/test/router.test.js +++ b/test/router.test.js @@ -5,17 +5,20 @@ import { loadBundledManifest, resolveCommand, listCommands, normalizeCatalog, is const manifest = loadBundledManifest(); const resolve = (line) => resolveCommand(manifest.commands, line.split(" ")); -test("bundled manifest is a v1.7.0 snapshot of `hey commands --json` + per-command help", () => { - assert.equal(manifest.source, "hey commands --json + hey --help"); - assert.equal(manifest.hey_version, "1.7.0"); +test("bundled manifest is a snapshot of `hey commands --json` + per-command help from HEY main after v1.7.0", () => { + assert.match(manifest.source, /^hey commands --json \+ hey --help \(basecamp\/hey-cli main at 8bf9310, 2026-10-03; unreleased, built from source\)$/); + assert.equal(manifest.hey_version, "1.7.0+main.8bf9310"); + assert.equal(manifest.hey_commit, "8bf9310c0b1df7448e39ed1f81826ae795bd8b5c"); + assert.equal(manifest.hey_commit_date, "2026-10-03"); assert.equal(manifest.commands.length, 49); }); test("every runnable command in the manifest resolves to itself", () => { const runnable = listCommands(manifest.commands); - // 144 canonical commands + 2 aliases (login, logout) + 5 shortcut groups - // + 3 groups HEY also runs on their own (set-aside, set-aside group, skill). - assert.equal(runnable.length, 154); + // 145 canonical commands (v1.7.0's 144 + contact deliver from HEY main) + 2 aliases + // (login, logout) + 5 shortcut groups + 3 groups HEY also runs on their own + // (set-aside, set-aside group, skill). + assert.equal(runnable.length, 155); for (const node of runnable) { const result = resolve(node.path); assert.equal(result.error, undefined, node.path); diff --git a/test/routing.test.js b/test/routing.test.js index ce14b12..7f09272 100644 --- a/test/routing.test.js +++ b/test/routing.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { loadBundledManifest, listCommands } from "../src/router.js"; import { runMode, sendCommands, userOnlyPaths } from "../src/policy.js"; import { patternsFor } from "../src/arity.js"; +import { FLAG_RULES, requiredFlags } from "../src/args.js"; import { makeFakeHey, runAxi } from "./helpers.js"; const manifest = loadBundledManifest(); @@ -14,10 +15,11 @@ const gated = new Set([...sendCommands(), "auth token"]); function argsFor(node) { const [pattern] = patternsFor(node); const positionals = (pattern?.args || []).filter((arg) => arg.required).map(() => "123"); - const typed = (flag) => (flag.type === "int" ? "5" : "x"); + const typed = (flag) => FLAG_RULES[node.path]?.choices?.[`--${flag.name}`]?.[0] ?? (flag.type === "int" ? "5" : "x"); + const required = requiredFlags(node); const flags = [ ...((node.flags || []).some((flag) => flag.name === "limit") ? ["--limit", "5"] : []), - ...(node.flags || []).filter((flag) => /\(required\)$/.test(flag.desc || "")).flatMap((flag) => [`--${flag.name}`, typed(flag)]), + ...(node.flags || []).filter((flag) => required.includes(`--${flag.name}`)).flatMap((flag) => [`--${flag.name}`, typed(flag)]), ...(pattern?.oneOf || []).map((group) => group[0]).flatMap((flag) => { const spec = (node.flags || []).find((candidate) => `--${candidate.name}` === flag); return spec?.value ? [flag, "x"] : [flag];