caique 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <p align="center">
15
15
  <a href="https://www.npmjs.com/package/caique"><img src="https://img.shields.io/npm/v/caique?style=flat-square&color=0a6b47" alt="npm version" /></a>
16
16
  <img src="https://img.shields.io/badge/status-pre--release-a84c17?style=flat-square" alt="Status: pre-release" />
17
- <img src="https://img.shields.io/badge/runtime%20dependencies-0-0a6b47?style=flat-square" alt="Zero runtime dependencies" />
17
+ <img src="https://img.shields.io/badge/dependencies-closeout-0a6b47?style=flat-square" alt="One dependency: closeout" />
18
18
  <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT" />
19
19
  </p>
20
20
 
@@ -35,12 +35,13 @@ flags in, a verdict out.
35
35
 
36
36
  ```js
37
37
  import { decide } from 'caique/decide';
38
+ import { processRuntime } from 'caique';
38
39
 
39
40
  decide({
40
41
  value: undefined, // nothing was passed
41
42
  spec: { kind: 'text', message: 'Where should it go?' },
42
43
  option: 'output-dir',
43
- runtime: { env: process.env, isTTY: { stdin: process.stdin.isTTY } },
44
+ runtime: processRuntime(), // or your own { env, isTTY: { stdin } }
44
45
  required: true,
45
46
  });
46
47
  // no terminal -> { action: 'error', code: 'USAGE',
@@ -101,7 +102,7 @@ import { resolvePrompts } from 'caique/binding';
101
102
  const { values, failure } = await resolvePrompts({
102
103
  options, // { name: { required: true, prompt: { kind: 'text', message: 'Project name?' } } }
103
104
  values, // what every other source resolved
104
- runtime: { env: process.env, isTTY: { stdin: process.stdin.isTTY } },
105
+ runtime: processRuntime(),
105
106
  flags: { json, yes, interactive },
106
107
  io: { reader, writer },
107
108
  });
@@ -125,7 +126,7 @@ that touches a terminal:
125
126
  import { createIo } from 'caique/terminal';
126
127
  import { ask } from 'caique/ask';
127
128
 
128
- const io = createIo({ input: process.stdin, output: process.stdout });
129
+ const io = createIo(); // the terminal the program was started in
129
130
  await ask({ kind: 'password', message: 'Token?' }, io);
130
131
  io.close();
131
132
  ```
@@ -144,9 +145,11 @@ not a second implementation:
144
145
 
145
146
  ```js
146
147
  import { askList, canRender } from 'caique/raw';
147
- import { createIo } from 'caique/terminal';
148
+ import { createIo, streamsOf } from 'caique/terminal';
149
+ import { processRuntime } from 'caique';
148
150
 
149
- const io = { ...createIo({ input: process.stdin, output: process.stdout }), keys: process.stdin };
151
+ const rt = processRuntime();
152
+ const io = { ...createIo(streamsOf(rt)), keys: rt.stdin };
150
153
  const spec = { kind: 'select', message: 'Which host?', choices: [{ value: 'ora' }, { value: 'chalk' }] };
151
154
  const answer = canRender(io.keys) ? await askList(spec, io) : await ask(spec, io);
152
155
  ```
@@ -190,7 +193,37 @@ counted whole across its own resolved tree.
190
193
  - **`--interactive`** asks for every missing required option in one pass; **`--yes`** accepts
191
194
  every confirmation; cancellation exits `CANCELLED` and restores the terminal.
192
195
  - **Accessible mode** falls back to line input with no live redraw.
