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/README.md +31 -15
- package/package.json +1 -1
- package/skills/hey-axi/SKILL.md +48 -45
- package/skills/hey-axi/references/commands.md +39 -36
- 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 +128 -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 +197 -56
- package/src/text.js +3 -3
- package/src/version.js +1 -1
package/src/policy.js
CHANGED
|
@@ -21,17 +21,29 @@ export const DRAFT_NOTICE = "Saved as a DRAFT. Nothing was sent. Pass --allow-se
|
|
|
21
21
|
const SECRET_COMMANDS = new Set(["auth token"]);
|
|
22
22
|
|
|
23
23
|
// How a command runs. Anything not listed is "json": captured, parsed, rendered as TOON.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
const INTERACTIVE = new Set(["tui", "mcp", "auth login", "login", "setup", "setup agents", "setup claude", "setup codex", "setup omarchy", "upgrade"]);
|
|
28
|
-
// Interactive commands that can't do anything useful without a terminal.
|
|
29
|
-
const NEEDS_TTY = new Set(["tui"]);
|
|
24
|
+
// stream: long-running NDJSON, relayed event by event as it arrives (TOON, or NDJSON with --json)
|
|
25
|
+
// raw: prints non-JSON (a script, a CSV); relayed untouched
|
|
26
|
+
// Captured runs give HEY a closed stdin and HEY_NONINTERACTIVE=1, so it can never prompt.
|
|
30
27
|
const STREAM = new Set(["watch"]);
|
|
31
28
|
const RAW = new Set(["shell-completion generate"]);
|
|
32
29
|
|
|
30
|
+
// Commands that only work with a person at a terminal (a full-screen UI, a browser
|
|
31
|
+
// sign-in, a wizard) or that hand stdio to another program (an MCP server). hey-axi never
|
|
32
|
+
// runs them by default, TTY or not, so nothing can stop and wait for input. They run
|
|
33
|
+
// only with the explicit `--interactive` opt-in, which hands HEY the terminal; the
|
|
34
|
+
// person-facing ones also need a real terminal on stdin and stdout.
|
|
35
|
+
const USER_ONLY = {
|
|
36
|
+
tui: { reason: "it is a full-screen terminal UI", hint: "ask the user to run `hey-axi tui --interactive` in their own terminal" },
|
|
37
|
+
mcp: { reason: "it is a long-running MCP server that takes over stdin/stdout", hint: "to use it as an MCP server, register `hey-axi mcp --interactive` in the agent's MCP settings", tty: false },
|
|
38
|
+
setup: { reason: "it is the first-run wizard (browser sign-in and prompts)", hint: "ask the user to run `hey-axi setup --interactive` in their terminal; `hey-axi setup agents`, `setup claude` and `setup codex` run without prompts" },
|
|
39
|
+
"auth login": { reason: "it opens a browser and waits for the user", hint: "pass --token <token>, or ask the user to run `hey-axi auth login --interactive` in their terminal", unless: ["--token", "--cookie"] },
|
|
40
|
+
login: { reason: "it opens a browser and waits for the user", hint: "pass --token <token>, or ask the user to run `hey-axi login --interactive` in their terminal", unless: ["--token", "--cookie"] },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const personCommand = (path, flags) => Boolean(USER_ONLY[path]) && !USER_ONLY[path].unless?.some((flag) => flags.has(flag));
|
|
44
|
+
|
|
33
45
|
export function runMode(path, flags) {
|
|
34
|
-
if (
|
|
46
|
+
if (personCommand(path, flags) && flags.has("--interactive")) return "interactive";
|
|
35
47
|
if (STREAM.has(path)) return "stream";
|
|
36
48
|
if (RAW.has(path)) return "raw";
|
|
37
49
|
// CSV goes to stdout unless --output names a file (then HEY answers with JSON).
|
|
@@ -39,8 +51,140 @@ export function runMode(path, flags) {
|
|
|
39
51
|
return "json";
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
export function userOnly(path, flags = new Set(), io = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
|
|
55
|
+
if (!personCommand(path, flags)) {
|
|
56
|
+
if (!flags.has("--interactive")) return null;
|
|
57
|
+
return { error: "--interactive does not apply here", command: path, reason: `only ${Object.keys(USER_ONLY).join(", ")} (without --token) take over the terminal`, hint: `run \`hey-axi ${path}\` without --interactive` };
|
|
58
|
+
}
|
|
59
|
+
const rule = USER_ONLY[path];
|
|
60
|
+
if (!flags.has("--interactive")) return { error: "needs the user", command: path, reason: rule.reason, hint: rule.hint };
|
|
61
|
+
if (rule.tty !== false && !(io.stdin && io.stdout)) {
|
|
62
|
+
return { error: "needs the user", command: path, reason: `${rule.reason}, and --interactive needs a terminal on stdin and stdout`, hint: rule.hint };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function userOnlyPaths() {
|
|
68
|
+
return Object.keys(USER_ONLY);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Commands that fall back to opening $EDITOR when no content is given. hey-axi requires
|
|
72
|
+
// the content up front (exit 2) so nothing ever waits on an editor. `--message -` (and
|
|
73
|
+
// --note -, --content -) reads the content from stdin.
|
|
74
|
+
export const STDIN_VALUE_FLAGS = ["--message", "-m", "--message-html", "--note", "--note-html", "--content", "--content-html"];
|
|
75
|
+
const DATE_LIKE = /^(\d{4}-\d{2}-\d{2}|today|yesterday|tomorrow|(last|next|this)[ _-]\w+|mon|tue|wed|thu|fri|sat|sun)\w*$/i;
|
|
76
|
+
const CONTENT = {
|
|
77
|
+
compose: { flags: ["--message", "-m", "--message-html", "--attach"], what: "a message", hint: "pass --message \"...\" (or --message - to read it from stdin)" },
|
|
78
|
+
reply: { flags: ["--message", "-m", "--message-html", "--attach", "--dry-run"], what: "a message", hint: "pass --message \"...\" (or --message - to read it from stdin); --dry-run previews without one" },
|
|
79
|
+
"bulk-reply send": { flags: ["--message", "-m", "--message-html", "--attach"], what: "a message", hint: "pass --message \"...\" (or --message - to read it from stdin)" },
|
|
80
|
+
"contact note set": { flags: ["--note", "--note-html"], positional: (words) => words.length >= 2, what: "the note", hint: "pass --note \"...\" (or the note as a second argument, or --note - for stdin)" },
|
|
81
|
+
"journal write": { flags: ["--content", "--content-html"], positional: (words) => words.length >= 2 || (words.length === 1 && !DATE_LIKE.test(words[0])), what: "the entry", hint: "pass --content \"...\" (or --content - to read it from stdin)" },
|
|
82
|
+
"draft edit": { own: true, what: "a field to change", hint: "pass the fields to change, e.g. --message \"...\" or --subject \"...\"" },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Returns null, or a refusal when the command would open an editor.
|
|
86
|
+
export function contentProblem(node, flags, positionals = []) {
|
|
87
|
+
const rule = CONTENT[node.path];
|
|
88
|
+
if (!rule) return null;
|
|
89
|
+
// Shorthands count as their long flag (`-n` is --note for contact note set).
|
|
90
|
+
const has = (name) => flags.has(name) || (node.flags || []).some((flag) => `--${flag.name}` === name && flag.shorthand && flags.has(`-${flag.shorthand}`));
|
|
91
|
+
if (rule.own) {
|
|
92
|
+
if ((node.flags || []).some((flag) => has(`--${flag.name}`))) return null;
|
|
93
|
+
} else if (rule.flags.some(has) || rule.positional?.(positionals)) return null;
|
|
94
|
+
return { error: `missing ${rule.what} for \`${node.path}\``, command: node.path, reason: "without it HEY would open an editor and wait", hint: rule.hint };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Mutations whose desired end state may already hold. Only these commands can become a
|
|
98
|
+
// no-op, and only when HEY's failure says that exact end state already holds (each
|
|
99
|
+
// command has its own pattern; a generic "conflict" is not enough). Send, reply and read
|
|
100
|
+
// commands never become no-ops. Deletes are idempotent too: deleting something that
|
|
101
|
+
// is not there is a no-op.
|
|
102
|
+
// Each delete, and the words HEY uses for its target. A not-found that names something
|
|
103
|
+
// else ("calendar not found" for todo delete) is not a no-op.
|
|
104
|
+
const DELETES = new Map([
|
|
105
|
+
["draft delete", /\bdraft/i], ["event delete", /\bevent/i], ["todo delete", /\btodo/i], ["habit delete", /\bhabit/i],
|
|
106
|
+
["clip delete", /\bclip/i], ["snippet delete", /\bsnippet/i], ["timetrack delete", /\b(time ?track|track|entry)/i],
|
|
107
|
+
["timetrack category delete", /\bcategor/i], ["workflow delete", /\bworkflow/i], ["workflow stage delete", /\bstage/i],
|
|
108
|
+
["set-aside group delete", /\bgroup/i], ["contact note delete", /\bnote/i],
|
|
109
|
+
]);
|
|
110
|
+
const GENERIC_NOT_FOUND = /^\s*(the )?((requested )?(resource|record|item|object) )?(was )?not found\.?\s*$|^\s*404\b/i;
|
|
111
|
+
const ADDED = /\balready (in|on|has|have|added|labell?ed|a member|part of|belongs?)\b/i;
|
|
112
|
+
const REMOVED = /\b(not (in|on|labell?ed|a member|part of)|already removed|isn'?t (in|on|labell?ed))\b/i;
|
|
113
|
+
export const END_STATES = {
|
|
114
|
+
"todo complete": /\balready (been )?completed?\b/i,
|
|
115
|
+
"habit complete": /\balready (been )?completed?\b/i,
|
|
116
|
+
"todo uncomplete": /\b(already (incomplete|uncompleted|not completed?)|not (yet )?completed?)\b/i,
|
|
117
|
+
"habit uncomplete": /\b(already (incomplete|uncompleted|not completed?)|not (yet )?completed?)\b/i,
|
|
118
|
+
seen: /\balready (seen|read|marked (as )?(seen|read))\b/i,
|
|
119
|
+
unseen: /\balready (unseen|unread|marked (as )?(unseen|unread))\b/i,
|
|
120
|
+
move: /\balready in\b/i,
|
|
121
|
+
"label add": ADDED, "collection add": ADDED, "set-aside group add": ADDED, "workflow add": ADDED,
|
|
122
|
+
"label remove": REMOVED, "collection remove": REMOVED, "set-aside group remove": REMOVED, "workflow remove": REMOVED,
|
|
123
|
+
"screener approve": /\balready (screened in|approved)\b/i,
|
|
124
|
+
"screener deny": /\balready (screened out|denied)\b/i,
|
|
125
|
+
"screener clear": /\b(already cleared|not screened)\b/i,
|
|
126
|
+
"contact hide": /\balready hidden\b/i,
|
|
127
|
+
"contact show-again": /\b(already (shown|visible)|not hidden)\b/i,
|
|
128
|
+
"contact bundle": /\balready bundled\b/i,
|
|
129
|
+
"contact unbundle": /\b(already unbundled|not bundled)\b/i,
|
|
130
|
+
ignore: /\balready ignor/i,
|
|
131
|
+
"stop-ignoring": /\b(not ignor|already (unignored|not ignored))/i,
|
|
132
|
+
spam: /\balready (in |marked (as )?)?spam\b/i,
|
|
133
|
+
trash: /\balready (in (the )?)?trash(ed)?\b/i,
|
|
134
|
+
"bubble up": /\balready bubbled\b/i,
|
|
135
|
+
"bubble pop": /\b(already popped|not bubbled)\b/i,
|
|
136
|
+
"timetrack start": /\balready (running|started|tracking)\b/i,
|
|
137
|
+
"timetrack stop": /\bno (time ?track|timer|track)[^.]*running\b|\bnot running\b|\balready stopped\b/i,
|
|
138
|
+
"account use": /\balready (using|the default|selected)\b/i,
|
|
139
|
+
"config trust-local": /\balready trusted\b/i,
|
|
140
|
+
"config untrust-local": /\b(not trusted|already untrusted)\b/i,
|
|
141
|
+
share: /\balready shared\b/i,
|
|
142
|
+
unshare: /\b(not shared|already unshared)\b/i,
|
|
143
|
+
};
|
|
144
|
+
// Creates are not on the list: "already exists" doesn't say the existing one has the
|
|
145
|
+
// settings asked for, so it stays an error the agent can check.
|
|
146
|
+
|
|
147
|
+
// Commands whose end state names a destination: the failure must name the same one.
|
|
148
|
+
const DESTINATION = { move: "to" };
|
|
149
|
+
// Commands that add to / remove from a container given by a flag: a failure that names a
|
|
150
|
+
// different container ("Already in label Other") is not a no-op for this one.
|
|
151
|
+
const CONTAINER = {
|
|
152
|
+
"label add": "to", "collection add": "to", "set-aside group add": "to", "workflow add": "to",
|
|
153
|
+
"label remove": "from", "collection remove": "from", "set-aside group remove": "from", "workflow remove": "from",
|
|
154
|
+
};
|
|
155
|
+
const NAMED_CONTAINER = /\b(?:label|collection|group|workflow|box)\s+["'“]?([^\s"'”;,.]+)/gi;
|
|
156
|
+
|
|
157
|
+
export function noopFor(path, failure, positionals = [], values = {}) {
|
|
158
|
+
const text = `${failure.error || ""} ${failure.hint || ""}`;
|
|
159
|
+
const target = positionals.length ? ` ${positionals.join(" ")}` : "";
|
|
160
|
+
if (DELETES.has(path) && failure.kind === "not_found" && (GENERIC_NOT_FOUND.test(failure.error || "") || DELETES.get(path).test(failure.error || ""))) {
|
|
161
|
+
return { ok: true, noop: true, command: path, result: `nothing to delete:${target || " it"} is already gone (no-op)`, note: "if you expected it to exist, check the id with the matching list command" };
|
|
162
|
+
}
|
|
163
|
+
const endState = END_STATES[path];
|
|
164
|
+
// Several targets: if HEY names some of them, it must name all of them.
|
|
165
|
+
const ids = positionals.filter((word) => /^\d+$/.test(word));
|
|
166
|
+
if (ids.length > 1) {
|
|
167
|
+
const named = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(text));
|
|
168
|
+
if (named.length && named.length < ids.length) return null;
|
|
169
|
+
}
|
|
170
|
+
if (CONTAINER[path]) {
|
|
171
|
+
const wanted = String(values[CONTAINER[path]] ?? "").toLowerCase();
|
|
172
|
+
for (const match of text.matchAll(NAMED_CONTAINER)) {
|
|
173
|
+
if (!wanted || match[1].toLowerCase() !== wanted) return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const destination = DESTINATION[path] ? values[DESTINATION[path]] : undefined;
|
|
177
|
+
if (DESTINATION[path] && (!destination || !text.toLowerCase().includes(String(destination).toLowerCase()))) return null;
|
|
178
|
+
// A failure that also reports a failed part ("1 already seen; 2 failed") is not a no-op.
|
|
179
|
+
if (/\b(failed|cannot|can'?t|could ?n[o']t|unable|error)\b/i.test(failure.error || "")) return null;
|
|
180
|
+
if (endState && failure.kind !== "auth" && failure.kind !== "forbidden" && failure.kind !== "not_found" && endState.test(text)) {
|
|
181
|
+
return { ok: true, noop: true, command: path, result: `already done${target ? ` for${target}` : ""} (no-op)`, detail: failure.error };
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
42
186
|
export function interactivePaths() {
|
|
43
|
-
return
|
|
187
|
+
return userOnlyPaths();
|
|
44
188
|
}
|
|
45
189
|
|
|
46
190
|
const truthy = (value) => ["1", "true", "yes"].includes(String(value || "").toLowerCase());
|
|
@@ -63,9 +207,8 @@ export function sendStaging(path, flags, env = process.env) {
|
|
|
63
207
|
|
|
64
208
|
// Returns null when the command may run, or a structured refusal.
|
|
65
209
|
export function checkPolicy(path, flags, env = process.env, io = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
210
|
+
const person = userOnly(path, flags, io);
|
|
211
|
+
if (person) return person;
|
|
69
212
|
|
|
70
213
|
if (SEND_COMMANDS.has(path)) {
|
|
71
214
|
const rule = SEND_COMMANDS.get(path);
|
package/src/router.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Data-driven command routing for hey-axi.
|
|
2
2
|
//
|
|
3
|
-
// The command tree comes from HEY's own `hey commands --json` catalog
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// command.
|
|
3
|
+
// The command tree comes from HEY's own `hey commands --json` catalog, bundled as
|
|
4
|
+
// src/manifest.json (refreshed with `npm run refresh-manifest`). Routing never calls
|
|
5
|
+
// HEY: an unknown command is rejected before any dependency runs.
|
|
7
6
|
|
|
8
7
|
import { readFileSync } from "node:fs";
|
|
9
8
|
|
|
10
9
|
const BOOLEAN_DEFAULTS = new Set(["true", "false"]);
|
|
11
10
|
|
|
12
|
-
// Reduce HEY's catalog to what routing and help need.
|
|
13
|
-
|
|
11
|
+
// Reduce HEY's catalog to what routing and help need. Every flag keeps its default,
|
|
12
|
+
// including zero/false/empty ones, so `--help` can state each flag's default.
|
|
13
|
+
// `types` maps "path --flag" to the value type HEY's --help prints (int, string, ...).
|
|
14
|
+
export function normalizeCatalog(nodes, types = {}) {
|
|
14
15
|
return (nodes || []).map((node) => {
|
|
15
16
|
const out = { name: node.name, path: node.path || node.name, short: node.short || "" };
|
|
16
17
|
if (node.compatibility_usage) out.usage = node.compatibility_usage;
|
|
@@ -20,13 +21,19 @@ export function normalizeCatalog(nodes) {
|
|
|
20
21
|
.map((flag) => {
|
|
21
22
|
const entry = { name: flag.name };
|
|
22
23
|
if (flag.shorthand) entry.shorthand = flag.shorthand;
|
|
23
|
-
|
|
24
|
+
const type = types[`${node.path || node.name} --${flag.name}`];
|
|
25
|
+
if (type ? type !== "count" : !BOOLEAN_DEFAULTS.has(String(flag.default))) {
|
|
26
|
+
entry.value = true;
|
|
27
|
+
// Flags HEY hides from --help have no printed type; they take a string.
|
|
28
|
+
entry.type = type || "string";
|
|
29
|
+
}
|
|
30
|
+
if (type === "count") entry.type = "count";
|
|
24
31
|
if (flag.usage) entry.desc = flag.usage;
|
|
25
|
-
if (flag.default !== undefined
|
|
32
|
+
if (flag.default !== undefined) entry.default = String(flag.default);
|
|
26
33
|
return entry;
|
|
27
34
|
});
|
|
28
35
|
if (flags.length) out.flags = flags;
|
|
29
|
-
if (node.subcommands?.length) out.subcommands = normalizeCatalog(node.subcommands);
|
|
36
|
+
if (node.subcommands?.length) out.subcommands = normalizeCatalog(node.subcommands, types);
|
|
30
37
|
return out;
|
|
31
38
|
});
|
|
32
39
|
}
|
|
@@ -36,9 +43,11 @@ export function loadBundledManifest() {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
// A group node can be invoked by itself when HEY documents a shortcut form for it
|
|
39
|
-
// (`box <name|id>`, `label <id>`, ...)
|
|
46
|
+
// (`box <name|id>`, `label <id>`, ...), when it takes flags of its own (`search`), or
|
|
47
|
+
// when its USAGE has a form without a subcommand (`hey set-aside [flags]`).
|
|
40
48
|
export function isRunnable(node) {
|
|
41
|
-
return !node.subcommands?.length || Boolean(node.usage) || Boolean(node.flags?.length)
|
|
49
|
+
return !node.subcommands?.length || Boolean(node.usage) || Boolean(node.flags?.length)
|
|
50
|
+
|| (node.synopsis || []).some((line) => !line.includes("<command>"));
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
// Resolve leading positional words to a command node.
|
|
@@ -80,3 +89,28 @@ export function listCommands(commands) {
|
|
|
80
89
|
walk(commands);
|
|
81
90
|
return paths;
|
|
82
91
|
}
|
|
92
|
+
|
|
93
|
+
function distance(a, b) {
|
|
94
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
95
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
96
|
+
let previous = row[0];
|
|
97
|
+
row[0] = i;
|
|
98
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
99
|
+
const current = row[j];
|
|
100
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
101
|
+
previous = current;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return row[b.length];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Up to three command names close to a mistyped word, for "did you mean" help.
|
|
108
|
+
export function closestNames(word, nodes) {
|
|
109
|
+
if (!word) return [];
|
|
110
|
+
return (nodes || [])
|
|
111
|
+
.map((node) => ({ name: node.name, score: node.name.startsWith(word) || word.startsWith(node.name) ? 0 : distance(word, node.name) }))
|
|
112
|
+
.filter(({ score }) => score <= Math.max(2, Math.floor(word.length / 3)))
|
|
113
|
+
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
|
|
114
|
+
.slice(0, 3)
|
|
115
|
+
.map(({ name }) => name);
|
|
116
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Directory scope for the home view and the session hook (AXI principle 7: show only
|
|
2
|
+
// state relevant to the current directory). A `.hey-axi.json` in the working directory
|
|
3
|
+
// or any parent focuses that tree on one box, label or search, and optionally one
|
|
4
|
+
// linked account:
|
|
5
|
+
//
|
|
6
|
+
// { "label": "Acme", "account": "12345", "limit": 10 }
|
|
7
|
+
//
|
|
8
|
+
// `hey-axi setup scope --label Acme` writes it; with no file the home view is the
|
|
9
|
+
// account-wide Imbox and says so.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { HOME_LIMIT } from "./guide.js";
|
|
14
|
+
import { collapse } from "./home-path.js";
|
|
15
|
+
|
|
16
|
+
export const SCOPE_FILE = ".hey-axi.json";
|
|
17
|
+
const SOURCES = ["box", "label", "search"];
|
|
18
|
+
const KEYS = new Set([...SOURCES, "account", "limit"]);
|
|
19
|
+
|
|
20
|
+
export class ScopeError extends Error {}
|
|
21
|
+
|
|
22
|
+
function validate(config, path) {
|
|
23
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new ScopeError(`${collapse(path)} must hold a JSON object`);
|
|
24
|
+
const unknown = Object.keys(config).filter((key) => !KEYS.has(key));
|
|
25
|
+
if (unknown.length) throw new ScopeError(`${collapse(path)}: unknown key ${unknown.join(", ")} (allowed: ${[...KEYS].join(", ")})`);
|
|
26
|
+
const sources = SOURCES.filter((key) => config[key] !== undefined);
|
|
27
|
+
if (sources.length > 1) throw new ScopeError(`${collapse(path)}: use only one of ${SOURCES.join(", ")}`);
|
|
28
|
+
for (const key of [...SOURCES, "account"]) {
|
|
29
|
+
if (config[key] !== undefined && (typeof config[key] !== "string" || !config[key].trim())) throw new ScopeError(`${collapse(path)}: ${key} must be a non-empty string`);
|
|
30
|
+
}
|
|
31
|
+
if (config.limit !== undefined && !(Number.isInteger(config.limit) && config.limit > 0 && config.limit <= 100)) {
|
|
32
|
+
throw new ScopeError(`${collapse(path)}: limit must be a whole number from 1 to 100`);
|
|
33
|
+
}
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The nearest .hey-axi.json at or above `cwd`: { path, dir, config }, or null.
|
|
38
|
+
export function findScope(cwd = process.cwd()) {
|
|
39
|
+
let dir = resolve(cwd);
|
|
40
|
+
for (;;) {
|
|
41
|
+
const path = join(dir, SCOPE_FILE);
|
|
42
|
+
if (existsSync(path)) {
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new ScopeError(`${collapse(path)} is not valid JSON: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
return { path, dir, config: validate(config, path) };
|
|
50
|
+
}
|
|
51
|
+
const parent = dirname(dir);
|
|
52
|
+
if (parent === dir) return null;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What the home view runs for a scope, and how to describe it.
|
|
58
|
+
// argv: HEY arguments
|
|
59
|
+
// base: the equivalent hey-axi command line (for help lines)
|
|
60
|
+
// carry: selector args to carry into suggested commands
|
|
61
|
+
// label: one line for the `scope:` field
|
|
62
|
+
export function scopeQuery(scope) {
|
|
63
|
+
const config = scope?.config || {};
|
|
64
|
+
const limit = config.limit || HOME_LIMIT;
|
|
65
|
+
const carry = config.account ? ["--account", config.account] : [];
|
|
66
|
+
let argv;
|
|
67
|
+
let what;
|
|
68
|
+
if (config.search) {
|
|
69
|
+
argv = ["search", config.search];
|
|
70
|
+
what = `search ${config.search}`;
|
|
71
|
+
} else if (config.label) {
|
|
72
|
+
argv = ["label", "view", config.label, "--limit", String(limit)];
|
|
73
|
+
what = `label ${config.label}`;
|
|
74
|
+
} else {
|
|
75
|
+
const box = config.box || "imbox";
|
|
76
|
+
argv = ["box", "view", box, "--limit", String(limit)];
|
|
77
|
+
what = box === "imbox" ? "Imbox" : `box ${box}`;
|
|
78
|
+
}
|
|
79
|
+
const words = argv.filter((word, index) => !(word === "--limit" || argv[index - 1] === "--limit"));
|
|
80
|
+
// Single quotes: a search like $(date) or an account with spaces is shown literally.
|
|
81
|
+
const shell = (word) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${String(word).replace(/'/g, "'\\''")}'`);
|
|
82
|
+
const shownCarry = carry.map((word, index) => (index % 2 ? shell(word) : word));
|
|
83
|
+
const base = ["hey-axi", ...words.map(shell), ...shownCarry].join(" ");
|
|
84
|
+
const account = config.account ? `, account ${config.account}` : "";
|
|
85
|
+
const label = scope
|
|
86
|
+
? `${what}${account} (from ${collapse(scope.path)})`
|
|
87
|
+
: `account-wide ${what} (no ${SCOPE_FILE} here; \`hey-axi setup scope --label <name>\` focuses this directory)`;
|
|
88
|
+
return { argv: [...argv, ...carry], base, carry: shownCarry, label, limit, path: argv[0] === "search" ? "search" : `${argv[0]} view` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const SCOPE_HELP = [
|
|
92
|
+
"hey-axi setup scope — focus the home view and session hook in this directory on one box, label or search",
|
|
93
|
+
"",
|
|
94
|
+
"usage: hey-axi setup scope (--box <name|id> | --label <name|id> | --search <query>) [--account <id>] [--limit <n>]",
|
|
95
|
+
" hey-axi setup scope --status | --remove",
|
|
96
|
+
"",
|
|
97
|
+
"flags:",
|
|
98
|
+
" --box <name|id> show this box (default imbox)",
|
|
99
|
+
" --label <name|id> show threads with this label (default: none)",
|
|
100
|
+
" --search <query> show threads matching this search (default: none)",
|
|
101
|
+
" --account <id> use this linked account (default: HEY's default account)",
|
|
102
|
+
" --limit <n> threads to show, 1-100 (default 10)",
|
|
103
|
+
" --status show the scope that applies here; writes nothing (default false)",
|
|
104
|
+
" --remove delete ./.hey-axi.json (default false)",
|
|
105
|
+
"",
|
|
106
|
+
`Writes ./${SCOPE_FILE}; subdirectories inherit it. Re-running with the same settings changes nothing.`,
|
|
107
|
+
"",
|
|
108
|
+
"examples:",
|
|
109
|
+
" hey-axi setup scope --label Acme",
|
|
110
|
+
" hey-axi setup scope --search \"from:billing@acme.example\" --limit 5",
|
|
111
|
+
" hey-axi setup scope --status",
|
|
112
|
+
].join("\n");
|
|
113
|
+
|
|
114
|
+
export const SCOPE_FLAGS = new Set(["--box", "--label", "--search", "--account", "--limit", "--status", "--remove", "--help", "-h"]);
|
|
115
|
+
const SCOPE_VALUE_FLAGS = new Set(["--box", "--label", "--search", "--account", "--limit"]);
|
|
116
|
+
|
|
117
|
+
// `hey-axi setup scope ...`. Returns { output, code }.
|
|
118
|
+
export function setupScope(argv, cwd = process.cwd()) {
|
|
119
|
+
const values = {};
|
|
120
|
+
const flags = new Set();
|
|
121
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
122
|
+
const arg = argv[i];
|
|
123
|
+
const eq = arg.indexOf("=");
|
|
124
|
+
const name = arg.startsWith("--") && eq !== -1 ? arg.slice(0, eq) : arg;
|
|
125
|
+
if (!SCOPE_FLAGS.has(name)) {
|
|
126
|
+
return { code: 2, output: { ok: false, error: arg.startsWith("-") ? `unknown flag ${name} for \`setup scope\`` : `unexpected argument "${arg}" for \`setup scope\``, help: ["valid flags for `setup scope`: --box, --label, --search, --account, --limit, --status, --remove", "Run `hey-axi setup scope --help`"] } };
|
|
127
|
+
}
|
|
128
|
+
if (eq !== -1 && !SCOPE_VALUE_FLAGS.has(name)) {
|
|
129
|
+
return { code: 2, output: { ok: false, kind: "usage", error: `${name} is a switch and takes no value: pass ${name}, or leave it out (got ${arg})`, help: "Run `hey-axi setup scope --help`" } };
|
|
130
|
+
}
|
|
131
|
+
flags.add(name);
|
|
132
|
+
if (SCOPE_VALUE_FLAGS.has(name)) {
|
|
133
|
+
const value = eq !== -1 ? arg.slice(eq + 1) : argv[i + 1];
|
|
134
|
+
if (value === undefined || (eq === -1 && value.startsWith("--"))) return { code: 2, output: { ok: false, error: `missing value for ${name}`, help: "Run `hey-axi setup scope --help`" } };
|
|
135
|
+
values[name.slice(2)] = value;
|
|
136
|
+
if (eq === -1) i += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const path = join(resolve(cwd), SCOPE_FILE);
|
|
140
|
+
if (flags.has("--status") || flags.has("--remove")) {
|
|
141
|
+
if (Object.keys(values).length || (flags.has("--status") && flags.has("--remove"))) {
|
|
142
|
+
return { code: 2, output: { ok: false, error: "--status and --remove take no other flags", help: "Run `hey-axi setup scope --status` or `hey-axi setup scope --remove`" } };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (flags.has("--status")) {
|
|
146
|
+
try {
|
|
147
|
+
const scope = findScope(cwd);
|
|
148
|
+
return { code: 0, output: { scope: scopeQuery(scope).label } };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return { code: 1, output: { ok: false, error: error.message, help: `Fix or delete the file, then run \`hey-axi setup scope --status\`` } };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (flags.has("--remove")) {
|
|
154
|
+
if (!existsSync(path)) return { code: 0, output: { scope: `nothing to remove: no ${SCOPE_FILE} in this directory (no-op)` } };
|
|
155
|
+
rmSync(path);
|
|
156
|
+
return { code: 0, output: { scope: `removed ${collapse(path)}`, help: ["Run `hey-axi` to see the home view for this directory"] } };
|
|
157
|
+
}
|
|
158
|
+
const config = {};
|
|
159
|
+
for (const key of ["box", "label", "search", "account"]) if (values[key] !== undefined) config[key] = values[key];
|
|
160
|
+
if (values.limit !== undefined) {
|
|
161
|
+
if (!/^\d+$/.test(values.limit)) return { code: 2, output: { ok: false, error: `--limit needs a whole number, got "${values.limit}"` } };
|
|
162
|
+
config.limit = Number(values.limit);
|
|
163
|
+
}
|
|
164
|
+
if (!SOURCES.some((key) => config[key] !== undefined)) {
|
|
165
|
+
return { code: 2, output: { ok: false, error: "`setup scope` needs one of --box, --label, --search", help: ["usage: hey-axi setup scope (--box <name|id> | --label <name|id> | --search <query>) [--account <id>] [--limit <n>]"] } };
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
validate(config, path);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { code: 2, output: { ok: false, error: error.message.replace(`${collapse(path)}: `, ""), help: "Run `hey-axi setup scope --help`" } };
|
|
171
|
+
}
|
|
172
|
+
const next = `${JSON.stringify(config, null, 2)}\n`;
|
|
173
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
174
|
+
if (current === next) return { code: 0, output: { scope: `already up to date (no changes): ${scopeQuery({ path, config }).label}` } };
|
|
175
|
+
writeFileSync(path, next);
|
|
176
|
+
return { code: 0, output: { scope: `${current === null ? "wrote" : "updated"} ${scopeQuery({ path, config }).label}`, help: ["Run `hey-axi` to see the scoped home view", "Run `hey-axi setup scope --remove` to undo"] } };
|
|
177
|
+
}
|