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.
- package/README.md +29 -15
- package/package.json +1 -1
- package/skills/hey-axi/SKILL.md +48 -45
- package/skills/hey-axi/references/commands.md +36 -33
- package/src/activity.js +160 -0
- package/src/args.js +75 -10
- package/src/arity.js +94 -0
- package/src/cli.js +373 -136
- package/src/errors.js +111 -31
- package/src/examples.js +57 -0
- package/src/guide.js +21 -2
- package/src/home-path.js +12 -0
- package/src/home.js +48 -16
- package/src/hooks.js +176 -23
- package/src/manifest.json +699 -251
- package/src/policy.js +154 -11
- package/src/router.js +45 -11
- package/src/scope.js +177 -0
- package/src/shape.js +184 -53
- package/src/text.js +3 -3
- package/src/version.js +1 -1
package/src/activity.js
ADDED
|
@@ -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
|
|
35
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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 ? "
|
|
136
|
+
return `--${flag.name}${short}${flag.value ? ` <${flag.type || "value"}>` : ""}`;
|
|
96
137
|
}
|
|
97
138
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|