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/cli.js CHANGED
@@ -1,27 +1,57 @@
1
1
  // hey-axi's CLI. Loaded by src/hey-axi.js only after the --version fast path.
2
+ //
3
+ // Order of checks, all before HEY runs: the command must exist, its flags must be known,
4
+ // have their values and the right types, its positional arguments must fit HEY's USAGE,
5
+ // the safety policy must allow it, and content that would otherwise open an editor must
6
+ // be given. Only then is HEY started, non-interactively.
2
7
 
3
8
  import { spawn } from "node:child_process";
9
+ import { readFileSync } from "node:fs";
4
10
  import { createInterface } from "node:readline";
5
11
  import { encode } from "@toon-format/toon";
6
- import { loadBundledManifest, normalizeCatalog, resolveCommand } from "./router.js";
7
- import { checkPolicy, runMode, sendStaging } from "./policy.js";
8
- import { heyFailure } from "./errors.js";
9
- import { RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js";
10
- import { FieldError, LIST_FIELDS, DETAIL_FIELDS, shapeData, shapeEnvelope } from "./shape.js";
12
+ import { closestNames, loadBundledManifest, resolveCommand } from "./router.js";
13
+ import { STDIN_VALUE_FLAGS, checkPolicy, contentProblem, noopFor, runMode, sendCommands, sendStaging, userOnlyPaths } from "./policy.js";
14
+ import { translateFailure, cleanLine, warningLines } from "./errors.js";
15
+ import { RAW_OUTPUT_FLAGS, flagLabel, flagValue, hasAny, nodeValueFlags, scanArgs, stripAxiFlags, validateFlags, valueFlagSet } from "./args.js";
16
+ import { argumentHelp, checkArity, oneOfHelp } from "./arity.js";
17
+ import { FieldError, LIST_FIELDS, DETAIL_FIELDS, carrySelectors, withSelectors, shapeEnvelope } from "./shape.js";
11
18
  import { homeView } from "./home.js";
12
19
  import { DESCRIPTION } from "./guide.js";
13
20
  import { heyToAxi, shellWord } from "./text.js";
21
+ import { examplesFor, flagDefault } from "./examples.js";
14
22
  import { VERSION } from "./version.js";
15
23
 
16
24
  // Resolve the HEY CLI: an explicit HEY_BIN wins, otherwise `hey` is looked up on PATH
17
25
  // by spawn(), the same way a shell would find it.
18
26
  const HEY = process.env.HEY_BIN || "hey";
19
27
 
28
+ // HEY never prompts or opens an editor under hey-axi: stdin is closed and these are set.
29
+ const QUIET_ENV = { HEY_NONINTERACTIVE: "1", EDITOR: "false", VISUAL: "false" };
30
+ const heyEnv = () => ({ ...process.env, ...QUIET_ENV });
31
+
32
+ const ALWAYS_ALLOWED = "--help, --json, --quiet, --fields, --full, --account, --ids-only, --count, --markdown, --html, --styled, --jq, --stats, --verbose, --base-url, --allow-send, --allow-secret, --interactive";
33
+
34
+ const GLOBAL_HELP = [
35
+ "--account <id|all> linked account (default: HEY's default account)",
36
+ "--base-url <url> HEY server (default: HEY's configured server)",
37
+ "--json the same shaped result as compact JSON (default false: TOON)",
38
+ "--quiet drop HEY's summary/notice/breadcrumbs/meta, keep data, count and hints (default false)",
39
+ "--fields <a,b|all> columns to show (default: the default fields)",
40
+ "--full HEY's complete, untruncated result (default false)",
41
+ "--ids-only, --count, --markdown, --html, --styled, --jq <expr> HEY's own output format, printed as-is (default: none)",
42
+ "--stats, --verbose request stats / debug logging on stderr (default false)",
43
+ ];
44
+ const OWN_FLAG_HELP = [
45
+ [(path) => sendCommands().includes(path), "--allow-send really send (default false: saved as a draft, or refused when there is no draft mode)"],
46
+ [(path) => path === "auth token", "--allow-secret print the token (default false: refused)"],
47
+ [(path) => userOnlyPaths().includes(path), "--interactive hand HEY the terminal; for a person at a terminal (default false: refused)"],
48
+ ];
49
+
20
50
  function usage(manifest) {
21
51
  const lines = [
22
52
  `hey-axi ${VERSION} — ${DESCRIPTION}`,
23
53
  "",
24
- "Run `hey-axi` with no arguments for a live home view (Imbox summary + next commands).",
54
+ "Run `hey-axi` with no arguments for a live home view (mail for this directory's scope + next commands).",
25
55
  "",
26
56
  `commands (from hey ${manifest.hey_version}; run \`hey-axi <command> --help\` for details):`,
27
57
  ];
@@ -30,22 +60,26 @@ function usage(manifest) {
30
60
  lines.push(` ${node.name}${subs} — ${node.short}`);
31
61
  }
32
62
  lines.push(
63
+ " setup hooks — session start/end hooks for Claude Code, Codex and OpenCode (hey-axi's own)",
64
+ " setup scope — focus the home view in this directory on a box, label or search (hey-axi's own)",
33
65
  "",
34
- "output (HEY's global flags may go anywhere on the line):",
66
+ "output (global flags may go anywhere on the line):",
35
67
  " default HEY's JSON envelope (data, summary, notice, breadcrumbs, meta) rendered as TOON",
36
- " --json Print HEY's JSON envelope as compact JSON instead of TOON",
37
- " --quiet Drop the envelope: just the data (HEY then writes notices to stderr)",
68
+ " --json The same shaped result as compact JSON instead of TOON",
69
+ " --quiet Drop HEY's summary/notice/breadcrumbs/meta; keep data, count, empty state and hints",
38
70
  " --ids-only, --count, --markdown, --html, --styled, --jq <expr>",
39
71
  " Passed to HEY untouched; HEY's output is printed as-is",
40
72
  " --fields a,b,c Choose list columns (aliases from the defaults or dotted paths); --fields all keeps every field",
41
73
  " --full HEY's complete, untruncated result (no field selection, no truncation)",
42
- " --account <id|email>, --base-url <url>, --stats, --verbose Forwarded to HEY",
74
+ " --account <id|all> Linked account (default: HEY's default account); also --base-url <url>, --stats, --verbose",
75
+ " --interactive Hand HEY the terminal for tui, setup, browser auth login and mcp (refused without it)",
43
76
  " --help Show this help (or `hey-axi <command> --help`)",
44
77
  " -v, -V, --version Print hey-axi's version (`hey-axi version` also shows HEY's)",
45
78
  "",
46
- "lists default to a few columns and cut long text (with its total size); unknown flags are rejected (exit 2).",
47
- "",
48
- "session hook: `hey-axi setup hooks` shows the home view at the start of Claude Code, Codex and OpenCode sessions.",
79
+ "lists show at most 4 columns, a count, and cut long text (with its total size).",
80
+ "unknown commands, flags or arguments, flags missing a value, and wrong value types are rejected before HEY runs (exit 2).",
81
+ "exit codes: 0 success (including no-ops), 1 error, 2 usage error or refusal. Errors are TOON on stdout with `kind` and `help`.",
82
+ "HEY never prompts: content that would open an editor must be passed (--message, or --message - for stdin).",
49
83
  "",
50
84
  "safety:",
51
85
  " Nothing is sent without --allow-send (or HEY_AXI_ALLOW_SEND=1): compose and reply are saved",
@@ -55,10 +89,15 @@ function usage(manifest) {
55
89
  return lines.join("\n");
56
90
  }
57
91
 
58
- function commandHelp(node) {
92
+ export function commandHelp(node) {
59
93
  const lines = [`hey-axi ${node.path} — ${node.short}`];
60
- for (const synopsis of node.synopsis || []) lines.push(`usage: ${heyToAxi(synopsis)}`);
94
+ for (const synopsis of node.synopsis || []) if (!synopsis.includes("<command>") || !node.usage) lines.push(`usage: ${heyToAxi(synopsis)}`);
61
95
  if (node.usage) lines.push(`shortcut: hey-axi ${node.usage}`);
96
+ const args = [...argumentHelp(node), ...oneOfHelp(node)];
97
+ if (args.length) {
98
+ lines.push("", "arguments:");
99
+ for (const arg of args) lines.push(` ${arg}`);
100
+ }
62
101
  if (node.subcommands?.length) {
63
102
  lines.push("", "subcommands:");
64
103
  for (const child of node.subcommands) lines.push(` ${child.path} — ${child.short}`);
@@ -66,25 +105,28 @@ function commandHelp(node) {
66
105
  if (node.flags?.length) {
67
106
  lines.push("", "flags:");
68
107
  for (const flag of node.flags) {
69
- const extra = [flag.desc, flag.default !== undefined ? `(default ${flag.default})` : ""].filter(Boolean).join(" ");
70
- lines.push(` ${flagLabel(flag)}${extra ? ` ${extra}` : ""}`);
108
+ const extra = [flag.desc, `(${flagDefault(flag)})`].filter(Boolean).join(" ");
109
+ lines.push(` ${flagLabel(flag)} ${extra}`);
71
110
  }
72
111
  }
112
+ lines.push("", "global flags (allowed on every command):", ...GLOBAL_HELP.map((line) => ` ${line}`));
113
+ const own = OWN_FLAG_HELP.filter(([applies]) => applies(node.path)).map(([, line]) => ` ${line}`);
114
+ if (own.length) lines.push(...own);
73
115
  const fields = LIST_FIELDS[node.path] || DETAIL_FIELDS[node.path];
74
116
  if (fields) {
75
117
  lines.push("", `default fields: ${fields.map((spec) => spec.split("=")[0]).join(", ")} (--fields a,b,c to choose, --fields all for every field, --full for HEY's untouched result)`);
76
118
  }
77
- if (node.examples?.length) {
78
- lines.push("", "examples:");
79
- for (const example of node.examples) lines.push(` ${heyToAxi(example)}`);
80
- }
119
+ lines.push("", "examples:");
120
+ for (const example of examplesFor(node)) lines.push(` ${example}`);
81
121
  if (node.notes) lines.push("", `notes: ${heyToAxi(node.notes)}`);
82
- const mode = runMode(node.path, new Set());
83
- if (mode === "interactive") lines.push("", "runs interactively: HEY gets the terminal; output is not converted");
84
- if (mode === "stream") lines.push("", "streams HEY's NDJSON line by line until it exits or is interrupted");
122
+ const mode = runMode(node.path, new Set(["--interactive"]));
123
+ if (mode === "interactive") lines.push("", "needs a person (or, for mcp, an MCP client): refused (exit 2) unless --interactive is passed; then HEY gets the terminal");
124
+ if (mode === "stream") lines.push("", "streams each event as it arrives (a TOON block per event; --json for HEY's NDJSON) until it exits or is interrupted");
85
125
  if (mode === "raw") lines.push("", "prints HEY's output as-is (not JSON)");
86
126
  const gate = checkPolicy(node.path, new Set(), {}, { stdin: true, stdout: true });
87
127
  if (gate) lines.push("", `${gate.error}: ${gate.reason}; ${gate.hint}`);
128
+ const content = contentProblem(node, new Set(), []);
129
+ if (content) lines.push("", `required: ${content.error.replace("missing ", "")} (${content.hint}); HEY never opens an editor under hey-axi`);
88
130
  if (sendStaging(node.path, new Set(), {})) lines.push("", "without --allow-send (or HEY_AXI_ALLOW_SEND=1) hey-axi adds --draft: it's saved as a draft, not sent");
89
131
  return lines.join("\n");
90
132
  }
@@ -93,6 +135,41 @@ function output(value) {
93
135
  process.stdout.write(`${encode(value)}\n`);
94
136
  }
95
137
 
138
+ // Only HEY's real warnings reach stderr; debug and progress noise is dropped.
139
+ function writeWarnings(text) {
140
+ for (const line of warningLines(text)) process.stderr.write(`${line}\n`);
141
+ }
142
+
143
+ const HOOK_END_HELP = [
144
+ "hey-axi hook session-end — record what this agent session did (run by the session-end hooks)",
145
+ "usage: hey-axi hook session-end (reads the hook's JSON payload from stdin: session_id, cwd)",
146
+ "",
147
+ "Folds this session's hey-axi activity in the current directory scope (command names and numeric",
148
+ "ids only) into a one-line summary that the next home view shows as last_session. Does nothing",
149
+ "unless `hey-axi setup hooks` turned capture on. Takes no flags; always exits 0.",
150
+ "",
151
+ "examples:",
152
+ " echo '{\"session_id\":\"abc\",\"cwd\":\"/path/to/project\"}' | hey-axi hook session-end",
153
+ " hey-axi setup hooks # installs this hook for Claude Code, Codex and OpenCode",
154
+ ].join("\n");
155
+
156
+ // The invocation's --account/--base-url, carried into every suggested command, including
157
+ // those in refusals.
158
+ let helpCarry = [];
159
+
160
+ function fail(value, code) {
161
+ // Every refusal says what kind of failure it is (exit 2 is always a usage error).
162
+ if (value && value.ok === false && !value.kind) value = { ok: false, kind: code === 2 ? "usage" : "command_error", ...value };
163
+ if (value && value.ok === false && helpCarry.length) {
164
+ for (const key of ["help", "hint"]) {
165
+ if (Array.isArray(value[key])) value[key] = value[key].map((line) => carrySelectors(line, helpCarry));
166
+ else if (typeof value[key] === "string") value[key] = carrySelectors(value[key], helpCarry);
167
+ }
168
+ }
169
+ output(value);
170
+ process.exit(code);
171
+ }
172
+
96
173
  function spawnErrorMessage(error) {
97
174
  if (error.code === "ENOENT") {
98
175
  return process.env.HEY_BIN
@@ -102,16 +179,17 @@ function spawnErrorMessage(error) {
102
179
  return error.message;
103
180
  }
104
181
 
105
- // Run HEY with captured output. `extra` holds the format flags hey-axi adds.
182
+ // Run HEY with captured output and no stdin. `extra` holds the format flags hey-axi adds.
106
183
  function runHey(args, extra = ["--json"]) {
107
184
  return new Promise((resolve) => {
108
- const child = spawn(HEY, [...args, ...extra], { stdio: ["inherit", "pipe", "pipe"] });
185
+ const child = spawn(HEY, [...args, ...extra], { stdio: ["ignore", "pipe", "pipe"], env: heyEnv() });
109
186
  let stdout = "";
110
187
  let stderr = "";
111
188
  child.stdout.on("data", (chunk) => { stdout += chunk; });
112
189
  child.stderr.on("data", (chunk) => { stderr += chunk; });
113
190
  child.on("error", (error) => resolve({ status: 127, error: spawnErrorMessage(error) }));
114
- child.on("close", (status) => resolve({ status, stdout, stderr }));
191
+ // Killed by a signal: no exit status, and not a success.
192
+ child.on("close", (status, signal) => resolve({ status: status ?? (signal ? 1 : 0), signal, stdout, stderr }));
115
193
  });
116
194
  }
117
195
 
@@ -125,47 +203,80 @@ function forwardSignals(child) {
125
203
  return () => handlers.forEach(([signal, handler]) => process.off(signal, handler));
126
204
  }
127
205
 
128
- const exitStatus = (status, signal) => status ?? (signal ? 128 + (({ SIGHUP: 1, SIGINT: 2, SIGTERM: 15 })[signal] || 0) : 0);
206
+ // Interactive commands (a person at a terminal): HEY gets the terminal.
207
+ function runHeyInteractive(args) {
208
+ return new Promise((resolve) => {
209
+ const child = spawn(HEY, args, { stdio: "inherit" });
210
+ const release = forwardSignals(child);
211
+ child.on("error", (error) => { release(); resolve({ status: 127, error: spawnErrorMessage(error) }); });
212
+ child.on("close", (status, signal) => { release(); resolve({ status: status ?? (signal ? 1 : 0), signal }); });
213
+ });
214
+ }
129
215
 
130
- // Run HEY exactly as typed, with stdio going straight to the terminal (raw output and
131
- // interactive commands).
216
+ // Raw output (a script, a CSV, --ids-only ...): stdout is held until HEY exits, then
217
+ // printed untouched on success. On failure nothing of HEY's is printed; the failure is
218
+ // reported as a structured error on stdout instead.
132
219
  function runHeyRaw(args) {
133
220
  return new Promise((resolve) => {
134
- const child = spawn(HEY, args, { stdio: "inherit" });
221
+ const child = spawn(HEY, args, { stdio: ["ignore", "pipe", "pipe"], env: heyEnv() });
135
222
  const release = forwardSignals(child);
223
+ const out = [];
224
+ let stderr = "";
225
+ child.stdout.on("data", (chunk) => { out.push(chunk); });
226
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
136
227
  child.on("error", (error) => { release(); resolve({ status: 127, error: spawnErrorMessage(error) }); });
137
- child.on("close", (status, signal) => { release(); resolve({ status: exitStatus(status, signal) }); });
228
+ child.on("close", (status, signal) => { release(); resolve({ status: status ?? (signal ? 1 : 0), signal, stderr, stdout: Buffer.concat(out) }); });
138
229
  });
139
230
  }
140
231
 
141
- // Run a long-lived NDJSON producer (hey watch), relaying each complete line the moment
142
- // it arrives so consumers never see a partial event.
143
- function runHeyStream(args) {
232
+ // Run a long-lived NDJSON producer (hey watch), relaying each complete event the moment
233
+ // it arrives so consumers never see a partial event: as a TOON block followed by a blank
234
+ // line by default, or as HEY's NDJSON line with --json.
235
+ function runHeyStream(args, { toon = true } = {}) {
144
236
  return new Promise((resolve) => {
145
- const child = spawn(HEY, args, { stdio: ["inherit", "pipe", "inherit"] });
237
+ const child = spawn(HEY, args, { stdio: ["ignore", "pipe", "pipe"], env: heyEnv() });
146
238
  const release = forwardSignals(child);
239
+ let stderr = "";
240
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
147
241
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
148
- lines.on("line", (line) => process.stdout.write(`${line}\n`));
242
+ lines.on("line", (line) => {
243
+ if (!toon) return process.stdout.write(`${line}\n`);
244
+ let event;
245
+ try {
246
+ event = JSON.parse(line);
247
+ } catch {
248
+ // Not an event: a diagnostic. It goes to stderr, cleaned, never into the stream.
249
+ const clean = cleanLine(line);
250
+ if (clean) process.stderr.write(`hey-axi: ${clean}\n`);
251
+ return undefined;
252
+ }
253
+ process.stdout.write(`${encode(event)}\n\n`);
254
+ });
149
255
  // A consumer that stops reading (e.g. `| head -1`) ends the watch cleanly.
150
256
  process.stdout.on("error", (error) => {
151
257
  if (error.code === "EPIPE") child.kill("SIGTERM");
152
258
  });
153
259
  child.on("error", (error) => { release(); resolve({ status: 127, error: spawnErrorMessage(error) }); });
154
- child.on("close", (status, signal) => { release(); lines.close(); resolve({ status: exitStatus(status, signal) }); });
260
+ child.on("close", (status, signal) => { release(); lines.close(); resolve({ status: status ?? 0, signal, stderr, stdout: "" }); });
155
261
  });
156
262
  }
157
263
 
264
+ // Read the session-end hook's JSON payload from stdin, if one is piped (never waits on a terminal).
265
+ function readHookInput() {
266
+ if (process.stdin.isTTY) return {};
267
+ try {
268
+ const text = readFileSync(0, "utf8");
269
+ return text.trim() ? JSON.parse(text) : {};
270
+ } catch {
271
+ return {};
272
+ }
273
+ }
158
274
 
159
- // Ask the installed HEY for its live catalog, for commands newer than the bundled manifest.
160
- async function discoverCommands() {
161
- const result = await runHey(["commands"]);
162
- if (result.status !== 0) return null;
275
+ function scopeDirFor(cwd, findScope) {
163
276
  try {
164
- const parsed = JSON.parse(result.stdout);
165
- const data = Array.isArray(parsed) ? parsed : parsed?.data;
166
- return Array.isArray(data) ? normalizeCatalog(data) : null;
277
+ return findScope(cwd)?.dir || cwd;
167
278
  } catch {
168
- return null;
279
+ return cwd;
169
280
  }
170
281
  }
171
282
 
@@ -176,83 +287,163 @@ if (args.length === 0) {
176
287
  process.exit(0);
177
288
  }
178
289
  if (args[0] === "--help" || args[0] === "-h") {
179
- process.stdout.write(`${usage(manifest)}\n`);
180
- process.exit(0);
290
+ // Top-level help takes nothing else: `hey-axi --help box` is `hey-axi box --help`.
291
+ const rest = args.slice(1).filter((arg) => arg !== "--help" && arg !== "-h");
292
+ if (rest.length && !rest[0].startsWith("-")) {
293
+ args = [...rest, "--help"];
294
+ } else if (rest.length) {
295
+ fail({ ok: false, error: `unknown flag ${rest[0]} for \`hey-axi --help\``, help: ["Run `hey-axi --help` for all commands", "Run `hey-axi <command> --help` for one command"] }, 2);
296
+ } else {
297
+ process.stdout.write(`${usage(manifest)}\n`);
298
+ process.exit(0);
299
+ }
181
300
  }
182
301
 
183
- const { words, flags } = scanArgs(args, valueFlagSet(manifest.commands));
302
+ // hey-axi's own commands: the session-end hook, `setup hooks` and `setup scope`.
303
+ if (args[0] === "hook" && args[1] === "session-end" && args.length === 3 && (args[2] === "--help" || args[2] === "-h")) {
304
+ process.stdout.write(`${HOOK_END_HELP}\n`);
305
+ process.exit(0);
306
+ }
307
+ if (args[0] === "hook" && args[1] === "session-end" && args.length === 2) {
308
+ const { endSession } = await import("./activity.js");
309
+ const { findScope } = await import("./scope.js");
310
+ const input = readHookInput();
311
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
312
+ const { sessionIdFrom } = await import("./activity.js");
313
+ const session = [input.session_id, input.sessionID, input.sessionId].find((value) => typeof value === "string" && value) || sessionIdFrom();
314
+ output(endSession(scopeDirFor(cwd, findScope), session));
315
+ process.exit(0);
316
+ }
317
+ if (args[0] === "setup" && args[1] === "scope") {
318
+ const { SCOPE_HELP, SCOPE_FLAGS, setupScope } = await import("./scope.js");
319
+ const strange = args.slice(2).find((arg) => arg.startsWith("-") && !SCOPE_FLAGS.has(arg.split("=", 1)[0]));
320
+ if (strange) fail({ ok: false, error: `unknown flag ${strange.split("=", 1)[0]} for \`setup scope\``, help: ["valid flags for `setup scope`: --box, --label, --search, --account, --limit, --status, --remove", "Run `hey-axi setup scope --help`"] }, 2);
321
+ if (args.includes("--help") || args.includes("-h")) {
322
+ process.stdout.write(`${SCOPE_HELP}\n`);
323
+ process.exit(0);
324
+ }
325
+ const { output: result, code } = setupScope(args.slice(2));
326
+ fail(result, code);
327
+ }
184
328
 
185
- // hey-axi's own setup command; every other `setup …` is HEY's (interactive).
186
- if (words[0] === "setup" && words[1] === "hooks" && words.length === 2) {
329
+ const first = scanArgs(args, valueFlagSet(manifest.commands));
330
+ for (const name of ["account", "base-url"]) {
331
+ const value = flagValue(args, name);
332
+ if (value !== undefined) helpCarry.push(`--${name}`, shellWord(value));
333
+ }
334
+ if (first.words[0] === "setup" && first.words[1] === "hooks") {
187
335
  const { HOOKS_HELP, setupHooks } = await import("./hooks.js");
188
- if (flags.has("--help") || flags.has("-h")) {
336
+ if (first.words.length > 2) fail({ ok: false, error: `unexpected argument "${first.words[2]}" for \`setup hooks\``, help: "Run `hey-axi setup hooks --help`" }, 2);
337
+ // Its flags are all switches: `--remove=false` is refused, not read as --remove.
338
+ const switchValue = first.values.find(([name]) => ["--project", "--status", "--remove"].includes(name));
339
+ if (switchValue) fail({ ok: false, error: `${switchValue[0]} is a switch and takes no value: pass ${switchValue[0]}, or leave it out`, help: "Run `hey-axi setup hooks --help`" }, 2);
340
+ if (first.flags.has("--help") || first.flags.has("-h")) {
189
341
  process.stdout.write(`${HOOKS_HELP}\n`);
190
342
  process.exit(0);
191
343
  }
192
- const { output: result, code } = await setupHooks(flags);
193
- output(result);
194
- process.exit(code);
344
+ const { output: result, code } = await setupHooks(first.flags);
345
+ fail(result, code);
195
346
  }
196
347
 
197
- const heyArgs = stripAxiFlags(args);
198
- const json = flags.has("--json");
199
- const quiet = flags.has("--quiet");
200
- const full = flags.has("--full");
201
- const raw = hasAny(flags, RAW_OUTPUT_FLAGS);
202
- const command = heyArgs.filter((arg) => arg !== "--json" && arg !== "--quiet");
203
- const fieldsArg = flagValue(args, "fields");
204
- const fields = fieldsArg === undefined ? null : fieldsArg.trim() === "all" ? "all" : fieldsArg.split(",").map((name) => name.trim()).filter(Boolean);
205
- // The command as the agent typed it, minus output/shaping flags, for `--full` hints.
206
- const commandLine = ["hey-axi", ...stripAxiFlags(args).filter((arg) => arg !== "--json" && arg !== "--quiet")].map(shellWord).join(" ");
207
-
208
- let resolved = resolveCommand(manifest.commands, words);
209
- if (resolved.error === "unknown command" || resolved.error === "unknown subcommand") {
210
- const live = await discoverCommands();
211
- const retry = live && resolveCommand(live, words);
212
- if (retry && !retry.error) resolved = retry;
348
+ if (first.words.length === 0) {
349
+ // Only flags, no command (`hey-axi --account 5`).
350
+ if (first.missing.length) fail({ ok: false, kind: "usage", error: `missing value for ${first.missing.join(", ")}`, help: "Run `hey-axi --help` for all commands" }, 2);
351
+ fail({ ok: false, kind: "usage", error: "no command given", help: ["Run `hey-axi` with no arguments for the home view", "Run `hey-axi --help` for all commands"] }, 2);
213
352
  }
214
353
 
354
+ const resolved = resolveCommand(manifest.commands, first.words);
215
355
  if (resolved.error) {
216
356
  const failure = { ok: false, error: resolved.error };
217
357
  if (resolved.path) failure.command = resolved.path;
218
358
  if (resolved.word) failure.word = resolved.word;
219
359
  if (resolved.subcommands) failure.subcommands = resolved.subcommands;
220
- failure.help = resolved.path ? `Run \`hey-axi ${resolved.path} --help\`` : "Run `hey-axi --help` for all commands";
221
- output(failure);
222
- process.exit(2);
360
+ let level = manifest.commands;
361
+ if (resolved.path) for (const word of resolved.path.split(" ")) level = level.find((node) => node.name === word)?.subcommands || [];
362
+ const close = closestNames(resolved.word, level);
363
+ failure.help = [
364
+ ...close.map((name) => `Did you mean \`hey-axi ${resolved.path ? `${resolved.path} ` : ""}${name}\`?`),
365
+ resolved.path ? `Run \`hey-axi ${resolved.path} --help\`` : "Run `hey-axi --help` for all commands",
366
+ ];
367
+ fail(failure, 2);
223
368
  }
369
+ const { node, path } = resolved;
370
+
371
+ // Re-read argv with this command's own value flags, for positionals and flag values.
372
+ let scan = scanArgs(args, nodeValueFlags(node));
373
+ if (scan.words.slice(0, resolved.consumed).join(" ") !== first.words.slice(0, resolved.consumed).join(" ")) scan = first;
374
+ const { flags } = scan;
375
+ const positionals = scan.words.slice(resolved.consumed);
376
+ const usageLines = (node.synopsis || []).filter((line) => !line.includes("<command>") || !node.usage).map((line) => `usage: ${heyToAxi(line)}`);
224
377
 
225
- const invalid = validateFlags(resolved.node, flags);
378
+ const invalid = validateFlags(node, flags, { values: scan.values, missingValues: scan.missing });
226
379
  if (invalid) {
227
- const valid = (resolved.node.flags || []).map(flagLabel);
228
- const failure = { ok: false, command: resolved.path };
229
- if (invalid.unknown) {
230
- failure.error = `unknown flag ${invalid.unknown.join(", ")} for \`${resolved.path}\``;
231
- } else {
232
- failure.error = `missing required flag ${invalid.missing.join(", ")} for \`${resolved.path}\``;
233
- }
380
+ const valid = (node.flags || []).map(flagLabel);
381
+ const failure = { ok: false, command: path, kind: "usage" };
382
+ if (invalid.unknown) failure.error = `unknown flag ${invalid.unknown.join(", ")} for \`${path}\``;
383
+ else if (invalid.missingValue) failure.error = `missing value for ${invalid.missingValue.join(", ")} (use ${invalid.missingValue[0]}=<value> if the value starts with "-")`;
384
+ else if (invalid.badValue) failure.error = invalid.badValue;
385
+ else failure.error = `missing required flag ${invalid.missing.join(", ")} for \`${path}\``;
234
386
  failure.help = [
235
- valid.length ? `valid flags for \`${resolved.path}\`: ${valid.join(", ")}` : `\`${resolved.path}\` takes no flags of its own`,
236
- "always allowed: --help, --json, --quiet, --fields, --full, --account, --ids-only, --count, --markdown, --html, --styled, --jq, --stats, --verbose, --base-url, --allow-send, --allow-secret",
237
- ...(resolved.node.synopsis || []).map((line) => `usage: ${heyToAxi(line)}`),
387
+ valid.length ? `valid flags for \`${path}\`: ${valid.join(", ")}` : `\`${path}\` takes no flags of its own`,
388
+ `always allowed: ${ALWAYS_ALLOWED}`,
389
+ ...usageLines,
238
390
  ];
239
- output(failure);
240
- process.exit(2);
391
+ fail(failure, 2);
241
392
  }
242
393
 
243
394
  if (flags.has("--help") || flags.has("-h")) {
244
- process.stdout.write(`${commandHelp(resolved.node)}\n`);
395
+ process.stdout.write(`${commandHelp(node)}\n`);
245
396
  process.exit(0);
246
397
  }
247
398
 
248
- const refusal = checkPolicy(resolved.path, flags);
249
- if (refusal) {
250
- output({ ok: false, ...refusal });
251
- process.exit(2);
399
+ const arity = checkArity(node, positionals, flags);
400
+ if (arity) {
401
+ const failure = { ok: false, command: path, kind: "usage", error: arity.error };
402
+ if (node.subcommands?.length && positionals.length === 0) failure.subcommands = node.subcommands.map((child) => child.name);
403
+ const close = node.subcommands?.length && positionals.length ? closestNames(positionals[0], node.subcommands) : [];
404
+ failure.help = [
405
+ ...close.map((name) => `Did you mean \`hey-axi ${path} ${name}\`?`),...usageLines, ...(node.usage ? [`shortcut: hey-axi ${node.usage}`] : []), ...(arity.hint ? [arity.hint] : []), `example: ${examplesFor(node)[0]}`];
406
+ fail(failure, 2);
407
+ }
408
+
409
+ const refusal = checkPolicy(path, flags);
410
+ if (refusal) fail({ ok: false, ...refusal }, 2);
411
+
412
+ const missingContent = contentProblem(node, flags, positionals);
413
+ if (missingContent) fail({ ok: false, kind: "usage", ...missingContent }, 2);
414
+
415
+ let heyArgs = stripAxiFlags(args);
416
+ // `--message -` (and --note -, --content -): read the content from stdin.
417
+ const fromStdin = scan.values.filter(([name, value]) => STDIN_VALUE_FLAGS.includes(name) && value === "-").map(([name]) => name);
418
+ if (fromStdin.length) {
419
+ if (process.stdin.isTTY) fail({ ok: false, kind: "usage", error: `${fromStdin[0]} - reads from stdin, but nothing is piped in`, help: `pipe the text in (e.g. \`cat note.md | hey-axi ${path} ... ${fromStdin[0]} -\`) or pass it as ${fromStdin[0]} "..."` }, 2);
420
+ const text = readFileSync(0, "utf8");
421
+ heyArgs = heyArgs.map((arg, index) => {
422
+ if (arg === "-" && fromStdin.includes(heyArgs[index - 1])) return text;
423
+ const eq = arg.indexOf("=");
424
+ return eq !== -1 && arg.slice(eq + 1) === "-" && fromStdin.includes(arg.slice(0, eq)) ? `${arg.slice(0, eq)}=${text}` : arg;
425
+ });
426
+ }
427
+
428
+ const json = flags.has("--json");
429
+ const quiet = flags.has("--quiet");
430
+ const full = flags.has("--full");
431
+ const raw = hasAny(flags, RAW_OUTPUT_FLAGS);
432
+ const command = heyArgs.filter((arg) => arg !== "--json" && arg !== "--quiet");
433
+ const fieldsArg = flagValue(args, "fields");
434
+ const fields = fieldsArg === undefined ? null : fieldsArg.trim() === "all" ? "all" : fieldsArg.split(",").map((name) => name.trim()).filter(Boolean);
435
+ // The command as the agent typed it, minus output/shaping flags, for `--full`/`--all` hints.
436
+ const commandLine = ["hey-axi", ...stripAxiFlags(args).filter((arg) => arg !== "--json" && arg !== "--quiet")].map(shellWord).join(" ");
437
+ // Selectors to carry into suggested commands.
438
+ const carry = [];
439
+ for (const name of ["account", "base-url"]) {
440
+ const value = flagValue(args, name);
441
+ if (value !== undefined) carry.push(`--${name}`, shellWord(value));
252
442
  }
443
+ const pageFlags = (node.flags || []).map((flag) => flag.name).filter((name) => ["all", "page", "limit"].includes(name));
253
444
 
254
445
  // compose/reply without a send opt-in: save a draft instead of sending.
255
- const staging = sendStaging(resolved.path, flags);
446
+ const staging = sendStaging(path, flags);
256
447
  if (staging) {
257
448
  heyArgs.push(staging.flag);
258
449
  command.push(staging.flag);
@@ -270,76 +461,122 @@ function markStaged(parsed) {
270
461
  return { ...marker, data: parsed };
271
462
  }
272
463
 
273
- const mode = runMode(resolved.path, flags);
464
+ function reportFailure(result) {
465
+ const { failure, exitCode, warnings } = translateFailure(result, { path });
466
+ // The fix-it commands keep the invocation's --account/--base-url.
467
+ for (const key of ["hint", "help"]) {
468
+ if (Array.isArray(failure[key])) failure[key] = failure[key].map((line) => carrySelectors(line, carry));
469
+ else if (failure[key]) failure[key] = carrySelectors(failure[key], carry);
470
+ }
471
+ for (const line of warnings) process.stderr.write(`${line}\n`);
472
+ const noop = exitCode !== 0 && noopFor(path, failure, positionals, { to: flagValue(args, "to"), from: flagValue(args, "from") });
473
+ if (noop) {
474
+ if (json) process.stdout.write(`${JSON.stringify(noop)}\n`);
475
+ else output(noop);
476
+ process.exit(0);
477
+ }
478
+ if (json) process.stdout.write(`${JSON.stringify(failure)}\n`);
479
+ else output(failure);
480
+ process.exit(exitCode);
481
+ }
482
+
483
+ const mode = runMode(path, flags);
484
+ if (mode === "interactive") {
485
+ // Only reached with the explicit --interactive opt-in (and a terminal, except for mcp).
486
+ const result = await runHeyInteractive(heyArgs);
487
+ if (result.error) reportFailure(result);
488
+ process.exit(result.status === 0 ? 0 : 1);
489
+ }
274
490
  if (raw || mode !== "json") {
275
- // interactive: tui, login, setup, mcp, upgrade; stream: watch; raw: scripts, CSV, and
276
- // --ids-only/--count/--markdown/--html/--styled/--jq. HEY owns the output.
277
- const result = mode === "stream" ? await runHeyStream(heyArgs) : await runHeyRaw(heyArgs);
278
- if (result.error) output(heyFailure(result));
279
- process.exit(result.status);
491
+ // stream: watch; raw: scripts, CSV, and --ids-only/--count/--markdown/--html/--styled/--jq.
492
+ const result = mode === "stream" ? await runHeyStream(heyArgs, { toon: !json }) : await runHeyRaw(heyArgs);
493
+ // A watch stopped by a signal ended the way the agent asked: not an error.
494
+ if (mode === "stream" && (result.signal || result.status === 130 || result.status === 143)) process.exit(0);
495
+ // HEY's stdout here is data, not an error message: it never goes into the failure.
496
+ if (result.error || result.status !== 0) reportFailure({ ...result, stdout: "" });
497
+ writeWarnings(result.stderr);
498
+ if (mode !== "stream" && result.stdout?.length) process.stdout.write(result.stdout);
499
+ process.exit(0);
280
500
  }
281
501
 
282
502
  // `hey-axi version`: hey-axi's own version next to HEY's.
283
- if (resolved.path === "version") {
503
+ if (path === "version") {
284
504
  const result = await runHey(command, ["--json"]);
285
505
  const versions = { hey_axi: VERSION };
286
- if (result.status === 0) {
287
- try {
288
- const parsed = JSON.parse(result.stdout);
289
- const data = parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
290
- versions.hey = data?.version ?? data;
291
- if (data?.commit) versions.hey_commit = data.commit;
292
- } catch {
293
- versions.hey = result.stdout.trim();
294
- }
295
- } else {
506
+ if (result.status !== 0) {
296
507
  // hey-axi still knows its own version; the failure says what's wrong with HEY.
297
- const failure = { ok: false, hey_axi: VERSION, ...heyFailure(result) };
298
- delete failure.ok;
299
- output({ ok: false, ...failure });
300
- process.exit(result.status);
508
+ const { failure, exitCode } = translateFailure(result, { path });
509
+ fail({ ok: false, hey_axi: VERSION, ...failure }, exitCode);
510
+ }
511
+ try {
512
+ const parsed = JSON.parse(result.stdout);
513
+ const data = parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
514
+ versions.hey = data?.version ?? data;
515
+ if (data?.commit) versions.hey_commit = data.commit;
516
+ } catch {
517
+ versions.hey = cleanLine(result.stdout);
301
518
  }
302
519
  if (json) process.stdout.write(`${JSON.stringify(versions)}\n`);
303
520
  else output(versions);
304
521
  process.exit(0);
305
522
  }
306
523
 
307
- const result = await runHey(command, quiet ? ["--json", "--quiet"] : ["--json"]);
308
- if (result.status === 0 && result.stderr) process.stderr.write(result.stderr);
309
-
310
- if (result.status !== 0) {
311
- // Pass HEY's exit code through: 1 usage, 2 not found, 3 auth, 4 forbidden,
312
- // 5 rate limit, 6 network, 7 API, 8 ambiguous (see `hey help exit-codes`).
313
- output(heyFailure(result));
314
- process.exit(result.status);
315
- }
524
+ const result = await runHey(command, ["--json"]);
525
+ if (result.status !== 0) reportFailure(result);
526
+ writeWarnings(result.stderr);
316
527
 
317
528
  let parsed;
529
+ let plainText = false;
318
530
  try {
319
531
  parsed = JSON.parse(result.stdout);
320
- } catch (error) {
321
- output({ ok: false, error: "HEY returned invalid JSON", detail: error.message });
322
- process.exit(1);
532
+ } catch {
533
+ plainText = true;
534
+ // HEY succeeded but printed text: show it, cleaned and cut, rather than fail.
535
+ const text = String(result.stdout || "").trim();
536
+ if (!text) parsed = { ok: true, result: "done (HEY printed nothing)" };
537
+ else if (full || text.length <= 1000) parsed = { ok: true, output: text };
538
+ else parsed = { ok: true, output: `${text.slice(0, 1000)}… (truncated, ${text.length} chars total)`, help: [`Run \`${commandLine} --full\` to see the complete output`] };
323
539
  }
324
540
 
325
541
  let shaped;
326
542
  try {
327
- if (full) shaped = parsed;
328
- else if (quiet) {
329
- const { data, truncated } = shapeData(parsed, { path: resolved.path, fields });
330
- shaped = data;
331
- if (truncated) process.stderr.write(`hey-axi: long text was truncated; run \`${commandLine} --full\` to see all of it\n`);
332
- } else shaped = shapeEnvelope(parsed, { path: resolved.path, fields, commandLine });
543
+ shaped = full || plainText ? parsed : shapeEnvelope(parsed, { path, fields, commandLine, carry, pageFlags, quiet, all: flags.has("--all") });
333
544
  } catch (error) {
334
545
  if (!(error instanceof FieldError)) throw error;
335
- output({
546
+ fail({
336
547
  ok: false,
337
- error: `unknown field ${error.unknown.join(", ")} for \`${resolved.path}\``,
548
+ kind: "usage",
549
+ error: `unknown field ${error.unknown.join(", ")} for \`${path}\``,
338
550
  help: [`available fields: ${error.available.join(", ")}`, "dotted paths (creator.email_address) and --fields all also work"],
339
- });
340
- process.exit(2);
551
+ }, 2);
341
552
  }
342
553
  shaped = markStaged(shaped);
554
+
555
+ // A list whose HEY result carried no next steps still gets one (AXI principle 9): the
556
+ // matching view/show command when there is one, else how to see every field.
557
+ if (!full && !quiet && !plainText && shaped && typeof shaped === "object" && shaped.count !== undefined) {
558
+ const help = Array.isArray(shaped.help) ? shaped.help : [];
559
+ const hasNext = help.some((line) => /^Run `hey-axi /.test(line) && !/ --(all|full|page|limit)\b/.test(line));
560
+ if (!hasNext) {
561
+ const words = path.split(" ");
562
+ const parent = resolveCommand(manifest.commands, words.slice(0, -1));
563
+ const siblings = words.length > 1 && !parent.error ? parent.node.subcommands || [] : [];
564
+ // After an empty list, suggest creating; after a list, suggest viewing.
565
+ const wanted = shaped.empty ? ["create", "add"] : ["view", "show", "read"];
566
+ const sibling = wanted.map((name) => siblings.find((child) => child.name === name && child.path !== path)).find(Boolean);
567
+ const next = sibling && shaped.empty
568
+ ? `Run \`${withSelectors(`hey-axi ${sibling.path} --help`, carry)}\` to see how to add one`
569
+ : sibling
570
+ ? `Run \`${withSelectors(`hey-axi ${sibling.path} <id>`, carry)}\` to see one in full`
571
+ : shaped.empty ? `Run \`${withSelectors(`hey-axi ${path} --help`, carry)}\` to check the filters` : `Run \`${commandLine} --fields all\` to see every field`;
572
+ shaped.help = [next, ...help];
573
+ }
574
+ }
575
+
576
+ const { recordActivity } = await import("./activity.js");
577
+ const { findScope } = await import("./scope.js");
578
+ recordActivity({ path, positionals, staged: Boolean(staging), sent: sendCommands().includes(path) && !staging && !flags.has("--draft") && !flags.has("--dry-run"), scopeDir: scopeDirFor(process.cwd(), findScope) });
579
+
343
580
  if (json) process.stdout.write(`${JSON.stringify(shaped)}\n`);
344
581
  else output(shaped);
345
582
  }