hey-axi 0.2.0 → 0.3.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.
@@ -0,0 +1,160 @@
1
+ // Session lifecycle capture (AXI principle 7). Once `hey-axi setup hooks` has been run,
2
+ // hey-axi notes what it did in each directory scope (command paths and numeric ids only:
3
+ // no subjects, bodies or addresses) in a local journal. The session-end hook
4
+ // (`hey-axi hook session-end`) folds the journal into a one-line summary of the session,
5
+ // and the next session's home view shows it, so new sessions start knowing which drafts
6
+ // are waiting and what was already triaged. `setup hooks --remove` turns capture off and
7
+ // deletes the journal.
8
+ //
9
+ // State lives in $HEY_AXI_STATE_DIR, else $XDG_STATE_HOME/hey-axi, else ~/.local/state/hey-axi.
10
+
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { createHash } from "node:crypto";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ export function stateDir(env = process.env) {
17
+ if (env.HEY_AXI_STATE_DIR) return env.HEY_AXI_STATE_DIR;
18
+ return join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "hey-axi");
19
+ }
20
+
21
+ const files = (dir = stateDir()) => ({
22
+ dir,
23
+ enabled: join(dir, "capture-enabled"),
24
+ journal: join(dir, "activity.jsonl"),
25
+ sessions: join(dir, "sessions"),
26
+ });
27
+
28
+ export function captureEnabled() {
29
+ return existsSync(files().enabled);
30
+ }
31
+
32
+ export function enableCapture() {
33
+ const f = files();
34
+ mkdirSync(f.dir, { recursive: true });
35
+ if (existsSync(f.enabled)) return false;
36
+ writeFileSync(f.enabled, "hey-axi session capture is on; `hey-axi setup hooks --remove` turns it off\n");
37
+ return true;
38
+ }
39
+
40
+ export function disableCapture() {
41
+ const f = files();
42
+ const had = existsSync(f.enabled) || existsSync(f.journal) || existsSync(f.sessions);
43
+ rmSync(f.enabled, { force: true });
44
+ rmSync(f.journal, { force: true });
45
+ rmSync(f.sessions, { recursive: true, force: true });
46
+ return had;
47
+ }
48
+
49
+ // The agent session a command ran in, when the harness says: Claude Code sets
50
+ // CLAUDE_CODE_SESSION_ID and Codex CODEX_THREAD_ID in the commands they run, matching the
51
+ // session_id their session-end hooks receive. HEY_AXI_SESSION_ID overrides both.
52
+ export function sessionIdFrom(env = process.env) {
53
+ return env.HEY_AXI_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || env.CODEX_THREAD_ID || env.CODEX_SESSION_ID || env.OPENCODE_SESSION_ID || "";
54
+ }
55
+
56
+ const SUMMARY_LIMIT = 300;
57
+
58
+ const scopeKey = (scopeDir) => createHash("sha256").update(scopeDir).digest("hex").slice(0, 16);
59
+
60
+ const READ_WORDS = new Set(["list", "view", "show", "read", "status", "history", "categories", "current", "filters", "preview", "senders", "token", "day", "week", "trusted-locals"]);
61
+ const READ_COMMANDS = new Set(["search", "box", "label", "collection", "bundle", "workflow", "set-aside", "set-aside group", "doctor", "version", "commands", "config show"]);
62
+
63
+ // "read", "mutation" or null (not worth remembering).
64
+ export function activityKind(path) {
65
+ if (path === "thread read") return "read";
66
+ const last = path.split(" ").pop();
67
+ if (READ_WORDS.has(last) || READ_COMMANDS.has(path) || path.startsWith("auth") || path.startsWith("setup") || path.startsWith("shell-completion") || path === "watch") return null;
68
+ return "mutation";
69
+ }
70
+
71
+ // Append one entry. Never throws: capture must not break a command.
72
+ export function recordActivity({ path, positionals = [], staged = false, sent = false, scopeDir }) {
73
+ try {
74
+ if (!captureEnabled()) return;
75
+ const kind = staged ? "draft" : sent ? "sent" : activityKind(path);
76
+ if (!kind) return;
77
+ const ids = positionals.filter((word) => /^\d+$/.test(word)).slice(0, 20);
78
+ const f = files();
79
+ const session = sessionIdFrom();
80
+ appendFileSync(f.journal, `${JSON.stringify({ at: new Date().toISOString(), scope: scopeKey(scopeDir), ...(session ? { session } : {}), path, kind, ids })}\n`);
81
+ } catch {
82
+ // capture is best-effort
83
+ }
84
+ }
85
+
86
+ function readJournal(f) {
87
+ if (!existsSync(f.journal)) return [];
88
+ return readFileSync(f.journal, "utf8").split("\n").filter(Boolean).flatMap((line) => {
89
+ try {
90
+ return [JSON.parse(line)];
91
+ } catch {
92
+ return [];
93
+ }
94
+ });
95
+ }
96
+
97
+ function summarize(entries) {
98
+ const groups = new Map();
99
+ for (const entry of entries) {
100
+ const key = entry.kind === "draft" ? `${entry.path} (saved as draft, not sent)` : entry.kind === "sent" ? `${entry.path} (sent)` : entry.path;
101
+ const group = groups.get(key) || { count: 0, ids: [] };
102
+ group.count += 1;
103
+ group.ids.push(...entry.ids);
104
+ groups.set(key, group);
105
+ }
106
+ const line = [...groups].map(([key, { count, ids }]) => {
107
+ const shown = [...new Set(ids)].slice(0, 5);
108
+ return `${key} ×${count}${shown.length ? ` [${shown.join(", ")}${ids.length > shown.length ? ", …" : ""}]` : ""}`;
109
+ }).join("; ");
110
+ // Session-start context loads every session: keep it to one short line.
111
+ return line.length > SUMMARY_LIMIT ? `${line.slice(0, SUMMARY_LIMIT)}… (${groups.size} kinds of action)` : line;
112
+ }
113
+
114
+ // The session-end hook: fold this session's journal entries (in this scope) into the
115
+ // scope's last-session summary. With a session id, only that session's entries (and
116
+ // entries whose session is unknown) are taken, so concurrent sessions in the same
117
+ // directory don't merge. A session with no hey-axi activity leaves the previous
118
+ // summary in place.
119
+ export function endSession(scopeDir, sessionId = "") {
120
+ const f = files();
121
+ if (!existsSync(f.enabled)) return { captured: 0 };
122
+ const key = scopeKey(scopeDir);
123
+ const entries = readJournal(f);
124
+ const isMine = (entry) => entry.scope === key && (!sessionId || !entry.session || entry.session === sessionId);
125
+ const mine = entries.filter(isMine);
126
+ if (!mine.length) return { captured: 0 };
127
+ mkdirSync(f.sessions, { recursive: true });
128
+ const summary = {
129
+ ended_at: new Date().toISOString(),
130
+ started_at: mine[0].at,
131
+ summary: summarize(mine),
132
+ drafts: mine.filter((entry) => entry.kind === "draft").length,
133
+ };
134
+ writeFileSync(join(f.sessions, `${key}.json`), `${JSON.stringify(summary)}\n`);
135
+ const rest = entries.filter((entry) => !isMine(entry));
136
+ writeFileSync(f.journal, rest.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
137
+ return { captured: mine.length };
138
+ }
139
+
140
+ function localTime(iso) {
141
+ const date = new Date(iso);
142
+ const pad = (n) => String(n).padStart(2, "0");
143
+ const offset = -date.getTimezoneOffset();
144
+ const sign = offset >= 0 ? "+" : "-";
145
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())} (UTC${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)})`;
146
+ }
147
+
148
+ // { line, drafts } for the home view, or null.
149
+ export function lastSession(scopeDir) {
150
+ try {
151
+ const f = files();
152
+ if (!existsSync(f.enabled)) return null;
153
+ const path = join(f.sessions, `${scopeKey(scopeDir)}.json`);
154
+ if (!existsSync(path)) return null;
155
+ const summary = JSON.parse(readFileSync(path, "utf8"));
156
+ return { line: `ended ${localTime(summary.ended_at)}: ${summary.summary}`, drafts: summary.drafts };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
package/src/args.js CHANGED
@@ -8,7 +8,7 @@ export const GLOBAL_BOOLEAN_FLAGS = ["json", "quiet", "ids-only", "count", "mark
8
8
  export const GLOBAL_SHORTHANDS = ["-v", "-h"];
9
9
 
10
10
  // hey-axi's own flags. Never forwarded to HEY.
11
- export const AXI_BOOLEAN_FLAGS = ["allow-send", "allow-secret", "full"];
11
+ export const AXI_BOOLEAN_FLAGS = ["allow-send", "allow-secret", "full", "interactive"];
12
12
  export const AXI_VALUE_FLAGS = ["fields"];
13
13
 
14
14
  // Output selectors that make HEY print something other than a JSON envelope. When any is
@@ -31,11 +31,15 @@ export function valueFlagSet(commands) {
31
31
  return names;
32
32
  }
33
33
 
34
- // Split argv into positional words (for routing) and flag names (for mode selection).
35
- // The argv itself is never reordered: HEY (cobra) accepts flags anywhere.
34
+ // Split argv into positional words (for routing and arity) and flag names (for mode
35
+ // selection). Also returns each flag's value and the value flags that are missing one
36
+ // (`--limit` at the end, or followed by another flag). The argv itself is never
37
+ // reordered: HEY (cobra) accepts flags anywhere.
36
38
  export function scanArgs(argv, valueFlags) {
37
39
  const words = [];
38
40
  const flags = new Set();
41
+ const values = [];
42
+ const missing = [];
39
43
  for (let i = 0; i < argv.length; i += 1) {
40
44
  const arg = argv[i];
41
45
  if (arg === "--") {
@@ -43,14 +47,51 @@ export function scanArgs(argv, valueFlags) {
43
47
  break;
44
48
  }
45
49
  if (arg.startsWith("-") && arg !== "-") {
46
- const [name] = arg.split("=", 1);
50
+ // Shorthand with its value attached (`-mhi`) or stacked shorthands (`-vv`).
51
+ if (/^-[A-Za-z]./.test(arg) && !arg.startsWith("--")) {
52
+ // Each letter is a shorthand until one takes a value; the rest is its value.
53
+ // Every letter is recorded, so an unknown one (`-vZ`) is rejected by name.
54
+ for (let j = 1; j < arg.length; j += 1) {
55
+ const name = `-${arg[j]}`;
56
+ if (arg[j] === "=") {
57
+ values.push([`-${arg[j - 1]}`, arg.slice(j + 1)]);
58
+ break;
59
+ }
60
+ flags.add(name);
61
+ if (valueFlags.has(name)) {
62
+ values.push([name, arg.slice(arg[j + 1] === "=" ? j + 2 : j + 1)]);
63
+ break;
64
+ }
65
+ }
66
+ continue;
67
+ }
68
+ const eq = arg.indexOf("=");
69
+ const name = eq === -1 ? arg : arg.slice(0, eq);
47
70
  flags.add(name);
48
- if (!arg.includes("=") && valueFlags.has(name)) i += 1;
71
+ if (eq !== -1) values.push([name, arg.slice(eq + 1)]);
72
+ else if (valueFlags.has(name)) {
73
+ const next = argv[i + 1];
74
+ if (next === undefined || looksLikeFlag(next)) missing.push(name);
75
+ else {
76
+ values.push([name, next]);
77
+ i += 1;
78
+ }
79
+ }
49
80
  continue;
50
81
  }
51
82
  words.push(arg);
52
83
  }
53
- return { words, flags };
84
+ return { words, flags, values, missing: [...new Set(missing)] };
85
+ }
86
+
87
+ // "-5" and "-" are values; "--to" and "-m" are flags.
88
+ function looksLikeFlag(arg) {
89
+ return arg === "--" || (arg.startsWith("-") && arg !== "-" && !/^-\d/.test(arg));
90
+ }
91
+
92
+ // The value flags of one command, plus the global and hey-axi ones.
93
+ export function nodeValueFlags(node) {
94
+ return valueFlagSet(node ? [{ flags: node.flags }] : []);
54
95
  }
55
96
 
56
97
  export function hasAny(flags, names) {
@@ -92,23 +133,47 @@ const flagName = (flag) => (flag.startsWith("--") ? flag : flag.slice(0, 2));
92
133
 
93
134
  export function flagLabel(flag) {
94
135
  const short = flag.shorthand ? `, -${flag.shorthand}` : "";
95
- return `--${flag.name}${short}${flag.value ? " <value>" : ""}`;
136
+ return `--${flag.name}${short}${flag.value ? ` <${flag.type || "value"}>` : ""}`;
96
137
  }
97
138
 
98
- // AXI: reject unknown flags (and missing required ones) before HEY is ever called.
99
- // Returns null, or { unknown: [...] } / { missing: [...] }.
100
- export function validateFlags(node, flags) {
139
+ const GLOBAL_TYPES = { "--account": "string", "--jq": "string", "--base-url": "string", "--fields": "string" };
140
+ const GO_DURATION = /^(0|([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+)$/;
141
+
142
+ function typeError(name, type, value) {
143
+ if (type === "int" && !/^-?\d+$/.test(value)) return `${name} needs a whole number, got "${value}"`;
144
+ if (type === "duration" && !GO_DURATION.test(value)) return `${name} needs a duration such as 30s, 10m or 1h, got "${value}"`;
145
+ // A switch is on when present and off when absent; an explicit value (`--draft=false`)
146
+ // is refused so that what hey-axi's policy sees is exactly what HEY would do.
147
+ if (type === "bool") return `${name} is a switch and takes no value: pass ${name} to turn it on, or leave it out (got ${name}=${value})`;
148
+ if (type === "count" && !/^\d+$/.test(value)) return `${name} counts repeats (${name} ${name}) or takes a whole number, got "${value}"`;
149
+ return null;
150
+ }
151
+
152
+ // AXI: reject unknown flags, flags without their value, values of the wrong type and
153
+ // missing required flags before HEY is ever called.
154
+ // Returns null, or { unknown } / { missingValue } / { badValue } / { missing }.
155
+ export function validateFlags(node, flags, { values = [], missingValues = [] } = {}) {
101
156
  const allowed = new Set([
102
157
  ...[...GLOBAL_VALUE_FLAGS, ...GLOBAL_BOOLEAN_FLAGS, ...AXI_BOOLEAN_FLAGS, ...AXI_VALUE_FLAGS].map((name) => `--${name}`),
103
158
  ...GLOBAL_SHORTHANDS,
104
159
  ]);
160
+ const types = new Map(Object.entries(GLOBAL_TYPES));
161
+ for (const name of [...GLOBAL_BOOLEAN_FLAGS, ...AXI_BOOLEAN_FLAGS]) types.set(`--${name}`, "bool");
105
162
  for (const flag of node.flags || []) {
106
163
  allowed.add(`--${flag.name}`);
107
164
  if (flag.shorthand) allowed.add(`-${flag.shorthand}`);
165
+ const type = flag.value ? flag.type || "string" : flag.type === "count" ? "count" : "bool";
166
+ types.set(`--${flag.name}`, type);
167
+ if (flag.shorthand) types.set(`-${flag.shorthand}`, type);
108
168
  }
109
169
  const unknown = [...flags].map(flagName).filter((flag) => !allowed.has(flag));
110
170
  if (unknown.length) return { unknown: [...new Set(unknown)] };
111
171
  if (flags.has("--help") || flags.has("-h")) return null;
172
+ if (missingValues.length) return { missingValue: missingValues };
173
+ for (const [name, value] of values) {
174
+ const problem = typeError(name, types.get(name), value);
175
+ if (problem) return { badValue: problem };
176
+ }
112
177
  const missing = (node.flags || [])
113
178
  .filter((flag) => /\(required\)$/.test(flag.desc || ""))
114
179
  .filter((flag) => !flags.has(`--${flag.name}`) && !(flag.shorthand && flags.has(`-${flag.shorthand}`)))
package/src/arity.js ADDED
@@ -0,0 +1,94 @@
1
+ // Positional-argument checks from HEY's own USAGE lines (bundled in src/manifest.json),
2
+ // so a missing id or a stray extra word fails loud (exit 2) before HEY runs.
3
+ //
4
+ // hey thread read <thread-id> [flags] one required argument
5
+ // hey seen <box-item-id>... [flags] one or more
6
+ // hey journal write [date] [content] [flags] up to two, both optional
7
+ // hey bubble up <id>... (--now | --on <date> | --tomorrow) [flags]
8
+ // plus exactly one of those flags
9
+
10
+ // Parse one USAGE line. Returns null for group lines (`hey box <command> [flags]`).
11
+ export function parseSynopsis(line, path) {
12
+ const prefix = `hey ${path}`;
13
+ if (!line.startsWith(prefix)) return null;
14
+ let rest = line.slice(prefix.length);
15
+ const oneOf = [];
16
+ rest = rest.replace(/\(([^)]*)\)/g, (_, group) => {
17
+ oneOf.push(group.split("|").map((part) => part.trim().split(/\s+/)[0]).filter((flag) => flag.startsWith("-")));
18
+ return " ";
19
+ });
20
+ const args = [];
21
+ for (const token of rest.trim().split(/\s+/).filter(Boolean)) {
22
+ if (token === "[flags]") continue;
23
+ if (token === "<command>") return null;
24
+ const match = token.match(/^([<[])([^>\]]+)[>\]](\.\.\.)?$/);
25
+ if (!match) continue;
26
+ args.push({ name: match[2], required: match[1] === "<", variadic: Boolean(match[3]) });
27
+ }
28
+ return { args, oneOf };
29
+ }
30
+
31
+ // Every argument pattern that applies when `node` is run with leftover words.
32
+ // A group run by its shortcut (`box imbox`) uses HEY's compatibility usage.
33
+ export function patternsFor(node) {
34
+ const lines = [...(node.synopsis || [])];
35
+ if (node.usage) lines.push(`hey ${node.usage}`);
36
+ return lines.map((line) => parseSynopsis(line, node.path)).filter(Boolean);
37
+ }
38
+
39
+ const describe = (args) => args.map(({ name, required, variadic }) => `${required ? `<${name}>` : `[${name}]`}${variadic ? "..." : ""}`).join(" ");
40
+
41
+ function fits(pattern, count) {
42
+ const min = pattern.args.filter((arg) => arg.required).length;
43
+ const max = pattern.args.some((arg) => arg.variadic) ? Infinity : pattern.args.length;
44
+ return count >= min && count <= max;
45
+ }
46
+
47
+ // Returns null when the positionals and one-of flag groups fit, or { error, expected }.
48
+ export function checkArity(node, positionals, flags) {
49
+ const patterns = patternsFor(node);
50
+ if (!patterns.length) return null;
51
+ const matching = patterns.filter((pattern) => fits(pattern, positionals.length));
52
+ if (!matching.length) {
53
+ const most = Math.max(...patterns.map((pattern) => (pattern.args.some((arg) => arg.variadic) ? Infinity : pattern.args.length)));
54
+ const expected = patterns.map((pattern) => describe(pattern.args)).filter(Boolean);
55
+ if (positionals.length > most) {
56
+ const extra = positionals.slice(most);
57
+ const takes = most === 0 ? "takes no arguments" : `takes at most ${most}: ${expected.join(" or ")}`;
58
+ return {
59
+ error: `unexpected argument${extra.length > 1 ? "s" : ""} ${extra.map((word) => `"${word}"`).join(" ")} for \`${node.path}\` (${takes})`,
60
+ hint: most >= 1 ? "quote text that contains spaces, e.g. \"two words\"" : undefined,
61
+ };
62
+ }
63
+ const pattern = patterns.find((candidate) => candidate.args.filter((arg) => arg.required).length > positionals.length) || patterns[0];
64
+ const missing = pattern.args.filter((arg) => arg.required).slice(positionals.length).map((arg) => `<${arg.name}>`);
65
+ return { error: `missing argument ${missing.join(" ")} for \`${node.path}\`` };
66
+ }
67
+ for (const group of matching[0].oneOf) {
68
+ const present = group.filter((flag) => flags.has(flag));
69
+ if (present.length !== 1) {
70
+ return {
71
+ error: present.length
72
+ ? `${present.join(" and ")} can't be combined for \`${node.path}\`; pass exactly one of ${group.join(", ")}`
73
+ : `\`${node.path}\` needs one of ${group.join(", ")}`,
74
+ };
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // Required/optional arguments for --help, e.g. "<thread-id> (required)".
81
+ export function argumentHelp(node) {
82
+ const seen = new Map();
83
+ for (const pattern of patternsFor(node)) {
84
+ for (const arg of pattern.args) {
85
+ const key = arg.name;
86
+ if (!seen.has(key)) seen.set(key, `${arg.required ? `<${arg.name}>` : `[${arg.name}]`}${arg.variadic ? "..." : ""} ${arg.required ? "required" : "optional"}${arg.variadic ? ", one or more" : ""}`);
87
+ }
88
+ }
89
+ return [...seen.values()];
90
+ }
91
+
92
+ export function oneOfHelp(node) {
93
+ return patternsFor(node).flatMap((pattern) => pattern.oneOf).map((group) => `exactly one of ${group.join(", ")} (required)`);
94
+ }