hey-axi 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // What hey-axi will and won't run, beyond "is it a HEY command".
2
2
 
3
3
  // hey-axi's own flags. Stripped before anything is forwarded to HEY.
4
- export const AXI_FLAGS = ["--allow-send", "--allow-secret"];
4
+ export const AXI_FLAGS = ["--allow-send", "--allow-secret", "--allow-destructive"];
5
5
 
6
6
  // Commands that deliver email.
7
7
  // safe: HEY flags that already mean nothing is sent
@@ -20,6 +20,18 @@ export const DRAFT_NOTICE = "Saved as a DRAFT. Nothing was sent. Pass --allow-se
20
20
  // Commands that print a credential into the agent's context.
21
21
  const SECRET_COMMANDS = new Set(["auth token"]);
22
22
 
23
+ // Commands that destroy a whole set of things at once, with no confirmation from HEY and
24
+ // no single command that undoes them. Refused unless --allow-destructive (or
25
+ // HEY_AXI_ALLOW_DESTRUCTIVE=1).
26
+ // what: what the command would do, in the refusal
27
+ // instead: what to do first, in the refusal
28
+ const DESTRUCTIVE_COMMANDS = new Map([
29
+ ["screener clear", {
30
+ what: "moves everything waiting in the Screener to Trash (the whole queue for the account, every sender, not one; shared threads lose your access instead) without asking. It approves or denies no one, so those senders come back to the Screener on their next email",
31
+ instead: "check what is waiting with `hey-axi screener list`, and decide one sender at a time with `hey-axi screener deny <clearance-id>` (or `screener approve`); to really clear the whole queue, confirm with the user, then pass --allow-destructive (or set HEY_AXI_ALLOW_DESTRUCTIVE=1)",
32
+ }],
33
+ ]);
34
+
23
35
  // How a command runs. Anything not listed is "json": captured, parsed, rendered as TOON.
24
36
  // stream: long-running NDJSON, relayed event by event as it arrives (TOON, or NDJSON with --json)
25
37
  // raw: prints non-JSON (a script, a CSV); relayed untouched
