hey-axi 0.2.0

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 ADDED
@@ -0,0 +1,91 @@
1
+ // What hey-axi will and won't run, beyond "is it a HEY command".
2
+
3
+ // hey-axi's own flags. Stripped before anything is forwarded to HEY.
4
+ export const AXI_FLAGS = ["--allow-send", "--allow-secret"];
5
+
6
+ // Commands that deliver email.
7
+ // safe: HEY flags that already mean nothing is sent
8
+ // stage: the flag hey-axi adds, when there's no send opt-in, to save a draft instead
9
+ // why: for commands with no draft mode, why hey-axi refuses and what to do instead
10
+ const SEND_COMMANDS = new Map([
11
+ ["compose", { safe: ["--draft"], stage: "--draft" }],
12
+ ["reply", { safe: ["--draft", "--dry-run"], stage: "--draft" }],
13
+ ["forward", { safe: [], why: "HEY has no draft mode for forward", instead: "use `hey-axi reply <id> --to <addr>` (staged as a draft) or pass --allow-send" }],
14
+ ["draft send", { safe: [], why: "this delivers an existing draft and has no draft mode", instead: "review it with `hey-axi draft show <id>`, then pass --allow-send" }],
15
+ ["bulk-reply send", { safe: [], why: "HEY has no draft mode for bulk replies", instead: "check it with `hey-axi bulk-reply preview <ids>`, then pass --allow-send" }],
16
+ ]);
17
+
18
+ export const DRAFT_NOTICE = "Saved as a DRAFT. Nothing was sent. Pass --allow-send (or set HEY_AXI_ALLOW_SEND=1) to really send.";
19
+
20
+ // Commands that print a credential into the agent's context.
21
+ const SECRET_COMMANDS = new Set(["auth token"]);
22
+
23
+ // How a command runs. Anything not listed is "json": captured, parsed, rendered as TOON.
24
+ // interactive: HEY gets the terminal (inherited stdio); nothing is injected or parsed
25
+ // stream: long-running NDJSON, relayed line by line as it arrives
26
+ // raw: prints non-JSON (a script, a CSV); relayed untouched
27
+ const INTERACTIVE = new Set(["tui", "mcp", "auth login", "login", "setup", "setup agents", "setup claude", "setup codex", "setup omarchy", "upgrade"]);
28
+ // Interactive commands that can't do anything useful without a terminal.
29
+ const NEEDS_TTY = new Set(["tui"]);
30
+ const STREAM = new Set(["watch"]);
31
+ const RAW = new Set(["shell-completion generate"]);
32
+
33
+ export function runMode(path, flags) {
34
+ if (INTERACTIVE.has(path)) return "interactive";
35
+ if (STREAM.has(path)) return "stream";
36
+ if (RAW.has(path)) return "raw";
37
+ // CSV goes to stdout unless --output names a file (then HEY answers with JSON).
38
+ if (path === "timetrack export" && !flags.has("--output") && !flags.has("-o")) return "raw";
39
+ return "json";
40
+ }
41
+
42
+ export function interactivePaths() {
43
+ return [...INTERACTIVE];
44
+ }
45
+
46
+ const truthy = (value) => ["1", "true", "yes"].includes(String(value || "").toLowerCase());
47
+
48
+ export function sendCommands() {
49
+ return [...SEND_COMMANDS.keys()];
50
+ }
51
+
52
+ function sendAllowed(flags, env) {
53
+ return flags.has("--allow-send") || truthy(env.HEY_AXI_ALLOW_SEND);
54
+ }
55
+
56
+ // For a send command with a draft mode and no opt-in, return the flag to add so HEY
57
+ // saves a draft instead of sending. Returns null when nothing needs staging.
58
+ export function sendStaging(path, flags, env = process.env) {
59
+ const rule = SEND_COMMANDS.get(path);
60
+ if (!rule?.stage || sendAllowed(flags, env) || rule.safe.some((flag) => flags.has(flag))) return null;
61
+ return { flag: rule.stage, notice: DRAFT_NOTICE };
62
+ }
63
+
64
+ // Returns null when the command may run, or a structured refusal.
65
+ export function checkPolicy(path, flags, env = process.env, io = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
66
+ if (NEEDS_TTY.has(path) && !(io.stdin && io.stdout)) {
67
+ return { error: "needs a terminal", command: path, reason: "interactive full-screen UI", hint: `run \`hey ${path}\` in a terminal` };
68
+ }
69
+
70
+ if (SEND_COMMANDS.has(path)) {
71
+ const rule = SEND_COMMANDS.get(path);
72
+ if (!rule.stage && !sendAllowed(flags, env)) {
73
+ return {
74
+ error: "send blocked",
75
+ command: path,
76
+ reason: `this command delivers email, and ${rule.why}`,
77
+ hint: rule.instead,
78
+ };
79
+ }
80
+ }
81
+
82
+ if (SECRET_COMMANDS.has(path) && !(flags.has("--allow-secret") || truthy(env.HEY_AXI_ALLOW_SECRETS))) {
83
+ return {
84
+ error: "secret output blocked",
85
+ command: path,
86
+ reason: "prints an access token",
87
+ hint: "pass --allow-secret (or set HEY_AXI_ALLOW_SECRETS=1) if you really need it",
88
+ };
89
+ }
90
+ return null;
91
+ }
package/src/router.js ADDED
@@ -0,0 +1,82 @@
1
+ // Data-driven command routing for hey-axi.
2
+ //
3
+ // The command tree comes from HEY's own `hey commands --json` catalog: a bundled
4
+ // snapshot (src/manifest.json, refreshed with `npm run refresh-manifest`), plus
5
+ // discovery from the installed binary at runtime when the snapshot doesn't know a
6
+ // command.
7
+
8
+ import { readFileSync } from "node:fs";
9
+
10
+ const BOOLEAN_DEFAULTS = new Set(["true", "false"]);
11
+
12
+ // Reduce HEY's catalog to what routing and help need.
13
+ export function normalizeCatalog(nodes) {
14
+ return (nodes || []).map((node) => {
15
+ const out = { name: node.name, path: node.path || node.name, short: node.short || "" };
16
+ if (node.compatibility_usage) out.usage = node.compatibility_usage;
17
+ if (node.agent_notes) out.notes = node.agent_notes;
18
+ const flags = (node.flags || [])
19
+ .filter((flag) => flag.name !== "help")
20
+ .map((flag) => {
21
+ const entry = { name: flag.name };
22
+ if (flag.shorthand) entry.shorthand = flag.shorthand;
23
+ if (!BOOLEAN_DEFAULTS.has(String(flag.default))) entry.value = true;
24
+ if (flag.usage) entry.desc = flag.usage;
25
+ if (flag.default !== undefined && !["", "false", "[]", "0"].includes(String(flag.default))) entry.default = String(flag.default);
26
+ return entry;
27
+ });
28
+ if (flags.length) out.flags = flags;
29
+ if (node.subcommands?.length) out.subcommands = normalizeCatalog(node.subcommands);
30
+ return out;
31
+ });
32
+ }
33
+
34
+ export function loadBundledManifest() {
35
+ return JSON.parse(readFileSync(new URL("./manifest.json", import.meta.url), "utf8"));
36
+ }
37
+
38
+ // A group node can be invoked by itself when HEY documents a shortcut form for it
39
+ // (`box <name|id>`, `label <id>`, ...) or when it takes flags of its own (`search`).
40
+ export function isRunnable(node) {
41
+ return !node.subcommands?.length || Boolean(node.usage) || Boolean(node.flags?.length);
42
+ }
43
+
44
+ // Resolve leading positional words to a command node.
45
+ // Returns { node, path, consumed } on success, or { error, ... } when the words
46
+ // don't name a runnable command.
47
+ export function resolveCommand(commands, words) {
48
+ let level = commands;
49
+ let node = null;
50
+ let consumed = 0;
51
+ for (const word of words) {
52
+ const next = level?.find((candidate) => candidate.name === word);
53
+ if (!next) break;
54
+ node = next;
55
+ consumed += 1;
56
+ level = next.subcommands;
57
+ }
58
+ if (!node) return { error: "unknown command", word: words[0] };
59
+ if (!isRunnable(node)) {
60
+ const extra = words[consumed];
61
+ return {
62
+ error: extra === undefined ? "incomplete command" : "unknown subcommand",
63
+ path: node.path,
64
+ word: extra,
65
+ subcommands: node.subcommands.map((child) => child.name),
66
+ };
67
+ }
68
+ return { node, path: node.path, consumed };
69
+ }
70
+
71
+ // Collect every leaf (and runnable group) path, for help and coverage reporting.
72
+ export function listCommands(commands) {
73
+ const paths = [];
74
+ const walk = (nodes) => {
75
+ for (const node of nodes || []) {
76
+ if (isRunnable(node)) paths.push(node);
77
+ walk(node.subcommands);
78
+ }
79
+ };
80
+ walk(commands);
81
+ return paths;
82
+ }
package/src/shape.js ADDED
@@ -0,0 +1,251 @@
1
+ // Shape HEY's JSON envelope for agents (AXI principles 2-5 and 9):
2
+ // - minimal default fields per list command, `--fields a,b,c` to choose, `--fields all`
3
+ // - long text truncated with its total size, `--full` to get HEY's output untouched
4
+ // - a definitive `empty` line for empty lists, `count: N of T total` when HEY knows T
5
+ // - HEY's breadcrumbs as `help` lines that name hey-axi commands
6
+ import { heyToAxi } from "./text.js";
7
+
8
+ // A field is "key" or "key=path", where path is dotted (`creator.name`), may index an
9
+ // array (`messages.0.creator.name`), may map over one (`recipients.to[].email_address`,
10
+ // joined with ", "), and may list fallbacks (`name|subject`).
11
+ const POSTINGS = ["id", "topic_id", "from=creator.name|sender.name|alternative_sender_name", "subject=name|subject", "seen", "at=active_at|created_at|updated_at"];
12
+ const EVENTS = ["id", "title", "starts_at", "ends_at", "calendar=calendar.name"];
13
+ const NAMED = ["id", "name"];
14
+
15
+ export const LIST_FIELDS = {
16
+ box: POSTINGS,
17
+ "box view": POSTINGS,
18
+ label: POSTINGS,
19
+ "label view": POSTINGS,
20
+ collection: POSTINGS,
21
+ "collection view": POSTINGS,
22
+ bundle: POSTINGS,
23
+ "bundle view": POSTINGS,
24
+ "set-aside view": POSTINGS,
25
+ "set-aside group view": POSTINGS,
26
+ "contact threads": POSTINGS,
27
+ "bubble list": [...POSTINGS, "bubbles_up=bubble_up_schedule.bubble_up_at"],
28
+ "box list": ["id", "kind", "name"],
29
+ search: ["id", "topic_id", "subject", "from=messages.0.creator.name|creator.name", "at=updated_at|created_at"],
30
+ "screener list": ["id", "name", "email_address", "subject", "topic_id"],
31
+ "screener history": ["id", "status", "name=name|petitioner.name", "email_address=email_address|petitioner.email_address", "decided=decided|updated_at"],
32
+ "event list": EVENTS,
33
+ "event day": EVENTS,
34
+ "event week": EVENTS,
35
+ "todo list": ["id", "title", "starts_at", "completed_at"],
36
+ "habit list": ["id", "title=title|name", "days"],
37
+ "journal list": ["id", "starts_at", "content"],
38
+ "draft list": ["id", "subject", "summary", "at=updated_at|created_at"],
39
+ "contact list": ["id", "name", "email_address"],
40
+ "label list": NAMED,
41
+ "collection list": NAMED,
42
+ "workflow list": NAMED,
43
+ "calendar list": ["id", "name", "kind", "owned"],
44
+ "attachment list": ["id", "filename", "content_type", "byte_size"],
45
+ "clip list": ["id", "content", "topic_id", "at=created_at"],
46
+ "snippet list": ["id", "name", "content"],
47
+ "timetrack list": ["id", "starts_at", "ends_at", "category", "notes"],
48
+ "timetrack categories": ["id", "title"],
49
+ "account list": ["id", "email=email|email_address", "name", "active"],
50
+ "set-aside group list": ["id", "thread_count"],
51
+ };
52
+
53
+ // Detail views keep their long text (truncated), but drop URLs and duplicated fields.
54
+ export const DETAIL_FIELDS = {
55
+ "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"],
56
+ };
57
+
58
+ export const LIST_TEXT_LIMIT = 120;
59
+ export const DETAIL_TEXT_LIMIT = 1000;
60
+
61
+ const HEURISTIC_KEYS = ["id", "topic_id", "name", "title", "subject", "email_address", "status", "kind", "type", "starts_at", "created_at"];
62
+
63
+ const present = (value) => value !== undefined && value !== null && value !== "";
64
+
65
+ function getPath(value, parts) {
66
+ if (parts.length === 0) return value;
67
+ if (value === null || typeof value !== "object") return undefined;
68
+ const [part, ...rest] = parts;
69
+ if (part.endsWith("[]")) {
70
+ const list = value[part.slice(0, -2)];
71
+ if (!Array.isArray(list)) return undefined;
72
+ const values = list.map((item) => getPath(item, rest)).filter(present);
73
+ return values.length ? values.join(", ") : undefined;
74
+ }
75
+ return getPath(value[part], rest);
76
+ }
77
+
78
+ function resolve(item, path) {
79
+ for (const alternative of path.split("|")) {
80
+ const value = getPath(item, alternative.split("."));
81
+ if (present(value)) return value;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ export function parseField(spec) {
87
+ const index = spec.indexOf("=");
88
+ return index === -1 ? { key: spec, path: spec } : { key: spec.slice(0, index), path: spec.slice(index + 1) };
89
+ }
90
+
91
+ // Fields for a list without a declared schema: up to five familiar keys.
92
+ function heuristicFields(items) {
93
+ const keys = new Set(items.flatMap((item) => (item && typeof item === "object" ? Object.keys(item) : [])));
94
+ if (keys.size <= 5) return null;
95
+ const picked = HEURISTIC_KEYS.filter((key) => keys.has(key)).slice(0, 5);
96
+ return picked.length >= 2 ? picked : null;
97
+ }
98
+
99
+ // Project items onto fields. Returns { rows, unknown } where unknown lists requested
100
+ // fields that matched nothing in any item.
101
+ export function project(items, specs, { strict = false } = {}) {
102
+ const fields = specs.map(parseField);
103
+ const rows = items.map((item) => Object.fromEntries(fields.map(({ key, path }) => [key, resolve(item, path)])));
104
+ const used = fields.filter(({ key }) => rows.some((row) => row[key] !== undefined));
105
+ const unknown = strict && items.length ? fields.filter((field) => !used.includes(field)).map(({ key }) => key) : [];
106
+ const keep = strict ? fields : used;
107
+ return {
108
+ rows: rows.map((row) => Object.fromEntries(keep.map(({ key }) => [key, row[key] === undefined ? null : row[key]]))),
109
+ unknown,
110
+ };
111
+ }
112
+
113
+ // Truncate long strings anywhere in a value. Calls onTruncate for each cut.
114
+ export function truncateDeep(value, limit, onTruncate, list = false) {
115
+ if (typeof value === "string") {
116
+ if (value.length <= limit) return value;
117
+ onTruncate();
118
+ return list
119
+ ? `${value.slice(0, limit)}… (${value.length} chars)`
120
+ : `${value.slice(0, limit)}… (truncated, ${value.length} chars total)`;
121
+ }
122
+ if (Array.isArray(value)) return value.map((item) => truncateDeep(item, limit, onTruncate, list));
123
+ if (value && typeof value === "object") {
124
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, truncateDeep(item, limit, onTruncate, list)]));
125
+ }
126
+ return value;
127
+ }
128
+
129
+ const isObjectList = (value) => Array.isArray(value) && value.every((item) => item && typeof item === "object" && !Array.isArray(item));
130
+ const DROP_CONTAINER_KEY = (key) => /(^|_)url$|^signed_stream_name$/.test(key);
131
+
132
+ // Find the collection in HEY's `data`: the array itself, or the one array of objects
133
+ // inside a container object (e.g. a box with its postings).
134
+ export function findList(data) {
135
+ if (isObjectList(data)) return { list: data };
136
+ if (data && typeof data === "object" && !Array.isArray(data)) {
137
+ const keys = Object.keys(data).filter((key) => isObjectList(data[key]));
138
+ if (keys.length === 1) return { list: data[keys[0]], key: keys[0], container: data };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ export function fieldsFor(path, items) {
144
+ return LIST_FIELDS[path] || DETAIL_FIELDS[path] || heuristicFields(items);
145
+ }
146
+
147
+ // Every key an agent could pass to --fields for this result.
148
+ export function availableFields(path, items) {
149
+ const names = new Set((fieldsFor(path, items) || []).map((spec) => parseField(spec).key));
150
+ for (const item of items.slice(0, 5)) for (const key of Object.keys(item || {})) names.add(key);
151
+ return [...names];
152
+ }
153
+
154
+ export class FieldError extends Error {
155
+ constructor(unknown, available) {
156
+ super("unknown field");
157
+ this.unknown = unknown;
158
+ this.available = available;
159
+ }
160
+ }
161
+
162
+ // Shape `data` for one command. options: { path, fields: string[]|"all"|null }.
163
+ // Returns { data, truncated, empty }.
164
+ export function shapeData(data, { path, fields = null }) {
165
+ let truncated = false;
166
+ const mark = () => { truncated = true; };
167
+ const found = findList(data);
168
+ const detail = Boolean(DETAIL_FIELDS[path]) || !found;
169
+
170
+ if (!found) {
171
+ return { data: truncateDeep(data, DETAIL_TEXT_LIMIT, mark), truncated, empty: false };
172
+ }
173
+
174
+ const { list, key, container } = found;
175
+ let rows = list;
176
+ let projected = false;
177
+ if (fields !== "all") {
178
+ const explicit = Array.isArray(fields) && fields.length > 0;
179
+ const defaults = fieldsFor(path, list);
180
+ let specs = defaults;
181
+ if (explicit) {
182
+ const known = new Map((defaults || []).map((spec) => [parseField(spec).key, spec]));
183
+ specs = fields.map((name) => known.get(name) || name);
184
+ }
185
+ if (specs) {
186
+ const result = project(list, specs, { strict: explicit });
187
+ if (result.unknown.length) throw new FieldError(result.unknown, availableFields(path, list));
188
+ rows = result.rows;
189
+ projected = true;
190
+ }
191
+ }
192
+ const listLimit = detail || !projected ? DETAIL_TEXT_LIMIT : LIST_TEXT_LIMIT;
193
+ rows = truncateDeep(rows, listLimit, mark, !detail && projected);
194
+
195
+ let shaped = rows;
196
+ if (container) {
197
+ shaped = {};
198
+ for (const [name, value] of Object.entries(container)) {
199
+ if (name === key) shaped[name] = rows;
200
+ else if (fields === "all" || (!DROP_CONTAINER_KEY(name) && (value === null || typeof value !== "object"))) {
201
+ shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
202
+ }
203
+ }
204
+ }
205
+ return { data: shaped, truncated, empty: list.length === 0 };
206
+ }
207
+
208
+ function breadcrumbHelp(crumb) {
209
+ if (typeof crumb === "string") return heyToAxi(crumb);
210
+ if (!crumb || typeof crumb !== "object" || !crumb.command) return null;
211
+ const description = crumb.description ? ` to ${crumb.description.charAt(0).toLowerCase()}${crumb.description.slice(1)}` : "";
212
+ return `Run \`${heyToAxi(crumb.command)}\`${description}`;
213
+ }
214
+
215
+ // Shape a whole HEY envelope. options: { path, fields, commandLine }.
216
+ // Returns the object to print.
217
+ export function shapeEnvelope(envelope, { path, fields = null, commandLine }) {
218
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || !("data" in envelope)) {
219
+ // Not an envelope (some commands answer with a bare object): just keep it short.
220
+ let truncated = false;
221
+ const data = truncateDeep(envelope, DETAIL_TEXT_LIMIT, () => { truncated = true; });
222
+ if (!truncated) return data;
223
+ const help = [`Run \`${commandLine} --full\` to see complete content`];
224
+ return data && typeof data === "object" && !Array.isArray(data) ? { ...data, help } : { data, help };
225
+ }
226
+
227
+ const { data, truncated, empty } = shapeData(envelope.data, { path, fields });
228
+ const out = {};
229
+ if (envelope.ok !== undefined) out.ok = envelope.ok;
230
+ if (envelope.summary) out.summary = envelope.summary;
231
+ const meta = envelope.meta && typeof envelope.meta === "object" ? { ...envelope.meta } : null;
232
+ if (meta && typeof meta.total_count === "number") {
233
+ const found = findList(envelope.data);
234
+ const shown = found ? found.list.length : 0;
235
+ out.count = `${shown} of ${meta.total_count} total`;
236
+ delete meta.total_count;
237
+ }
238
+ if (meta) delete meta.pages_fetched;
239
+ out.data = data;
240
+ if (empty) out.empty = `0 results for \`${commandLine}\``;
241
+ for (const [key, value] of Object.entries(envelope)) {
242
+ if (["ok", "data", "summary", "meta", "breadcrumbs", "notice"].includes(key)) continue;
243
+ out[key] = value;
244
+ }
245
+ if (envelope.notice) out.notice = heyToAxi(envelope.notice);
246
+ const help = (Array.isArray(envelope.breadcrumbs) ? envelope.breadcrumbs : []).map(breadcrumbHelp).filter(Boolean);
247
+ if (truncated) help.push(`Run \`${commandLine} --full\` to see complete content`);
248
+ if (help.length) out.help = help;
249
+ if (meta && Object.keys(meta).length) out.meta = meta;
250
+ return out;
251
+ }
package/src/text.js ADDED
@@ -0,0 +1,14 @@
1
+ // Small text helpers shared by output, help and errors.
2
+
3
+ // Point command suggestions at hey-axi instead of the wrapped CLI: "hey thread read 1"
4
+ // becomes "hey-axi thread read 1". Only `hey` followed by a lowercase command word is
5
+ // rewritten, so prose ("Hey there", "hey.com") is left alone.
6
+ export function heyToAxi(text) {
7
+ if (typeof text !== "string") return text;
8
+ return text.replace(/(^|[\s`'"(])hey (?=[a-z][a-z-]*)/g, "$1hey-axi ");
9
+ }
10
+
11
+ // Quote an argv word for display in a suggested command line.
12
+ export function shellWord(word) {
13
+ return /^[\w@%+=:,./<>-]+$/.test(word) ? word : `'${String(word).replace(/'/g, `'\\''`)}'`;
14
+ }
package/src/version.js ADDED
@@ -0,0 +1,3 @@
1
+ // Leaf module: no imports, so `hey-axi --version` answers before the CLI graph loads.
2
+ // test/version.test.js keeps this in sync with package.json.
3
+ export const VERSION = "0.2.0";