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/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +60 -0
- package/skills/hey-axi/SKILL.md +125 -0
- package/skills/hey-axi/references/commands.md +161 -0
- package/src/args.js +117 -0
- package/src/cli.js +345 -0
- package/src/errors.js +61 -0
- package/src/guide.js +28 -0
- package/src/hey-axi.js +10 -0
- package/src/home.js +50 -0
- package/src/hooks.js +93 -0
- package/src/manifest.json +3572 -0
- package/src/policy.js +91 -0
- package/src/router.js +82 -0
- package/src/shape.js +251 -0
- package/src/text.js +14 -0
- package/src/version.js +3 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// hey-axi's CLI. Loaded by src/hey-axi.js only after the --version fast path.
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
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";
|
|
11
|
+
import { homeView } from "./home.js";
|
|
12
|
+
import { DESCRIPTION } from "./guide.js";
|
|
13
|
+
import { heyToAxi, shellWord } from "./text.js";
|
|
14
|
+
import { VERSION } from "./version.js";
|
|
15
|
+
|
|
16
|
+
// Resolve the HEY CLI: an explicit HEY_BIN wins, otherwise `hey` is looked up on PATH
|
|
17
|
+
// by spawn(), the same way a shell would find it.
|
|
18
|
+
const HEY = process.env.HEY_BIN || "hey";
|
|
19
|
+
|
|
20
|
+
function usage(manifest) {
|
|
21
|
+
const lines = [
|
|
22
|
+
`hey-axi ${VERSION} — ${DESCRIPTION}`,
|
|
23
|
+
"",
|
|
24
|
+
"Run `hey-axi` with no arguments for a live home view (Imbox summary + next commands).",
|
|
25
|
+
"",
|
|
26
|
+
`commands (from hey ${manifest.hey_version}; run \`hey-axi <command> --help\` for details):`,
|
|
27
|
+
];
|
|
28
|
+
for (const node of manifest.commands) {
|
|
29
|
+
const subs = node.subcommands?.length ? ` ${node.subcommands.map((child) => child.name).join("|")}` : "";
|
|
30
|
+
lines.push(` ${node.name}${subs} — ${node.short}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push(
|
|
33
|
+
"",
|
|
34
|
+
"output (HEY's global flags may go anywhere on the line):",
|
|
35
|
+
" 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)",
|
|
38
|
+
" --ids-only, --count, --markdown, --html, --styled, --jq <expr>",
|
|
39
|
+
" Passed to HEY untouched; HEY's output is printed as-is",
|
|
40
|
+
" --fields a,b,c Choose list columns (aliases from the defaults or dotted paths); --fields all keeps every field",
|
|
41
|
+
" --full HEY's complete, untruncated result (no field selection, no truncation)",
|
|
42
|
+
" --account <id|email>, --base-url <url>, --stats, --verbose Forwarded to HEY",
|
|
43
|
+
" --help Show this help (or `hey-axi <command> --help`)",
|
|
44
|
+
" -v, -V, --version Print hey-axi's version (`hey-axi version` also shows HEY's)",
|
|
45
|
+
"",
|
|
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.",
|
|
49
|
+
"",
|
|
50
|
+
"safety:",
|
|
51
|
+
" Nothing is sent without --allow-send (or HEY_AXI_ALLOW_SEND=1): compose and reply are saved",
|
|
52
|
+
" as drafts (hey-axi adds --draft and says so); forward, draft send and bulk-reply send are refused.",
|
|
53
|
+
" auth token needs --allow-secret / HEY_AXI_ALLOW_SECRETS=1.",
|
|
54
|
+
);
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function commandHelp(node) {
|
|
59
|
+
const lines = [`hey-axi ${node.path} — ${node.short}`];
|
|
60
|
+
for (const synopsis of node.synopsis || []) lines.push(`usage: ${heyToAxi(synopsis)}`);
|
|
61
|
+
if (node.usage) lines.push(`shortcut: hey-axi ${node.usage}`);
|
|
62
|
+
if (node.subcommands?.length) {
|
|
63
|
+
lines.push("", "subcommands:");
|
|
64
|
+
for (const child of node.subcommands) lines.push(` ${child.path} — ${child.short}`);
|
|
65
|
+
}
|
|
66
|
+
if (node.flags?.length) {
|
|
67
|
+
lines.push("", "flags:");
|
|
68
|
+
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}` : ""}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const fields = LIST_FIELDS[node.path] || DETAIL_FIELDS[node.path];
|
|
74
|
+
if (fields) {
|
|
75
|
+
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
|
+
}
|
|
77
|
+
if (node.examples?.length) {
|
|
78
|
+
lines.push("", "examples:");
|
|
79
|
+
for (const example of node.examples) lines.push(` ${heyToAxi(example)}`);
|
|
80
|
+
}
|
|
81
|
+
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");
|
|
85
|
+
if (mode === "raw") lines.push("", "prints HEY's output as-is (not JSON)");
|
|
86
|
+
const gate = checkPolicy(node.path, new Set(), {}, { stdin: true, stdout: true });
|
|
87
|
+
if (gate) lines.push("", `${gate.error}: ${gate.reason}; ${gate.hint}`);
|
|
88
|
+
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
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function output(value) {
|
|
93
|
+
process.stdout.write(`${encode(value)}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function spawnErrorMessage(error) {
|
|
97
|
+
if (error.code === "ENOENT") {
|
|
98
|
+
return process.env.HEY_BIN
|
|
99
|
+
? `HEY CLI not found at HEY_BIN=${process.env.HEY_BIN}`
|
|
100
|
+
: "HEY CLI not found on PATH; install it (https://github.com/basecamp/hey-cli) or set HEY_BIN";
|
|
101
|
+
}
|
|
102
|
+
return error.message;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Run HEY with captured output. `extra` holds the format flags hey-axi adds.
|
|
106
|
+
function runHey(args, extra = ["--json"]) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const child = spawn(HEY, [...args, ...extra], { stdio: ["inherit", "pipe", "pipe"] });
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let stderr = "";
|
|
111
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
112
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
113
|
+
child.on("error", (error) => resolve({ status: 127, error: spawnErrorMessage(error) }));
|
|
114
|
+
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Relay SIGINT/SIGTERM to HEY and let it decide how to exit, instead of dying first.
|
|
119
|
+
function forwardSignals(child) {
|
|
120
|
+
const handlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => {
|
|
121
|
+
const handler = () => child.kill(signal);
|
|
122
|
+
process.on(signal, handler);
|
|
123
|
+
return [signal, handler];
|
|
124
|
+
});
|
|
125
|
+
return () => handlers.forEach(([signal, handler]) => process.off(signal, handler));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const exitStatus = (status, signal) => status ?? (signal ? 128 + (({ SIGHUP: 1, SIGINT: 2, SIGTERM: 15 })[signal] || 0) : 0);
|
|
129
|
+
|
|
130
|
+
// Run HEY exactly as typed, with stdio going straight to the terminal (raw output and
|
|
131
|
+
// interactive commands).
|
|
132
|
+
function runHeyRaw(args) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const child = spawn(HEY, args, { stdio: "inherit" });
|
|
135
|
+
const release = forwardSignals(child);
|
|
136
|
+
child.on("error", (error) => { release(); resolve({ status: 127, error: spawnErrorMessage(error) }); });
|
|
137
|
+
child.on("close", (status, signal) => { release(); resolve({ status: exitStatus(status, signal) }); });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
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) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const child = spawn(HEY, args, { stdio: ["inherit", "pipe", "inherit"] });
|
|
146
|
+
const release = forwardSignals(child);
|
|
147
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
148
|
+
lines.on("line", (line) => process.stdout.write(`${line}\n`));
|
|
149
|
+
// A consumer that stops reading (e.g. `| head -1`) ends the watch cleanly.
|
|
150
|
+
process.stdout.on("error", (error) => {
|
|
151
|
+
if (error.code === "EPIPE") child.kill("SIGTERM");
|
|
152
|
+
});
|
|
153
|
+
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) }); });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
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;
|
|
163
|
+
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;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function main(args = process.argv.slice(2)) {
|
|
173
|
+
const manifest = loadBundledManifest();
|
|
174
|
+
if (args.length === 0) {
|
|
175
|
+
output(await homeView(runHey));
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
179
|
+
process.stdout.write(`${usage(manifest)}\n`);
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { words, flags } = scanArgs(args, valueFlagSet(manifest.commands));
|
|
184
|
+
|
|
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) {
|
|
187
|
+
const { HOOKS_HELP, setupHooks } = await import("./hooks.js");
|
|
188
|
+
if (flags.has("--help") || flags.has("-h")) {
|
|
189
|
+
process.stdout.write(`${HOOKS_HELP}\n`);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
const { output: result, code } = await setupHooks(flags);
|
|
193
|
+
output(result);
|
|
194
|
+
process.exit(code);
|
|
195
|
+
}
|
|
196
|
+
|
|
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;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (resolved.error) {
|
|
216
|
+
const failure = { ok: false, error: resolved.error };
|
|
217
|
+
if (resolved.path) failure.command = resolved.path;
|
|
218
|
+
if (resolved.word) failure.word = resolved.word;
|
|
219
|
+
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);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const invalid = validateFlags(resolved.node, flags);
|
|
226
|
+
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
|
+
}
|
|
234
|
+
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)}`),
|
|
238
|
+
];
|
|
239
|
+
output(failure);
|
|
240
|
+
process.exit(2);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (flags.has("--help") || flags.has("-h")) {
|
|
244
|
+
process.stdout.write(`${commandHelp(resolved.node)}\n`);
|
|
245
|
+
process.exit(0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const refusal = checkPolicy(resolved.path, flags);
|
|
249
|
+
if (refusal) {
|
|
250
|
+
output({ ok: false, ...refusal });
|
|
251
|
+
process.exit(2);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// compose/reply without a send opt-in: save a draft instead of sending.
|
|
255
|
+
const staging = sendStaging(resolved.path, flags);
|
|
256
|
+
if (staging) {
|
|
257
|
+
heyArgs.push(staging.flag);
|
|
258
|
+
command.push(staging.flag);
|
|
259
|
+
process.stderr.write(`hey-axi: ${staging.notice}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Put hey-axi's "saved as a draft, not sent" marker at the top of the result.
|
|
263
|
+
function markStaged(parsed) {
|
|
264
|
+
if (!staging) return parsed;
|
|
265
|
+
const marker = { sent: false, saved_as: "draft", axi_notice: staging.notice };
|
|
266
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
267
|
+
const { ok, ...rest } = parsed;
|
|
268
|
+
return ok === undefined ? { ...marker, ...rest } : { ok, ...marker, ...rest };
|
|
269
|
+
}
|
|
270
|
+
return { ...marker, data: parsed };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const mode = runMode(resolved.path, flags);
|
|
274
|
+
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);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// `hey-axi version`: hey-axi's own version next to HEY's.
|
|
283
|
+
if (resolved.path === "version") {
|
|
284
|
+
const result = await runHey(command, ["--json"]);
|
|
285
|
+
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 {
|
|
296
|
+
// 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);
|
|
301
|
+
}
|
|
302
|
+
if (json) process.stdout.write(`${JSON.stringify(versions)}\n`);
|
|
303
|
+
else output(versions);
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
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
|
+
}
|
|
316
|
+
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(result.stdout);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
output({ ok: false, error: "HEY returned invalid JSON", detail: error.message });
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let shaped;
|
|
326
|
+
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 });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (!(error instanceof FieldError)) throw error;
|
|
335
|
+
output({
|
|
336
|
+
ok: false,
|
|
337
|
+
error: `unknown field ${error.unknown.join(", ")} for \`${resolved.path}\``,
|
|
338
|
+
help: [`available fields: ${error.available.join(", ")}`, "dotted paths (creator.email_address) and --fields all also work"],
|
|
339
|
+
});
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
shaped = markStaged(shaped);
|
|
343
|
+
if (json) process.stdout.write(`${JSON.stringify(shaped)}\n`);
|
|
344
|
+
else output(shaped);
|
|
345
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Turn a failed HEY run into hey-axi's error shape, keeping HEY's own error envelope
|
|
2
|
+
// ({ok:false,error,code,hint,meta}, written to stderr in --json mode) when there is one.
|
|
3
|
+
// HEY may print plain warning lines (e.g. about the keyring) before the envelope; those
|
|
4
|
+
// are kept as `warning`. Suggestions name hey-axi commands, and when HEY gives no hint
|
|
5
|
+
// a `help` line for the exit code says what to do next.
|
|
6
|
+
|
|
7
|
+
import { heyToAxi } from "./text.js";
|
|
8
|
+
|
|
9
|
+
// HEY's exit codes (`hey help exit-codes`) → the next step for an agent.
|
|
10
|
+
const NEXT_STEP = {
|
|
11
|
+
1: "Run `hey-axi <command> --help` to check the arguments",
|
|
12
|
+
2: "Check the id; list commands such as `hey-axi box view imbox` show valid ids (threads use topic_id)",
|
|
13
|
+
3: "Ask the user to run `hey auth login` in their terminal, then `hey-axi auth status`",
|
|
14
|
+
4: "This account can't do that; check `hey-axi account list` and --account",
|
|
15
|
+
5: "Rate limited by HEY: wait a minute, then retry",
|
|
16
|
+
6: "Network problem reaching HEY: check the connection, then retry",
|
|
17
|
+
7: "HEY's API returned an error: retry later, or run `hey-axi doctor`",
|
|
18
|
+
8: "More than one match: pass an exact id instead of a name",
|
|
19
|
+
127: "Install the HEY CLI (curl -fsSL https://hey.com/install-cli | bash) or set HEY_BIN",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function withHelp(failure) {
|
|
23
|
+
if (failure.hint) failure.hint = heyToAxi(failure.hint);
|
|
24
|
+
else if (NEXT_STEP[failure.exit_code]) failure.help = NEXT_STEP[failure.exit_code];
|
|
25
|
+
return failure;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findEnvelope(text) {
|
|
29
|
+
if (!text) return null;
|
|
30
|
+
const starts = [];
|
|
31
|
+
if (text.trimStart().startsWith("{")) starts.push(text.indexOf("{"));
|
|
32
|
+
for (let i = text.indexOf("\n{"); i !== -1; i = text.indexOf("\n{", i + 1)) starts.push(i + 1);
|
|
33
|
+
for (const start of starts) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(text.slice(start));
|
|
36
|
+
if (parsed && typeof parsed === "object" && parsed.ok === false && parsed.error) {
|
|
37
|
+
return { envelope: parsed, prefix: text.slice(0, start).trim() };
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// not an envelope from here; try the next candidate
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function heyFailure(result) {
|
|
47
|
+
if (result.error) return withHelp({ ok: false, error: result.error, exit_code: result.status });
|
|
48
|
+
for (const text of [result.stderr, result.stdout]) {
|
|
49
|
+
const found = findEnvelope(text);
|
|
50
|
+
if (!found) continue;
|
|
51
|
+
const failure = { ok: false, error: found.envelope.error };
|
|
52
|
+
for (const key of ["code", "hint", "meta"]) {
|
|
53
|
+
if (found.envelope[key] !== undefined && found.envelope[key] !== "") failure[key] = found.envelope[key];
|
|
54
|
+
}
|
|
55
|
+
if (found.prefix) failure.warning = found.prefix;
|
|
56
|
+
failure.exit_code = result.status;
|
|
57
|
+
return withHelp(failure);
|
|
58
|
+
}
|
|
59
|
+
const message = (result.stderr || "").trim() || (result.stdout || "").trim() || "hey command failed";
|
|
60
|
+
return withHelp({ ok: false, error: message, exit_code: result.status });
|
|
61
|
+
}
|
package/src/guide.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Static guidance shared by the no-args home view and the generated part of
|
|
2
|
+
// skills/hey-axi/SKILL.md (scripts/gen-skill.js), so the two never drift.
|
|
3
|
+
|
|
4
|
+
export const DESCRIPTION = "HEY email, calendars and todos for agents: compact TOON over Basecamp's HEY CLI; outgoing mail is saved as a draft unless --allow-send";
|
|
5
|
+
|
|
6
|
+
// How many Imbox threads the home view (and the session hook) shows.
|
|
7
|
+
export const HOME_LIMIT = 10;
|
|
8
|
+
|
|
9
|
+
export const HOME_HELP = [
|
|
10
|
+
"Run `hey-axi thread read <topic_id>` to read a thread",
|
|
11
|
+
"Run `hey-axi box view imbox --limit 50` for more (also feed, papertrail, aside, later)",
|
|
12
|
+
"Run `hey-axi search \"<query>\"` to search mail",
|
|
13
|
+
"Run `hey-axi screener list` for senders waiting to be screened",
|
|
14
|
+
"Run `hey-axi reply <topic_id> --message \"...\"` to draft a reply (not sent without --allow-send)",
|
|
15
|
+
"Run `hey-axi --help` for all commands; lists take --fields a,b / --fields all, long text --full",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const SETUP_HELP = {
|
|
19
|
+
missing: [
|
|
20
|
+
"Install the HEY CLI: curl -fsSL https://hey.com/install-cli | bash (or brew install --cask basecamp/tap/hey)",
|
|
21
|
+
"If it is installed outside PATH, set HEY_BIN=/path/to/hey",
|
|
22
|
+
],
|
|
23
|
+
auth: [
|
|
24
|
+
"Ask the user to run `hey auth login` in their own terminal (it opens a browser)",
|
|
25
|
+
"Run `hey-axi auth status` to check afterwards",
|
|
26
|
+
],
|
|
27
|
+
other: ["Run `hey-axi doctor` to find login and configuration problems"],
|
|
28
|
+
};
|
package/src/hey-axi.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point. `-v`, `-V` and `--version` are answered before the CLI graph loads
|
|
3
|
+
// (AXI "--version fast path"); everything else goes to src/cli.js.
|
|
4
|
+
import { tryFastPath } from "axi-sdk-js/fast-path";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
|
|
7
|
+
if (!tryFastPath(process.argv.slice(2), { version: VERSION })) {
|
|
8
|
+
const { main } = await import("./cli.js");
|
|
9
|
+
await main();
|
|
10
|
+
}
|
package/src/home.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The no-args home view (AXI principles 8 and 10): who this is, then live Imbox state,
|
|
2
|
+
// then the next commands. Also what the session hook prints. It never fails hard:
|
|
3
|
+
// a missing or signed-out HEY is reported as `status` with the fix (exit 0), so a
|
|
4
|
+
// session hook still gives the agent something useful.
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { DESCRIPTION, HOME_HELP, HOME_LIMIT, SETUP_HELP } from "./guide.js";
|
|
8
|
+
import { shapeData } from "./shape.js";
|
|
9
|
+
import { heyFailure } from "./errors.js";
|
|
10
|
+
import { heyToAxi } from "./text.js";
|
|
11
|
+
|
|
12
|
+
export function collapse(path, home = homedir()) {
|
|
13
|
+
return path && home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function homeView(runHey, { execPath = process.argv[1] } = {}) {
|
|
17
|
+
const out = { bin: collapse(execPath), description: DESCRIPTION };
|
|
18
|
+
const result = await runHey(["box", "view", "imbox", "--limit", String(HOME_LIMIT)], ["--json"]);
|
|
19
|
+
if (result.status === 127) {
|
|
20
|
+
out.status = result.error || "HEY CLI not found";
|
|
21
|
+
out.help = SETUP_HELP.missing;
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
if (result.status !== 0) {
|
|
25
|
+
const failure = heyFailure(result);
|
|
26
|
+
if (result.status === 3 || failure.code === "auth") {
|
|
27
|
+
out.status = "not signed in to HEY";
|
|
28
|
+
out.help = SETUP_HELP.auth;
|
|
29
|
+
} else {
|
|
30
|
+
out.status = `HEY error: ${heyToAxi(failure.error)}`;
|
|
31
|
+
out.help = [...(failure.hint ? [heyToAxi(failure.hint)] : []), ...SETUP_HELP.other];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
let envelope;
|
|
36
|
+
try {
|
|
37
|
+
envelope = JSON.parse(result.stdout);
|
|
38
|
+
} catch {
|
|
39
|
+
out.status = "HEY returned invalid JSON";
|
|
40
|
+
out.help = SETUP_HELP.other;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
const { data, empty } = shapeData(envelope.data, { path: "box view" });
|
|
44
|
+
const threads = Array.isArray(data) ? data : data?.postings;
|
|
45
|
+
out.imbox = envelope.summary || `${threads?.length ?? 0} threads`;
|
|
46
|
+
if (empty || !threads?.length) out.imbox = "0 threads: the Imbox is empty";
|
|
47
|
+
else out.threads = threads;
|
|
48
|
+
out.help = HOME_HELP;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `hey-axi setup hooks`: register the no-args home view as session-start context for
|
|
2
|
+
// Claude Code, Codex and OpenCode (AXI principle 7), using axi-sdk-js's hook installer.
|
|
3
|
+
// Only this explicit command writes agent config; nothing else ever does.
|
|
4
|
+
//
|
|
5
|
+
// hey-axi setup hooks install or repair (user scope: ~/.claude, ~/.codex, ~/.config/opencode)
|
|
6
|
+
// hey-axi setup hooks --project the same, for the current directory (.claude/, .codex/, .opencode/)
|
|
7
|
+
// hey-axi setup hooks --status report only, no writes
|
|
8
|
+
// hey-axi setup hooks --remove remove hey-axi's managed hooks (other hooks are left alone)
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { collapse } from "./home.js";
|
|
13
|
+
|
|
14
|
+
const MARKER = "hey-axi";
|
|
15
|
+
export const HOOK_FLAGS = ["--project", "--status", "--remove", "--help", "-h"];
|
|
16
|
+
|
|
17
|
+
export const HOOKS_HELP = [
|
|
18
|
+
"hey-axi setup hooks — show the hey-axi home view (Imbox summary + next commands) at the start of every agent session",
|
|
19
|
+
"",
|
|
20
|
+
"usage: hey-axi setup hooks [--project] [--status | --remove]",
|
|
21
|
+
"",
|
|
22
|
+
"flags:",
|
|
23
|
+
" --project target this directory (.claude/settings.json, .codex/hooks.json, .opencode/plugins/) instead of your user config",
|
|
24
|
+
" --status report what is installed; writes nothing",
|
|
25
|
+
" --remove remove hey-axi's managed hooks; other hooks are left alone",
|
|
26
|
+
"",
|
|
27
|
+
"agents: Claude Code (SessionStart hook), Codex (SessionStart hook; also sets [features].hooks = true in ~/.codex/config.toml), OpenCode (managed plugin)",
|
|
28
|
+
"Re-running is a no-op when nothing changed, and repairs the command path after hey-axi moves.",
|
|
29
|
+
"Note: the hook puts Imbox senders and subjects into every session's context. Use --project to limit it to one directory.",
|
|
30
|
+
"",
|
|
31
|
+
"examples:",
|
|
32
|
+
" hey-axi setup hooks",
|
|
33
|
+
" hey-axi setup hooks --project",
|
|
34
|
+
" hey-axi setup hooks --status",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
function snapshot(paths) {
|
|
38
|
+
return paths.map((path) => (existsSync(path) ? readFileSync(path, "utf8") : null));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describe(status) {
|
|
42
|
+
return {
|
|
43
|
+
claude: `${status.claude.installed ? "installed" : "not installed"} (${collapse(status.claude.path)})`,
|
|
44
|
+
codex: `${status.codex.installed ? "installed" : "not installed"} (${collapse(status.codex.path)}; hooks feature ${status.codex.userFeatureEnabled ? "on" : "off"} in ${collapse(status.codex.userFeaturePath)})`,
|
|
45
|
+
opencode: `${status.opencode.installed ? "installed" : "not installed"} (${collapse(status.opencode.path)})`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Returns { output, code }.
|
|
50
|
+
export async function setupHooks(flags, { execPath = process.argv[1] } = {}) {
|
|
51
|
+
const unknown = [...flags].filter((flag) => !HOOK_FLAGS.includes(flag));
|
|
52
|
+
if (unknown.length) {
|
|
53
|
+
return { code: 2, output: { ok: false, error: `unknown flag ${unknown.join(", ")} for \`setup hooks\``, help: "valid flags for `setup hooks`: --project, --status, --remove" } };
|
|
54
|
+
}
|
|
55
|
+
if (flags.has("--status") && flags.has("--remove")) {
|
|
56
|
+
return { code: 2, output: { ok: false, error: "--status and --remove can't be combined", help: "Run `hey-axi setup hooks --status` or `hey-axi setup hooks --remove`" } };
|
|
57
|
+
}
|
|
58
|
+
const sdk = await import("axi-sdk-js");
|
|
59
|
+
const scope = flags.has("--project") ? "project" : "user";
|
|
60
|
+
const options = { marker: MARKER, scope, projectDir: process.cwd(), homeDir: homedir() };
|
|
61
|
+
const before = sdk.sessionStartHookStatus(options);
|
|
62
|
+
const files = [before.claude.path, before.codex.path, before.codex.userFeaturePath, before.opencode.path];
|
|
63
|
+
const errors = [];
|
|
64
|
+
const onError = (message) => errors.push(message);
|
|
65
|
+
|
|
66
|
+
if (flags.has("--status")) {
|
|
67
|
+
return { code: 0, output: { hooks: `status (${scope} scope)`, ...describe(before) } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const was = snapshot(files);
|
|
71
|
+
if (flags.has("--remove")) {
|
|
72
|
+
sdk.uninstallSessionStartHooks({ ...options, onError });
|
|
73
|
+
} else {
|
|
74
|
+
sdk.installSessionStartHooks({
|
|
75
|
+
...options,
|
|
76
|
+
execPath,
|
|
77
|
+
binaryNames: [MARKER],
|
|
78
|
+
distEntrypoints: ["src/hey-axi.js"],
|
|
79
|
+
onError,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const after = sdk.sessionStartHookStatus(options);
|
|
83
|
+
const changed = snapshot(files).some((content, index) => content !== was[index]);
|
|
84
|
+
const verb = flags.has("--remove") ? (changed ? "removed" : "nothing to remove") : changed ? "installed or updated" : "already up to date (no changes)";
|
|
85
|
+
const output = { hooks: `${verb} (${scope} scope)`, ...describe(after) };
|
|
86
|
+
if (!flags.has("--remove") && !after.claude.installed && !after.codex.installed && !after.opencode.installed) {
|
|
87
|
+
output.ok = false;
|
|
88
|
+
output.error = "no hook was installed";
|
|
89
|
+
}
|
|
90
|
+
if (errors.length) output.errors = errors;
|
|
91
|
+
if (!flags.has("--remove")) output.help = ["Start a new agent session to see the home view", "Run `hey-axi setup hooks --remove` to undo"];
|
|
92
|
+
return { code: errors.length || output.ok === false ? 1 : 0, output };
|
|
93
|
+
}
|