@staix/agent-hub 0.12.42 → 0.12.43

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.
@@ -52,10 +52,14 @@ export interface PiOptions {
52
52
  onApprovalStop?: (reason: string) => void;
53
53
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
54
54
  stopGraceMs?: number;
55
+ /** Host process-identity reader; never supplied by bridge events or peer config. */
56
+ signature?: typeof processSignature;
55
57
  }
56
58
  export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
57
59
  type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
58
60
  type VerifiedEmptyResume = { sessionId: string };
61
+ type TerminalOwnerObservation = { pid: number; signature: string; sessionId: string; sessionFile: string; ownerToken: string; checkedAt: number };
62
+ const TERMINAL_OWNER_MAX_AGE_MS = 1_000;
59
63
  function ownerStillAlive(pid: number, signature: string | undefined): boolean { const current = processSignature(pid); if (current !== undefined) return current === signature; try { process.kill(pid, 0); return true; } catch { return false; } }
60
64
 
61
65
 
@@ -121,6 +125,7 @@ export class PiPeer extends BasePeer {
121
125
  private ownerPid?: number;
122
126
  private ownerSignature?: string;
123
127
  private ownerMonitor?: ReturnType<typeof setInterval>;
128
+ private terminalOwnerObservation?: TerminalOwnerObservation;
124
129
  private tuiExit?: Promise<void>;
125
130
  private resolveTuiExit?: () => void;
126
131
  private tuiCommands = new Map<string, { command: Record<string, unknown>; resolve: (value: any) => void; reject: (error: Error) => void }>();
@@ -139,7 +144,7 @@ export class PiPeer extends BasePeer {
139
144
  if (this.state === "busy" || this.starting || this.activeTools) return false;
140
145
  if (this.sessionFile && !existsSync(this.sessionFile) && (this.activityObserved || this.persistedOnce)) return false;
141
146
  if (this.opts.mode === "headless") return this.state === "idle" || !this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null;
142
- if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && processSignature(this.ownerPid) === this.ownerSignature;
147
+ if (this.state === "idle") return this.ownerClaimed && !!this.ownerPid && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
143
148
  return this.stopping && !this.ownerClaimed && (!this.ownerPid || !ownerStillAlive(this.ownerPid, this.ownerSignature));
144
149
  }
145
150
  /** Explicit replacement may retain an unpersisted identity, but never a live/unknown owner or active effects. */
@@ -337,6 +342,7 @@ export class PiPeer extends BasePeer {
337
342
  }
338
343
 
339
344
  private async startImpl(): Promise<void> {
345
+ this.terminalOwnerObservation = undefined;
340
346
  this.stopping = false;
341
347
  this.rosterDetached = false; this.rosterUnownedRun = false;
342
348
  this.requestedStopReason = undefined;
@@ -366,7 +372,7 @@ export class PiPeer extends BasePeer {
366
372
  }
367
373
  if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
368
374
  const body = await request.json() as any;
369
- if (url.pathname === "/event") { if (body.type === "session_start" && this.stopping) return Response.json({ ok: false, error: "Pi launch has ended" }, { status: 409 }); if (body.type === "session_start" && (typeof body.sessionId !== "string" || !body.sessionId || typeof body.sessionFile !== "string" || !body.sessionFile)) return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 }); if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || (this.sessionId && body.sessionId !== this.sessionId) || (this.sessionFile && body.sessionFile !== this.sessionFile))) return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 }); if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature || processSignature(body.pid) !== body.signature)) return Response.json({ ok: false, error: "Pi process identity is not verified" }, { status: 409 }); this.handleBridgeEvent(body); return Response.json({ ok: true }); }
375
+ if (url.pathname === "/event") { if (body.type === "session_start" && this.stopping) return Response.json({ ok: false, error: "Pi launch has ended" }, { status: 409 }); if (body.type === "session_start" && (typeof body.sessionId !== "string" || !body.sessionId || typeof body.sessionFile !== "string" || !body.sessionFile)) return Response.json({ ok: false, error: "Pi session identity is missing" }, { status: 400 }); if (body.type === "session_start" && this.ownerClaimed && (body.ownerToken !== this.ownerToken || body.pid !== this.ownerPid || body.signature !== this.ownerSignature || (this.sessionId && body.sessionId !== this.sessionId) || (this.sessionFile && body.sessionFile !== this.sessionFile))) return Response.json({ ok: false, error: "Pi session owner or identity already claimed" }, { status: 409 }); if (body.type === "session_start" && (!Number.isInteger(body.pid) || !body.signature || this.readOwnerSignature(body.pid) !== body.signature)) return Response.json({ ok: false, error: "Pi process identity is not verified" }, { status: 409 }); this.handleBridgeEvent(body); return Response.json({ ok: true }); }
370
376
  if (url.pathname === "/ack") {
371
377
  const pending = this.tuiCommands.get(String(body.id));
372
378
  if (!pending) return Response.json({ ok: false }, { status: 404 });
@@ -415,7 +421,7 @@ export class PiPeer extends BasePeer {
415
421
  // The hub spawned this headless owner and get_state proved its session. Pin the same
416
422
  // identity a native extension claim uses, including fakes that do not load the extension.
417
423
  if (!this.ownerClaimed) {
418
- const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
424
+ const pid = this.proc?.pid, signature = pid ? this.readOwnerSignature(pid) : undefined;
419
425
  if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
420
426
  this.ownerPid = pid; this.ownerSignature = signature;
421
427
  this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
@@ -426,12 +432,26 @@ export class PiPeer extends BasePeer {
426
432
  this.setState("idle");
427
433
  }
428
434
 
435
+ private readOwnerSignature(pid: number): string | undefined { return (this.opts.signature ?? processSignature)(pid); }
436
+
429
437
  private verifiedOwner(): boolean {
430
- return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
438
+ return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && this.readOwnerSignature(this.ownerPid) === this.ownerSignature;
439
+ }
440
+
441
+ private rememberTerminalOwner(): void {
442
+ if (!this.ownerClaimed || !this.ownerPid || !this.ownerSignature) return;
443
+ this.terminalOwnerObservation = { pid: this.ownerPid, signature: this.ownerSignature, sessionId: this.sessionId,
444
+ sessionFile: this.sessionFile, ownerToken: this.ownerToken, checkedAt: performance.now() };
431
445
  }
432
446
 
433
447
  terminalOwner(_launchId: string): { generation: string; nativePid: number } | undefined {
434
- if (this.opts.mode !== "tui" || !this.started || this.stopping || this.rosterDetached || !this.verifiedOwner() || !this.sessionId || !this.sessionFile ||
448
+ const observed = this.terminalOwnerObservation;
449
+ // Reconciliation reads the monitor's bounded observation, never spawning ps on its 1 s tick.
450
+ // Effect admission and teardown keep their fresh process-identity checks.
451
+ if (this.opts.mode !== "tui" || !this.started || this.stopping || this.rosterDetached || this.state === "offline" ||
452
+ !this.ownerClaimed || !observed || performance.now() - observed.checkedAt > TERMINAL_OWNER_MAX_AGE_MS ||
453
+ observed.pid !== this.ownerPid || observed.signature !== this.ownerSignature || observed.sessionId !== this.sessionId ||
454
+ observed.sessionFile !== this.sessionFile || observed.ownerToken !== this.ownerToken || !this.sessionId || !this.sessionFile ||
435
455
  this.opts.sessionId && this.opts.sessionId !== this.sessionId || this.opts.sessionFile && this.opts.sessionFile !== this.sessionFile) return undefined;
436
456
  const generation = this.sessionGeneration;
437
457
  return generation && this.ownerPid ? { generation, nativePid: this.ownerPid } : undefined;
@@ -448,6 +468,7 @@ export class PiPeer extends BasePeer {
448
468
  /** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
449
469
  async detachForRoster(): Promise<void> {
450
470
  if (this.opts.mode !== "tui") throw new Error("Pi roster detach requires a personal TUI");
471
+ this.terminalOwnerObservation = undefined;
451
472
  this.rosterDetached = true; this.rosterEpoch++;
452
473
  this.setState("offline");
453
474
  clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
@@ -489,6 +510,7 @@ export class PiPeer extends BasePeer {
489
510
  async stop(reason?: string): Promise<void> {
490
511
  if (reason !== undefined) this.requestedStopReason = reason;
491
512
  this.rosterEpoch++;
513
+ this.terminalOwnerObservation = undefined;
492
514
  this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
493
515
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
494
516
  this.activeDeliveryIds.clear();
@@ -713,6 +735,7 @@ export class PiPeer extends BasePeer {
713
735
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
714
736
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
715
737
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
738
+ this.rememberTerminalOwner();
716
739
  this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
717
740
  }
718
741
  if (event.type === "session_shutdown") {
@@ -723,7 +746,7 @@ export class PiPeer extends BasePeer {
723
746
  if (this.opts.mode === "tui") this.reportExit(exit);
724
747
  }
725
748
  if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
726
- && processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
749
+ && this.readOwnerSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
727
750
  && typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
728
751
  const pending = this.pendingToolGenerations.get(event.toolCallId);
729
752
  if (pending && pending.generation === event.generation) pending.abort.abort();
@@ -846,6 +869,7 @@ export class PiPeer extends BasePeer {
846
869
  }
847
870
 
848
871
  private fail(error: Error): void {
872
+ this.terminalOwnerObservation = undefined;
849
873
  const approvalStopReason = this.approvalStopReason;
850
874
  this.abortTools();
851
875
  this.agentRunning = false;
@@ -866,18 +890,24 @@ export class PiPeer extends BasePeer {
866
890
  private startOwnerMonitor(): void {
867
891
  this.clearOwnerMonitor();
868
892
  if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
869
- this.ownerMonitor = setInterval(() => {
870
- if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
871
- const exit = this.exitMetadata("owner_lost");
872
- this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
873
- this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
874
- for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
875
- this.tuiCommands.clear(); this.tuiQueue = [];
876
- this.fail(new Error("Pi owner process exited without session_shutdown"));
877
- this.reportExit(exit);
878
- }, 500);
893
+ this.ownerMonitor = setInterval(() => this.checkOwner(), 500);
879
894
  this.ownerMonitor.unref?.();
880
895
  }
896
+ private checkOwner(): void {
897
+ if (!this.ownerPid) { this.terminalOwnerObservation = undefined; return; }
898
+ const current = this.readOwnerSignature(this.ownerPid);
899
+ if (current !== undefined && current === this.ownerSignature) { this.rememberTerminalOwner(); return; }
900
+ this.terminalOwnerObservation = undefined;
901
+ // Unknown process identity retains the existing live-owner fence, but is never terminal proof.
902
+ if (current === undefined) { try { process.kill(this.ownerPid, 0); return; } catch { /* positively gone */ } }
903
+ const exit = this.exitMetadata("owner_lost");
904
+ this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
905
+ this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
906
+ for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
907
+ this.tuiCommands.clear(); this.tuiQueue = [];
908
+ this.fail(new Error("Pi owner process exited without session_shutdown"));
909
+ this.reportExit(exit);
910
+ }
881
911
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
882
912
  private async endApprovalTurn(generation: number, reason: string): Promise<void> {
883
913
  if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || !this.acceptingTools || this.state === "offline") return;
@@ -0,0 +1,173 @@
1
+ import { HELP } from "./help.ts";
2
+
3
+ /** An option's value: a fixed list, a path, free text, or none (a flag). */
4
+ type Value = readonly string[] | "path" | "text" | null;
5
+ interface Node { children: Set<string>; options: Map<string, Value>; equals: Set<string> }
6
+
7
+ /** Printed instead of candidates when the shell should complete file names. */
8
+ export const FILES = "__files__";
9
+ const WORD = /^[a-z][a-z0-9-]*$/;
10
+ const ALTERNATION = /^[a-z][a-z0-9-]*(\|[a-z][a-z0-9-]*)*$/;
11
+
12
+ /** Split at spaces outside brackets; a bracket group stays one token. */
13
+ function tokens(text: string): string[] {
14
+ const out: string[] = [];
15
+ let depth = 0, current = "";
16
+ for (const ch of text) {
17
+ if (ch === "[") depth++;
18
+ if (ch === "]") depth--;
19
+ if (ch === " " && depth === 0) { if (current) out.push(current); current = ""; } else current += ch;
20
+ }
21
+ if (current) out.push(current);
22
+ return out;
23
+ }
24
+
25
+ /** Split at ` | ` outside brackets: `ahub sdlc status [task] | explain <task>` has two alternatives. */
26
+ function alternatives(text: string): string[] {
27
+ const out: string[] = [];
28
+ let depth = 0, start = 0;
29
+ for (let i = 0; i < text.length; i++) {
30
+ if (text[i] === "[") depth++;
31
+ if (text[i] === "]") depth--;
32
+ if (depth === 0 && text.startsWith(" | ", i)) { out.push(text.slice(start, i)); start = i + 3; i += 2; }
33
+ }
34
+ return [...out, text.slice(start)];
35
+ }
36
+
37
+ /**
38
+ * A value spec: `a|b|<iso>` offers the literals a and b; an upper-case word (`N`) is a placeholder. Inside angle
39
+ * brackets the words are placeholders (`<path|id>` is a path, `<id|node>` free text), except a list of three or more
40
+ * (`<blue|teal|violet|ochre>`) and the keywords none and inherit.
41
+ */
42
+ // ponytail: the keyword list is what help uses today; a new keyword inside <> needs adding here.
43
+ function value(spec: string): Value {
44
+ if (spec.startsWith("<")) {
45
+ const parts = spec.slice(1, -1).split("|");
46
+ if (parts.length >= 3) return parts;
47
+ if (/path|dir|file/.test(spec)) return "path";
48
+ const keywords = parts.filter(part => part === "none" || part === "inherit");
49
+ return keywords.length ? keywords : "text";
50
+ }
51
+ const literal = spec.split("|").filter(part => /^[a-z0-9][\w.-]*$/.test(part));
52
+ if (literal.length) return literal;
53
+ return /path|dir|file/.test(spec) ? "path" : "text";
54
+ }
55
+
56
+ /** Options in a token group: `--flag`, `--name <value>`, `--name a|b`, `--name=a|b`; a group never lends its value to another. */
57
+ function options(group: string[], into: Node): void {
58
+ for (let i = 0; i < group.length; i++) {
59
+ const token = group[i]!;
60
+ if (token.startsWith("[")) { options(tokens(token.slice(1, -1)), into); continue; }
61
+ const eq = /^(--?[a-zA-Z][\w-]*)=(.+)$/.exec(token);
62
+ if (eq) { into.options.set(eq[1]!, value(eq[2]!)); into.equals.add(eq[1]!); continue; }
63
+ if (!/^--?[a-zA-Z][\w-]*$/.test(token)) continue;
64
+ const next = group[i + 1];
65
+ if (next !== undefined && !next.startsWith("-") && !next.startsWith("[") && next !== "|") { into.options.set(token, value(next)); i++; }
66
+ else if (!into.options.has(token)) into.options.set(token, null);
67
+ }
68
+ }
69
+
70
+ let table: Map<string, Node> | undefined;
71
+ /** The completion table, read from the `ahub help` entries so it cannot drift from what help documents. */
72
+ export function completionTable(): Map<string, Node> {
73
+ if (table) return table;
74
+ const nodes = new Map<string, Node>();
75
+ const node = (path: readonly string[]) => {
76
+ const key = path.join(" ");
77
+ let found = nodes.get(key);
78
+ if (!found) { found = { children: new Set(), options: new Map(), equals: new Set() }; nodes.set(key, found); }
79
+ if (path.length) node(path.slice(0, -1)).children.add(path.at(-1)!);
80
+ return found;
81
+ };
82
+ const root = node([]);
83
+ root.options.set("--project", "path").set("--live-test", null).set("--help", null).set("-h", null);
84
+ for (const { usage } of HELP) {
85
+ if (usage.includes("<command>")) continue; // the global --project and --live-test forms, set above
86
+ let parent: string[] = [];
87
+ alternatives(usage.replace(/^ahub /, "")).forEach((alt, index) => {
88
+ const parts = tokens(alt);
89
+ if (parts[0]?.startsWith("-")) { options(parts, index ? node(parent.slice(0, -1)) : root); return; }
90
+ let prefixes: string[][] = [index ? parent.slice(0, -1) : []];
91
+ let i = 0;
92
+ for (; i < parts.length && ALTERNATION.test(parts[i]!); i++) prefixes = prefixes.flatMap(prefix => parts[i]!.split("|").map(word => [...prefix, word]));
93
+ const sub = parts[i]?.match(/^\[([a-z][a-z0-9-]*(?:\|[a-z][a-z0-9-]*)+)\]$/);
94
+ for (const path of prefixes) {
95
+ const target = node(path);
96
+ if (sub) for (const word of sub[1]!.split("|")) node([...path, word]);
97
+ options(parts.slice(i), target);
98
+ }
99
+ parent = prefixes[0]!;
100
+ });
101
+ }
102
+ return (table = nodes);
103
+ }
104
+
105
+ /** Candidates for the last word, given every word after `ahub`. Static: no daemon, project or network. */
106
+ export function completeWords(words: readonly string[]): string[] {
107
+ const nodes = completionTable(), root = nodes.get("")!;
108
+ const cur = words.at(-1) ?? "";
109
+ const path: string[] = [];
110
+ let pending: Value | undefined;
111
+ let positional = false; // after a positional argument no subcommand can follow
112
+ const optionOf = (name: string) => nodes.get(path.join(" "))?.options.get(name) ?? root.options.get(name);
113
+ for (const word of words.slice(0, -1)) {
114
+ if (pending !== undefined) { pending = undefined; continue; }
115
+ if (word.startsWith("-")) { const kind = optionOf(word); if (kind && !word.includes("=")) pending = kind; continue; }
116
+ if (!positional && nodes.get(path.join(" "))?.children.has(word)) path.push(word);
117
+ else positional = true;
118
+ }
119
+ const values = (kind: Value, prefix = "") => kind === "path" ? [FILES] : Array.isArray(kind) ? kind.map(v => prefix + v) : [];
120
+ if (pending !== undefined) return values(pending).filter(v => v === FILES || v.startsWith(cur));
121
+ const eq = /^(--?[\w-]+)=/.exec(cur);
122
+ if (eq) return values(optionOf(eq[1]!) ?? null, eq[0]).filter(v => v === FILES || v.startsWith(cur));
123
+ const here = nodes.get(path.join(" "))!;
124
+ // An option documented as `--name=value` is offered as `--name=`; the shell adds no space after it.
125
+ const opts = [...new Set([...here.options.keys(), ...(path.length ? [] : root.options.keys())])].map(o => here.equals.has(o) ? `${o}=` : o);
126
+ const children = positional ? [] : [...here.children];
127
+ const candidates = cur.startsWith("-") ? opts : [...children, ...(cur && children.length ? [] : opts)];
128
+ const matched = candidates.filter(c => c.startsWith(cur)).sort();
129
+ return matched.length || cur.startsWith("-") || children.length ? matched : [FILES];
130
+ }
131
+
132
+ const SCRIPTS: Record<string, string> = {
133
+ zsh: `#compdef ahub
134
+ # ahub zsh completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
135
+ _ahub() {
136
+ local -a c
137
+ c=("\${(@f)$(ahub __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)}")
138
+ c=(\${c:#})
139
+ if [[ "\${c[1]}" == ${FILES} ]]; then _files; return; fi
140
+ compadd -Q -S '' -- \${(M)c:#*=}
141
+ compadd -Q -- \${c:#*=}
142
+ }
143
+ compdef _ahub ahub
144
+ `,
145
+ bash: `# ahub bash completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
146
+ _ahub() {
147
+ local IFS=$'\\n' cur=\${COMP_WORDS[COMP_CWORD]}
148
+ local c=($(ahub __complete -- "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null))
149
+ if [ "\${c[0]}" = ${FILES} ]; then COMPREPLY=($(compgen -f -- "$cur")); else COMPREPLY=("\${c[@]}"); fi
150
+ case "\${COMPREPLY[*]}" in *=) compopt -o nospace 2>/dev/null ;; esac
151
+ }
152
+ complete -o default -F _ahub ahub
153
+ `,
154
+ fish: `# ahub fish completion: candidates come from \`ahub __complete\`, read from ahub's own help entries.
155
+ function __ahub_complete
156
+ set -l cur (commandline -ct)
157
+ # "$cur" keeps an empty current word as an argument: Tab after a space completes the next word
158
+ set -l out (ahub __complete -- (commandline -opc)[2..-1] "$cur" 2>/dev/null)
159
+ if test "$out[1]" = ${FILES}
160
+ __fish_complete_path (commandline -ct)
161
+ else
162
+ printf '%s\\n' $out
163
+ end
164
+ end
165
+ complete -c ahub -f -a '(__ahub_complete)'
166
+ `,
167
+ };
168
+
169
+ export const COMPLETION_SHELLS = Object.keys(SCRIPTS);
170
+ /** The script for one shell, or undefined for a shell ahub does not support. */
171
+ export function completionScript(shell: string | undefined): string | undefined {
172
+ return shell === undefined ? undefined : SCRIPTS[shell];
173
+ }