193
- - **Drop-in paths** for inquirer and clack, graded by their own suites.
196
+ - **A migration path from `@inquirer/prompts` and `@clack/prompts`**, graded by their own
197
+ suites. See below for what that is graded at today, which is zero.
198
+
199
+ ## Which incumbents this is measured against
200
+
201
+ **`@inquirer/prompts`** (28.8 M/wk) and **`@clack/prompts`**. Those two, and not the
202
+ package whose download count is larger:
203
+
204
+ - **`inquirer` (34.3 M/wk) is out of scope, deliberately.** Its 34 million are the *legacy*
205
+ `inquirer.prompt([...])` façade, an API its own maintainer moved off; a new CLI written
206
+ today writes `@inquirer/prompts`. Reproducing the legacy object API would be work spent
207
+ on a shape nobody new adopts, and it is not on this package's roadmap.
208
+ - **`@inquirer/core`** is what the compatibility oracle grades, because it is where the
209
+ prompt *loop* — the keypress state machine both façades sit on — is actually tested.
210
+ `inquirer`'s own npm tarball ships **no tests at all**, so there is nothing there to grade.
211
+
212
+ | Incumbent's suite | Cases | Their own package | `caique` |
213
+ | :-- | --: | --: | --: |
214
+ | [`@inquirer/core` 12.0.3](https://github.com/SBoudrias/Inquirer.js) | 41 | 41 (100%) | **0 (0.0%)** |
215
+ | [`@clack/prompts` 1.8.1](https://github.com/bombshell-dev/clack) | 606 | 576 (95.0%) | **0 (0.0%)** |
216
+
217
+ Measured 2026-09-14 by `npm run compat`, which runs each incumbent's own unedited suite
218
+ twice: once against the incumbent (the control — the column that proves the gate works)
219
+ and once against caique. **Both caique columns are zero, and they are zero because no
220
+ façade exists yet** — `caique` exports `ask`, `decide` and `spec`, not `createPrompt` or
221
+ `text` in their spelling. The row is here because a claimed replacement with no number
222
+ beside it is a claim; this is the number, and it can only go up from here.
223
+
224
+ The 30 cases `@clack/prompts` fails against itself are `path.test.ts`, which mocks
225
+ `node:fs` in a way this repository's vitest does not reproduce — a harness divergence,
226
+ recorded with its reason in `packages/compat-oracle/src/hosts.ts` rather than rounded away.
194
227
 
195
228
  ## Following along
196
229
 
@@ -207,7 +240,29 @@ and argue with it — before it exists:
207
240
  Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on
208
241
  [burgee](https://www.npmjs.com/package/burgee) declares what it is,
209
242
  [roundel](https://www.npmjs.com/package/roundel) carries its colours,
210
- [flagstaff](https://www.npmjs.com/package/flagstaff) flies it, and caique answers back. Each
211
- is an independent package; none requires the others.
243
+ [flagstaff](https://www.npmjs.com/package/flagstaff) flies it, and caique answers back.
244
+ caique installs one of them: [closeout](https://www.npmjs.com/package/closeout), because a
245
+ prompt hides the cursor and owes it back however the process dies, and there is exactly one
246
+ correct implementation of that. Nothing outside this repository is installed.
212
247
 
213
248
  MIT © Ofri Peretz — see [LICENSE](./LICENSE).
249
+
250
+ ## Benchmarks
251
+
252
+ Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
253
+
254
+ Graded by the incumbent's own test suite:
255
+
256
+ | suite | passing |
257
+ | :-- | --: |
258
+ | `clack` | 0 / 606 |
259
+ | `inquirer-core` | 0 / 41 |
260
+
261
+ Weight, installed and tree-inclusive: **84,740 bytes** against **182,219** for the incumbents it replaces — a ratio of **0.4650** (@inquirer/core not installed here, so the ceiling is understated).
262
+
263
+ That ratio is not yet a claim: nothing here passes an incumbent suite, so it is the weight of a package that does not do the job.
264
+ ## Where it sits
265
+
266
+ Plugins register under the `widgets` key, against the one schema the whole family shares.
267
+
268
+ Nothing in this family builds on it yet, and it builds on `closeout`.
package/dist/index.d.ts CHANGED
@@ -10,3 +10,9 @@ export * from './decide.js';
10
10
  export * from './raw.js';
11
11
  export * from './spec.js';
12
12
  export * from './terminal.js';
13
+ /**
14
+ * Named, not starred: `runtime.ts` and `decide.ts` both call their slice `Runtime`, and two
15
+ * star exports of one name is an ambiguity TypeScript resolves by dropping it. The root
16
+ * keeps `decide`'s — the one a caller of `decide()` constructs — and takes the function.
17
+ */
18
+ export { processRuntime } from './runtime.js';
package/dist/index.js CHANGED
@@ -10,4 +10,10 @@ export * from './decide.js';
10
10
  export * from './raw.js';
11
11
  export * from './spec.js';
12
12
  export * from './terminal.js';
13
+ /**
14
+ * Named, not starred: `runtime.ts` and `decide.ts` both call their slice `Runtime`, and two
15
+ * star exports of one name is an ambiguity TypeScript resolves by dropping it. The root
16
+ * keeps `decide`'s — the one a caller of `decide()` constructs — and takes the function.
17
+ */
18
+ export { processRuntime } from './runtime.js';
13
19
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,81 @@
1
+ import { type PromptSpec } from './spec.js';
2
+ /**
3
+ * The plugin contract version. One number for the family — the same `1` flagstaff and
4
+ * roundel declare, written out rather than imported for the reason in the file comment.
5
+ */
6
+ export declare const CONTRACT = 1;
7
+ /**
8
+ * Two named states of plain data, which a grader renders a widget with (R7).
9
+ *
10
+ * It carries no behaviour, so reading it does not mean running the author's code — that is
11
+ * why an optional `sample` does not turn a plugin into a program.
12
+ */
13
+ export interface WidgetSample {
14
+ running: unknown;
15
+ done: unknown;
16
+ }
17
+ /**
18
+ * How a plugin draws one kind of prompt.
19
+ *
20
+ * `static(spec)` is what a pipe, an agent and a screen reader get; it is what `projection()`
21
+ * already returns for the six built-ins, so a plugin widget slots into the same surface
22
+ * rather than beside it. `frame` is optional and drives `caique/raw`.
23
+ */
24
+ export interface Widget {
25
+ static: (spec: PromptSpec) => string;
26
+ frame?: (t: number, spec: PromptSpec) => string;
27
+ sample?: WidgetSample;
28
+ }
29
+ /**
30
+ * The keys caique reads. Declared structurally: any object with these fields is a plugin
31
+ * here, whatever else it carries.
32
+ */
33
+ export interface Plugin {
34
+ name: string;
35
+ contract?: number;
36
+ widgets?: Record<string, Widget>;
37
+ }
38
+ export type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_STATIC_PROJECTION' | 'E_UNKNOWN_KIND';
39
+ /** A refused plugin says what is wrong and what to do about it — the family's one vocabulary. */
40
+ export declare class PluginError extends Error {
41
+ readonly code: PluginErrorCode;
42
+ readonly fix: string;
43
+ constructor(code: PluginErrorCode, message: string, fix: string);
44
+ }
45
+ /**
46
+ * Refuse a plugin that cannot contribute a widget, at the door.
47
+ *
48
+ * Every refusal here is about the `widgets` key or the plugin's own identity. A key another
49
+ * layer owns is not inspected and not rejected (R1) — caique has no opinion about a spinner.
50
+ */
51
+ export declare function validate(plugin: unknown): asserts plugin is Plugin;
52
+ /** Which plugin last contributed each kind — the shadowing a `plugin check` prints. */
53
+ export interface Contribution {
54
+ kind: string;
55
+ from: string;
56
+ /** Plugins that contributed this kind earlier and were overridden, in order. */
57
+ shadowed: string[];
58
+ }
59
+ /**
60
+ * Register a plugin. Later wins, like ESLint flat config: the array is ordered, a caller
61
+ * reads it top to bottom, and the last word on a kind is the one nearest the program.
62
+ */
63
+ export declare function register(plugin: unknown): void;
64
+ /** Forget every registered plugin. For tests, and for a program that re-registers at runtime. */
65
+ export declare function reset(): void;
66
+ /** The plugins registered, in registration order. */
67
+ export declare function registered(): readonly Plugin[];
68
+ /** Every kind a plugin contributed, with who won it and who it shadowed. */
69
+ export declare function widgets(): Contribution[];
70
+ /** The widget that draws `kind`, or nothing when no plugin registered one. */
71
+ export declare function widgetFor(kind: string): Widget | undefined;
72
+ /** Every kind that can be drawn right now: the six, plus whatever is registered. */
73
+ export declare function kinds(): string[];
74
+ /**
75
+ * The static projection for *any* kind — the one surface a caller needs.
76
+ *
77
+ * The six are still drawn by caique; anything else is a registered widget's `static`. A
78
+ * kind that is neither is a refusal naming what *is* registered, so the reader can see the
79
+ * typo rather than a text prompt where their rating widget should have been.
80
+ */
81
+ export declare function projectionOf(spec: PromptSpec): string;
package/dist/plugin.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The plugin host for caique's half of the contract (`plugin-contract` R1, R5, R6, R7, R8).
3
+ *
4
+ * A plugin is one plain object shared by the whole family. This file keeps the key caique
5
+ * understands — `widgets`, a prompt kind and how to draw it — and **ignores every other key
6
+ * without complaining**, which is what makes the same object work on any subset of the
7
+ * family that is installed. A plugin written for flagstaff registers here and contributes
8
+ * its widgets; its `tokens`, `glyphs` and `components` are not caique's business and are
9
+ * not an error.
10
+ *
11
+ * **Nothing here imports another layer**, and the plugin shape is declared rather than
12
+ * imported (R3). Types erase, so an import would cost nothing at run time — and it would
13
+ * still put flagstaff in caique's dependency story, which is the one thing the family
14
+ * promises it does not do.
15
+ *
16
+ * **A widget is the same shape a flagstaff component is** — `{ static, frame?, sample? }` —
17
+ * and a widget without `static` is refused with `E_NO_STATIC_PROJECTION`, the same code and
18
+ * the same fix shape. That sameness is the whole content of R5: the moment the two shapes
19
+ * differ by a key, "the same shape" stops being a fact a lock can hold and becomes prose.
20
+ *
21
+ * **Why this file, and not `ask.ts`, raises `E_UNKNOWN_KIND`.** `PromptKind` is an open
22
+ * union now, so `{ kind: 'acme-rating' }` type-checks whether or not anyone can draw it.
23
+ * The refusal therefore has to happen where the registry is, and it has to be *loud*: a
24
+ * kind nobody registered rendered as a text prompt is the silent-wrong-answer failure this
25
+ * package exists to prevent, wearing a hat.
26
+ */
27
+ import { projection } from './ask.js';
28
+ import { BUILT_IN_KINDS } from './spec.js';
29
+ /**
30
+ * The plugin contract version. One number for the family — the same `1` flagstaff and
31
+ * roundel declare, written out rather than imported for the reason in the file comment.
32
+ */
33
+ export const CONTRACT = 1;
34
+ /** A refused plugin says what is wrong and what to do about it — the family's one vocabulary. */
35
+ export class PluginError extends Error {
36
+ code;
37
+ fix;
38
+ constructor(code, message, fix) {
39
+ super(message);
40
+ this.code = code;
41
+ this.fix = fix;
42
+ this.name = 'PluginError';
43
+ }
44
+ }
45
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
46
+ /** The six, in the order a person reads them — for the `fix` on an unknown kind. */
47
+ const builtIns = () => [...BUILT_IN_KINDS].join(', ');
48
+ /**
49
+ * Refuse a plugin that cannot contribute a widget, at the door.
50
+ *
51
+ * Every refusal here is about the `widgets` key or the plugin's own identity. A key another
52
+ * layer owns is not inspected and not rejected (R1) — caique has no opinion about a spinner.
53
+ */
54
+ export function validate(plugin) {
55
+ if (!isRecord(plugin))
56
+ throw new PluginError('E_PLUGIN_SCHEMA', 'a plugin is a plain object', 'export an object, not a function or an array');
57
+ if (typeof plugin['name'] !== 'string' || plugin['name'] === '') {
58
+ throw new PluginError('E_PLUGIN_SCHEMA', 'a plugin needs a name', 'add `name: "…"` — it is how a shadowed widget is reported');
59
+ }
60
+ const contract = plugin['contract'];
61
+ if (contract !== undefined && (!Number.isInteger(contract) || contract > CONTRACT)) {
62
+ throw new PluginError('E_PLUGIN_CONTRACT', `plugin "${plugin['name']}" declares contract ${String(contract)}; this caique knows ${CONTRACT}`, 'upgrade caique, or lower the plugin’s contract');
63
+ }
64
+ validateWidgets(plugin['widgets'], plugin['name']);
65
+ }
66
+ function validateWidgets(widgetMap, name) {
67
+ if (widgetMap === undefined)
68
+ return;
69
+ if (!isRecord(widgetMap))
70
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": widgets must be an object`, 'map a prompt kind to a widget: `{ static, frame?, sample? }`');
71
+ for (const [kind, widget] of Object.entries(widgetMap)) {
72
+ if (kind === '')
73
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": a widget’s kind is empty`, 'name the kind — it is what a `PromptSpec` sets as `kind`');
74
+ /**
75
+ * A plugin may not replace one of the six. The built-ins are the accessible floor and
76
+ * the drop-in surface, and `password` in particular guarantees that nothing writes back
77
+ * what it read — a third party that could override it could defeat that from a config
78
+ * file. Extension is the space *outside* the six, which is exactly what widening
79
+ * `PromptKind` opened up.
80
+ */
81
+ if (BUILT_IN_KINDS.has(kind)) {
82
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": "${kind}" is a built-in prompt kind`, `caique draws the six itself (${builtIns()}); name a kind of your own`);
83
+ }
84
+ if (!isRecord(widget))
85
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": widget "${kind}" is not an object`, 'a widget is `{ static, frame?, sample? }`');
86
+ /** U3 everywhere else, and the reason a third-party prompt cannot break the non-TTY guarantee. */
87
+ if (typeof widget['static'] !== 'function') {
88
+ throw new PluginError('E_NO_STATIC_PROJECTION', `plugin "${name}": widget "${kind}" has no static projection`, 'add `static: (spec) => "…"` — it is what a pipe, an agent and a screen reader get');
89
+ }
90
+ if (widget['frame'] !== undefined && typeof widget['frame'] !== 'function') {
91
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": widget "${kind}" has a \`frame\` that is not a function`, 'a frame is `(t, spec) => "…"`, or leave it out and the widget is line-mode only');
92
+ }
93
+ validateSample(widget['sample'], name, kind);
94
+ }
95
+ }
96
+ function validateSample(sample, name, kind) {
97
+ if (sample === undefined)
98
+ return;
99
+ if (!isRecord(sample) || !('running' in sample) || !('done' in sample)) {
100
+ throw new PluginError('E_PLUGIN_SCHEMA', `plugin "${name}": widget "${kind}" has a malformed \`sample\``, 'a sample is `{ running, done }` — two named states of plain data, which a grader renders the widget with');
101
+ }
102
+ }
103
+ const order = [];
104
+ /**
105
+ * Register a plugin. Later wins, like ESLint flat config: the array is ordered, a caller
106
+ * reads it top to bottom, and the last word on a kind is the one nearest the program.
107
+ */
108
+ export function register(plugin) {
109
+ validate(plugin);
110
+ order.push(plugin);
111
+ }
112
+ /** Forget every registered plugin. For tests, and for a program that re-registers at runtime. */
113
+ export function reset() {
114
+ order.length = 0;
115
+ }
116
+ /** The plugins registered, in registration order. */
117
+ export function registered() {
118
+ return order;
119
+ }
120
+ /** Every kind a plugin contributed, with who won it and who it shadowed. */
121
+ export function widgets() {
122
+ const by = new Map();
123
+ for (const plugin of order) {
124
+ for (const kind of Object.keys(plugin.widgets ?? {})) {
125
+ const existing = by.get(kind);
126
+ by.set(kind, { kind, from: plugin.name, shadowed: existing === undefined ? [] : [...existing.shadowed, existing.from] });
127
+ }
128
+ }
129
+ return [...by.values()];
130
+ }
131
+ /** The widget that draws `kind`, or nothing when no plugin registered one. */
132
+ export function widgetFor(kind) {
133
+ // Walked backwards because later wins, and the first hit from the end is the winner.
134
+ for (let i = order.length - 1; i >= 0; i -= 1) {
135
+ const widget = order[i].widgets?.[kind];
136
+ if (widget !== undefined)
137
+ return widget;
138
+ }
139
+ return undefined;
140
+ }
141
+ /** Every kind that can be drawn right now: the six, plus whatever is registered. */
142
+ export function kinds() {
143
+ return [...BUILT_IN_KINDS, ...widgets().map((c) => c.kind)];
144
+ }
145
+ /**
146
+ * The static projection for *any* kind — the one surface a caller needs.
147
+ *
148
+ * The six are still drawn by caique; anything else is a registered widget's `static`. A
149
+ * kind that is neither is a refusal naming what *is* registered, so the reader can see the
150
+ * typo rather than a text prompt where their rating widget should have been.
151
+ */
152
+ export function projectionOf(spec) {
153
+ if (BUILT_IN_KINDS.has(spec.kind))
154
+ return projection(spec);
155
+ const widget = widgetFor(spec.kind);
156
+ if (widget === undefined) {
157
+ const known = widgets().map((c) => c.kind);
158
+ const named = known.length === 0 ? 'no plugin has registered a widget' : `registered kinds: ${known.join(', ')}`;
159
+ throw new PluginError('E_UNKNOWN_KIND', `no widget draws prompt kind ${JSON.stringify(spec.kind)} — ${named}`, `register a plugin whose \`widgets\` defines ${JSON.stringify(spec.kind)}, or use a built-in kind: ${builtIns()}`);
160
+ }
161
+ return widget.static(spec).trimEnd();
162
+ }
163
+ //# sourceMappingURL=plugin.js.map
package/dist/raw.d.ts CHANGED
@@ -1,20 +1,3 @@
1
- /**
2
- * The raw-mode renderer: arrow keys and a moving highlight for `select` and `multiselect`,
3
- * on a terminal that can take them.
4
- *
5
- * **It answers the same questions `ask()` does, and returns the same answers.** That is the
6
- * whole arrangement: line mode is the floor (R5), this sits on top, and a caller chooses
7
- * between them by asking whether the terminal is one. Anything this can do that line mode
8
- * cannot is decoration; anything line mode can do that this cannot would be a bug.
9
- *
10
- * **On the dependency the design named.** It said "spinner from flagstaff", and this does
11
- * not import flagstaff. A prompt has no spinner — it is waiting for a person, not for work
12
- * — and the repaint it needs is three escape sequences, written here. Importing flagstaff
13
- * to get them would make the one package that talks to a human the only one in the family
14
- * that requires a sibling, which is the rule `caique`'s own README states. If a prompt ever
15
- * needs to show progress *while* it waits, that is a caller composing `hoist()` around
16
- * `ask()`, not this file reaching for it.
17
- */
18
1
  import { type Asked, type Io } from './ask.js';
19
2
  import { type Choice, type PromptSpec } from './spec.js';
20
3
  /** A stream that can be put into raw mode and read a key at a time. */
@@ -50,6 +33,9 @@ export declare function renderList(spec: PromptSpec, choices: Choice[], state: L
50
33
  * Returns the same `Asked` shape `ask()` does, so a caller can swap the two without
51
34
  * knowing which ran — and cancels the same way, because `Ctrl-C` in raw mode is a byte and
52
35
  * not a signal, and a person who presses it means to leave.
36
+ *
37
+ * The cursor is hidden through `closeout`: the byte path resolves and `restore()` runs in
38
+ * the `finally`; every path that never reaches the `finally` is the exit hook's.
53
39
  */
54
40
  export declare function askList(spec: PromptSpec, io: RawIo, multi?: boolean): Promise<Asked>;
55
41
  export {};
package/dist/raw.js CHANGED
@@ -7,20 +7,23 @@
7
7
  * between them by asking whether the terminal is one. Anything this can do that line mode
8
8
  * cannot is decoration; anything line mode can do that this cannot would be a bug.
9
9
  *
10
- * **On the dependency the design named.** It said "spinner from flagstaff", and this does
11
- * not import flagstaff. A prompt has no spinner — it is waiting for a person, not for work
12
- * — and the repaint it needs is three escape sequences, written here. Importing flagstaff
13
- * to get them would make the one package that talks to a human the only one in the family
14
- * that requires a sibling, which is the rule `caique`'s own README states. If a prompt ever
15
- * needs to show progress *while* it waits, that is a caller composing `hoist()` around
16
- * `ask()`, not this file reaching for it.
10
+ * **On its one dependency.** The design said "spinner from flagstaff"; this imports
11
+ * `closeout` instead, and the difference is the point. A repaint is one escape sequence
12
+ * and belongs here. Hiding the cursor is a global side effect on someone else's terminal,
13
+ * and the obligation it creates — put it back however the process dies — is not a repaint.
14
+ * This file used to own `HIDE_CURSOR`/`SHOW_CURSOR`, the family's third copy, and restore
15
+ * on one path only: the keypress loop, which sees Ctrl-C because raw mode delivers it as a
16
+ * byte. A `SIGINT` from a parent, a `SIGTERM`, a crash or a `process.exit()` elsewhere
17
+ * never reached it, and left the cursor invisible until the user typed `reset` (measured
18
+ * against `dist/raw.js`, 2026-09-15: hide 1, show 0). `hideCursor()` registers the restore
19
+ * in the call that hides, so the two cannot drift.
17
20
  */
21
+ import { hideCursor } from 'closeout/cursor';
22
+ import exitHook from 'closeout/exit-hook';
18
23
  import {} from './ask.js';
19
24
  import {} from './spec.js';
20
25
  const ESC = '\u001B';
21
26
  const CSI = `${ESC}[`;
22
- const HIDE_CURSOR = `${CSI}?25l`;
23
- const SHOW_CURSOR = `${CSI}?25h`;
24
27
  /** Column 1, up `n` lines, clear to the end of the screen — the only repaint this needs. */
25
28
  const erase = (lines) => `${CSI}1G${lines > 1 ? `${CSI}${lines - 1}A` : ''}${CSI}0J`;
26
29
  /**
@@ -94,12 +97,22 @@ function chosen(choices, state, multi) {
94
97
  return choices[state.cursor]?.value ?? '';
95
98
  return [...state.selected].sort((a, b) => a - b).map((index) => choices[index]?.value ?? '');
96
99
  }
100
+ /**
101
+ * The writer, seen as a terminal, for `hideCursor()` — which refuses a non-TTY. caique's
102
+ * `Writer` is one method and has no `isTTY`: the terminal test this renderer makes is
103
+ * `canRender(io.keys)`, on the other half of the same terminal, and it has already been
104
+ * made. So it is answered here rather than re-asked of a stream that cannot answer.
105
+ */
106
+ const asTerminal = (writer) => ({ write: (text) => writer.write(text), isTTY: true });
97
107
  /**
98
108
  * Drive a list prompt with the arrow keys, repainting in place.
99
109
  *
100
110
  * Returns the same `Asked` shape `ask()` does, so a caller can swap the two without
101
111
  * knowing which ran — and cancels the same way, because `Ctrl-C` in raw mode is a byte and
102
112
  * not a signal, and a person who presses it means to leave.
113
+ *
114
+ * The cursor is hidden through `closeout`: the byte path resolves and `restore()` runs in
115
+ * the `finally`; every path that never reaches the `finally` is the exit hook's.
103
116
  */
104
117
  export async function askList(spec, io, multi = false) {
105
118
  const choices = spec.choices ?? [];
@@ -107,11 +120,14 @@ export async function askList(spec, io, multi = false) {
107
120
  let painted = 0;
108
121
  const paint = () => {
109
122
  const frame = renderList(spec, choices, state, multi);
110
- io.writer.write((painted === 0 ? HIDE_CURSOR : erase(painted)) + frame);
123
+ io.writer.write((painted === 0 ? '' : erase(painted)) + frame);
111
124
  painted = frame.split('\n').length;
112
125
  };
113
126
  io.keys.setRawMode?.(true);
114
127
  io.keys.resume?.();
128
+ // Hide and register the restore together. `restore()` shows the cursor and unregisters,
129
+ // so a prompt that ends normally leaves nothing behind for exit to do.
130
+ const restore = hideCursor(asTerminal(io.writer), exitHook);
115
131
  paint();
116
132
  try {
117
133
  return await new Promise((resolve) => {
@@ -130,15 +146,16 @@ export async function askList(spec, io, multi = false) {
130
146
  };
131
147
  const done = (answer) => {
132
148
  io.keys.off('data', onData);
133
- // Leave the answered question on screen, the cursor back, and the terminal as it
134
- // was found: a prompt that exits in raw mode leaves the shell unusable.
135
- io.writer.write(`${erase(painted)}${renderList(spec, choices, state, multi)}\n${SHOW_CURSOR}`);
149
+ // Leave the answered question on screen; the `finally` puts the cursor and the
150
+ // terminal back, because a prompt that exits in raw mode leaves the shell unusable.
151
+ io.writer.write(`${erase(painted)}${renderList(spec, choices, state, multi)}\n`);
136
152
  resolve(answer);
137
153
  };
138
154
  io.keys.on('data', onData);
139
155
  });
140
156
  }
141
157
  finally {
158
+ restore();
142
159
  io.keys.setRawMode?.(false);
143
160
  io.keys.pause?.();
144
161
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The slice of the world caique needs, and the one file here that names `process` (Y9) —
3
+ * the same seam `paratext/src/runtime.ts` declares. `decide()` already took the fields it
4
+ * reads and `createIo()` already took its streams; this is the other half, so a program can
5
+ * get a real one without writing `process.stdin` itself.
6
+ *
7
+ * A **function**, for paratext's reason: a runtime built at import freezes the environment
8
+ * as it was when the module graph loaded, which is before a test can say what it wants.
9
+ * `runtime.test.ts` asks twice across a change, so a captured constant cannot pass.
10
+ */
11
+ /**
12
+ * Declared structurally, not imported: burgee's `Runtime` satisfies it, so does a literal in
13
+ * a test. `decide.ts` names the narrower pair it reads for the same reason; this satisfies it.
14
+ */
15
+ export interface Runtime {
16
+ /** `decide()` reads `CI`: set means nobody is there to type (R6). */
17
+ env: Record<string, string | undefined>;
18
+ stdin: NodeJS.ReadableStream & {
19
+ isTTY?: boolean;
20
+ };
21
+ stdout: NodeJS.WritableStream & {
22
+ isTTY?: boolean;
23
+ };
24
+ /** `stdin` decides whether a person can be asked at all; `stdout`, whether anything is drawn. */
25
+ isTTY: {
26
+ stdin: boolean;
27
+ stdout: boolean;
28
+ };
29
+ }
30
+ /** What a real process looks like. Callers that have not got one pass their own. */
31
+ export declare const processRuntime: () => Runtime;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The slice of the world caique needs, and the one file here that names `process` (Y9) —
3
+ * the same seam `paratext/src/runtime.ts` declares. `decide()` already took the fields it
4
+ * reads and `createIo()` already took its streams; this is the other half, so a program can
5
+ * get a real one without writing `process.stdin` itself.
6
+ *
7
+ * A **function**, for paratext's reason: a runtime built at import freezes the environment
8
+ * as it was when the module graph loaded, which is before a test can say what it wants.
9
+ * `runtime.test.ts` asks twice across a change, so a captured constant cannot pass.
10
+ */
11
+ /** What a real process looks like. Callers that have not got one pass their own. */
12
+ export const processRuntime = () => ({
13
+ env: process.env,
14
+ stdin: process.stdin,
15
+ stdout: process.stdout,
16
+ isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
17
+ });
18
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1,300 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json",
4
+ "title": "flagstaff plugin",
5
+ "description": "A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().",
6
+ "type": "object",
7
+ "required": [
8
+ "name"
9
+ ],
10
+ "additionalProperties": true,
11
+ "properties": {
12
+ "name": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "description": "The plugin's name; also the prefix a host may use when two plugins contribute the same key."
16
+ },
17
+ "contract": {
18
+ "type": "integer",
19
+ "minimum": 1,
20
+ "description": "The plugin contract this object follows. A host refuses a newer contract than it knows."
21
+ },
22
+ "tokens": {
23
+ "type": "object",
24
+ "description": "A roundel theme: semantic token name to a hex colour, contrast-checked when flown.",
25
+ "additionalProperties": {
26
+ "type": "string",
27
+ "pattern": "^#[0-9a-fA-F]{6}$"
28
+ },
29
+ "propertyNames": {
30
+ "enum": [
31
+ "error",
32
+ "warn",
33
+ "ok",
34
+ "hint",
35
+ "muted",
36
+ "command",
37
+ "flag",
38
+ "value",
39
+ "heading",
40
+ "ground"
41
+ ],
42
+ "description": "roundel's ten semantic tokens, and nothing else — `roundel`'s own validate() refuses any other name."
43
+ }
44
+ },
45
+ "glyphs": {
46
+ "type": "object",
47
+ "description": "Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.",
48
+ "additionalProperties": {
49
+ "type": "string",
50
+ "minLength": 1
51
+ }
52
+ },
53
+ "spinners": {
54
+ "type": "object",
55
+ "description": "Spinner styles by name, in cli-spinners' shape plus the static projection.",
56
+ "additionalProperties": {
57
+ "$ref": "#/$defs/spinner"
58
+ }
59
+ },
60
+ "borders": {
61
+ "type": "object",
62
+ "description": "Border styles a box can be drawn with, by name.",
63
+ "additionalProperties": {
64
+ "$ref": "#/$defs/border"
65
+ }
66
+ },
67
+ "components": {
68
+ "type": "object",
69
+ "description": "Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.",
70
+ "additionalProperties": {
71
+ "$ref": "#/$defs/component"
72
+ }
73
+ },
74
+ "capabilities": {
75
+ "$ref": "#/$defs/capabilities"
76
+ }
77
+ },
78
+ "$defs": {
79
+ "spinner": {
80
+ "type": "object",
81
+ "required": [
82
+ "frames",
83
+ "interval",
84
+ "static"
85
+ ],
86
+ "properties": {
87
+ "frames": {
88
+ "type": "array",
89
+ "items": {
90
+ "type": "string"
91
+ },
92
+ "minItems": 1
93
+ },
94
+ "interval": {
95
+ "type": "integer",
96
+ "minimum": 1,
97
+ "description": "Milliseconds between frames on a terminal."
98
+ },
99
+ "static": {
100
+ "type": "string",
101
+ "description": "What a pipe prints instead of the animation."
102
+ }
103
+ }
104
+ },
105
+ "component": {
106
+ "type": "object",
107
+ "required": [
108
+ "static"
109
+ ],
110
+ "properties": {
111
+ "static": {
112
+ "description": "(state) => string. Required: the projection every non-terminal mode prints."
113
+ },
114
+ "frame": {
115
+ "description": "(t, state) => string. Optional: the frame at t milliseconds since hoisting."
116
+ },
117
+ "sample": {
118
+ "type": "object",
119
+ "required": [
120
+ "running",
121
+ "done"
122
+ ],
123
+ "description": "Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.",
124
+ "properties": {
125
+ "running": {
126
+ "description": "The state to open with."
127
+ },
128
+ "done": {
129
+ "description": "The state to close with."
130
+ }
131
+ }
132
+ },
133
+ "interval": {
134
+ "type": "integer",
135
+ "minimum": 1,
136
+ "description": "Milliseconds between repaints when `frame` is given; 80 when omitted."
137
+ }
138
+ }
139
+ },
140
+ "border": {
141
+ "type": "object",
142
+ "required": [
143
+ "topLeft",
144
+ "top",
145
+ "topRight",
146
+ "left",
147
+ "right",
148
+ "bottomLeft",
149
+ "bottom",
150
+ "bottomRight"
151
+ ],
152
+ "description": "cli-boxes' shape exactly, so that corpus imports unchanged.",
153
+ "properties": {
154
+ "topLeft": {
155
+ "type": "string"
156
+ },
157
+ "top": {
158
+ "type": "string"
159
+ },
160
+ "topRight": {
161
+ "type": "string"
162
+ },
163
+ "left": {
164
+ "type": "string"
165
+ },
166
+ "right": {
167
+ "type": "string"
168
+ },
169
+ "bottomLeft": {
170
+ "type": "string"
171
+ },
172
+ "bottom": {
173
+ "type": "string"
174
+ },
175
+ "bottomRight": {
176
+ "type": "string"
177
+ }
178
+ }
179
+ },
180
+ "capability": {
181
+ "type": "object",
182
+ "required": [
183
+ "name",
184
+ "osc",
185
+ "when",
186
+ "encode",
187
+ "fallback"
188
+ ],
189
+ "additionalProperties": false,
190
+ "description": "One paratext capability: what it says to the terminal, when the terminal is believed to understand it, and what prints when it does not. No functions, so it travels through JSON. `encode` and `fallback` are templates: `{field}` is the field's value, `{field|base64}` is it base64-encoded, and `[ … ]` is emitted only when every field inside it has a value.",
191
+ "properties": {
192
+ "name": {
193
+ "type": "string",
194
+ "minLength": 1,
195
+ "description": "How callers name it. Registering an existing name replaces it — how a caller corrects a guess we got wrong."
196
+ },
197
+ "osc": {
198
+ "description": "The OSC code this speaks, or 'BEL' for the bell and for protocols that are not OSC at all, such as Kitty's.",
199
+ "oneOf": [
200
+ {
201
+ "type": "integer",
202
+ "minimum": 0
203
+ },
204
+ {
205
+ "const": "BEL"
206
+ }
207
+ ]
208
+ },
209
+ "when": {
210
+ "type": "object",
211
+ "description": "When the terminal is believed to understand it. Every clause must hold; `termProgram` and `envAny` are ORs within themselves. Guesses — no terminal answers 'do you do OSC 1337' — and therefore data a caller can replace.",
212
+ "additionalProperties": false,
213
+ "properties": {
214
+ "tty": {
215
+ "type": "boolean",
216
+ "description": "Refuse a pipe. Almost always true: a file that receives OSC gets control bytes in it."
217
+ },
218
+ "termProgram": {
219
+ "type": "array",
220
+ "items": {
221
+ "type": "string"
222
+ },
223
+ "description": "Any one of these TERM_PROGRAM values."
224
+ },
225
+ "envAny": {
226
+ "type": "array",
227
+ "items": {
228
+ "type": "string"
229
+ },
230
+ "description": "Any one of these environment variables merely being set, as VTE announces itself."
231
+ },
232
+ "term": {
233
+ "type": "string",
234
+ "description": "An exact TERM — Kitty is xterm-kitty."
235
+ }
236
+ }
237
+ },
238
+ "encode": {
239
+ "type": "string",
240
+ "minLength": 1,
241
+ "description": "The bytes, as a template, for a terminal that does understand."
242
+ },
243
+ "fallback": {
244
+ "type": "string",
245
+ "description": "What to print when it does not — PRINCIPLES rule 6. '' is a legitimate answer, a window title having nothing to say in a log; absence is not, which is why this is required and may be ''."
246
+ }
247
+ },
248
+ "examples": [
249
+ {
250
+ "name": "kitty-image",
251
+ "osc": "BEL",
252
+ "when": {
253
+ "tty": true,
254
+ "term": "xterm-kitty"
255
+ },
256
+ "encode": "\u001b_Ga=T,f=100;{base64}\u001b\\",
257
+ "fallback": "{caption}"
258
+ }
259
+ ]
260
+ },
261
+ "capabilities": {
262
+ "type": "object",
263
+ "description": "paratext capabilities by name — the OSC section of a plugin, read the way flagstaff reads `spinners`.",
264
+ "additionalProperties": {
265
+ "$ref": "#/$defs/capability"
266
+ }
267
+ },
268
+ "capabilityDocument": {
269
+ "description": "What paratext's `check()` accepts. Two shapes, and only one of them survives 1.0.",
270
+ "oneOf": [
271
+ {
272
+ "description": "The family shape: a plugin carrying its capabilities under `capabilities`.",
273
+ "type": "object",
274
+ "required": [
275
+ "name",
276
+ "capabilities"
277
+ ],
278
+ "properties": {
279
+ "name": {
280
+ "type": "string",
281
+ "minLength": 1
282
+ },
283
+ "contract": {
284
+ "type": "integer",
285
+ "minimum": 1
286
+ },
287
+ "capabilities": {
288
+ "$ref": "#/$defs/capabilities"
289
+ }
290
+ }
291
+ },
292
+ {
293
+ "deprecated": true,
294
+ "description": "Deprecated: one capability as the whole document, the shape paratext's schema had before this one absorbed it. Accepted for one minor release, removed at 1.0 — `check()` validates it and says so.",
295
+ "$ref": "#/$defs/capability"
296
+ }
297
+ ]
298
+ }
299
+ }
300
+ }
package/dist/spec.d.ts CHANGED
@@ -9,8 +9,29 @@
9
9
  *
10
10
  * Nothing here imports a runtime, a stream or a terminal. This file is data.
11
11
  */
12
- /** The six kinds a prompt can be. Anything more is a wizard, which is out of scope. */
13
- export type PromptKind = 'text' | 'confirm' | 'select' | 'multiselect' | 'password' | 'path';
12
+ /**
13
+ * The six kinds a prompt can be — plus whatever a plugin adds.
14
+ *
15
+ * **Why `(string & {})` rather than a closed union** (`plugin-contract` R5, D5). A closed
16
+ * union makes a plugin's seventh kind a type error, so hosting `widgets` at all would be a
17
+ * breaking change written as an additive one: every caller of `caique/plugin` would need
18
+ * this file edited before it could name its own kind. The intersection keeps all six
19
+ * literals in an editor's completion list — which a bare `string` would throw away — while
20
+ * admitting the kinds `caique/plugin` renders.
21
+ *
22
+ * Anything more than a *kind* is still a wizard, which is out of scope. Widening the type
23
+ * does not widen the model: a prompt is one question with one answer, whoever draws it.
24
+ */
25
+ export type PromptKind = 'text' | 'confirm' | 'select' | 'multiselect' | 'password' | 'path' | (string & {});
26
+ /**
27
+ * The six caique draws itself, as data rather than as a `switch` nobody can read back.
28
+ *
29
+ * `caique/plugin` needs this set to answer two questions a plugin makes askable for the
30
+ * first time: whether a kind is already spoken for, and which kinds a refusal should name.
31
+ * Deriving it from the widget dispatch in `ask.ts` would mean two lists that agree only by
32
+ * inspection, which is the drift the family's locks exist to prevent.
33
+ */
34
+ export declare const BUILT_IN_KINDS: ReadonlySet<string>;
14
35
  /** One choice in a `select` or `multiselect`. `value` is what the option receives. */
15
36
  export interface Choice {
16
37
  value: string;
package/dist/spec.js CHANGED
@@ -9,6 +9,15 @@
9
9
  *
10
10
  * Nothing here imports a runtime, a stream or a terminal. This file is data.
11
11
  */
12
+ /**
13
+ * The six caique draws itself, as data rather than as a `switch` nobody can read back.
14
+ *
15
+ * `caique/plugin` needs this set to answer two questions a plugin makes askable for the
16
+ * first time: whether a kind is already spoken for, and which kinds a refusal should name.
17
+ * Deriving it from the widget dispatch in `ask.ts` would mean two lists that agree only by
18
+ * inspection, which is the drift the family's locks exist to prevent.
19
+ */
20
+ export const BUILT_IN_KINDS = new Set(['text', 'confirm', 'select', 'multiselect', 'password', 'path']);
12
21
  /** `--output-dir`, which is what a refusal has to say to be actionable. */
13
22
  export function flagOf(option) {
14
23
  return `--${option}`;
@@ -1,5 +1,6 @@
1
1
  import { type Io } from './ask.js';
2
- /** The stream pair a terminal Io is built over: `process.stdin` and `process.stdout`. */
2
+ import { type Runtime } from './runtime.js';
3
+ /** The stream pair a terminal Io is built over: a `Runtime`'s `stdin` and its `stdout`. */
3
4
  export interface Streams {
4
5
  input: NodeJS.ReadableStream & {
5
6
  isTTY?: boolean;
@@ -8,13 +9,18 @@ export interface Streams {
8
9
  isTTY?: boolean;
9
10
  };
10
11
  }
12
+ /** A runtime's two streams as the pair `createIo` takes — the mapping, written down once. */
13
+ export declare const streamsOf: (runtime: Runtime) => Streams;
11
14
  /**
12
15
  * A reader and writer over a real stream pair.
13
16
  *
14
17
  * The reader resolves `undefined` when the stream ends, which `ask()` reads as a
15
18
  * cancellation — `Ctrl-D` and a closed pipe both arrive that way, and both mean nobody is
16
19
  * going to type.
20
+ *
21
+ * With no argument it builds over the real process, read when it is called and not at
22
+ * import: a program that wants the terminal it was started in writes `createIo()`.
17
23
  */
18
- export declare function createIo(streams: Streams): Io & {
24
+ export declare function createIo(streams?: Streams): Io & {
19
25
  close: () => void;
20
26
  };
package/dist/terminal.js CHANGED
@@ -13,6 +13,9 @@
13
13
  */
14
14
  import { createInterface } from 'node:readline';
15
15
  import {} from './ask.js';
16
+ import { processRuntime } from './runtime.js';
17
+ /** A runtime's two streams as the pair `createIo` takes — the mapping, written down once. */
18
+ export const streamsOf = (runtime) => ({ input: runtime.stdin, output: runtime.stdout });
16
19
  /** Accepts a chunk and writes nothing: what a muted stream's `write` does. */
17
20
  const swallow = () => true;
18
21
  /**
@@ -36,8 +39,11 @@ function mute(rl, streams) {
36
39
  * The reader resolves `undefined` when the stream ends, which `ask()` reads as a
37
40
  * cancellation — `Ctrl-D` and a closed pipe both arrive that way, and both mean nobody is
38
41
  * going to type.
42
+ *
43
+ * With no argument it builds over the real process, read when it is called and not at
44
+ * import: a program that wants the terminal it was started in writes `createIo()`.
39
45
  */
40
- export function createIo(streams) {
46
+ export function createIo(streams = streamsOf(processRuntime())) {
41
47
  const rl = createInterface({ input: streams.input, output: streams.output, terminal: streams.output.isTTY === true });
42
48
  let ended = false;
43
49
  rl.once('close', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caique",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The parrot that always answers back, and the boat that goes between ship and shore. Prompts that are flags first, so agents answer before they are asked and non-TTY callers get an error naming the flag, never a hang. Drop-in path for inquirer and clack.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,11 +28,17 @@
28
28
  "import": "./dist/decide.js",
29
29
  "default": "./dist/decide.js"
30
30
  },
31
+ "./plugin": {
32
+ "types": "./dist/plugin.d.ts",
33
+ "import": "./dist/plugin.js",
34
+ "default": "./dist/plugin.js"
35
+ },
31
36
  "./raw": {
32
37
  "types": "./dist/raw.d.ts",
33
38
  "import": "./dist/raw.js",
34
39
  "default": "./dist/raw.js"
35
40
  },
41
+ "./schema.json": "./dist/schema.json",
36
42
  "./spec": {
37
43
  "types": "./dist/spec.d.ts",
38
44
  "import": "./dist/spec.js",
@@ -50,7 +56,7 @@
50
56
  "!dist/**/*.test.*"
51
57
  ],
52
58
  "scripts": {
53
- "build": "tsc -p tsconfig.build.json",
59
+ "build": "tsc -p tsconfig.build.json && node ../../scripts/schema-to-dist.mjs",
54
60
  "typecheck": "tsc -p tsconfig.json --noEmit",
55
61
  "test": "vitest run --passWithNoTests",
56
62
  "coverage": "vitest run --coverage.enabled",
@@ -76,6 +82,9 @@
76
82
  "agent",
77
83
  "non-tty"
78
84
  ],
85
+ "dependencies": {
86
+ "closeout": "^0.2.0"
87
+ },
79
88
  "devDependencies": {
80
89
  "vitest": "^5.0.0"
81
90
  }