@sibylline-labs/eris 0.0.0-stage → 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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * bash.ts — `eris completion bash`: the bash completion script on stdout.
3
+ */
4
+
5
+ import type { CliContext } from "../../../main";
6
+ import type { Session } from "../../compose";
7
+ import { runShell } from "./shell";
8
+
9
+ const USAGE = `Usage: eris completion bash
10
+
11
+ Emit the bash completion script to stdout. Load it into the current shell
12
+ or make it permanent:
13
+
14
+ source <(eris completion bash) # this shell only
15
+ echo 'source <(eris completion bash)' >> ~/.bashrc # every shell
16
+
17
+ The emitted script embeds a snapshot of the eris command tree, generated from
18
+ the verb registry; sourcing via process substitution (as above) keeps it
19
+ current automatically. See docs/COMPLETION.md.
20
+ `;
21
+
22
+ export function runBash(args: string[], ctx: CliContext & { session?: Session }): number {
23
+ return runShell("bash", USAGE, args, ctx);
24
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * domain.ts — the completion domains a row's slots carry (`CompletionDomain`,
3
+ * src/cli/registry.ts): the function a domain names, and the check composition
4
+ * (src/cli/compose.ts) runs on a slot's words and on every helper text before
5
+ * the emitter (./emit.ts) writes them into the script.
6
+ *
7
+ * The script is sourced by every shell that loads eris completion (bash, and
8
+ * zsh through bashcompinit), and a helper sits between core's own definitions
9
+ * in it. Sourcing must define the helper's one function and run nothing, so the
10
+ * check must find where that function ends exactly as both shells do. It does
11
+ * not model shell quoting: it accepts a subset whose framing bash and zsh agree
12
+ * on and refuses everything else, naming the line:
13
+ *
14
+ * - the text is exactly one definition, `__eris_dom_<name>() {`, whose body
15
+ * closes at its final `}`, then a newline: nothing before it, nothing after;
16
+ * - printable ASCII, tab and newline only; no comment, no backtick, no heredoc
17
+ * or here-string, no `$'…'`, `$"…"` or `$[…]`, no function defined inside;
18
+ * - a backslash only inside quotes, and only before a letter or digit;
19
+ * - `${ }` holds a name and its operators only (no quote, nesting or `( )`);
20
+ * - `{` and `}` as words only where both shells read them as a group's braces:
21
+ * `{` in command position, `}` after `;` or a newline, closing one the scan
22
+ * opened (zsh reads a lone `}` as a closing brace anywhere on a line);
23
+ * - inside `$( )`, no `case` and no unbalanced parenthesis inside a quote,
24
+ * which older bash can read as the substitution's end.
25
+ *
26
+ * A helper that is valid shell may still be refused; rewrite it into the
27
+ * subset. Pure and import-free, because composition runs on every command line.
28
+ */
29
+
30
+ import type { CompletionDomain, SlotWord } from "../../registry";
31
+
32
+ /** A literal slot word: the emitted script embeds it inside double quotes and a case label. */
33
+ export const WORD = /^[A-Za-z0-9_][A-Za-z0-9_?-]*$/;
34
+
35
+ const DOMAIN_NAME = /^@[a-z][a-z0-9_]*$/;
36
+ const PREFIX = "__eris_dom_";
37
+ /** The one header a helper starts with: `name() {`, then a blank or newline. */
38
+ const HEADER = /^(__eris_dom_[A-Za-z0-9_]+)\(\)[ \t]*\{(?=[ \t\n])/;
39
+ /** Anything but printable ASCII, tab and newline. */
40
+ const OUTSIDE_ASCII = /[^\t\n\x20-\x7e]/;
41
+ /** Characters that end a shell word. */
42
+ const META = new Set([" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">"]);
43
+ /** What `${` … `}` may hold: a name, `$name`, and the operators on it. */
44
+ const PARAM_CHAR = /[A-Za-z0-9_:=+?#%/*@!.,~^\-[\] \t]/;
45
+ /** What may follow `$` inside `${ }`: a name or a special parameter. */
46
+ const PARAM_DOLLAR = /[A-Za-z0-9_@*#?!$-]/;
47
+
48
+ /** The function the emitted slot calls for `domain`. */
49
+ export function domainFunction(domain: string): string {
50
+ return `${PREFIX}${domain.slice(1)}`;
51
+ }
52
+
53
+ /** Every helper text a domain carries: its library, then its own function. */
54
+ export function helperTexts(d: CompletionDomain): readonly string[] {
55
+ return [...(d.library ?? []), d.helper];
56
+ }
57
+
58
+ /** Why `word` cannot stand in a slot, else null. Its helper texts are `scanHelper`'s. */
59
+ export function slotWordRefusal(word: SlotWord): string | null {
60
+ if (typeof word === "string") {
61
+ return word === "@file" || WORD.test(word) ? null : `'${word}' is not a completion word (letters, digits, '_', '?', '-'), '@file' or a domain`;
62
+ }
63
+ const d = word as Partial<CompletionDomain> | null;
64
+ if (typeof d !== "object" || d === null || typeof d.domain !== "string" || typeof d.helper !== "string") {
65
+ return `${JSON.stringify(word)} is neither a completion word nor a domain ({ domain, helper })`;
66
+ }
67
+ if (d.library !== undefined && (!Array.isArray(d.library) || !d.library.every((t) => typeof t === "string"))) {
68
+ return `domain '${d.domain}': library must be a list of helper texts`;
69
+ }
70
+ if (d.domain === "@file") return "'@file' is built in: write it as the word '@file'";
71
+ if (!DOMAIN_NAME.test(d.domain)) return `domain '${d.domain}' is not a domain name ('@', then lower-case letters, digits, '_')`;
72
+ return null;
73
+ }
74
+
75
+ /** The one function a helper text defines, or why it cannot be emitted. */
76
+ export type HelperScan = { readonly ok: true; readonly function: string } | { readonly ok: false; readonly why: string };
77
+
78
+ const lineAt = (text: string, at: number): number => text.slice(0, at).split("\n").length;
79
+
80
+ /** Where the scan is: code (the body, or inside `$( )`), `${ }`, or a double or single quote. */
81
+ type Frame =
82
+ | { kind: "code"; readonly sub: boolean; readonly start: number; braces: number; parens: number }
83
+ | { kind: "param" }
84
+ | { kind: "dq" | "sq"; readonly inSub: boolean; parens: number };
85
+
86
+ class Refusal extends Error {}
87
+
88
+ /** The body from just after its opening `{`: the index just past its closing `}`. Throws a Refusal. */
89
+ function scanBody(text: string, start: number): number {
90
+ const stack: Frame[] = [{ kind: "code", sub: false, start, braces: 1, parens: 0 }];
91
+ const inSub = () => stack.some((f) => f.kind === "code" && f.sub);
92
+ const refuse = (j: number, why: string): never => {
93
+ throw new Refusal(`${why} (line ${lineAt(text, j)})`);
94
+ };
95
+ /** The last non-blank character before `j` in a code frame starting at `from`; "" at the frame's start. */
96
+ const prevWord = (j: number, from: number): string => {
97
+ let k = j - 1;
98
+ while (k >= from && (text[k] === " " || text[k] === "\t")) k--;
99
+ return k >= from ? text[k]! : "";
100
+ };
101
+ const backslash = (j: number) => {
102
+ if (!/[A-Za-z0-9]/.test(text[j + 1] ?? "")) refuse(j, "has a backslash before something other than a letter or digit");
103
+ };
104
+ for (let j = start; j < text.length; j++) {
105
+ const c = text[j]!;
106
+ const next = text[j + 1];
107
+ const top = stack[stack.length - 1]!;
108
+ if (c === "`") refuse(j, "uses a backtick: use $( ) instead");
109
+ if (top.kind === "sq") {
110
+ if (c === "'") {
111
+ if (top.parens !== 0) refuse(j, "has an unbalanced parenthesis in a quote inside $( )");
112
+ stack.pop();
113
+ } else if (c === "\\") backslash(j);
114
+ else if (top.inSub && (c === "(" || c === ")")) top.parens += c === "(" ? 1 : -1;
115
+ continue;
116
+ }
117
+ if (top.kind === "param") {
118
+ if (c === "}") stack.pop();
119
+ else if (c === "$" && PARAM_DOLLAR.test(next ?? "")) j++;
120
+ else if (!PARAM_CHAR.test(c)) refuse(j, "has a ${ } holding more than a name and its operators");
121
+ continue;
122
+ }
123
+ if (c === "$" && next === "$") {
124
+ j++;
125
+ continue;
126
+ }
127
+ if (c === "$" && (next === "'"|| next === '"' || next === "[")) refuse(j, `uses $${next}, which the shells read differently`);
128
+ if (c === "$" && next === "(") {
129
+ stack.push({ kind: "code", sub: true, start: j + 2, braces: 0, parens: 0 });
130
+ j++;
131
+ continue;
132
+ }
133
+ if (c === "$" && next === "{") {
134
+ stack.push({ kind: "param" });
135
+ j++;
136
+ continue;
137
+ }
138
+ if (top.kind === "dq") {
139
+ if (c === '"') {
140
+ if (top.parens !== 0) refuse(j, "has an unbalanced parenthesis in a quote inside $( )");
141
+ stack.pop();
142
+ } else if (c === "\\") backslash(j);
143
+ else if (top.inSub && (c === "(" || c === ")")) top.parens += c === "(" ? 1 : -1;
144
+ continue;
145
+ }
146
+ // Code: the body, or the inside of a `$( )`.
147
+ const code = top as Extract<Frame, { kind: "code" }>;
148
+ if (c === "\\") refuse(j, "has a backslash outside quotes");
149
+ if (c === "'" || c === '"') {
150
+ stack.push({ kind: c === "'" ? "sq" : "dq", inSub: inSub(), parens: 0 });
151
+ continue;
152
+ }
153
+ if (c === "#" && text[j - 1] !== "$") refuse(j, "has a comment or a bare '#': keep comments in the TypeScript beside it");
154
+ if (c === "<" && next === "<") refuse(j, "uses a heredoc or here-string");
155
+ const wordStart = j === code.start || META.has(text[j - 1]!);
156
+ const wordEnd = (k: number) => k >= text.length || META.has(text[k]!);
157
+ if (wordStart && /^function[ \t]/.test(text.slice(j, j + 9))) refuse(j, "defines a function inside a function");
158
+ if (wordStart && code.sub && /^case\b/.test(text.slice(j, j + 5))) refuse(j, "uses case inside $( )");
159
+ if (c === "(") {
160
+ if (/^\([ \t]*\)/.test(text.slice(j))) refuse(j, "defines a function inside a function");
161
+ code.parens++;
162
+ } else if (c === ")") {
163
+ if (code.parens > 0) code.parens--;
164
+ else if (code.sub) stack.pop();
165
+ } else if (c === "{" && wordStart && wordEnd(j + 1)) {
166
+ const before = prevWord(j, code.start);
167
+ const twoBefore = text.slice(0, j).trimEnd();
168
+ if (!/[ \t\n]/.test(next ?? "") || !["", "\n", ";", "&", "|", "("].includes(before) || twoBefore.endsWith(";;")) {
169
+ refuse(j, "has a '{' word outside command position");
170
+ }
171
+ code.braces++;
172
+ } else if (c === "}" && wordStart && wordEnd(j + 1)) {
173
+ const before = prevWord(j, code.start);
174
+ const twoBefore = text.slice(0, j).trimEnd();
175
+ if (!["\n", ";"].includes(before) || twoBefore.endsWith(";;") || !/[ \t\n;]/.test(next ?? "") || code.braces === 0) {
176
+ refuse(j, "has a '}' word that does not close a group after ';' or a newline");
177
+ }
178
+ code.braces--;
179
+ if (!code.sub && code.braces === 0) return j + 1;
180
+ }
181
+ }
182
+ return refuse(text.length - 1, stack.length > 1 ? "ends inside a quote, expansion or substitution" : "ends inside the function body");
183
+ }
184
+
185
+ /** The one function `text` defines, or why it cannot be emitted as a helper. */
186
+ export function scanHelper(text: string): HelperScan {
187
+ const fail = (why: string): HelperScan => ({ ok: false, why });
188
+ if (!text.endsWith("\n")) return fail("does not end with a newline");
189
+ const odd = OUTSIDE_ASCII.exec(text);
190
+ if (odd !== null) return fail(`carries a character outside printable ASCII (line ${lineAt(text, odd.index)})`);
191
+ const head = HEADER.exec(text);
192
+ if (head === null) {
193
+ return fail(`must be exactly one definition, starting at its first character with '${PREFIX}<name>() {'`);
194
+ }
195
+ try {
196
+ const end = scanBody(text, head[0].length);
197
+ if (end !== text.length - 1) return fail(`puts text after the function's closing brace (line ${lineAt(text, end)}): one function per helper text`);
198
+ } catch (e) {
199
+ if (e instanceof Refusal) return fail(e.message);
200
+ throw e;
201
+ }
202
+ return { ok: true, function: head[1]! };
203
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * emit.ts — the bash and zsh completion scripts.
3
+ *
4
+ * The command tree is generated from the registry it is handed: groups,
5
+ * subs, nested subs, each path's long flags, which flags take a value and
6
+ * which of those values are file names (`file` on the flag's row), and each
7
+ * row's positional slots (`complete`): literal words, file names, or a
8
+ * completion domain, whose helper text is emitted once and whose function the
9
+ * slot calls with the words typed in the earlier slots. A verb added to
10
+ * src/cli/registry.ts, or declared by a plugin, completes with no other edit.
11
+ * Composition has checked every slot word and helper (./domain.ts).
12
+ *
13
+ * The rest is the tab-press engine, which is shell by nature and is emitted
14
+ * as text. Its contract (docs/COMPLETION.md):
15
+ * - a tab press never runs eris and never touches the network;
16
+ * - bash-3.2-clean (macOS /bin/bash) and shared verbatim by the zsh route,
17
+ * which loads it through bashcompinit.
18
+ *
19
+ * This runs on every shell start (`source <(eris completion bash)`): it
20
+ * imports the registry and nothing else, so no other verb's source, Bash or
21
+ * TS, is on its path.
22
+ */
23
+
24
+ import { REGISTRY, type Row } from "../../registry";
25
+ import { domainFunction, helperTexts, WORD } from "./domain";
26
+
27
+ export type Shell = "bash" | "zsh";
28
+ type Registry = Readonly<Record<string, Row>>;
29
+
30
+ const FLAG = /^--[a-z][a-z0-9-]*$/;
31
+
32
+ function safe(word: string, re: RegExp, what: string): string {
33
+ if (!re.test(word)) throw new Error(`completion: ${what} '${word}' is not shell-safe`);
34
+ return word;
35
+ }
36
+
37
+ const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
38
+ const uniqSorted = (xs: Iterable<string>): string[] => [...new Set(xs)].sort(byCodePoint);
39
+
40
+ /** The lookup key the engine builds for a command path: words joined by `__`. */
41
+ const keyOf = (path: string): string => path.split(" ").join("__");
42
+
43
+ function subsIn(reg: Registry, path: string): string[] {
44
+ const depth = path.split(" ").length + 1;
45
+ return Object.keys(reg)
46
+ .filter((k) => k.startsWith(`${path} `) && k.split(" ").length === depth)
47
+ .map((k) => safe(k.split(" ")[depth - 1]!, WORD, "verb"));
48
+ }
49
+
50
+ function flagNames(row: Row | undefined): string[] {
51
+ return (row?.flags ?? []).map((f) => safe(f.name, FLAG, "flag"));
52
+ }
53
+
54
+ function caseFn(name: string, arms: readonly [string, string][], fallback: string): string {
55
+ const body = arms.map(([label, action]) => ` ${label}) ${action} ;;\n`).join("");
56
+ return `${name}() {\n case "$1" in\n${body} *) ${fallback} ;;\n esac\n}\n`;
57
+ }
58
+
59
+ function predicateFn(name: string, subject: string, labels: readonly string[]): string {
60
+ const arm = labels.length > 0 ? ` ${labels.join("|")}) return 0 ;;\n` : "";
61
+ return `${name}() {\n case ${subject} in\n${arm} esac\n return 1\n}\n`;
62
+ }
63
+
64
+ /** The generated half: the tree, the domain helpers, the flags and the positional slots. */
65
+ function generated(reg: Registry): { tree: string; helpers: string; argv: string } {
66
+ const paths = Object.keys(reg);
67
+ const groups = uniqSorted(paths.filter((p) => !p.includes(" ")).map((g) => safe(g, WORD, "group")));
68
+
69
+ const verbArms: [string, string][] = [];
70
+ for (const path of [...paths].sort(byCodePoint)) {
71
+ const subs = subsIn(reg, path);
72
+ if (subs.length > 0) verbArms.push([keyOf(path), `echo "${uniqSorted(subs).join(" ")}"`]);
73
+ }
74
+
75
+ // A path offers its own flags; a group's own key, used before a verb is
76
+ // typed (and for a verb the registry does not know), offers the union of
77
+ // every row under it.
78
+ const flagArms: [string, string][] = [];
79
+ for (const path of [...paths].sort(byCodePoint)) {
80
+ const own = path.includes(" ") ? flagNames(reg[path]) : paths.filter((p) => p === path || p.startsWith(`${path} `)).flatMap((p) => flagNames(reg[p]));
81
+ flagArms.push([keyOf(path), `echo "${uniqSorted([...own, "--help"]).join(" ")}"`]);
82
+ }
83
+
84
+ const valueFlags = uniqSorted(paths.flatMap((p) => (reg[p]!.flags ?? []).filter((f) => f.takesValue).map((f) => safe(f.name, FLAG, "flag"))));
85
+ // Registry order, first seen: core's rows precede a plugin's, and the emitted
86
+ // case arm lists them in that order.
87
+ const fileFlags = [...new Set(paths.flatMap((p) => (reg[p]!.flags ?? []).filter((f) => f.takesValue && f.file === true).map((f) => safe(f.name, FLAG, "flag"))))];
88
+
89
+ const slotArms: [string, string][] = [];
90
+ const fileSlots: string[] = [];
91
+ // Each distinct helper and library text once, in the order its first slot is emitted.
92
+ const texts = new Set<string>();
93
+ for (const path of [...paths].sort(byCodePoint)) {
94
+ reg[path]!.complete?.forEach((slot, n) => {
95
+ const label = `"${keyOf(path)} ${n}"`;
96
+ if (slot.includes("@file")) {
97
+ fileSlots.push(label);
98
+ return;
99
+ }
100
+ const words = slot.map((t) => {
101
+ if (typeof t === "string") return safe(t, WORD, "slot word");
102
+ for (const text of helperTexts(t)) texts.add(text);
103
+ return `$(${domainFunction(t.domain)} "$@")`;
104
+ });
105
+ slotArms.push([label, `echo "${words.join(" ")}"`]);
106
+ });
107
+ }
108
+ const helpers = texts.size === 0 ? "" : `# Completion domains: the helpers the rows carry.\n${[...texts].join("\n")}\n`;
109
+
110
+ const tree =
111
+ `__eris_comp_groups() {\n echo "${groups.join(" ")}"\n}\n\n` +
112
+ `${caseFn("__eris_comp_verbs", verbArms, 'echo ""')}\n` +
113
+ `${caseFn("__eris_comp_flags", flagArms, 'echo ""')}\n`;
114
+ const argv =
115
+ "# Flags whose next word is a user-supplied value (never completed as a\n# positional), and the ones whose value is a file name.\n" +
116
+ `${predicateFn("__eris_comp_valflag", '"$1"', valueFlags)}\n` +
117
+ `${predicateFn("__eris_comp_fileflag", '"$1"', fileFlags)}\n` +
118
+ '# The positional slots: __eris_comp_slot <key> <n> [<word>...] prints the\n# words for slot n of the command <key>, given the words typed in the earlier\n# slots; __eris_comp_fileslot says it is a file.\n' +
119
+ `__eris_comp_slot() {\n local slot="$1 $2"\n shift 2\n case "$slot" in\n${slotArms.map(([l, a]) => ` ${l}) ${a} ;;\n`).join("")} esac\n return 0\n}\n\n` +
120
+ `${predicateFn("__eris_comp_fileslot", '"$1 $2"', fileSlots)}\n`;
121
+ return { tree, helpers, argv };
122
+ }
123
+
124
+ const BASH_HEADER = `# eris bash completion — generated by 'eris completion bash'.
125
+ # The command tree below is a snapshot of the eris verb registry
126
+ # (src/cli/registry.ts) at emission time; re-emit after upgrading eris, or
127
+ # source via process substitution so it regenerates at shell startup. Works on
128
+ # bash 3.2+ (macOS /bin/bash). See docs/COMPLETION.md.
129
+ `;
130
+
131
+ const ZSH_HEADER = `# eris zsh completion — generated by 'eris completion zsh'.
132
+ # Route: bashcompinit wrapping the shared bash completion body (the engine
133
+ # sets the ksharrays localoption so COMP_WORDS indexing matches bash). The
134
+ # command tree below is a snapshot of the eris verb registry at emission
135
+ # time; source via process substitution so it regenerates at shell startup.
136
+ # See docs/COMPLETION.md.
137
+ if [ -z "\${ZSH_VERSION:-}" ]; then
138
+ echo "eris: this completion script is for zsh — use 'eris completion bash' for bash" >&2
139
+ return 1 2>/dev/null || exit 1
140
+ fi
141
+ if ! command -v compdef >/dev/null 2>&1; then
142
+ autoload -U +X compinit && compinit
143
+ fi
144
+ autoload -U +X bashcompinit && bashcompinit
145
+ `;
146
+
147
+ /** The tab-press entry point. */
148
+ const ENGINE = `_eris_completions() {
149
+ # Under zsh/bashcompinit, give this function bash's 0-based array
150
+ # indexing; under bash the command doesn't exist and is silenced.
151
+ setopt localoptions ksharrays 2>/dev/null || :
152
+
153
+ local cur prev group verb sub key words
154
+ local i w p np pw
155
+ COMPREPLY=()
156
+ cur="\${COMP_WORDS[COMP_CWORD]}"
157
+ prev=""
158
+ [ "$COMP_CWORD" -ge 1 ] && prev="\${COMP_WORDS[COMP_CWORD-1]}"
159
+
160
+ # A value-taking flag right before the cursor: complete files for the
161
+ # file-valued ones, otherwise stay silent while the user types the value.
162
+ if __eris_comp_valflag "$prev"; then
163
+ __eris_comp_fileflag "$prev" && COMPREPLY=( $(compgen -f -- "$cur") )
164
+ return 0
165
+ fi
166
+
167
+ if [ "$COMP_CWORD" -eq 1 ]; then
168
+ COMPREPLY=( $(compgen -W "$(__eris_comp_groups) --help --version" -- "$cur") )
169
+ return 0
170
+ fi
171
+
172
+ group="\${COMP_WORDS[1]}"
173
+
174
+ # Walk the words after the group (before the cursor): the first positional
175
+ # is the verb (verb-ful groups only), the next is the nested sub when the
176
+ # verb is itself a group, the rest are counted as arguments. Flags and the
177
+ # values of value-taking flags are skipped.
178
+ verb=""; sub=""; np=0; pw=()
179
+ i=2
180
+ while [ "$i" -lt "$COMP_CWORD" ]; do
181
+ w="\${COMP_WORDS[i]}"
182
+ p="\${COMP_WORDS[i-1]}"
183
+ if __eris_comp_valflag "$p"; then i=$((i + 1)); continue; fi
184
+ case "$w" in
185
+ -*) i=$((i + 1)); continue ;;
186
+ esac
187
+ if [ -z "$verb" ] && [ -n "$(__eris_comp_verbs "$group")" ]; then
188
+ verb="$w"
189
+ elif [ -n "$verb" ] && [ -z "$sub" ] && [ "$np" -eq 0 ] && [ -n "$(__eris_comp_verbs "\${group}__\${verb}")" ]; then
190
+ sub="$w"
191
+ else
192
+ pw[np]="$w"
193
+ np=$((np + 1))
194
+ fi
195
+ i=$((i + 1))
196
+ done
197
+ key="$group"
198
+ [ -n "$verb" ] && key="\${key}__\${verb}"
199
+ [ -n "$sub" ] && key="\${key}__\${sub}"
200
+
201
+ # Flags of the deepest known path; else the group's whole set.
202
+ case "$cur" in
203
+ -*)
204
+ words=$(__eris_comp_flags "$key")
205
+ [ -n "$words" ] || words=$(__eris_comp_flags "$group")
206
+ [ -n "$words" ] || words="--help"
207
+ COMPREPLY=( $(compgen -W "$words" -- "$cur") )
208
+ return 0
209
+ ;;
210
+ esac
211
+
212
+ # Verb position of a verb-ful group.
213
+ if [ -z "$verb" ] && [ -n "$(__eris_comp_verbs "$group")" ]; then
214
+ COMPREPLY=( $(compgen -W "$(__eris_comp_verbs "$group")" -- "$cur") )
215
+ return 0
216
+ fi
217
+
218
+ # Nested sub position (\`eris <group> <verb> <TAB>\`).
219
+ if [ -n "$verb" ] && [ -z "$sub" ]; then
220
+ words=$(__eris_comp_verbs "\${group}__\${verb}")
221
+ if [ -n "$words" ]; then
222
+ COMPREPLY=( $(compgen -W "$words" -- "$cur") )
223
+ return 0
224
+ fi
225
+ fi
226
+
227
+ # Positional slots with a known domain, handed the earlier slots' words.
228
+ # Everything else intentionally offers nothing (silent degradation).
229
+ if __eris_comp_fileslot "$key" "$np"; then
230
+ COMPREPLY=( $(compgen -f -- "$cur") )
231
+ return 0
232
+ fi
233
+ words=$(__eris_comp_slot "$key" "$np" \${pw[@]+"\${pw[@]}"})
234
+ [ -n "$words" ] && COMPREPLY=( $(compgen -W "$words" -- "$cur") )
235
+ return 0
236
+ }
237
+
238
+ complete -F _eris_completions eris
239
+ `;
240
+
241
+ /** The full script for `shell`, generated from `reg`. Deterministic: the
242
+ * same registry emits the same bytes. */
243
+ export function emitScript(shell: Shell, reg: Registry = REGISTRY): string {
244
+ const { tree, helpers, argv } = generated(reg);
245
+ return `${shell === "zsh" ? ZSH_HEADER : BASH_HEADER}${tree}${helpers}${argv}${ENGINE}`;
246
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * shell.ts — the one body behind `eris completion bash` and `eris completion
3
+ * zsh`: `--help` prints the verb's usage, no argument prints the script, and
4
+ * anything else is refused with nothing on stdout (a shell rc sources stdout).
5
+ *
6
+ * The tree is the session's composed registry (src/cli/compose.ts): core rows
7
+ * plus plugin rows. Rows are data, so no plugin handler loads here.
8
+ */
9
+
10
+ import { CliExit, readVerbArgs } from "../../args";
11
+ import type { CliContext } from "../../../main";
12
+ import type { Session } from "../../compose";
13
+ import { emitScript, type Shell } from "./emit";
14
+
15
+ export function runShell(shell: Shell, usage: string, args: string[], ctx: CliContext & { session?: Session }): number {
16
+ try {
17
+ if (readVerbArgs(`completion ${shell}`, args, ctx, { help: usage }) === null) return 0;
18
+ } catch (e) {
19
+ if (e instanceof CliExit) return e.code;
20
+ throw e;
21
+ }
22
+ ctx.stdout.write(emitScript(shell, ctx.session?.cli.registry));
23
+ return 0;
24
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * zsh.ts — `eris completion zsh`: the zsh completion script on stdout.
3
+ */
4
+
5
+ import type { CliContext } from "../../../main";
6
+ import type { Session } from "../../compose";
7
+ import { runShell } from "./shell";
8
+
9
+ const USAGE = `Usage: eris completion zsh
10
+
11
+ Emit the zsh completion script to stdout. Load it into the current shell
12
+ or make it permanent:
13
+
14
+ source <(eris completion zsh) # this shell only
15
+ echo 'source <(eris completion zsh)' >> ~/.zshrc # every shell
16
+
17
+ Implementation: zsh loads the same completion body as bash through
18
+ bashcompinit (one body, two thin wrappers — the lower-maintenance route).
19
+ See docs/COMPLETION.md.
20
+ `;
21
+
22
+ export function runZsh(args: string[], ctx: CliContext & { session?: Session }): number {
23
+ return runShell("zsh", USAGE, args, ctx);
24
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * completion.ts — `eris completion <shell>`: emit a shell tab-completion
3
+ * script. The script is generated from the verb registry
4
+ * (./completion/emit.ts); `bash` is ./completion/bash.ts, `zsh`
5
+ * ./completion/zsh.ts.
6
+ *
7
+ * It runs on every shell start (`source <(eris completion bash)`), so its
8
+ * import graph is the registry and nothing else: no other verb, and no Bash.
9
+ */
10
+
11
+ import { groupArgs } from "../args";
12
+ import type { CliContext } from "../../main";
13
+ import type { Session } from "../compose";
14
+ import { runBash } from "./completion/bash";
15
+ import { runZsh } from "./completion/zsh";
16
+
17
+ const USAGE_HEAD = `Usage: eris completion <shell>
18
+
19
+ Shells:
20
+ bash Emit the bash completion script (bash 3.2+ clean)
21
+ zsh Emit the zsh completion script (bashcompinit route)
22
+
23
+ Load into the current shell: source <(eris completion bash)
24
+ Make permanent: echo 'source <(eris completion bash)' >> ~/.bashrc
25
+ echo 'source <(eris completion zsh)' >> ~/.zshrc
26
+
27
+ Completes command groups, verbs, flags and file-valued flags. A tab press
28
+ never runs eris and never touches the network.
29
+ `;
30
+
31
+ const USAGE_TAIL = `See docs/COMPLETION.md.
32
+ `;
33
+
34
+ /** Core's text, with a distribution's paragraph (`composeCli`'s `completionNote`) between the two halves. */
35
+ function usageText(note: string | null): string {
36
+ return `${USAGE_HEAD}${note === null ? "" : `${note}\n`}${USAGE_TAIL}`;
37
+ }
38
+
39
+ export async function runCompletion(args: string[], ctx: CliContext & { session?: Session }): Promise<number> {
40
+ const g = groupArgs("completion", args);
41
+ if (g.kind === "sub") return (g.sub === "bash" ? runBash : runZsh)(g.rest, ctx);
42
+ if (g.kind === "bare" || g.kind === "help") {
43
+ ctx.stdout.write(usageText(ctx.session?.cli.completionNote ?? null));
44
+ return 0;
45
+ }
46
+ ctx.stderr.write(`ERROR Unknown completion shell: ${g.sub} (supported: bash, zsh)\n`);
47
+ return 1;
48
+ }