@fungi.computer/whistle 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,374 @@
1
- # Temporary Holding Version
1
+ # Whistle (`@fungi.computer/whistle`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Whistle is a typed command graph for apps that expose the same actions in many
4
+ places. You write one command definition. Whistle derives six immutable views
5
+ from it: key bindings, command palette, slash commands, help, menu, and an agent
6
+ view for AI tools. Every caller, from a key press to an agent, runs the command
7
+ through one typed `execute` door and gets one typed outcome back.
8
+
9
+ Use it when a keyboard shortcut, a palette entry, a `/slash` command and an
10
+ agent tool should all mean the same action, show the same availability, and fail
11
+ the same way. Whistle owns the command graph. Your host keeps key matching,
12
+ rendering, transport, authorization, and the real work.
13
+
14
+ ## Install or set up
15
+
16
+ ```sh
17
+ npm install @fungi.computer/whistle
18
+ ```
19
+
20
+ Whistle is an ESM package with Zod as its only runtime dependency. It runs in
21
+ Node, browsers, and Workers. It has five entrypoints:
22
+
23
+ - `@fungi.computer/whistle`: the runtime, `scopeSnapshot`, the keymap adapter,
24
+ and the command types.
25
+ - `@fungi.computer/whistle/wire`: Zod schemas and parsers for command
26
+ definitions, prompts, notices, and prompt answers.
27
+ - `@fungi.computer/whistle/interaction`: a renderer-neutral interaction store.
28
+ - `@fungi.computer/whistle/catalog`: parses a backend's `whistle.catalog/v1`
29
+ command catalog and turns it into commands.
30
+
31
+ - `@fungi.computer/whistle/html`: binds command projections to a DOM keymap.
32
+ HTML users install the optional peer `@opentui/keymap@0.5.10`; core users do
33
+ not need it.
34
+
35
+ ## Quick start
36
+
37
+ This creates a runtime, contributes one command to the palette and slash
38
+ surfaces, reads the snapshot, and runs the command:
39
+
40
+ ```ts
41
+ import { createWhistle } from "@fungi.computer/whistle";
42
+
43
+ const whistle = createWhistle();
44
+
45
+ const lease = whistle.contribute({
46
+ sourceId: "editor",
47
+ namespace: "editor",
48
+ commands: [
49
+ {
50
+ id: "save",
51
+ title: "Save file",
52
+ category: "File",
53
+ projections: {
54
+ palette: {},
55
+ slash: { aliases: ["save"] },
56
+ bindings: { values: ["mod+s"] },
57
+ },
58
+ handler: () => ({ saved: true }),
59
+ },
60
+ ],
61
+ });
62
+
63
+ const snapshot = whistle.snapshot();
64
+ console.log(snapshot.palette.map((row) => row.commandId)); // ["editor:save"]
65
+ console.log(snapshot.slash.map((row) => row.alias)); // ["save"]
66
+
67
+ const outcome = await whistle.execute("editor:save", { origin: "palette" });
68
+ if (outcome.status === "handled") console.log(outcome.result); // { saved: true }
69
+
70
+ lease.dispose();
71
+ whistle.close();
72
+ ```
73
+
74
+ A command's canonical ID is `<namespace>:<id>`. Every projection row carries
75
+ that ID, so a palette click, a key press and a slash command all call the same
76
+ `execute`.
77
+
78
+ ### Bind a browser keyboard
79
+
80
+ ```sh
81
+ npm install @opentui/keymap@0.5.10
82
+ ```
83
+
84
+ ```ts
85
+ import { createWhistle } from "@fungi.computer/whistle";
86
+ import { bindHtmlKeymap } from "@fungi.computer/whistle/html";
87
+
88
+ const whistle = createWhistle();
89
+ whistle.contribute({
90
+ sourceId: "browser",
91
+ namespace: "browser",
92
+ commands: [
93
+ {
94
+ id: "save",
95
+ title: "Save",
96
+ category: "File",
97
+ projections: { bindings: { values: ["mod+s"] } },
98
+ handler: () => ({ saved: true }),
99
+ },
100
+ ],
101
+ });
102
+ const keyboard = bindHtmlKeymap(document.body, whistle, "keyboard");
103
+ // Contributions and availability updates refresh the projected key layer.
104
+ keyboard.dispose();
105
+ whistle.close();
106
+ ```
107
+
108
+ `dispose` removes the Whistle layer and subscription, and can be called twice.
109
+ The underlying HTML keymap host follows the document's lifetime; this helper
110
+ does not remove its DOM listeners. Commands still execute through Whistle.
111
+
112
+ ## Core concepts
113
+
114
+ ### Commands, contributions and leases
115
+
116
+ A command has an `id`, a `title`, a `category`, an optional `description`, a
117
+ `handler`, and optional `projections`, `action` and `availability`. A command
118
+ shows up only on the surfaces its `projections` name.
119
+
120
+ `contribute` admits a batch of commands from one `sourceId` into one
121
+ `namespace`, optionally inside a focus `scope` such as one open window.
122
+ Admission is atomic: the whole contribution is validated before any of it goes
123
+ live. It throws when a namespace already belongs to another source, when a
124
+ canonical ID or a slash alias in the same scope repeats, or when a field is
125
+ blank.
126
+
127
+ Each contribution returns a lease for that exact generation. `lease.update`
128
+ swaps its commands atomically. `lease.dispose` removes it. Disposing a stale
129
+ lease never removes a replacement. Both calls are idempotent.
130
+
131
+ ### Snapshots and projections
132
+
133
+ `snapshot()` returns six frozen arrays: `bindings`, `palette`, `slash`, `help`,
134
+ `menu` and `agent`. Rows are immutable copies. Each surface sorts by its
135
+ explicit `order`, then by command ID in UTF-8 byte order, so the order never
136
+ depends on insertion. A snapshot keeps its identity until the graph or some
137
+ availability changes. `subscribe(listener)` tells you when the graph changes.
138
+
139
+ The agent view drops `presentation` metadata and keeps the semantic
140
+ `inputSchema` and `outputSchema`, so an agent gets a tool description and never
141
+ a UI layout. `scopeSnapshot(snapshot, scope)` gives the human and agent view for
142
+ one focused scope: global rows plus that scope's rows, with a focused slash
143
+ alias or binding shadowing the same global one. Agent rows carry their scope and
144
+ follow the same filter. The complete runtime snapshot still lists all scopes.
145
+
146
+ ### Execution
147
+
148
+ `execute(commandId, input)` is the only way a handler runs. `input.origin` is
149
+ one of eight closed origins: `agent`, `browser`, `keyboard`, `menu`, `palette`,
150
+ `programmatic`, `slash` and `terminal`. The outcome is one of:
151
+
152
+ - `handled`: the handler finished. Its return value is in `result`. This does
153
+ not mean physical work or a durable workflow completed.
154
+ - `unavailable`: the command's `availability()` said no. The reason comes back.
155
+ - `failed`: the handler threw. `error` is JSON: `{ code, message, details? }`.
156
+ - `missing`: no live command has that ID.
157
+ - `invalid`: the command ID is blank.
158
+ - `closed`: the runtime was closed first.
159
+
160
+ ### Availability
161
+
162
+ A command can supply a synchronous `availability()` reader over current host
163
+ facts. `snapshot()` keeps unavailable commands visible with their reason, and
164
+ `execute()` reads availability again right before calling the handler.
165
+ Availability is a convenience check. It is not authorization, so the handler
166
+ still admits the real request. The host updates its own facts and publishes
167
+ snapshots over its own connection. Whistle does no polling or broadcast.
168
+
169
+ ### Failures
170
+
171
+ Throw `WhistleActionError(code, message, details?)` to choose the failure code
172
+ and attach JSON details. Any other `Error` becomes `command_failed`. Stacks and
173
+ non-Error thrown values never cross the execution result.
174
+
175
+ ### Interactions
176
+
177
+ A running command can talk to its human caller through `input.interaction`.
178
+ `prompt` asks one `choice`, `text` (optionally `secret`) or `confirmation`
179
+ question. `notify` shows `info`, `progress`, `handoff` (open a URL, optionally
180
+ enter a code) or `success`. The vocabulary names the kind of answer, never a
181
+ widget.
182
+
183
+ A caller can pass its own interaction. Otherwise `present(presenter)` registers
184
+ the mounted client that opens one for each human-origin execution. Agent and
185
+ programmatic executions get none. Whistle settles the session with `handled`,
186
+ `cancelled` or `failed` when the handler ends, and the presenter can `retry` the
187
+ same execution. The newest presenter wins until its disposer runs.
188
+
189
+ `createWhistleInteractionHost` from `./interaction` is a renderer-neutral store
190
+ for one interaction. It parses every prompt, notice and answer with the `./wire`
191
+ schemas, and a terminal or browser renderer draws its snapshot.
192
+
193
+ ### Slash arguments
194
+
195
+ A slash projection can carry `arguments`, a data-only grammar for the text after
196
+ the alias. Fixed `words` map to complete argument objects. `text` puts the rest
197
+ of the line into one field of a fixed argument object, optionally with a request
198
+ ID field for idempotent retries. Clients parse, complete and hint from this
199
+ data, so no client names a command in code.
200
+
201
+ ### Catalogs
202
+
203
+ A backend advertises its commands as a `whistle.catalog/v1` catalog of the same
204
+ data-only definitions. `parseCatalog` validates it at the client boundary.
205
+ `catalogCommands(catalog, invoke)` turns it into commands whose handlers call
206
+ your `invoke` function. Each reply is a step: `done` (optional notice and
207
+ result) or `prompt` (one question plus the complete arguments of the next call,
208
+ with the answer going into a named field). Multi-step flows stay stateless on
209
+ the wire and run through the caller's interaction.
210
+
211
+ ### Closing
212
+
213
+ `close()` is synchronous and idempotent and returns `undefined`. The first call
214
+ releases every live contribution and leaves the shared empty snapshot. Leases
215
+ issued earlier become harmless. An `execute` Promise returned before `close`
216
+ still settles, and may settle `handled`. A later `execute` returns `closed`, and
217
+ a later valid `contribute` throws `WhistleClosedError`.
218
+
219
+ ### Typing a reusable handler
220
+
221
+ Use indexed access on `WhistleCommand` to declare a handler apart from a command
222
+ literal:
223
+
224
+ ```ts
225
+ import type { WhistleCommand } from "@fungi.computer/whistle";
226
+
227
+ type SaveArguments = Readonly<{ path: string }>;
228
+ type SaveResult = Readonly<{ saved: string }>;
229
+
230
+ const save: WhistleCommand<SaveArguments, SaveResult>["handler"] = ({
231
+ arguments: input,
232
+ }) => (input === undefined ? undefined : { saved: input.path });
233
+ ```
234
+
235
+ The handler receives `WhistleExecutionInput<SaveArguments>`: the origin,
236
+ optional arguments, a cancellation signal and an optional interaction. It
237
+ returns `SaveResult`, `undefined`, or a Promise of either.
238
+ `WhistleCommandHandler` names this callback type. Commands with different
239
+ argument and result types can share one `readonly WhistleCommand<never>[]`
240
+ contribution. Contributions erase those generics at runtime, so the handler
241
+ still validates the arguments it actually gets.
242
+
243
+ ### Presentation hints
244
+
245
+ `action.inputSchema` and `action.outputSchema` describe structured data and keep
246
+ opaque JSON annotations. `action.presentation` is an optional local hint:
247
+ `action`, `choice`, `form`, `confirmation`, or `custom` with opaque JSON for an
248
+ owning client's own field or gesture bindings. A server capability must not
249
+ prescribe its form, menu or other rendering. Human projections keep the hint.
250
+ The agent projection drops it.
251
+
252
+ ## API reference
253
+
254
+ See the generated [API reference](docs/api/README.md). It covers the root
255
+ entrypoint and the `wire`, `interaction` and `catalog` subpaths.
256
+
257
+ ## Examples
258
+
259
+ ### Show a command as unavailable
260
+
261
+ ```ts
262
+ import { createWhistle } from "@fungi.computer/whistle";
263
+
264
+ let signedIn = false;
265
+ const whistle = createWhistle();
266
+ whistle.contribute({
267
+ sourceId: "account",
268
+ namespace: "account",
269
+ commands: [
270
+ {
271
+ id: "sync",
272
+ title: "Sync now",
273
+ category: "Account",
274
+ projections: { palette: {}, agent: {} },
275
+ availability: () =>
276
+ signedIn
277
+ ? { status: "available" }
278
+ : { status: "unavailable", reason: "Sign in first." },
279
+ handler: () => undefined,
280
+ },
281
+ ],
282
+ });
283
+
284
+ console.log(whistle.snapshot().palette[0]?.availability);
285
+ // { status: "unavailable", reason: "Sign in first." }
286
+ console.log(await whistle.execute("account:sync", { origin: "agent" }));
287
+ // { status: "unavailable", reason: "Sign in first." }
288
+ signedIn = true;
289
+ ```
290
+
291
+ ### Fail with a code
292
+
293
+ ```ts
294
+ import { WhistleActionError, createWhistle } from "@fungi.computer/whistle";
295
+
296
+ const whistle = createWhistle();
297
+ whistle.contribute({
298
+ sourceId: "files",
299
+ namespace: "files",
300
+ commands: [
301
+ {
302
+ id: "open",
303
+ title: "Open file",
304
+ category: "File",
305
+ handler: () => {
306
+ throw new WhistleActionError("not_found", "No such file", {
307
+ path: "a.txt",
308
+ });
309
+ },
310
+ },
311
+ ],
312
+ });
313
+
314
+ const outcome = await whistle.execute("files:open", { origin: "terminal" });
315
+ // { status: "failed", error: { code: "not_found", message: "No such file", details: { path: "a.txt" } } }
316
+ ```
317
+
318
+ ### Load a backend catalog
319
+
320
+ ```ts
321
+ import { createWhistle } from "@fungi.computer/whistle";
322
+ import { catalogCommands, parseCatalog } from "@fungi.computer/whistle/catalog";
323
+
324
+ const catalog = parseCatalog(await (await fetch("/commands")).json());
325
+ const whistle = createWhistle();
326
+ whistle.contribute({
327
+ sourceId: "backend",
328
+ namespace: "backend",
329
+ commands: catalogCommands(catalog, async (commandId, args, signal) => {
330
+ const response = await fetch(`/commands/${commandId}`, {
331
+ method: "POST",
332
+ body: JSON.stringify(args),
333
+ signal,
334
+ });
335
+ return response.json();
336
+ }),
337
+ });
338
+ ```
339
+
340
+ ### Checks
341
+
342
+ From a checkout, the unit tests cover the runtime, wire schemas, interaction
343
+ host and catalog:
344
+
345
+ ```sh
346
+ pnpm --filter @fungi.computer/whistle test
347
+ ```
348
+
349
+ ## Limits and errors
350
+
351
+ - A runtime holds at most 64 live contributions and 256 live commands.
352
+ - A command has at most 16 binding values and 16 slash aliases. A slash grammar
353
+ has at most 32 words.
354
+ - A catalog has at most 128 commands, and a catalog flow stops after 16 steps
355
+ with `flow_too_long`. A catalog command that needs a prompt fails with
356
+ `interaction_required` when there is no interaction, which is always the case
357
+ for agent and programmatic origins.
358
+ - Prompt and notice text is capped at 8192 characters. A choice prompt has 1 to
359
+ 256 options. Notice URLs must be `http` or `https`.
360
+ - Namespaces must not contain a colon, and command IDs, titles, categories and
361
+ labels must not be blank. Violations throw a plain `Error` from `contribute`
362
+ or `lease.update`.
363
+ - These bounds are defenses inside the implementation. They are not stable API
364
+ promises.
365
+
366
+ Whistle does not parse key presses or slash input from raw text, render UI, run
367
+ transport, or keep durable delivery state. It owns no browser, terminal, DOM,
368
+ React, product or authorization state, and has no timers or background work. Use
369
+ the `./wire` parsers on untrusted input before it becomes a command, prompt or
370
+ notice.
371
+
372
+ ## Contribute and license
373
+
374
+ MIT. See [LICENSE](./LICENSE).