@@ -157,14 +169,27 @@ const NAMED_CONTAINER = /\b(?:label|collection|group|workflow|box)\s+["'“]?([^
157
169
  export function noopFor(path, failure, positionals = [], values = {}) {
158
170
  const text = `${failure.error || ""} ${failure.hint || ""}`;
159
171
  const target = positionals.length ? ` ${positionals.join(" ")}` : "";
160
- if (DELETES.has(path) && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) {
172
+ // One day of a repeating event (--occurrence) is not "already gone" on a not-found:
173
+ // the day may simply not be in the series (a wrong date), so it stays an error.
174
+ const occurrence = path === "event delete" && values.occurrence;
175
+ if (DELETES.has(path) && !occurrence && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) {
161
176
  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" };
162
177
  }
163
178
  const endState = END_STATES[path];
164
- // Several targets: if HEY names some of them, it must name all of them.
179
+ if (!endState) return null;
180
+ // The ids asked for must be the ones HEY's message is about. Every number the message
181
+ // names has to be one of them (or the --to/--from container), and when it names any,
182
+ // it has to name them all: "1 already seen" is not about `seen 2`, and "5 already
183
+ // seen" is not about `seen 5 6`. A bare count is only accepted for the whole batch
184
+ // ("all 2 already seen"). A message naming no number at all ("Already seen") stands.
165
185
  const ids = positionals.filter((word) => /^\d+$/.test(word));
166
- if (ids.length > 1) {
167
- const named = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(text));
186
+ if (ids.length) {
187
+ const allowed = new Set([...ids, ...["to", "from"].map((key) => values[key]).filter((value) => value !== undefined).map(String)]);
188
+ const numbers = [...text.matchAll(/(?<![\w.-])\d+(?![\w.-])/g)].map((match) => match[0]);
189
+ const strangers = numbers.filter((number) => !allowed.has(number));
190
+ const wholeBatch = strangers.length === 1 && Number(strangers[0]) === ids.length && new RegExp(`\\ball ${strangers[0]}\\b`, "i").test(text);
191
+ if (strangers.length && !wholeBatch) return null;
192
+ const named = ids.filter((id) => numbers.includes(id));
168
193
  if (named.length && named.length < ids.length) return null;
169
194
  }
170
195
  if (CONTAINER[path]) {
@@ -177,7 +202,7 @@ export function noopFor(path, failure, positionals = [], values = {}) {
177
202
  if (DESTINATION[path] && (!destination || !text.toLowerCase().includes(String(destination).toLowerCase()))) return null;
178
203
  // A failure that also reports a failed part ("1 already seen; 2 failed") is not a no-op.
179
204
  if (/\b(failed|cannot|can'?t|could ?n[o']t|unable|error)\b/i.test(failure.error || "")) return null;
180
- if (endState && failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) {
205
+ if (failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) {
181
206
  return { ok: true, noop: true, command: path, result: `already done${target ? ` for${target}` : ""} (no-op)`, detail: failure.error };
182
207
  }
183
208
  return null;
@@ -193,6 +218,14 @@ export function sendCommands() {
193
218
  return [...SEND_COMMANDS.keys()];
194
219
  }
195
220
 
221
+ export function destructiveCommands() {
222
+ return [...DESTRUCTIVE_COMMANDS.keys()];
223
+ }
224
+
225
+ function destructiveAllowed(flags, env) {
226
+ return flags.has("--allow-destructive") || truthy(env.HEY_AXI_ALLOW_DESTRUCTIVE);
227
+ }
228
+
196
229
  function sendAllowed(flags, env) {
197
230
  return flags.has("--allow-send") || truthy(env.HEY_AXI_ALLOW_SEND);
198
231
  }
@@ -230,5 +263,15 @@ export function checkPolicy(path, flags, env = process.env, io = { stdin: proces
230
263
  hint: "pass --allow-secret (or set HEY_AXI_ALLOW_SECRETS=1) if you really need it",
231
264
  };
232
265
  }
266
+
267
+ if (DESTRUCTIVE_COMMANDS.has(path) && !destructiveAllowed(flags, env)) {
268
+ const rule = DESTRUCTIVE_COMMANDS.get(path);
269
+ return {
270
+ error: "destructive command blocked",
271
+ command: path,
272
+ reason: `this command ${rule.what}. hey-axi did not run it, so nothing was changed`,
273
+ hint: rule.instead,
274
+ };
275
+ }
233
276
  return null;
234
277
  }
package/src/router.js CHANGED
@@ -38,6 +38,17 @@ export function normalizeCatalog(nodes, types = {}) {
38
38
  });
39
39
  }
40
40
 
41
+ // Which HEY the manifest covers, in words: "v1.7.0", or for a snapshot of an unreleased
42
+ // branch ("1.7.0+main.9dfe00f") "v1.7.0 plus the commands on HEY main as of 2026-10-06
43
+ // (commit 9dfe00f, unreleased)".
44
+ export function coverageLabel(manifest) {
45
+ const version = String(manifest.hey_version || "unknown");
46
+ const match = version.match(/^(\d+\.\d+\.\d+)\+([\w.-]+)\.([0-9a-f]{7,})$/);
47
+ if (!match) return /^\d/.test(version) ? `v${version}` : version;
48
+ const date = manifest.hey_commit_date ? ` as of ${manifest.hey_commit_date}` : "";
49
+ return `v${match[1]} plus the commands on HEY ${match[2]}${date} (commit ${match[3]}, unreleased)`;
50
+ }
51
+
41
52
  export function loadBundledManifest() {
42
53
  return JSON.parse(readFileSync(new URL("./manifest.json", import.meta.url), "utf8"));
43
54
  }
package/src/send.js ADDED
@@ -0,0 +1,121 @@
1
+ // Reading what HEY says about outgoing mail.
2
+ //
3
+ // HEY CLI v1.7.0 answers a send with a summary only. HEY's main branch (unreleased as of
4
+ // 2026-10-06, commit 9dfe00f) also:
5
+ // - names what went out: data.id (the message), data.topic_id (its thread), subject,
6
+ // and delayed (true while Undo Send holds it back; the thread doesn't show it yet)
7
+ // - fails a send HEY refused (usually the sending limit) with code not_delivered (exit 7),
8
+ // naming the draft HEY kept in meta.draft_id; sending again only makes another draft
9
+ // - refuses a --to/--cc/--bcc address it would otherwise drop (code usage,
10
+ // "not a valid email address: X") before anything is sent
11
+ // hey-axi reads both shapes. It also refuses clearly broken addresses itself, before HEY
12
+ // runs, so a v1.7.0 HEY can't silently drop a recipient either.
13
+
14
+ // Commands whose recipient flags HEY checks (compose, reply, forward, draft edit).
15
+ export const RECIPIENT_COMMANDS = new Set(["compose", "reply", "forward", "draft edit"]);
16
+ const RECIPIENT_FLAGS = new Set(["--to", "--cc", "--bcc"]);
17
+
18
+ // Split a recipient list on commas outside quotes, comments and angle brackets
19
+ // ("Bryan, Annie" <annie@example.com> is one recipient).
20
+ export function splitAddresses(text) {
21
+ const out = [];
22
+ let current = "";
23
+ let quoted = false;
24
+ let depth = 0;
25
+ let angled = false;
26
+ for (const char of String(text)) {
27
+ if (char === "\"" && depth === 0) quoted = !quoted;
28
+ else if (!quoted && char === "(") depth += 1;
29
+ else if (!quoted && char === ")" && depth > 0) depth -= 1;
30
+ else if (!quoted && depth === 0 && char === "<") angled = true;
31
+ else if (!quoted && depth === 0 && char === ">") angled = false;
32
+ if (char === "," && !quoted && depth === 0 && !angled) {
33
+ if (current.trim()) out.push(current.trim());
34
+ current = "";
35
+ } else current += char;
36
+ }
37
+ if (current.trim()) out.push(current.trim());
38
+ return out;
39
+ }
40
+
41
+ // A deliberately loose check: a local part, an @, and a domain with a dot and a top-level
42
+ // part of two or more characters. Anything it refuses, HEY refuses too (or, before its
43
+ // main branch, silently drops); HEY's own, stricter check still runs after it.
44
+ const ADDRESS = /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;.]{2,}$/u;
45
+
46
+ export function invalidAddress(entry) {
47
+ const angle = entry.match(/<([^<>]*)>\s*$/);
48
+ const address = (angle ? angle[1] : entry).trim();
49
+ return ADDRESS.test(address) ? null : address || entry;
50
+ }
51
+
52
+ // Returns null, or a refusal (exit 2) naming the first address HEY would drop.
53
+ export function recipientProblem(path, values = []) {
54
+ if (!RECIPIENT_COMMANDS.has(path)) return null;
55
+ for (const [name, value] of values) {
56
+ if (!RECIPIENT_FLAGS.has(name)) continue;
57
+ for (const entry of splitAddresses(value)) {
58
+ const bad = invalidAddress(entry);
59
+ if (bad) return recipientRefusal(path, bad);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function recipientRefusal(path, address) {
66
+ return {
67
+ error: `not a valid email address: ${address}`,
68
+ command: path,
69
+ sent: false,
70
+ reason: "HEY would drop this recipient without saying so (no domain, or no top-level domain); nothing was sent",
71
+ hint: `fix or remove "${address}": every recipient needs a full address such as name@example.com`,
72
+ };
73
+ }
74
+
75
+ // HEY's own refusal of a recipient, as its error line words it.
76
+ export const HEY_BAD_ADDRESS = /^not a valid email address: (.+)$/i;
77
+
78
+ // The draft HEY kept when it refused a send: meta.draft_id, else the id in its hint or message.
79
+ export function keptDraftId(envelope) {
80
+ const fromMeta = envelope?.meta?.draft_id;
81
+ if (fromMeta !== undefined && fromMeta !== null && /^\d+$/.test(String(fromMeta))) return String(fromMeta);
82
+ const text = `${envelope?.hint || ""} ${envelope?.error || ""}`;
83
+ const match = text.match(/\bdraft(?: send)? (\d+)\b/i);
84
+ return match ? match[1] : null;
85
+ }
86
+
87
+ // The failure for a send HEY refused and kept as a draft: never retry the send.
88
+ export function notDelivered(envelope) {
89
+ const draft = keptDraftId(envelope);
90
+ const failure = {
91
+ ok: false,
92
+ kind: "not_delivered",
93
+ sent: false,
94
+ error: draft
95
+ ? `not sent: HEY kept the message as draft ${draft} instead of sending it (usually the account's sending limit)`
96
+ : "not sent: HEY kept the message as a draft instead of sending it (usually the account's sending limit)",
97
+ };
98
+ if (draft) failure.draft_id = draft;
99
+ failure.help = draft
100
+ ? [
101
+ `Run \`hey-axi draft show ${draft}\` to review it`,
102
+ `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`,
103
+ ]
104
+ : ["Run `hey-axi draft list` to find the draft HEY kept; don't repeat the send, which only makes another draft"];
105
+ return failure;
106
+ }
107
+
108
+ // Mark a delivered message's result. `data` is HEY's answer: v1.7.0 names nothing; main
109
+ // names id, topic_id, subject and delayed. Nothing is guessed when HEY leaves it out.
110
+ export function markSent(result) {
111
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
112
+ const data = result.data && typeof result.data === "object" && !Array.isArray(result.data) ? result.data : {};
113
+ const marker = { sent: true };
114
+ if (data.delayed === true) {
115
+ // topic_id is the thread it is on (forward's thread_id names the thread forwarded).
116
+ const thread = data.topic_id;
117
+ 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)`;
118
+ }
119
+ const { ok, ...rest } = result;
120
+ return ok === undefined ? { ...marker, ...rest } : { ok, ...marker, ...rest };
121
+ }
package/src/shape.js CHANGED
@@ -38,7 +38,7 @@ export const LIST_FIELDS = {
38
38
  "event week": EVENTS,
39
39
  "todo list": ["id", "title", "starts_at", "completed_at"],
40
40
  "habit list": ["id", "title=title|name", "days"],
41
- "journal list": ["id", "starts_at", "content"],
41
+ "journal list": ["id", "date=date|starts_at"],
42
42
  "draft list": ["id", "subject", "summary", "at=updated_at|created_at"],
43
43
  "contact list": ["id", "name", "email_address"],
44
44
  "label list": NAMED,
@@ -46,8 +46,8 @@ export const LIST_FIELDS = {
46
46
  "workflow list": NAMED,
47
47
  "calendar list": ["id", "name", "kind", "owned"],
48
48
  "attachment list": ["id", "filename", "content_type", "byte_size"],
49
- "clip list": ["id", "content", "topic_id", "at=created_at"],
50
- "snippet list": ["id", "name", "content"],
49
+ "clip list": ["id", "topic_id=topic_id|topic.id", "at=created_at"],
50
+ "snippet list": ["id", "name"],
51
51
  "timetrack list": ["id", "starts_at", "ends_at", "category"],
52
52
  "timetrack categories": ["id", "title"],
53
53
  "account list": ["id", "email=email|email_address", "name", "active"],
@@ -55,6 +55,14 @@ export const LIST_FIELDS = {
55
55
  };
56
56
 
57
57
  // Detail views keep their long text (truncated), but drop URLs and duplicated fields.
58
+ // Lists whose entries are long-form content: the list shows ids and dates only, and a help
59
+ // line says how to get the content (AXI principle 2: long text belongs in detail views).
60
+ export const CONTENT_LISTS = {
61
+ "journal list": (line, carry) => `Run \`${withSelectors("hey-axi journal read <date>", carry)}\` to read one entry, or \`${line} --fields id,starts_at,content\` to include previews`,
62
+ "clip list": (line) => `Run \`${line} --fields id,content\` to include each clip's content`,
63
+ "snippet list": (line) => `Run \`${line} --fields id,name,content\` to include each snippet's content`,
64
+ };
65
+
58
66
  export const DETAIL_FIELDS = {
59
67
  "thread read": ["id", "at=created_at", "from=creator.name|sender.name", "email=creator.email_address|sender.email_address", "to=recipients.to[].email_address", "cc=recipients.cc[].email_address", "body=body|summary"],
60
68
  };
@@ -279,7 +287,8 @@ export function listSize(envelope, shown, container = null) {
279
287
  export function countLine({ shown, total, more, complete, all = false }) {
280
288
  if (total !== undefined) return `${shown} of ${total} total`;
281
289
  if (more) return `${shown} shown; more available`;
282
- return complete || all || shown === 0 ? `${shown} total` : `${shown} shown; no more pages reported`;
290
+ // An empty page is only "0 total" when HEY says nothing is left (or --all fetched everything).
291
+ return complete || all ? `${shown} total` : `${shown} shown; no more pages reported`;
283
292
  }
284
293
 
285
294
  // Append the invocation's selectors (`--account 2`) to a suggested command, unless the
@@ -372,6 +381,7 @@ export function shapeEnvelope(envelope, { path, fields = null, commandLine, carr
372
381
  const more = (size || multiSize) && moreHelp(size || multiSize, { commandLine, pageFlags });
373
382
  if (more) help.push(more);
374
383
  if (truncated) help.push(`Run \`${commandLine} --full\` to see complete content`);
384
+ if (CONTENT_LISTS[path] && !fields && !empty && found) help.push(CONTENT_LISTS[path](commandLine, carry));
375
385
  if (help.length) out.help = help;
376
386
  if (enveloped && !quiet && envelope.meta && typeof envelope.meta === "object") {
377
387
  const meta = { ...envelope.meta };
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Leaf module: no imports, so `hey-axi --version` answers before the CLI graph loads.
2
2
  // test/version.test.js keeps this in sync with package.json.
3
- export const VERSION = "0.3.0";
3
+ export const VERSION = "0.3.2";