hey-axi 0.2.0 → 0.3.1

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/shape.js CHANGED
@@ -1,15 +1,19 @@
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`
1
+ // Shape HEY's JSON for agents (AXI principles 2-5 and 9):
2
+ // - at most four default fields per list command, `--fields a,b,c` to choose, `--fields all`
3
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
4
+ // - every list gets a `count` (`N of T total`, `N total`, or `N shown; more available`)
5
+ // and a definitive `empty` line when it has no rows, enveloped or not
6
+ // - HEY's breadcrumbs as `help` lines that name hey-axi commands and carry the
7
+ // invocation's selectors (--account, --base-url)
6
8
  import { heyToAxi } from "./text.js";
7
9
 
8
10
  // A field is "key" or "key=path", where path is dotted (`creator.name`), may index an
9
11
  // array (`messages.0.creator.name`), may map over one (`recipients.to[].email_address`,
10
12
  // 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
+ // Box rows keep both ids: `id` (the box item) feeds seen/unseen/move/label, `topic_id`
14
+ // (the thread) feeds thread read/reply/forward. `seen` and `at` are one --fields away.
15
+ const POSTINGS = ["id", "topic_id", "from=creator.name|sender.name|alternative_sender_name", "subject=name|subject"];
16
+ const EVENTS = ["id", "title", "starts_at", "ends_at"];
13
17
  const NAMED = ["id", "name"];
14
18
 
15
19
  export const LIST_FIELDS = {
@@ -24,17 +28,17 @@ export const LIST_FIELDS = {
24
28
  "set-aside view": POSTINGS,
25
29
  "set-aside group view": POSTINGS,
26
30
  "contact threads": POSTINGS,
27
- "bubble list": [...POSTINGS, "bubbles_up=bubble_up_schedule.bubble_up_at"],
31
+ "bubble list": ["id", "topic_id", "subject=name|subject", "bubbles_up=bubble_up_schedule.bubble_up_at"],
28
32
  "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"],
33
+ search: ["topic_id", "subject", "from=messages.0.creator.name|creator.name", "at=updated_at|created_at"],
34
+ "screener list": ["id", "email_address", "subject", "topic_id"],
35
+ "screener history": ["id", "status", "email_address=email_address|petitioner.email_address", "decided=decided|updated_at"],
32
36
  "event list": EVENTS,
33
37
  "event day": EVENTS,
34
38
  "event week": EVENTS,
35
39
  "todo list": ["id", "title", "starts_at", "completed_at"],
36
40
  "habit list": ["id", "title=title|name", "days"],
37
- "journal list": ["id", "starts_at", "content"],
41
+ "journal list": ["id", "date=date|starts_at"],
38
42
  "draft list": ["id", "subject", "summary", "at=updated_at|created_at"],
39
43
  "contact list": ["id", "name", "email_address"],
40
44
  "label list": NAMED,
@@ -42,15 +46,23 @@ export const LIST_FIELDS = {
42
46
  "workflow list": NAMED,
43
47
  "calendar list": ["id", "name", "kind", "owned"],
44
48
  "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"],
49
+ "clip list": ["id", "topic_id=topic_id|topic.id", "at=created_at"],
50
+ "snippet list": ["id", "name"],
51
+ "timetrack list": ["id", "starts_at", "ends_at", "category"],
48
52
  "timetrack categories": ["id", "title"],
49
53
  "account list": ["id", "email=email|email_address", "name", "active"],
50
54
  "set-aside group list": ["id", "thread_count"],
51
55
  };
52
56
 
53
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
+
54
66
  export const DETAIL_FIELDS = {
55
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"],
56
68
  };
@@ -88,12 +100,15 @@ export function parseField(spec) {
88
100
  return index === -1 ? { key: spec, path: spec } : { key: spec.slice(0, index), path: spec.slice(index + 1) };
89
101
  }
90
102
 
91
- // Fields for a list without a declared schema: up to five familiar keys.
103
+ export const MAX_LIST_FIELDS = 4;
104
+
105
+ // Fields for a list without a declared schema: up to four familiar keys, or the first
106
+ // four keys when none of the familiar ones are there.
92
107
  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;
108
+ const keys = [...new Set(items.flatMap((item) => (item && typeof item === "object" ? Object.keys(item) : [])))];
109
+ if (keys.length <= MAX_LIST_FIELDS) return null;
110
+ const picked = HEURISTIC_KEYS.filter((key) => keys.includes(key)).slice(0, MAX_LIST_FIELDS);
111
+ return picked.length >= 2 ? picked : keys.slice(0, MAX_LIST_FIELDS);
97
112
  }
98
113
 
99
114
  // Project items onto fields. Returns { rows, unknown } where unknown lists requested
@@ -127,15 +142,22 @@ export function truncateDeep(value, limit, onTruncate, list = false) {
127
142
  }
128
143
 
129
144
  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);
145
+ // URLs and paging fields (summarized by `count` and the "see the rest" help) are left out.
146
+ const DROP_CONTAINER_KEY = (key) => /(^|_)url$|^signed_stream_name$|^(total_count|next_page|next_cursor|has_more)$/.test(key);
131
147
 
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).
148
+ // Find the collection in HEY's `data`: the array itself (of objects or of plain
149
+ // values), the one array of objects inside a container object (e.g. a box with its
150
+ // postings), or, when a container holds several, all of them (`lists`).
134
151
  export function findList(data) {
135
152
  if (isObjectList(data)) return { list: data };
136
- if (data && typeof data === "object" && !Array.isArray(data)) {
153
+ if (Array.isArray(data)) return { list: data, primitive: true };
154
+ if (data && typeof data === "object") {
137
155
  const keys = Object.keys(data).filter((key) => isObjectList(data[key]));
138
156
  if (keys.length === 1) return { list: data[keys[0]], key: keys[0], container: data };
157
+ if (keys.length > 1) return { lists: keys.map((key) => ({ key, list: data[key] })), container: data, list: keys.flatMap((key) => data[key]) };
158
+ // One list of plain values next to other fields ({items: ["a","b"], total_count: 40}).
159
+ const plain = Object.keys(data).filter((key) => Array.isArray(data[key]));
160
+ if (keys.length === 0 && plain.length === 1) return { list: data[plain[0]], key: plain[0], container: data, primitive: true };
139
161
  }
140
162
  return null;
141
163
  }
@@ -170,6 +192,38 @@ export function shapeData(data, { path, fields = null }) {
170
192
  if (!found) {
171
193
  return { data: truncateDeep(data, DETAIL_TEXT_LIMIT, mark), truncated, empty: false };
172
194
  }
195
+ if (found.primitive && !found.container) {
196
+ return { data: truncateDeep(found.list, LIST_TEXT_LIMIT, mark, true), truncated, empty: found.list.length === 0 };
197
+ }
198
+ if (found.primitive) {
199
+ const shaped = {};
200
+ for (const [name, value] of Object.entries(found.container)) {
201
+ if (name === found.key) shaped[name] = truncateDeep(value, LIST_TEXT_LIMIT, mark, true);
202
+ else if (fields === "all" || !DROP_CONTAINER_KEY(name)) shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
203
+ }
204
+ return { data: shaped, truncated, empty: found.list.length === 0 };
205
+ }
206
+ if (found.lists) {
207
+ // Several collections side by side: each gets the minimal schema of its own items.
208
+ const shaped = {};
209
+ for (const [name, value] of Object.entries(found.container)) {
210
+ const entry = found.lists.find((item) => item.key === name);
211
+ if (entry && Array.isArray(fields) && fields.length) {
212
+ // Explicit fields must exist in at least one of the lists.
213
+ const unknown = fields.filter((field) => !found.lists.some(({ list }) => project(list, [field], { strict: false }).rows.some((row) => Object.keys(row).length)));
214
+ if (unknown.length) throw new FieldError(unknown, [...new Set(found.lists.flatMap(({ list }) => list.slice(0, 5).flatMap((item) => Object.keys(item || {}))))]);
215
+ }
216
+ if (entry) {
217
+ const specs = fields === "all" ? null : Array.isArray(fields) && fields.length ? fields : heuristicFields(entry.list);
218
+ const rows = specs ? project(entry.list, specs).rows : entry.list;
219
+ shaped[name] = truncateDeep(rows, specs ? LIST_TEXT_LIMIT : DETAIL_TEXT_LIMIT, mark, Boolean(specs));
220
+ } else if (fields === "all" || !DROP_CONTAINER_KEY(name)) {
221
+ // Content next to the lists is kept (long text cut with its size), never dropped.
222
+ shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
223
+ }
224
+ }
225
+ return { data: shaped, truncated, empty: found.list.length === 0 };
226
+ }
173
227
 
174
228
  const { list, key, container } = found;
175
229
  let rows = list;
@@ -197,7 +251,8 @@ export function shapeData(data, { path, fields = null }) {
197
251
  shaped = {};
198
252
  for (const [name, value] of Object.entries(container)) {
199
253
  if (name === key) shaped[name] = rows;
200
- else if (fields === "all" || (!DROP_CONTAINER_KEY(name) && (value === null || typeof value !== "object"))) {
254
+ else if (fields === "all" || !DROP_CONTAINER_KEY(name)) {
255
+ // Content next to the list is kept (long text cut with its size), never dropped.
201
256
  shaped[name] = truncateDeep(value, DETAIL_TEXT_LIMIT, mark);
202
257
  }
203
258
  }
@@ -205,47 +260,133 @@ export function shapeData(data, { path, fields = null }) {
205
260
  return { data: shaped, truncated, empty: list.length === 0 };
206
261
  }
207
262
 
208
- function breadcrumbHelp(crumb) {
209
- if (typeof crumb === "string") return heyToAxi(crumb);
263
+ const MORE_NOTICE = /more (are )?available|more results|use --all|--page/i;
264
+ const TOTAL_NOTICE = /\b\d[\d,]* of (\d[\d,]*)\b/;
265
+
266
+ // What HEY told us about the size of a list: { shown, total?, more, next? }. Sources, in
267
+ // order: meta.total_count, a "Showing N of T" notice, and HEY's paging signals
268
+ // (next_page, has_more, a "more available" notice). With none of them the list is complete.
269
+ export function listSize(envelope, shown, container = null) {
270
+ // HEY puts paging in meta, or (box and other postings listings) next to the list in data.
271
+ const ownMeta = envelope && typeof envelope.meta === "object" && envelope.meta ? envelope.meta : {};
272
+ const side = container && typeof container === "object" ? Object.fromEntries(["total_count", "next_page", "next_cursor", "has_more"].filter((key) => key in container).map((key) => [key, container[key]])) : {};
273
+ const meta = { ...side, ...ownMeta };
274
+ if (typeof meta.total_count === "number" && meta.total_count < shown) delete meta.total_count;
275
+ const next = meta.next_page ?? meta.next_cursor ?? null;
276
+ const noticed = String(envelope?.notice || "").match(TOTAL_NOTICE);
277
+ const total = typeof meta.total_count === "number" ? meta.total_count : noticed ? Number(noticed[1].replace(/,/g, "")) : undefined;
278
+ const more = total !== undefined ? total > shown : Boolean(next || meta.has_more || MORE_NOTICE.test(envelope?.notice || ""));
279
+ // Complete only when HEY says so: a total, has_more: false, or a null next_page.
280
+ const complete = total !== undefined ? !more : !more && (meta.has_more === false || ("next_page" in meta && meta.next_page === null) || ("next_cursor" in meta && meta.next_cursor === null));
281
+ return { shown, total, more, next, complete };
282
+ }
283
+
284
+ // `count: 25 of 847 total` / `count: 8 total` / `count: 25 shown; more available` /
285
+ // `count: 25 shown; no more pages reported` (no total and no explicit end signal:
286
+ // hey-axi does not claim the list is complete).
287
+ export function countLine({ shown, total, more, complete, all = false }) {
288
+ if (total !== undefined) return `${shown} of ${total} total`;
289
+ if (more) return `${shown} shown; more available`;
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`;
292
+ }
293
+
294
+ // Append the invocation's selectors (`--account 2`) to a suggested command, unless the
295
+ // command already names them.
296
+ export function withSelectors(command, carry = []) {
297
+ const extra = [];
298
+ for (let i = 0; i < carry.length; i += 2) {
299
+ if (!command.includes(carry[i])) extra.push(carry[i], carry[i + 1]);
300
+ }
301
+ return extra.length ? `${command} ${extra.join(" ")}` : command;
302
+ }
303
+
304
+ // Carry the selectors into every `hey-axi …` command quoted inside a text (a hint, a
305
+ // string breadcrumb, an error's help line).
306
+ export function carrySelectors(text, carry = []) {
307
+ if (typeof text !== "string" || !carry.length) return text;
308
+ if (/^hey-axi \S/.test(text) && !/[`'"]/.test(text)) return withSelectors(text, carry);
309
+ // `…` may contain quotes ("<query>"); '…' and "…" may not contain their own quote.
310
+ let out = text.replace(/`(hey-axi [^`]+)`/g, (match, command) => `\`${withSelectors(command, carry)}\``);
311
+ out = out.replace(/(^|[^`\w])(['"])(hey-axi [^'"`]+?)\2/g, (match, lead, quote, command) => `${lead}${quote}${withSelectors(command, carry)}${quote}`);
312
+ // Unquoted "Run hey-axi …" to the end of the sentence.
313
+ return out.replace(/(\bRun:? )(hey-axi [^`'".;,\n]+?)(?=[.;,]?\s*$|[.;,]\s)/g, (match, run, command) => `${run}${withSelectors(command.trim(), carry)}`);
314
+ }
315
+
316
+ function breadcrumbHelp(crumb, carry) {
317
+ if (typeof crumb === "string") return carrySelectors(heyToAxi(crumb), carry);
210
318
  if (!crumb || typeof crumb !== "object" || !crumb.command) return null;
211
319
  const description = crumb.description ? ` to ${crumb.description.charAt(0).toLowerCase()}${crumb.description.slice(1)}` : "";
212
- return `Run \`${heyToAxi(crumb.command)}\`${description}`;
320
+ return `Run \`${withSelectors(heyToAxi(crumb.command), carry)}\`${description}`;
213
321
  }
214
322
 
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
- }
323
+ // The help line that reveals the rest of a truncated list (AXI principle 9).
324
+ // The command line without one flag and its value (`--page 1`, `--page=1`).
325
+ function dropFlag(commandLine, name) {
326
+ return commandLine.replace(new RegExp(`\\s--${name}(=\\S+|\\s+(?!-)\\S+)?(?=\\s|$)`, "g"), "");
327
+ }
226
328
 
227
- const { data, truncated, empty } = shapeData(envelope.data, { path, fields });
329
+ export function moreHelp(size, { commandLine: line, pageFlags = [] }) {
330
+ if (!size.more) return null;
331
+ // The hint replaces paging flags already on the line instead of repeating them.
332
+ const commandLine = dropFlag(dropFlag(line, "page"), "all");
333
+ const all = size.total !== undefined ? `all ${size.total}` : "all of them";
334
+ if (pageFlags.includes("all")) return `Run \`${commandLine} --all\` for ${all}`;
335
+ if (size.next && pageFlags.includes("page")) return `Run \`${commandLine} --page ${size.next}\` for the next page`;
336
+ if (pageFlags.includes("limit")) return `Run \`${dropFlag(commandLine, "limit")} --limit <n>\` to show more`;
337
+ return null;
338
+ }
339
+
340
+ // Shape whatever HEY printed. options: { path, fields, commandLine, carry, pageFlags, quiet }.
341
+ // carry: selector args from the invocation (["--account", "2"]) for suggested commands
342
+ // pageFlags: which of all/page/limit the command takes, for the "see the rest" hint
343
+ // quiet: drop HEY's summary, notice, breadcrumbs and meta; keep hey-axi's count,
344
+ // empty state and --full/--all hints
345
+ // Returns the object to print.
346
+ export function shapeEnvelope(envelope, { path, fields = null, commandLine, carry = [], pageFlags = [], quiet = false, all = false }) {
347
+ const enveloped = envelope && typeof envelope === "object" && !Array.isArray(envelope) && "data" in envelope;
348
+ const body = enveloped ? envelope.data : envelope;
349
+ const { data, truncated, empty } = shapeData(body, { path, fields });
350
+ const found = findList(body);
228
351
  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;
352
+ if (enveloped && envelope.ok !== undefined) out.ok = envelope.ok;
353
+ if (enveloped && envelope.summary && !quiet) out.summary = envelope.summary;
354
+ const size = found && !found.lists ? listSize(enveloped ? envelope : null, found.list.length, found.container) : null;
355
+ if (size) out.count = countLine({ ...size, all });
356
+ let multiSize = null;
357
+ if (found?.lists) {
358
+ // Several lists: each one's length, plus whatever HEY says about the whole result.
359
+ multiSize = listSize(enveloped ? envelope : null, found.list.length, found.container);
360
+ const parts = found.lists.map(({ key, list }) => `${list.length} ${key}`).join(", ");
361
+ out.count = multiSize.total !== undefined ? `${parts} (${multiSize.total} total)` : multiSize.more ? `${parts}; more available` : parts;
362
+ }
363
+ const nothing = body === null || body === undefined || (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0);
364
+ if (!enveloped && !found && !truncated && !nothing) return data;
239
365
  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;
366
+ const reported = size || multiSize;
367
+ if (empty && reported && (reported.more || reported.total > 0)) {
368
+ // An empty page while HEY reports more is not "nothing".
369
+ out.empty = `0 results on this page for \`${commandLine}\`; HEY reports ${reported.total > 0 ? `${reported.total} in total` : "more"}`;
370
+ } else if (empty) out.empty = `0 results for \`${commandLine}\``;
371
+ else if (nothing && !(enveloped && envelope.summary)) out.empty = `no data returned for \`${commandLine}\``;
372
+ const help = [];
373
+ if (enveloped && !quiet) {
374
+ for (const [key, value] of Object.entries(envelope)) {
375
+ if (["ok", "data", "summary", "meta", "breadcrumbs", "notice"].includes(key)) continue;
376
+ out[key] = value;
377
+ }
378
+ if (envelope.notice) out.notice = carrySelectors(heyToAxi(envelope.notice), carry);
379
+ help.push(...(Array.isArray(envelope.breadcrumbs) ? envelope.breadcrumbs : []).map((crumb) => breadcrumbHelp(crumb, carry)).filter(Boolean));
244
380
  }
245
- if (envelope.notice) out.notice = heyToAxi(envelope.notice);
246
- const help = (Array.isArray(envelope.breadcrumbs) ? envelope.breadcrumbs : []).map(breadcrumbHelp).filter(Boolean);
381
+ const more = (size || multiSize) && moreHelp(size || multiSize, { commandLine, pageFlags });
382
+ if (more) help.push(more);
247
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));
248
385
  if (help.length) out.help = help;
249
- if (meta && Object.keys(meta).length) out.meta = meta;
386
+ if (enveloped && !quiet && envelope.meta && typeof envelope.meta === "object") {
387
+ const meta = { ...envelope.meta };
388
+ for (const key of ["total_count", "pages_fetched", "next_page", "next_cursor", "has_more"]) delete meta[key];
389
+ if (Object.keys(meta).length) out.meta = meta;
390
+ }
250
391
  return out;
251
392
  }
package/src/text.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Small text helpers shared by output, help and errors.
2
2
 
3
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.
4
+ // becomes "hey-axi thread read 1". Only `hey` followed by a lowercase command word or a
5
+ // flag is rewritten, so prose ("Hey there", "hey.com") is left alone.
6
6
  export function heyToAxi(text) {
7
7
  if (typeof text !== "string") return text;
8
- return text.replace(/(^|[\s`'"(])hey (?=[a-z][a-z-]*)/g, "$1hey-axi ");
8
+ return text.replace(/(^|[\s`'"(])hey (?=[a-z][a-z-]*|--?[a-z])/g, "$1hey-axi ");
9
9
  }
10
10
 
11
11
  // Quote an argv word for display in a suggested command line.
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.2.0";
3
+ export const VERSION = "0.3.1";