@blastlauncher/cli 1.0.5 → 3.0.0-alpha.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/dist/cli.js ADDED
@@ -0,0 +1,855 @@
1
+ #!/usr/bin/env node
2
+ // The `blast` debug CLI: bundles and runs one extension command headlessly and
3
+ // prints every published scene — as a readable text tree, or as protocol-shaped
4
+ // NDJSON with `--json`. A `--script` drives the command (action / search /
5
+ // dropdown / back / confirm / dismiss / wait / expect / quit), which is how e2e tests interact
6
+ // with extensions. See "Debugging extensions" in docs/architecture.md.
7
+ import { parseBrowserTabs } from "./browser.js";
8
+ import { rmSync } from "node:fs";
9
+ import { readFile, rm } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { bundleCommand, MissingDependencyError, checkExtensionDependencies, DependenciesRequiredError, installExtensionDependencies, createSessionBroker, defaultDataDir, bundleModelProvider, startModelProvider, startCommand, } from "@blastlauncher/engine";
13
+ import { createHeadlessHandlers, parseApplications } from "./headless.js";
14
+ const EXIT_OK = 0;
15
+ const EXIT_FAILURE = 1;
16
+ const EXIT_USAGE = 2;
17
+ const EXIT_CRASH = 3;
18
+ const USAGE = `Run a Raycast extension command headlessly.
19
+
20
+ Usage: blast run <extensionDir> <command> [options]
21
+ blast deps <extensionDir> [--data-dir <dir>]
22
+ blast models <extensionDir>
23
+ blast complete <extensionDir> <modelId> <prompt>
24
+
25
+ Options:
26
+ --argument <name=value>
27
+ Set a manifest argument for this run (repeatable). An
28
+ argument the manifest marks "required" must be given,
29
+ and an unknown name is a usage error.
30
+ --fallback-text <text> Search text for a fallback command.
31
+ --grant <capability> Grant a capability to the command (repeatable). A
32
+ granted "clipboard.copy" records and prints the copied
33
+ text instead of touching an OS clipboard. LocalStorage
34
+ is always granted to the command's own extension; use
35
+ --data-dir to place its data.
36
+ --applications <file.json>
37
+ Answer getApplications with the JSON array of
38
+ applications in <file>; the file must hold objects with
39
+ "name" and "path". Defaults to an empty list.
40
+ --ai-decide <file.json> Supply AI.experimental_decide answers; denied otherwise.
41
+ --ai-answer <text> Answer AI.ask with this text; denied otherwise.
42
+ --browser-tabs <file.json>
43
+ Supply tabs (id, url, active; optional title, favicon, content).
44
+ --oauth-code <code> Answer OAuth authorization with this code; denied otherwise.
45
+ --clipboard <text> Answer Clipboard.read/readText with <text> at offset 0
46
+ and an empty text at older offsets. Defaults to an
47
+ empty text. No real clipboard is touched.
48
+ --selected-text <text> Answer getSelectedText; rejects when omitted or empty.
49
+ --selected-files <path> Answer getSelectedFinderItems (repeatable; default: []).
50
+ --frontmost <bundleId> Answer getFrontmostApplication from --applications,
51
+ or with that identifier and an empty path (default: Headless).
52
+ --data-dir <dir> Root directory for storage and dependencies (default:
53
+ "$XDG_DATA_HOME/blast"); the command's values are kept
54
+ in <dir>/storage/<extension>.json.
55
+ --script <file|-> Drive the command with a script of the commands below;
56
+ "-" reads the script from stdin. Without a script, the
57
+ first scene is printed and the run ends.
58
+ --json Emit protocol-shaped NDJSON instead of text trees.
59
+ --bundle-timeout <ms> Bundling limit (default: 60000; 0 disables).
60
+ --timeout <ms> Abort the run after <ms> milliseconds (exit code 3).
61
+ --help Show this help.
62
+
63
+ Capabilities a headless run always answers: applications.list
64
+ (--applications), clipboard.read (--clipboard), preferences.open and
65
+ selection.text, selection.files, applications.frontmost and command.metadata
66
+ (printed like any other capability). clipboard.paste and clipboard.clear still need
67
+ --grant; finder.show and trash also require --grant.
68
+
69
+ Script commands (one per line, "#" starts a comment):
70
+ action <title> Dispatch an Action or menu item with that title.
71
+ submenu <title>
72
+ Dispatch the onOpen handler of the ActionPanel.Submenu with
73
+ that title.
74
+ back Pop the current navigation view.
75
+ confirm Confirm the current alert.
76
+ dismiss Dismiss the current alert.
77
+ search <text> Dispatch the List or Grid onSearchTextChange handler.
78
+ more Load the next List/Grid page when hasMore is true.
79
+ dropdown <value>
80
+ Dispatch the List.Dropdown or Grid.Dropdown onChange handler
81
+ with the value.
82
+ wait Wait until the next scene is published.
83
+ expect <text> Fail (exit 1) unless the latest scene tree contains the text.
84
+ field <id> <value> Edit a Form field (checkbox values: true/false; dates: ISO;
85
+ file pickers and tag pickers: comma-separated values; omit
86
+ value to clear).
87
+ submit Run the first Action.SubmitForm.
88
+ quit Shut down and finish with exit code 0.
89
+
90
+ Exit codes: 0 success, 1 script or expectation failure, 2 usage error,
91
+ 3 extension crash or protocol error.`;
92
+ // Error kinds mapped to exit codes at the top level: usage errors print the
93
+ // usage text, script failures and crashes print an `# error` line on stderr.
94
+ class UsageError extends Error {
95
+ }
96
+ class ScriptError extends Error {
97
+ }
98
+ class RunError extends Error {
99
+ }
100
+ /** The runtime exited cleanly without producing the scene a step waited for. */
101
+ class CleanExit extends Error {
102
+ }
103
+ function handlerRef(value) {
104
+ return typeof value === "object" && value !== null && "$handler" in value
105
+ ? value
106
+ : undefined;
107
+ }
108
+ // One line per node, two-space indented: `Type "title" key=value… [onAction]`.
109
+ // Values are JSON-encoded and props keep their insertion order, so the text is
110
+ // stable enough to snapshot.
111
+ function renderTree(root) {
112
+ const lines = [];
113
+ const walk = (node, depth) => {
114
+ lines.push(`${" ".repeat(depth)}${describe(node)}`);
115
+ for (const child of node.children)
116
+ walk(child, depth + 1);
117
+ };
118
+ walk(root, 0);
119
+ return lines;
120
+ }
121
+ function describe(node) {
122
+ const entries = Object.entries(node.props);
123
+ const title = entries.find(([key, value]) => key === "title" && typeof value === "string");
124
+ const parts = [node.type];
125
+ if (title)
126
+ parts.push(JSON.stringify(title[1]));
127
+ for (const [key, value] of entries) {
128
+ if (title && key === "title")
129
+ continue;
130
+ parts.push(handlerRef(value) ? `[${key}]` : `${key}=${JSON.stringify(value)}`);
131
+ }
132
+ return parts.join(" ");
133
+ }
134
+ // Scenes print as they are published, with the logs that arrived since the
135
+ // previous scene flushed first; in --json mode the same messages are emitted in
136
+ // their protocol shape instead.
137
+ const write = (line) => {
138
+ console.log(line);
139
+ };
140
+ function createOutput(json) {
141
+ let logCursor = 0;
142
+ return {
143
+ flushLogs(session) {
144
+ for (; logCursor < session.logs.length; logCursor++) {
145
+ const log = session.logs[logCursor];
146
+ if (log === undefined)
147
+ break;
148
+ write(json ? JSON.stringify(log) : `# log ${log.message}`);
149
+ }
150
+ },
151
+ scene(scene) {
152
+ if (json)
153
+ return write(JSON.stringify(scene));
154
+ if (scene.root)
155
+ for (const line of renderTree(scene.root))
156
+ write(line);
157
+ },
158
+ capability(request, result) {
159
+ if (request.name.startsWith("oauth.tokens.")) {
160
+ const redacted = { ...request, params: {} };
161
+ return write(json ? JSON.stringify(redacted) : `# capability ${request.name} [redacted]`);
162
+ }
163
+ if (json)
164
+ return write(JSON.stringify(request));
165
+ if (result.ok) {
166
+ return write(`# capability ${request.name} ${JSON.stringify(request.params)}`);
167
+ }
168
+ return write(`# capability ${request.name} ${result.error.code}`);
169
+ },
170
+ };
171
+ }
172
+ function reportingBroker(inner, output) {
173
+ return async (request, context) => {
174
+ const result = await inner(request, context);
175
+ output.capability(request, result);
176
+ return result;
177
+ };
178
+ }
179
+ // Every granted capability records its request (printed by the broker) and
180
+ // succeeds without touching privileged host state.
181
+ function grantHandlers(grants) {
182
+ const handlers = {};
183
+ for (const name of grants) {
184
+ if (!["oauth.authorize", "ai.ask", "ai.decide", "browser.tabs", "browser.content"].includes(name))
185
+ handlers[name] = () => null;
186
+ }
187
+ return handlers;
188
+ }
189
+ function parseScript(source) {
190
+ const steps = [];
191
+ for (const [index, raw] of source.split(/\r?\n/).entries()) {
192
+ const line = raw.trim();
193
+ if (line === "" || line.startsWith("#"))
194
+ continue;
195
+ const separator = line.indexOf(" ");
196
+ const word = (separator === -1 ? line : line.slice(0, separator)).toLowerCase();
197
+ const rest = separator === -1 ? "" : line.slice(separator + 1).trim();
198
+ switch (word) {
199
+ case "action":
200
+ if (rest === "")
201
+ throw new ScriptError(`script line ${index + 1}: action needs a title`);
202
+ steps.push({ op: "action", title: rest });
203
+ break;
204
+ case "submenu":
205
+ if (rest === "")
206
+ throw new ScriptError(`script line ${index + 1}: submenu needs a title`);
207
+ steps.push({ op: "submenu", title: rest });
208
+ break;
209
+ case "search":
210
+ if (rest === "")
211
+ throw new ScriptError(`script line ${index + 1}: search needs text`);
212
+ steps.push({ op: "search", text: rest });
213
+ break;
214
+ case "confirm":
215
+ case "dismiss":
216
+ steps.push({ op: word });
217
+ break;
218
+ case "dropdown":
219
+ if (rest === "")
220
+ throw new ScriptError(`script line ${index + 1}: dropdown needs a value`);
221
+ steps.push({ op: "dropdown", value: rest });
222
+ break;
223
+ case "field": {
224
+ const split = rest.indexOf(" ");
225
+ if (rest === "")
226
+ throw new ScriptError(`script line ${index + 1}: field needs an id and value`);
227
+ steps.push({
228
+ op: "field",
229
+ id: split === -1 ? rest : rest.slice(0, split),
230
+ value: split === -1 ? "" : rest.slice(split + 1),
231
+ });
232
+ break;
233
+ }
234
+ case "more":
235
+ case "submit":
236
+ steps.push({ op: word });
237
+ break;
238
+ case "back":
239
+ steps.push({ op: "back" });
240
+ break;
241
+ case "wait":
242
+ steps.push({ op: "wait" });
243
+ break;
244
+ case "expect":
245
+ if (rest === "")
246
+ throw new ScriptError(`script line ${index + 1}: expect needs text`);
247
+ steps.push({ op: "expect", text: rest });
248
+ break;
249
+ case "quit":
250
+ steps.push({ op: "quit" });
251
+ break;
252
+ default:
253
+ throw new ScriptError(`script line ${index + 1}: unknown command ${JSON.stringify(word)}`);
254
+ }
255
+ }
256
+ return steps;
257
+ }
258
+ function findNode(root, match) {
259
+ if (match(root))
260
+ return root;
261
+ for (const child of root.children) {
262
+ const found = findNode(child, match);
263
+ if (found)
264
+ return found;
265
+ }
266
+ return undefined;
267
+ }
268
+ function waitForScene(session, after) {
269
+ const latest = session.latestScene;
270
+ if (latest && latest.revision > after)
271
+ return Promise.resolve(latest);
272
+ return new Promise((resolve, reject) => {
273
+ const unsubscribe = session.scenes((scene) => {
274
+ if (scene.revision > after) {
275
+ unsubscribe();
276
+ resolve(scene);
277
+ }
278
+ });
279
+ void session.exited.then((exit) => {
280
+ unsubscribe();
281
+ if (exit.error)
282
+ reject(new RunError(exit.error.message));
283
+ else if (exit.code === 0 && exit.signal === null) {
284
+ reject(new CleanExit("runtime exited without publishing the awaited scene"));
285
+ }
286
+ else
287
+ reject(new RunError(`runtime exited with code ${exit.code ?? exit.signal}`));
288
+ });
289
+ });
290
+ }
291
+ function currentScene(session) {
292
+ return session.latestScene ? Promise.resolve(session.latestScene) : waitForScene(session, -1);
293
+ }
294
+ async function runScript(session, steps) {
295
+ // Highest scene revision this script has synchronized with; `wait` resolves
296
+ // only for scenes newer than the one the previous step acted on.
297
+ let observed = session.latestScene?.revision ?? -1;
298
+ for (const step of steps) {
299
+ if (step.op === "quit")
300
+ return;
301
+ switch (step.op) {
302
+ case "confirm":
303
+ case "dismiss": {
304
+ const scene = await currentScene(session);
305
+ observed = Math.max(observed, scene.revision);
306
+ const alert = scene.root
307
+ ? findNode(scene.root, (node) => node.type === "Alert")
308
+ : undefined;
309
+ const name = step.op === "confirm" ? "primaryAction" : "dismissAction";
310
+ const node = alert?.children.find((child) => child.props.name === name);
311
+ const handler = node ? handlerRef(node.props.onAction) : undefined;
312
+ if (!node || !handler)
313
+ throw new ScriptError("no alert to answer");
314
+ session.sendEvent(node.id, handler.$handler, []);
315
+ break;
316
+ }
317
+ case "back": {
318
+ const scene = await currentScene(session);
319
+ observed = Math.max(observed, scene.revision);
320
+ const node = scene.root?.type === "Navigation" ? scene.root : undefined;
321
+ const handler = node ? handlerRef(node.props.pop) : undefined;
322
+ if (!node || !handler)
323
+ throw new ScriptError("no navigation view to pop");
324
+ session.sendEvent(node.id, handler.$handler, []);
325
+ break;
326
+ }
327
+ case "action": {
328
+ const scene = await currentScene(session);
329
+ observed = Math.max(observed, scene.revision);
330
+ const node = scene.root
331
+ ? findNode(scene.root, (item) => (item.type.startsWith("Action") || item.type === "MenuBarExtra.Item") &&
332
+ item.props.title === step.title)
333
+ : undefined;
334
+ const handler = node ? handlerRef(node.props.onAction) : undefined;
335
+ if (!node || !handler) {
336
+ throw new ScriptError(`no Action with onAction titled ${JSON.stringify(step.title)}`);
337
+ }
338
+ session.sendEvent(node.id, handler.$handler, node.type === "MenuBarExtra.Item" ? [{ type: "left-click" }] : []);
339
+ break;
340
+ }
341
+ case "submenu": {
342
+ const scene = await currentScene(session);
343
+ observed = Math.max(observed, scene.revision);
344
+ const node = scene.root
345
+ ? findNode(scene.root, (item) => item.type === "ActionPanel.Submenu" && item.props.title === step.title)
346
+ : undefined;
347
+ const handler = node ? handlerRef(node.props.onOpen) : undefined;
348
+ if (!node || !handler) {
349
+ throw new ScriptError(`no ActionPanel.Submenu with onOpen titled ${JSON.stringify(step.title)}`);
350
+ }
351
+ session.sendEvent(node.id, handler.$handler, []);
352
+ break;
353
+ }
354
+ case "field":
355
+ case "submit": {
356
+ const scene = await currentScene(session);
357
+ observed = Math.max(observed, scene.revision);
358
+ const node = scene.root
359
+ ? findNode(scene.root, (item) => step.op === "submit"
360
+ ? item.type === "Action.SubmitForm"
361
+ : item.type.startsWith("Form.") && item.props.id === step.id)
362
+ : undefined;
363
+ const handler = node
364
+ ? handlerRef(node.props[step.op === "submit" ? "onAction" : "onChange"])
365
+ : undefined;
366
+ if (!node || !handler)
367
+ throw new ScriptError(step.op === "submit"
368
+ ? "no Action.SubmitForm to submit"
369
+ : `no editable Form field ${JSON.stringify(step.id)}`);
370
+ let args = [];
371
+ if (step.op === "field") {
372
+ if (node.type === "Form.Checkbox" && !["true", "false"].includes(step.value))
373
+ throw new ScriptError("checkbox field value must be true or false");
374
+ const value = node.type === "Form.FilePicker" || node.type === "Form.TagPicker"
375
+ ? step.value === ""
376
+ ? []
377
+ : step.value.split(",")
378
+ : node.type === "Form.Checkbox"
379
+ ? step.value === "true"
380
+ : step.value;
381
+ args = [value];
382
+ }
383
+ session.sendEvent(node.id, handler.$handler, args);
384
+ break;
385
+ }
386
+ case "more": {
387
+ const scene = await currentScene(session);
388
+ observed = Math.max(observed, scene.revision);
389
+ const view = scene.root
390
+ ? findNode(scene.root, (item) => item.type === "List" || item.type === "Grid")
391
+ : undefined;
392
+ const pagination = view?.props.pagination;
393
+ const config = pagination && typeof pagination === "object" && !Array.isArray(pagination)
394
+ ? pagination
395
+ : undefined;
396
+ const handler = handlerRef(config && "onLoadMore" in config ? config.onLoadMore : undefined);
397
+ if (!view || !handler || !config || !("hasMore" in config) || config.hasMore !== true)
398
+ throw new ScriptError("no List or Grid with more pages to load");
399
+ session.sendEvent(view.id, handler.$handler, []);
400
+ break;
401
+ }
402
+ case "search": {
403
+ const scene = await currentScene(session);
404
+ observed = Math.max(observed, scene.revision);
405
+ const view = scene.root
406
+ ? findNode(scene.root, (item) => item.type === "List" || item.type === "Grid")
407
+ : undefined;
408
+ const handler = view ? handlerRef(view.props.onSearchTextChange) : undefined;
409
+ if (!view || !handler)
410
+ throw new ScriptError("no List or Grid with onSearchTextChange to search");
411
+ session.sendEvent(view.id, handler.$handler, [step.text]);
412
+ break;
413
+ }
414
+ case "dropdown": {
415
+ const scene = await currentScene(session);
416
+ observed = Math.max(observed, scene.revision);
417
+ const dropdown = scene.root
418
+ ? findNode(scene.root, (item) => item.type === "List.Dropdown" || item.type === "Grid.Dropdown")
419
+ : undefined;
420
+ const handler = dropdown ? handlerRef(dropdown.props.onChange) : undefined;
421
+ if (!dropdown || !handler)
422
+ throw new ScriptError("no List.Dropdown or Grid.Dropdown with onChange to select");
423
+ session.sendEvent(dropdown.id, handler.$handler, [step.value]);
424
+ break;
425
+ }
426
+ case "wait": {
427
+ observed = (await waitForScene(session, observed)).revision;
428
+ break;
429
+ }
430
+ case "expect": {
431
+ const scene = await currentScene(session);
432
+ const lines = scene.root ? renderTree(scene.root) : [];
433
+ if (!lines.some((line) => line.includes(step.text))) {
434
+ throw new ScriptError(`expected the scene to contain ${JSON.stringify(step.text)}`);
435
+ }
436
+ break;
437
+ }
438
+ }
439
+ }
440
+ }
441
+ /** `--argument name=value` values, checked against the manifest's arguments. */
442
+ function parseArgumentValues(specs, declared, command) {
443
+ const values = {};
444
+ for (const spec of specs) {
445
+ const separator = spec.indexOf("=");
446
+ if (separator < 1) {
447
+ throw new UsageError(`--argument must be name=value, got ${JSON.stringify(spec)}`);
448
+ }
449
+ const name = spec.slice(0, separator);
450
+ if (!declared.some((argument) => argument.name === name)) {
451
+ throw new UsageError(`command ${JSON.stringify(command)} has no argument ${JSON.stringify(name)}`);
452
+ }
453
+ values[name] = spec.slice(separator + 1);
454
+ }
455
+ const missing = declared.filter((argument) => argument.required === true && values[argument.name] === undefined);
456
+ if (missing.length) {
457
+ const names = missing.map((argument) => argument.name).join(", ");
458
+ throw new UsageError(`missing required ${missing.length === 1 ? "argument" : "arguments"} ${names} for ` +
459
+ `${JSON.stringify(command)}: pass --argument <name=value>`);
460
+ }
461
+ return values;
462
+ }
463
+ // Best-effort temp cleanup when the CLI itself is interrupted; the runtime
464
+ // child exits on its own once the parent's stdin pipe closes.
465
+ const SIGNAL_NUMBERS = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
466
+ let cleanupDirectory;
467
+ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
468
+ process.on(signal, () => {
469
+ if (cleanupDirectory !== undefined) {
470
+ rmSync(cleanupDirectory, { force: true, recursive: true });
471
+ }
472
+ process.exit(128 + SIGNAL_NUMBERS[signal]);
473
+ });
474
+ }
475
+ // A reader that stops early (`blast run … | head`) closes stdout: finish
476
+ // quietly instead of crashing on EPIPE.
477
+ process.stdout.on("error", (error) => {
478
+ if (error.code !== "EPIPE")
479
+ throw error;
480
+ if (cleanupDirectory !== undefined)
481
+ rmSync(cleanupDirectory, { force: true, recursive: true });
482
+ process.exit(EXIT_OK);
483
+ });
484
+ async function run(options) {
485
+ const output = createOutput(options.json);
486
+ let deadlineTimer;
487
+ const deadline = new Promise((_, reject) => {
488
+ if (options.timeoutMs !== undefined) {
489
+ deadlineTimer = setTimeout(() => {
490
+ reject(new RunError(`timed out after ${options.timeoutMs}ms`));
491
+ }, options.timeoutMs);
492
+ }
493
+ });
494
+ // A rejection landing between two races must not surface as unhandled.
495
+ deadline.catch(() => undefined);
496
+ let outDir;
497
+ let session;
498
+ let stopScenes;
499
+ try {
500
+ const steps = options.scriptSource === undefined ? undefined : parseScript(options.scriptSource);
501
+ let bundled;
502
+ try {
503
+ const dependencies = await checkExtensionDependencies({
504
+ extensionDir: options.extensionDir,
505
+ dataDir: options.dataDir,
506
+ });
507
+ if (dependencies.unresolved.length && (dependencies.needed || !dependencies.installable))
508
+ throw new DependenciesRequiredError(dependencies);
509
+ try {
510
+ bundled = await Promise.race([
511
+ bundleCommand({
512
+ extensionDir: options.extensionDir,
513
+ command: options.command,
514
+ ...(options.bundleTimeoutMs === undefined
515
+ ? {}
516
+ : { timeoutMs: options.bundleTimeoutMs }),
517
+ dataDir: options.dataDir,
518
+ }),
519
+ deadline,
520
+ ]);
521
+ }
522
+ catch (bundleError) {
523
+ if (!(bundleError instanceof MissingDependencyError))
524
+ throw bundleError;
525
+ const manifest = JSON.parse(await readFile(join(options.extensionDir, "package.json"), "utf8"));
526
+ const status = await checkExtensionDependencies({
527
+ extensionDir: options.extensionDir,
528
+ dataDir: options.dataDir,
529
+ });
530
+ throw new DependenciesRequiredError({
531
+ unresolved: bundleError.packages,
532
+ installable: bundleError.packages.every((name) => Object.hasOwn(manifest.dependencies ?? {}, name)),
533
+ pinned: status.pinned,
534
+ });
535
+ }
536
+ }
537
+ catch (error) {
538
+ if (error instanceof RunError)
539
+ throw error;
540
+ const message = error instanceof Error ? error.message : String(error);
541
+ // A bad extension or command name is the caller's usage error; any other
542
+ // bundling failure is reported like a crash.
543
+ if (/^(Unknown extension command|Invalid command name|Missing source for )/.test(message)) {
544
+ throw new UsageError(message);
545
+ }
546
+ throw new RunError(message);
547
+ }
548
+ for (const message of bundled.dependencyWarnings ?? []) {
549
+ write(options.json ? JSON.stringify({ type: "log", level: "warn", message }) : `# log ${message}`);
550
+ }
551
+ outDir = dirname(bundled.entry);
552
+ cleanupDirectory = outDir;
553
+ const argumentValues = parseArgumentValues(options.argumentSpecs, bundled.arguments, options.command);
554
+ // Local storage is granted to the session's own extension by default and
555
+ // overrides `--grant storage.*`, so a stale grant cannot disable it. The
556
+ // read-only headless defaults override a bare `--grant` the same way:
557
+ // answering them with null would break the extension.
558
+ const broker = createSessionBroker({ extensionName: bundled.extensionName, dataDir: options.dataDir }, {
559
+ handlers: {
560
+ ...grantHandlers(options.grants),
561
+ ...createHeadlessHandlers({
562
+ aiAnswer: options.aiAnswer,
563
+ browserTabs: options.browserTabs,
564
+ oauthCode: options.oauthCode,
565
+ applications: options.applications,
566
+ clipboard: options.clipboard,
567
+ selectedText: options.selectedText,
568
+ selectedFiles: options.selectedFiles,
569
+ frontmost: options.frontmost,
570
+ }),
571
+ ...(options.aiDecide === undefined ? {} : { "ai.decide": () => options.aiDecide }),
572
+ },
573
+ });
574
+ session = await Promise.race([
575
+ startCommand({
576
+ ...bundled,
577
+ argumentValues,
578
+ ...(options.fallbackText === undefined ? {} : { fallbackText: options.fallbackText }),
579
+ dataDir: options.dataDir,
580
+ availableAPIs: [
581
+ ...(options.aiAnswer === undefined ? [] : ["AI"]),
582
+ ...(options.browserTabs === undefined ? [] : ["BrowserExtension"]),
583
+ ],
584
+ broker: reportingBroker(broker, output),
585
+ }),
586
+ deadline,
587
+ ]).catch((error) => {
588
+ throw new RunError(error instanceof Error ? error.message : String(error));
589
+ });
590
+ const active = session;
591
+ stopScenes = active.scenes((scene) => {
592
+ output.flushLogs(active);
593
+ output.scene(scene);
594
+ });
595
+ await Promise.race([
596
+ steps === undefined ? waitForScene(active, -1) : runScript(active, steps),
597
+ deadline,
598
+ ]);
599
+ output.flushLogs(active);
600
+ // A graceful exit waits for host acknowledgements of pending writes (form
601
+ // drafts, storeValue). On a loaded machine that can exceed a few hundred ms,
602
+ // and SIGTERM then loses the data; a hung child is still bounded by
603
+ // `--timeout` through `deadline`.
604
+ const exit = await Promise.race([
605
+ active.shutdown({ timeoutMs: GRACEFUL_SHUTDOWN_MS }),
606
+ deadline,
607
+ ]);
608
+ if (exit.error)
609
+ throw new RunError(exit.error.message);
610
+ if (exit.code !== 0 || exit.signal !== null) {
611
+ throw new RunError(`runtime exited with code ${exit.code ?? exit.signal}`);
612
+ }
613
+ return EXIT_OK;
614
+ }
615
+ catch (error) {
616
+ if (error instanceof UsageError)
617
+ throw error;
618
+ if (error instanceof CleanExit)
619
+ return EXIT_OK;
620
+ const message = error instanceof Error ? error.message : String(error);
621
+ console.error(`# error ${message}`);
622
+ return error instanceof ScriptError ? EXIT_FAILURE : EXIT_CRASH;
623
+ }
624
+ finally {
625
+ if (deadlineTimer !== undefined)
626
+ clearTimeout(deadlineTimer);
627
+ stopScenes?.();
628
+ await session?.shutdown({ timeoutMs: 250 });
629
+ if (session)
630
+ output.flushLogs(session);
631
+ if (outDir !== undefined)
632
+ await rm(outDir, { recursive: true, force: true });
633
+ }
634
+ }
635
+ const GRACEFUL_SHUTDOWN_MS = 5000;
636
+ function parseArguments(argv) {
637
+ try {
638
+ return parseArgs({
639
+ args: argv,
640
+ allowPositionals: true,
641
+ options: {
642
+ argument: { type: "string", multiple: true },
643
+ grant: { type: "string", multiple: true },
644
+ applications: { type: "string" },
645
+ clipboard: { type: "string" },
646
+ "oauth-code": { type: "string" },
647
+ "ai-answer": { type: "string" },
648
+ "ai-decide": { type: "string" },
649
+ "browser-tabs": { type: "string" },
650
+ "selected-text": { type: "string" },
651
+ "selected-files": { type: "string", multiple: true },
652
+ frontmost: { type: "string" },
653
+ "data-dir": { type: "string" },
654
+ "fallback-text": { type: "string" },
655
+ script: { type: "string" },
656
+ json: { type: "boolean" },
657
+ timeout: { type: "string" },
658
+ "bundle-timeout": { type: "string" },
659
+ help: { type: "boolean" },
660
+ },
661
+ });
662
+ }
663
+ catch (error) {
664
+ throw new UsageError(error instanceof Error ? error.message : String(error));
665
+ }
666
+ }
667
+ async function readScript(specifier) {
668
+ try {
669
+ return specifier === "-" ? await readStdin() : await readFile(specifier, "utf8");
670
+ }
671
+ catch {
672
+ throw new UsageError(`cannot read script ${JSON.stringify(specifier)}`);
673
+ }
674
+ }
675
+ async function readDecisions(path) {
676
+ try {
677
+ return JSON.parse(await readFile(path, "utf8"));
678
+ }
679
+ catch {
680
+ throw new UsageError(`cannot read --ai-decide JSON file ${JSON.stringify(path)}`);
681
+ }
682
+ }
683
+ async function readBrowserTabs(specifier) {
684
+ try {
685
+ return parseBrowserTabs(await readFile(specifier, "utf8"));
686
+ }
687
+ catch (error) {
688
+ throw new UsageError(`--browser-tabs ${JSON.stringify(specifier)}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
689
+ }
690
+ }
691
+ async function readApplications(specifier) {
692
+ let text;
693
+ try {
694
+ text = await readFile(specifier, "utf8");
695
+ }
696
+ catch (error) {
697
+ throw new UsageError(`cannot read --applications file ${JSON.stringify(specifier)}`, {
698
+ cause: error,
699
+ });
700
+ }
701
+ try {
702
+ return parseApplications(text);
703
+ }
704
+ catch (error) {
705
+ throw new UsageError(`--applications ${JSON.stringify(specifier)}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
706
+ }
707
+ }
708
+ async function readStdin() {
709
+ const chunks = [];
710
+ for await (const chunk of process.stdin) {
711
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
712
+ }
713
+ return Buffer.concat(chunks).toString("utf8");
714
+ }
715
+ async function main(argv) {
716
+ const parsed = parseArguments(argv);
717
+ if (parsed.values.help) {
718
+ console.log(USAGE);
719
+ return EXIT_OK;
720
+ }
721
+ const [subcommand, extensionDir, command, prompt] = parsed.positionals;
722
+ if (subcommand === "deps") {
723
+ if (!extensionDir || parsed.positionals.length !== 2)
724
+ throw new UsageError("expected deps <extensionDir>");
725
+ if (parsed.values["data-dir"] === "")
726
+ throw new UsageError("--data-dir must not be empty");
727
+ await installExtensionDependencies({
728
+ extensionDir,
729
+ dataDir: parsed.values["data-dir"] ?? defaultDataDir(),
730
+ onLog: (message) => console.log(message.trimEnd()),
731
+ });
732
+ return EXIT_OK;
733
+ }
734
+ if (subcommand === "models" || subcommand === "complete") {
735
+ if (!extensionDir || parsed.positionals.length !== (subcommand === "models" ? 2 : 4))
736
+ throw new UsageError("expected models <extensionDir> or complete <extensionDir> <modelId> <prompt>");
737
+ const bundled = await bundleModelProvider({
738
+ extensionDir,
739
+ dataDir: parsed.values["data-dir"] ?? defaultDataDir(),
740
+ });
741
+ cleanupDirectory = dirname(bundled.entry);
742
+ let provider;
743
+ try {
744
+ provider = await startModelProvider({
745
+ ...bundled,
746
+ ...(parsed.values["data-dir"] === undefined ? {} : { dataDir: parsed.values["data-dir"] }),
747
+ });
748
+ const models = await provider.getModels();
749
+ if (subcommand === "models")
750
+ console.log(JSON.stringify(models));
751
+ else {
752
+ const model = models.find((item) => item.id === command);
753
+ if (!model)
754
+ throw new UsageError(`Unknown model ${command}`);
755
+ for await (const part of provider.stream(model, {
756
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
757
+ providerOptions: {
758
+ raycast: {
759
+ locale: "en",
760
+ currentDate: new Date().toISOString(),
761
+ sessionId: "blast-cli",
762
+ },
763
+ },
764
+ })) {
765
+ if (part.type === "text-delta")
766
+ process.stdout.write(part.textDelta ?? part.text ?? "");
767
+ else if (part.type === "text")
768
+ process.stdout.write(part.text);
769
+ else if (part.type === "finish")
770
+ console.log(`\n${JSON.stringify(part)}`);
771
+ }
772
+ }
773
+ return EXIT_OK;
774
+ }
775
+ finally {
776
+ await provider?.shutdown({ timeoutMs: 250 });
777
+ await rm(dirname(bundled.entry), { recursive: true, force: true });
778
+ }
779
+ }
780
+ if (subcommand !== "run" ||
781
+ extensionDir === undefined ||
782
+ command === undefined ||
783
+ parsed.positionals.length !== 3) {
784
+ throw new UsageError('expected "run <extensionDir> <command>"');
785
+ }
786
+ let timeoutMs;
787
+ if (parsed.values.timeout !== undefined) {
788
+ const value = Number(parsed.values.timeout);
789
+ if (!Number.isInteger(value) || value < 1) {
790
+ throw new UsageError("--timeout must be a positive number of milliseconds");
791
+ }
792
+ timeoutMs = value;
793
+ }
794
+ let bundleTimeoutMs;
795
+ if (parsed.values["bundle-timeout"] !== undefined) {
796
+ const value = Number(parsed.values["bundle-timeout"]);
797
+ if (parsed.values["bundle-timeout"].trim() === "" ||
798
+ !Number.isSafeInteger(value) ||
799
+ value < 0 ||
800
+ value > 2_147_483_647)
801
+ throw new UsageError("--bundle-timeout must be an integer between 0 and 2147483647 milliseconds");
802
+ bundleTimeoutMs = value;
803
+ }
804
+ const script = parsed.values.script;
805
+ const dataDir = parsed.values["data-dir"];
806
+ if (dataDir === "")
807
+ throw new UsageError("--data-dir must not be empty");
808
+ const applicationsPath = parsed.values.applications;
809
+ return run({
810
+ extensionDir,
811
+ command,
812
+ argumentSpecs: parsed.values.argument ?? [],
813
+ fallbackText: parsed.values["fallback-text"],
814
+ grants: parsed.values.grant ?? [],
815
+ applications: applicationsPath === undefined ? undefined : await readApplications(applicationsPath),
816
+ clipboard: parsed.values.clipboard,
817
+ aiAnswer: parsed.values["ai-answer"],
818
+ aiDecide: parsed.values["ai-decide"] === undefined
819
+ ? undefined
820
+ : await readDecisions(parsed.values["ai-decide"]),
821
+ browserTabs: parsed.values["browser-tabs"] === undefined
822
+ ? undefined
823
+ : await readBrowserTabs(parsed.values["browser-tabs"]),
824
+ oauthCode: parsed.values["oauth-code"],
825
+ selectedText: parsed.values["selected-text"],
826
+ selectedFiles: parsed.values["selected-files"] ?? [],
827
+ frontmost: parsed.values.frontmost,
828
+ dataDir: dataDir ?? defaultDataDir(),
829
+ scriptSource: script === undefined ? undefined : await readScript(script),
830
+ json: parsed.values.json === true,
831
+ timeoutMs,
832
+ bundleTimeoutMs,
833
+ });
834
+ }
835
+ let code;
836
+ try {
837
+ code = await main(process.argv.slice(2));
838
+ }
839
+ catch (error) {
840
+ if (error instanceof UsageError) {
841
+ console.error(`blast: ${error.message}`);
842
+ console.error(USAGE);
843
+ code = EXIT_USAGE;
844
+ }
845
+ else {
846
+ console.error(`# error ${error instanceof Error ? error.message : String(error)}`);
847
+ code = EXIT_CRASH;
848
+ }
849
+ }
850
+ // Let piped stdout and stderr drain before exiting so tests capture everything.
851
+ await Promise.all([
852
+ new Promise((done) => process.stdout.write("", () => done())),
853
+ new Promise((done) => process.stderr.write("", () => done())),
854
+ ]);
855
+ process.exit(code);