subrouter-cli 0.0.0-stage → 0.1.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.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
package/src/client.ts ADDED
@@ -0,0 +1,398 @@
1
+ // RouterClient — HTTP against the subrouter gateway. Mirrors the error handling of
2
+ // subrouter-web-chat/server.js and the Swift RouterClient: same envelope parsing,
3
+ // per-status hints, and digit-only Retry-After handling.
4
+
5
+ import { AnthropicStreamParser, parseAnthropicCompleteBody, toAnthropicBody } from './adapters/anthropic.ts';
6
+ import { OpenAiStreamParser, parseCompleteBody, toWireBody } from './adapters/openai.ts';
7
+ import { sseFrames } from './sse.ts';
8
+ import type { AssistantTurn, ChatMessage, ChatRequest, Dialect, StreamEvent, ToolDefinition } from './types.ts';
9
+
10
+ export const ANTHROPIC_VERSION = '2023-06-01';
11
+ const MAX_ERROR_TEXT = 1000;
12
+
13
+ type JsonObject = Record<string, unknown>;
14
+
15
+ export class RouterError extends Error {
16
+ status?: number;
17
+ type?: string;
18
+ code?: string;
19
+ param?: string;
20
+ requestId?: string;
21
+ retryAfterSeconds?: number;
22
+
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = 'RouterError';
26
+ }
27
+ }
28
+
29
+ export function statusHint(status: number): string {
30
+ switch (status) {
31
+ case 400:
32
+ return 'Check the request and model ID (run /models for the live catalog).';
33
+ case 401:
34
+ return 'The API key is missing or invalid — set SUBROUTER_API_KEY or edit ~/.config/subrouter-cli/config.json.';
35
+ case 403:
36
+ return 'This model is not on your allowlist, or the router admin turned it off.';
37
+ case 429:
38
+ return 'Rate limit, daily cap, or shared-pool pacing is limiting this request; try later or check quota via /usage.';
39
+ case 502:
40
+ return 'The provider is disconnected or its credentials need renewal.';
41
+ default:
42
+ return 'The router could not complete the request.';
43
+ }
44
+ }
45
+
46
+ function bounded(value: string): string {
47
+ const trimmed = value.trim();
48
+ return trimmed.length > MAX_ERROR_TEXT ? trimmed.slice(0, MAX_ERROR_TEXT) + '…' : trimmed;
49
+ }
50
+
51
+ /** Display copy for any failure: bare fetch network errors get actionable text. */
52
+ export function errorMessage(err: unknown): string {
53
+ if (err instanceof TypeError) return 'Network error — could not reach the router.';
54
+ return err instanceof Error ? err.message : String(err);
55
+ }
56
+
57
+ function scalarString(value: unknown): string | undefined {
58
+ if (typeof value === 'string') return bounded(value);
59
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
60
+ return undefined;
61
+ }
62
+
63
+ function safeJson(text: string): unknown {
64
+ try {
65
+ return JSON.parse(text);
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ async function drainBody(response: Response, cap = 4096): Promise<string> {
72
+ try {
73
+ const text = await response.text();
74
+ return text.length > cap ? text.slice(0, cap) : text;
75
+ } catch {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ /** delta-seconds only: all digits, capped at 60s (Swift parity — no HTTP dates). */
81
+ function retryAfterSecondsFrom(headers: Headers): number | undefined {
82
+ const raw = headers.get('retry-after')?.trim();
83
+ if (!raw || !/^\d+$/.test(raw)) return undefined;
84
+ const value = Number(raw);
85
+ if (!Number.isFinite(value)) return undefined;
86
+ return Math.min(value, 60);
87
+ }
88
+
89
+ async function routerErrorFromResponse(status: number, response: Response): Promise<RouterError> {
90
+ const body = safeJson(await drainBody(response)) as JsonObject | undefined;
91
+ const nested = body?.error;
92
+ const nestedObj = typeof nested === 'object' && nested !== null ? (nested as JsonObject) : undefined;
93
+ const message =
94
+ (typeof nestedObj?.message === 'string' && nestedObj.message) ||
95
+ (typeof body?.message === 'string' && body.message) ||
96
+ (typeof nested === 'string' ? nested : undefined) ||
97
+ statusHint(status);
98
+ const err = new RouterError(bounded(message));
99
+ err.status = status;
100
+ if (nestedObj) {
101
+ err.type = scalarString(nestedObj.type);
102
+ err.code = scalarString(nestedObj.code);
103
+ err.param = scalarString(nestedObj.param);
104
+ err.requestId = scalarString(nestedObj.request_id ?? nestedObj.requestId);
105
+ }
106
+ const retryAfter = retryAfterSecondsFrom(response.headers);
107
+ if (retryAfter !== undefined) {
108
+ err.retryAfterSeconds = retryAfter;
109
+ err.message += ` Retry after ${retryAfter} seconds.`;
110
+ }
111
+ return err;
112
+ }
113
+
114
+ /**
115
+ * Which wire dialect a model id speaks. A provider prefix always wins, so
116
+ * `openai/claude-proxy` is not silently rerouted (Swift parity).
117
+ */
118
+ export function dialectFor(model: string): Dialect {
119
+ const id = model.trim().toLowerCase();
120
+ if (!id) throw new RouterError('Model id is empty.');
121
+ if (id.startsWith('anthropic/')) return 'anthropic';
122
+ if (id.includes('/')) return 'openai';
123
+ if (id.startsWith('claude-') || id.startsWith('claude.')) return 'anthropic';
124
+ if (id.startsWith('gpt-') || id.startsWith('grok-') || id.startsWith('deepseek-')) return 'openai';
125
+ throw new RouterError(
126
+ `Cannot route model "${model}" — use the full provider-prefixed id from /models (e.g. opencode/glm-5.3-flash).`,
127
+ );
128
+ }
129
+
130
+ /** Strip the /v1 suffix: the Anthropic endpoints hang off the bare root. */
131
+ export function rootFromBase(baseUrl: string): string {
132
+ const url = new URL(baseUrl);
133
+ if (url.pathname === '/v1' || url.pathname === '/v1/') {
134
+ url.pathname = '';
135
+ } else {
136
+ url.pathname = url.pathname.replace(/\/+$/, '');
137
+ }
138
+ return url.toString().replace(/\/$/, '');
139
+ }
140
+
141
+ export interface ModelCatalogEntry {
142
+ id: string;
143
+ displayName?: string;
144
+ ownedBy?: string;
145
+ contextLength?: number;
146
+ maxOutputTokens?: number;
147
+ }
148
+
149
+ /** Pure catalog parsing; accepts {"data":[...]} and {"models":[...]} (Swift parity). */
150
+ export function parseModelCatalog(json: unknown): ModelCatalogEntry[] | null {
151
+ if (typeof json !== 'object' || json === null) return null;
152
+ const root = json as JsonObject;
153
+ const entries = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : null;
154
+ if (!entries) return null;
155
+ const result: ModelCatalogEntry[] = [];
156
+ for (const entry of entries) {
157
+ if (typeof entry === 'string') {
158
+ result.push({ id: entry });
159
+ continue;
160
+ }
161
+ if (typeof entry !== 'object' || entry === null) continue;
162
+ const e = entry as JsonObject;
163
+ const id = typeof e.id === 'string' ? e.id : typeof e.name === 'string' ? e.name : undefined;
164
+ if (!id) continue;
165
+ result.push({
166
+ id,
167
+ displayName: typeof e.display_name === 'string' ? e.display_name : undefined,
168
+ ownedBy: typeof e.owned_by === 'string' ? e.owned_by : undefined,
169
+ contextLength: positiveInteger(e.context_length),
170
+ maxOutputTokens: positiveInteger(e.max_output_tokens),
171
+ });
172
+ }
173
+ return result;
174
+ }
175
+
176
+ function positiveInteger(value: unknown): number | undefined {
177
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
178
+ }
179
+
180
+ export interface RouterClientOptions {
181
+ baseUrl: string; // OpenAI-compatible root, e.g. https://router.eva.pink/v1
182
+ apiKey: string;
183
+ debug?: (line: string) => void;
184
+ sleep?: (ms: number) => Promise<void>; // test seam: replaces the retry wait
185
+ }
186
+
187
+ export class RouterClient {
188
+ private baseUrl: string;
189
+ private apiKey: string;
190
+ private debug: (line: string) => void;
191
+ private wait: (ms: number) => Promise<void>;
192
+
193
+ constructor(options: RouterClientOptions) {
194
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
195
+ this.apiKey = options.apiKey;
196
+ this.debug = options.debug ?? (() => {});
197
+ this.wait = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
198
+ }
199
+
200
+ /** Bare root without /v1 — Anthropic endpoints hang off this. */
201
+ rootUrl(): string {
202
+ return rootFromBase(this.baseUrl);
203
+ }
204
+
205
+ /** The completion endpoint for a dialect (used by the harness and adapters). */
206
+ chatPath(dialect: Dialect): string {
207
+ return dialect === 'anthropic'
208
+ ? this.rootUrl() + '/v1/messages'
209
+ : this.baseUrl + '/chat/completions';
210
+ }
211
+
212
+ /** Auth headers per dialect; the router accepts Bearer everywhere (Swift parity). */
213
+ headers(dialect?: Dialect): Record<string, string> {
214
+ const h: Record<string, string> = {
215
+ Authorization: `Bearer ${this.apiKey}`,
216
+ 'Content-Type': 'application/json',
217
+ };
218
+ if (dialect === 'anthropic') {
219
+ h['x-api-key'] = this.apiKey;
220
+ h['anthropic-version'] = ANTHROPIC_VERSION;
221
+ }
222
+ return h;
223
+ }
224
+
225
+ /** GET {root}/healthz, no auth. */
226
+ async healthz(): Promise<boolean> {
227
+ try {
228
+ const res = await fetch(this.rootUrl() + '/healthz', { signal: AbortSignal.timeout(10_000) });
229
+ if (!res.ok) return false;
230
+ return (await res.text()).trim() === 'ok';
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ async listModels(signal?: AbortSignal): Promise<ModelCatalogEntry[]> {
237
+ const res = await fetch(this.baseUrl + '/models', {
238
+ headers: { Authorization: `Bearer ${this.apiKey}` },
239
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
240
+ cache: 'no-store',
241
+ });
242
+ if (!res.ok) throw await routerErrorFromResponse(res.status, res);
243
+ const entries = parseModelCatalog(await res.json());
244
+ if (!entries) {
245
+ throw new RouterError('GET /models: unexpected response shape (no "data" or "models" array).');
246
+ }
247
+ return entries;
248
+ }
249
+
250
+ /** The /usage endpoint only exists on the subrouter itself (Swift's supportsUsage guard). */
251
+ supportsUsage(): boolean {
252
+ try {
253
+ const url = new URL(this.baseUrl);
254
+ return url.protocol === 'https:' && url.host === 'router.eva.pink';
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /** GET /usage — typed/rendered in usage.ts; raw shape for now. */
261
+ async usage(signal?: AbortSignal): Promise<unknown> {
262
+ if (!this.supportsUsage()) {
263
+ throw new RouterError('Usage is unavailable for this connection.');
264
+ }
265
+ const res = await fetch(this.baseUrl + '/usage', {
266
+ headers: { Authorization: `Bearer ${this.apiKey}` },
267
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
268
+ cache: 'no-store',
269
+ });
270
+ if (!res.ok) throw await routerErrorFromResponse(res.status, res);
271
+ return await res.json();
272
+ }
273
+
274
+ /** Non-streaming chat. One retry for retryable failures (429/502/503/network). */
275
+ async complete(req: ChatRequest, signal?: AbortSignal): Promise<AssistantTurn> {
276
+ return await this.withRetry(async () => {
277
+ const res = await fetch(this.chatPath(req.dialect), {
278
+ method: 'POST',
279
+ headers: this.headers(req.dialect),
280
+ body: JSON.stringify(req.dialect === 'anthropic' ? toAnthropicBody(req) : toWireBody(req)),
281
+ signal: signal ?? AbortSignal.timeout(300_000),
282
+ });
283
+ if (!res.ok) throw await routerErrorFromResponse(res.status, res);
284
+ const json = (await res.json()) as unknown;
285
+ return req.dialect === 'anthropic' ? parseAnthropicCompleteBody(json) : parseCompleteBody(json);
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Streaming chat. Events flow to onEvent as they arrive; resolves with the assembled
291
+ * turn. Retried once on retryable failures, and only when nothing has been emitted yet
292
+ * (a mid-stream retry would duplicate text).
293
+ */
294
+ async chat(req: ChatRequest, onEvent: (e: StreamEvent) => void, signal?: AbortSignal): Promise<AssistantTurn> {
295
+ let emitted = false;
296
+ const wrap = (e: StreamEvent): void => {
297
+ emitted = true;
298
+ onEvent(e);
299
+ };
300
+ const attempt = async (): Promise<AssistantTurn> => {
301
+ const res = await fetch(this.chatPath(req.dialect), {
302
+ method: 'POST',
303
+ headers: this.headers(req.dialect),
304
+ body: JSON.stringify(req.dialect === 'anthropic' ? toAnthropicBody(req) : toWireBody(req)),
305
+ signal: signal ?? AbortSignal.timeout(300_000),
306
+ });
307
+ if (!res.ok) throw await routerErrorFromResponse(res.status, res);
308
+ if (!res.body) throw new RouterError('The router returned an empty stream.');
309
+ const parser = req.dialect === 'anthropic' ? new AnthropicStreamParser() : new OpenAiStreamParser();
310
+ for await (const frame of sseFrames(res.body)) {
311
+ if (req.dialect === 'openai' && frame.data.trim() === '[DONE]') break;
312
+ this.debug(`sse: ${frame.event ? frame.event + ' ' : ''}${frame.data.slice(0, 300)}`);
313
+ if (req.dialect === 'anthropic' && frame.event === 'message_stop') {
314
+ const events = parser.push(frame.data);
315
+ return { message: parser.assistantMessage(), stopReason: events[0]?.kind === 'stop' ? events[0].stopReason : 'unknown', usage: events[0]?.kind === 'stop' ? events[0].usage : undefined };
316
+ }
317
+ const events = parser.push(frame.data);
318
+ for (const e of events) {
319
+ if (e.kind === 'stop') {
320
+ return { message: parser.assistantMessage(), stopReason: e.stopReason, usage: e.usage };
321
+ }
322
+ wrap(e);
323
+ }
324
+ }
325
+ throw new RouterError('The router closed the response before it finished.');
326
+ };
327
+ try {
328
+ return await attempt();
329
+ } catch (err) {
330
+ if (emitted || !this.isRetryable(err)) throw err;
331
+ await this.sleep(this.retryDelayMs(err as RouterError));
332
+ return await attempt();
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Exact token count via POST /v1/messages/count_tokens (Messages body only, free).
338
+ * Advisory: callers must fall back to estimation on any failure.
339
+ */
340
+ async countTokens(model: string, system: string, messages: ChatMessage[], tools?: ToolDefinition[]): Promise<number> {
341
+ const body: Record<string, unknown> = toAnthropicBody({
342
+ model,
343
+ dialect: 'anthropic',
344
+ messages,
345
+ system,
346
+ tools,
347
+ stream: false,
348
+ });
349
+ const res = await fetch(this.rootUrl() + '/v1/messages/count_tokens', {
350
+ method: 'POST',
351
+ headers: this.headers('anthropic'),
352
+ body: JSON.stringify(body),
353
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
354
+ });
355
+ if (!res.ok) throw await routerErrorFromResponse(res.status, res);
356
+ const json = (await res.json()) as { input_tokens?: unknown };
357
+ if (typeof json.input_tokens !== 'number') {
358
+ throw new RouterError('count_tokens: unexpected response shape (no input_tokens).');
359
+ }
360
+ return json.input_tokens;
361
+ }
362
+
363
+ /** One retry for 429/502/503 and network failures; 400/401/403 pass through. */
364
+ private async withRetry<T>(attempt: () => Promise<T>): Promise<T> {
365
+ try {
366
+ return await attempt();
367
+ } catch (err) {
368
+ if (!this.isRetryable(err)) throw err;
369
+ await this.sleep(this.retryDelayMs(err as RouterError));
370
+ return await attempt();
371
+ }
372
+ }
373
+
374
+ private isRetryable(err: unknown): boolean {
375
+ // A user abort (Ctrl+C) is never retried.
376
+ if (err instanceof DOMException && err.name === 'AbortError') return false;
377
+ const e = err as RouterError;
378
+ return (
379
+ !e.status ||
380
+ e.status === 429 ||
381
+ e.status === 502 ||
382
+ e.status === 503 ||
383
+ err instanceof TypeError // fetch network failure
384
+ );
385
+ }
386
+
387
+ private retryDelayMs(err: RouterError): number {
388
+ if (err.retryAfterSeconds) return err.retryAfterSeconds * 1000;
389
+ return err.status === 429 ? 2000 : 1000;
390
+ }
391
+
392
+ private async sleep(ms: number): Promise<void> {
393
+ this.debug(`retrying after ${ms}ms`);
394
+ await this.wait(ms);
395
+ }
396
+ }
397
+
398
+ export { routerErrorFromResponse, drainBody, bounded };
@@ -0,0 +1,62 @@
1
+ // User-triggered macOS clipboard access only; never exposed as an agent tool.
2
+ import { execFile } from 'node:child_process';
3
+ import { imageFromBytes, MAX_IMAGE_BYTES, type ImageAttachment } from './images.ts';
4
+
5
+ // Fixed JXA reads AppKit's pasteboard directly. No shell, temp files, dependencies,
6
+ // or clipboard-derived code/arguments. Screenshots commonly arrive as TIFF.
7
+ export const CLIPBOARD_SCRIPT = `
8
+ ObjC.import('AppKit');
9
+ function encode(data) {
10
+ if (data.length > ${MAX_IMAGE_BYTES}) return JSON.stringify({ error: 'large' });
11
+ return JSON.stringify({ data: ObjC.unwrap(data.base64EncodedStringWithOptions(0)) });
12
+ }
13
+ function run() {
14
+ const board = $.NSPasteboard.generalPasteboard;
15
+ const png = board.dataForType($.NSPasteboardTypePNG);
16
+ if (png && !png.isNil()) return encode(png);
17
+ const tiff = board.dataForType($.NSPasteboardTypeTIFF);
18
+ if (!tiff || tiff.isNil()) return JSON.stringify({ error: 'empty' });
19
+ if (tiff.length > 40 * 1024 * 1024) return JSON.stringify({ error: 'large' });
20
+ const image = $.NSBitmapImageRep.imageRepWithData(tiff);
21
+ if (!image || image.isNil()) return JSON.stringify({ error: 'invalid' });
22
+ if (image.pixelsWide * image.pixelsHigh > 40000000) return JSON.stringify({ error: 'dimensions' });
23
+ const data = image.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
24
+ if (!data || data.isNil()) return JSON.stringify({ error: 'invalid' });
25
+ return encode(data);
26
+ }
27
+ `;
28
+
29
+ const MAX_BASE64_LENGTH = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
30
+ const unreadable = 'could not read the clipboard image — copy it again and press Ctrl+V';
31
+
32
+ export function decodeClipboardImage(output: string): ImageAttachment {
33
+ if (output.length > MAX_BASE64_LENGTH + 100) throw new Error('clipboard image exceeds the 10 MiB attachment limit');
34
+ let result: unknown;
35
+ try { result = JSON.parse(output); }
36
+ catch { throw new Error(unreadable); }
37
+ if (!result || typeof result !== 'object') throw new Error(unreadable);
38
+ const record = result as Record<string, unknown>;
39
+ if (record.error === 'empty') throw new Error('no image in the clipboard — copy an image or use Ctrl+Cmd+Shift+4; Cmd+V pastes text');
40
+ if (record.error === 'large') throw new Error('clipboard image exceeds the 10 MiB attachment limit');
41
+ if (record.error === 'dimensions') throw new Error('clipboard image is too large to convert — copy a smaller screenshot');
42
+ if (typeof record.data !== 'string' || !record.data || /[^A-Za-z0-9+/=]/.test(record.data)) throw new Error(unreadable);
43
+ const bytes = Buffer.from(record.data, 'base64');
44
+ if (bytes.toString('base64') !== record.data) throw new Error(unreadable);
45
+ return imageFromBytes(bytes, 'clipboard.png');
46
+ }
47
+
48
+ export async function readClipboardImage(signal?: AbortSignal): Promise<ImageAttachment> {
49
+ signal?.throwIfAborted();
50
+ if (process.platform !== 'darwin') throw new Error('clipboard images are supported on macOS only');
51
+ const output = await new Promise<string>((resolve, reject) => {
52
+ execFile('/usr/bin/osascript', ['-l', 'JavaScript', '-e', CLIPBOARD_SCRIPT], {
53
+ encoding: 'utf8', shell: false, timeout: 10_000,
54
+ maxBuffer: MAX_BASE64_LENGTH + 1024, signal,
55
+ }, (err, stdout) => {
56
+ if (err) reject(signal?.aborted ? signal.reason : new Error(unreadable));
57
+ else resolve(stdout);
58
+ });
59
+ });
60
+ signal?.throwIfAborted();
61
+ return decodeClipboardImage(output);
62
+ }