hey-axi 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/package.json +4 -2
- package/skills/hey-axi/SKILL.md +10 -3
- package/skills/hey-axi/references/commands.md +8 -7
- package/src/args.js +35 -6
- package/src/cli.js +38 -14
- package/src/errors.js +84 -15
- package/src/examples.js +12 -4
- package/src/guide.js +3 -1
- package/src/home.js +12 -3
- package/src/hooks.js +33 -6
- package/src/manifest.json +90 -46
- package/src/policy.js +49 -6
- package/src/router.js +11 -0
- package/src/send.js +121 -0
- package/src/shape.js +14 -4
- package/src/version.js +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
const
|
|
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 (
|
|
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"
|
|
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", "
|
|
50
|
-
"snippet list": ["id", "name"
|
|
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
|
-
|
|
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