rpc-agent 0.0.0-stage → 0.3.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +302 -2
  5. package/assets/bezels/imac.svg +63 -0
  6. package/assets/bezels/ipad.svg +27 -0
  7. package/assets/bezels/iphone-11-pro.svg +35 -0
  8. package/assets/bezels/iphone-12-mini.svg +26 -0
  9. package/assets/bezels/iphone-12-pro-max.svg +26 -0
  10. package/assets/bezels/iphone-17-pro.svg +172 -0
  11. package/assets/bezels/iphone-se.svg +37 -0
  12. package/assets/bezels/laptop.svg +25 -0
  13. package/assets/bezels/macbook-air.svg +235 -0
  14. package/assets/bezels/macbook-pro-13.svg +63 -0
  15. package/assets/bezels/macbook-pro-14.svg +115 -0
  16. package/assets/bezels/macbook-pro.svg +32 -0
  17. package/assets/bezels/macbook.svg +25 -0
  18. package/assets/bezels/monitor.svg +57 -0
  19. package/assets/bezels/phone-classic.svg +17 -0
  20. package/assets/bezels/phone-home.svg +12 -0
  21. package/assets/bezels/phone-island.svg +130 -0
  22. package/assets/bezels/phone-notch.svg +26 -0
  23. package/assets/bezels/surface-laptop.svg +84 -0
  24. package/assets/bezels/tablet.svg +25 -0
  25. package/assets/bezels/windows-laptop.svg +84 -0
  26. package/dist/args.js +189 -0
  27. package/dist/bezel.js +74 -0
  28. package/dist/browser.js +80 -0
  29. package/dist/capture.js +241 -0
  30. package/dist/cli.js +175 -0
  31. package/dist/composite.js +115 -0
  32. package/dist/devices.js +407 -0
  33. package/dist/errors.js +115 -0
  34. package/dist/help.js +110 -0
  35. package/dist/mcp.js +245 -0
  36. package/dist/options.js +51 -0
  37. package/dist/png.js +183 -0
  38. package/dist/slices.js +59 -0
  39. package/dist/term.js +46 -0
  40. package/dist/ui.js +488 -0
  41. package/package.json +67 -4
package/dist/ui.js ADDED
@@ -0,0 +1,488 @@
1
+ // Rich terminal UI: banner, clack rail, spinners, prompts and boxes.
2
+ // Only loaded when stdout is an interactive terminal (see term.ts). Never
3
+ // imported by `rpc mcp` or the --json paths.
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { connect } from 'node:net';
6
+ import { relative } from 'node:path';
7
+ import * as p from '@clack/prompts';
8
+ import pc from 'picocolors';
9
+ import { BrowserMissingError, installHint, launchBrowser } from './browser.js';
10
+ import { capture } from './capture.js';
11
+ import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
12
+ import { defaultShotOptions, normalizeUrl } from './options.js';
13
+ import { openCommand, openPath } from './term.js';
14
+ export function colors(enabled) {
15
+ return pc.createColors(enabled);
16
+ }
17
+ // ------------------------------------------------------------------ helpers
18
+ const ANSI = /\x1b\[[0-9;]*m/g;
19
+ export function visibleLength(s) {
20
+ return s.replace(ANSI, '').length;
21
+ }
22
+ function padAnsi(s, width) {
23
+ return s + ' '.repeat(Math.max(0, width - visibleLength(s)));
24
+ }
25
+ export function formatBytes(n) {
26
+ if (n < 1024)
27
+ return `${n} B`;
28
+ if (n < 1024 * 1024)
29
+ return `${Math.round(n / 1024)} KB`;
30
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
31
+ }
32
+ export function formatMs(ms) {
33
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
34
+ }
35
+ export function formatList(items, max = 5) {
36
+ if (items.length <= max)
37
+ return items.join(', ');
38
+ return `${items.slice(0, max).join(', ')} +${items.length - max} more`;
39
+ }
40
+ function rel(path) {
41
+ const r = relative(process.cwd(), path);
42
+ if (r === '')
43
+ return '.';
44
+ return r.startsWith('..') ? path : r;
45
+ }
46
+ function size(path) {
47
+ try {
48
+ return formatBytes(statSync(path).size);
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ function wrap(text, width) {
55
+ const out = [];
56
+ let line = '';
57
+ for (const word of text.split(/\s+/)) {
58
+ if (line && line.length + 1 + word.length > width) {
59
+ out.push(line);
60
+ line = word;
61
+ }
62
+ else {
63
+ line = line ? `${line} ${word}` : word;
64
+ }
65
+ }
66
+ if (line)
67
+ out.push(line);
68
+ return out;
69
+ }
70
+ function textWidth() {
71
+ return Math.max(40, Math.min(process.stdout.columns || 80, 110) - 12);
72
+ }
73
+ function version() {
74
+ try {
75
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
76
+ }
77
+ catch {
78
+ return '';
79
+ }
80
+ }
81
+ function viewport(d) {
82
+ return `${d.width}×${d.height} @${d.dpr}x`;
83
+ }
84
+ function quote(arg) {
85
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
86
+ }
87
+ /** The `rpc shot …` command that reproduces these options. */
88
+ export function shotCommand(o) {
89
+ const base = defaultShotOptions(o.url);
90
+ const parts = ['rpc', 'shot', quote(o.url)];
91
+ if (o.devices.join(',') !== base.devices.join(','))
92
+ parts.push('-d', o.devices.join(','));
93
+ if (o.bezels)
94
+ parts.push('--bezels');
95
+ if (o.fullPage)
96
+ parts.push('--full-page');
97
+ if (o.colorScheme)
98
+ parts.push('--color-scheme', o.colorScheme);
99
+ if (o.reducedMotion)
100
+ parts.push('--reduced-motion');
101
+ if (o.outDir !== base.outDir)
102
+ parts.push('-o', quote(o.outDir));
103
+ return parts.join(' ');
104
+ }
105
+ // ------------------------------------------------------------------ banner
106
+ const LOGO = [
107
+ '██████╗ ██████╗ ██████╗',
108
+ '██╔══██╗██╔══██╗██╔════╝',
109
+ '██████╔╝██████╔╝██║ ',
110
+ '██╔══██╗██╔═══╝ ██║ ',
111
+ '██║ ██║██║ ╚██████╗',
112
+ '╚═╝ ╚═╝╚═╝ ╚═════╝',
113
+ ];
114
+ // 256-colour greys, light to dark: readable on light and dark backgrounds.
115
+ const GRAYS = [250, 248, 245, 243, 240, 238];
116
+ export function banner(c) {
117
+ const lines = LOGO.map((l, i) => (c.isColorSupported ? `\x1b[38;5;${GRAYS[i]}m${l}\x1b[0m` : l));
118
+ return ['', ...lines, '', c.dim('Screenshots of your UI at real device sizes, in real bezels'), ''].join('\n');
119
+ }
120
+ function printBanner(c) {
121
+ if ((process.stdout.columns || 80) < 30)
122
+ return;
123
+ process.stdout.write(banner(c) + '\n');
124
+ }
125
+ function intro(c) {
126
+ p.intro(`${c.bgCyan(c.black(' rpc '))} ${c.dim(`v${version()}`)}`);
127
+ }
128
+ // ------------------------------------------------------------------ devices
129
+ const GROUP_LABELS = {
130
+ 'apple-phone': 'Apple phones',
131
+ 'android-phone': 'Android phones',
132
+ tablet: 'Tablets',
133
+ laptop: 'Laptops & desktops',
134
+ };
135
+ function categoryColor(c, cat) {
136
+ switch (cat) {
137
+ case 'apple-phone':
138
+ return c.cyan;
139
+ case 'android-phone':
140
+ return c.green;
141
+ case 'tablet':
142
+ return c.magenta;
143
+ case 'laptop':
144
+ return c.yellow;
145
+ }
146
+ }
147
+ function aliasesFor(id) {
148
+ return Object.entries(DEVICE_ALIASES)
149
+ .filter(([, target]) => target === id)
150
+ .map(([alias]) => alias);
151
+ }
152
+ /** `rpc devices` in a terminal: grouped, aligned, coloured by category. */
153
+ export function devicesTable(c) {
154
+ const idW = Math.max(...DEVICES.map(d => d.id.length));
155
+ const sizeW = Math.max(...DEVICES.map(d => `${d.width}×${d.height}`.length));
156
+ const nameW = Math.max(...DEVICES.map(d => d.name.length));
157
+ const dprW = Math.max(...DEVICES.map(d => `@${d.dpr}x`.length));
158
+ const out = [''];
159
+ out.push(` ${c.dim('device'.padEnd(idW))} ${c.dim('css px'.padEnd(sizeW))} ${c.dim('dpr'.padEnd(dprW))} ${c.dim('name'.padEnd(nameW))} ${c.dim('aliases')}`);
160
+ for (const cat of CATEGORY_ORDER) {
161
+ const list = DEVICES.filter(d => d.category === cat);
162
+ const tint = categoryColor(c, cat);
163
+ out.push('', `${c.bold(tint(GROUP_LABELS[cat]))} ${c.dim(String(list.length))}`);
164
+ for (const d of list) {
165
+ const aliases = aliasesFor(d.id);
166
+ out.push(` ${tint(d.id.padEnd(idW))} ${`${d.width}×${d.height}`.padEnd(sizeW)} ${c.dim(`@${d.dpr}x`.padEnd(dprW))} ${d.name.padEnd(nameW)} ${aliases.length ? c.yellow(aliases.join(', ')) : ''}`.trimEnd());
167
+ }
168
+ }
169
+ out.push('', `${c.dim(`${DEVICES.length} devices. Use ids or aliases with`)} rpc shot <url> -d iphone-15,ipad,desktop`, '');
170
+ return out.join('\n');
171
+ }
172
+ export function printDevices(c) {
173
+ process.stdout.write(devicesTable(c));
174
+ }
175
+ // ------------------------------------------------------------------ shot
176
+ /** Lines for the "Capture Summary" box. */
177
+ export function summaryLines(o, c) {
178
+ const yes = (b) => (b ? c.green('yes') : c.dim('no'));
179
+ const rows = [
180
+ ['devices', formatList(o.devices.map(d => c.cyan(d)))],
181
+ ['bezels', yes(o.bezels)],
182
+ ['full page', o.fullPage ? `${c.green('yes')}${o.saveSlices ? c.dim(' + slices') : ''}` : c.dim('no')],
183
+ ['colour scheme', o.colorScheme ?? c.dim('default')],
184
+ ];
185
+ if (o.reducedMotion)
186
+ rows.push(['reduced motion', c.green('yes')]);
187
+ if (o.waitFor)
188
+ rows.push(['wait for', o.waitFor]);
189
+ if (!o.emulate)
190
+ rows.push(['emulation', c.yellow('viewport + DPR only')]);
191
+ rows.push(['output', `${rel(o.outDir)}/`]);
192
+ const w = Math.max(...rows.map(([k]) => k.length)) + 1;
193
+ return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
194
+ }
195
+ function statusCell(s, c) {
196
+ if (s.error)
197
+ return c.red(s.code ?? 'error');
198
+ if (s.status == null)
199
+ return c.dim('--');
200
+ if (s.status >= 400)
201
+ return c.red(String(s.status));
202
+ if (s.status >= 300)
203
+ return c.yellow(String(s.status));
204
+ return c.green(String(s.status));
205
+ }
206
+ /** The per-device results table (device, viewport, status, time, console). */
207
+ export function resultsTable(rows, c) {
208
+ const cells = rows.map(({ shot, ms }) => {
209
+ const n = shot.consoleErrors?.length ?? 0;
210
+ return [
211
+ c.cyan(shot.device),
212
+ viewport({ ...shot.viewport, dpr: shot.dpr }),
213
+ statusCell(shot, c),
214
+ formatMs(ms),
215
+ shot.error ? c.dim('--') : n === 0 ? c.green('0') : c.yellow(String(n)),
216
+ ];
217
+ });
218
+ const head = ['device', 'viewport', 'status', 'time', 'console'];
219
+ const widths = head.map((h, i) => Math.max(h.length, ...cells.map(r => visibleLength(r[i]))));
220
+ const line = (r) => r.map((cell, i) => (i === r.length - 1 ? cell : padAnsi(cell, widths[i] + 2))).join('');
221
+ return [line(head.map(h => c.dim(h))), ...cells.map(line)];
222
+ }
223
+ /** Lines for the final "Captured N devices" box. */
224
+ export function capturedLines(result, c) {
225
+ const out = [];
226
+ const width = textWidth();
227
+ const paths = result.shots.flatMap(s => [s.shot?.path, s.framed?.path].filter((x) => Boolean(x)));
228
+ const pathW = Math.min(Math.max(0, ...paths.map(x => rel(x).length)), width - 18);
229
+ // Pad dimensions to one width so the " · size" separators line up in a column.
230
+ const dims = result.shots.flatMap(s => [s.shot, s.framed].filter(Boolean).map(f => `${f.width}×${f.height}`));
231
+ const dimsW = Math.max(0, ...dims.map(d => d.length));
232
+ const file = (path, w, h, extra = '') => {
233
+ const meta = [`${w}×${h}`.padEnd(dimsW), size(path), extra].filter(Boolean).join(' · ');
234
+ out.push(`${c.green('✓')} ${padAnsi(c.cyan(rel(path)), pathW)} ${c.dim(meta)}`);
235
+ };
236
+ // Devices that failed the same way share one line and one hint.
237
+ const failures = new Map();
238
+ for (const s of result.shots) {
239
+ if (!s.error)
240
+ continue;
241
+ const key = `${s.error}\n${s.hint ?? ''}`;
242
+ failures.set(key, [...(failures.get(key) ?? []), s]);
243
+ }
244
+ for (const s of result.shots) {
245
+ if (s.error)
246
+ continue;
247
+ if (s.shot) {
248
+ const extra = [
249
+ s.segments && s.segments > 1 ? `${s.segments} segments` : '',
250
+ s.clipped ? c.yellow(`clipped at ${s.capturedHeight} of ${s.scrollHeight} css px`) : '',
251
+ ]
252
+ .filter(Boolean)
253
+ .join(' · ');
254
+ file(s.shot.path, s.shot.width, s.shot.height, extra);
255
+ }
256
+ if (s.framed)
257
+ file(s.framed.path, s.framed.width, s.framed.height);
258
+ if (s.slices?.saved && s.slices.items[0]?.path) {
259
+ const first = rel(s.slices.items[0].path).replace(/-slice-\d+\.jpg$/, '-slice-NN.jpg');
260
+ out.push(`${c.green('✓')} ${c.cyan(first)} ${c.dim(`${s.slices.total} slices · ${s.slices.items[0].width}px wide`)}`);
261
+ }
262
+ if (s.consoleErrors?.length) {
263
+ const msg = s.consoleErrors[0].length > width - 20 ? `${s.consoleErrors[0].slice(0, width - 21)}…` : s.consoleErrors[0];
264
+ out.push(` ${c.yellow('console:')} ${c.dim(msg)}`);
265
+ }
266
+ }
267
+ for (const group of failures.values()) {
268
+ const first = group[0];
269
+ out.push(`${c.red('✗')} ${group.map(g => g.device).join(', ')}`);
270
+ for (const l of wrap(first.error ?? '', width - 4))
271
+ out.push(` ${c.red(l)}`);
272
+ if (first.hint) {
273
+ const [line, ...rest] = wrap(first.hint, width - 10);
274
+ out.push(` ${c.yellow('hint:')} ${c.dim(line ?? '')}`);
275
+ for (const l of rest)
276
+ out.push(` ${c.dim(l)}`);
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+ export async function runShot(options, run, c) {
282
+ if (!run.continued) {
283
+ printBanner(c);
284
+ intro(c);
285
+ p.note(summaryLines(options, c).join('\n'), 'Capture Summary');
286
+ }
287
+ const s = p.spinner();
288
+ s.start('Starting Chromium');
289
+ let browser;
290
+ try {
291
+ browser = await launchBrowser();
292
+ }
293
+ catch (err) {
294
+ if (err instanceof BrowserMissingError) {
295
+ s.error(c.red("Playwright's Chromium is not installed"));
296
+ p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
297
+ p.outro(`${c.red('Nothing captured.')} ${c.dim('Run: rpc install')}`);
298
+ return 1;
299
+ }
300
+ s.error(c.red(`Chromium did not start: ${err.message.split('\n')[0]}`));
301
+ p.outro(c.red('Nothing captured.'));
302
+ return 1;
303
+ }
304
+ s.stop(`Chromium ${c.dim(browser.version())}`);
305
+ const timed = [];
306
+ let current;
307
+ let started = 0;
308
+ let result;
309
+ const picked = options.devices.map(id => DEVICES.find(d => d.id === id)).filter((d) => Boolean(d));
310
+ const idW = Math.max(0, ...picked.map(d => d.id.length));
311
+ const vpW = Math.max(0, ...picked.map(d => viewport(d).length));
312
+ const label = (id, vp) => `${id.padEnd(idW)} ${c.dim(vp.padEnd(vpW))}`;
313
+ try {
314
+ result = await capture(options, {
315
+ browser,
316
+ onDeviceStart: (d) => {
317
+ started = performance.now();
318
+ current = p.spinner();
319
+ current.start(label(d.id, viewport(d)));
320
+ },
321
+ onDevice: shot => {
322
+ const ms = performance.now() - started;
323
+ timed.push({ shot, ms });
324
+ const line = `${label(shot.device, viewport({ ...shot.viewport, dpr: shot.dpr }))} ${statusCell(shot, c)} ${c.dim(formatMs(ms))}`;
325
+ if (shot.error)
326
+ current?.error(line);
327
+ else
328
+ current?.stop(line);
329
+ current = undefined;
330
+ },
331
+ });
332
+ }
333
+ catch (err) {
334
+ current?.error(c.red(err.message));
335
+ p.outro(c.red('Capture failed.'));
336
+ return 1;
337
+ }
338
+ finally {
339
+ await browser.close().catch(() => { });
340
+ }
341
+ const failed = result.shots.filter(x => x.error).length;
342
+ const ok = result.shots.length - failed;
343
+ p.note(resultsTable(timed, c).join('\n'), 'Results');
344
+ const n = result.shots.length;
345
+ const title = failed === 0
346
+ ? c.green(`Captured ${n} device${n === 1 ? '' : 's'}`)
347
+ : ok === 0
348
+ ? c.red(`Failed on ${n} device${n === 1 ? '' : 's'}`)
349
+ : c.yellow(`Captured ${ok} of ${n} devices`);
350
+ p.note(capturedLines(result, c).join('\n'), title);
351
+ if (run.showCommand)
352
+ p.log.message(`${c.dim('Re-run with:')} ${shotCommand(options)}`);
353
+ const dir = rel(result.outDir);
354
+ let advice = `Open with: ${openCommand().cmd} ${dir}`;
355
+ if (run.open && ok > 0)
356
+ advice = openPath(result.outDir) ? `Opened ${dir}` : advice;
357
+ if (ok === 0)
358
+ p.outro(`${c.red('Nothing captured.')} ${c.dim('See the hints above.')}`);
359
+ else if (failed > 0)
360
+ p.outro(`${c.yellow(`Done, ${failed} failed.`)} ${c.dim(advice)}`);
361
+ else
362
+ p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
363
+ return failed > 0 ? 1 : 0;
364
+ }
365
+ // ------------------------------------------------------------------ interactive
366
+ const PRESELECT = ['iphone-15-pro', 'ipad-air-11', 'desktop-1440'];
367
+ const DEV_PORTS = [3000, 5173, 4321, 8080];
368
+ function listening(port, timeoutMs = 300) {
369
+ return new Promise(res => {
370
+ const sock = connect({ host: 'localhost', port });
371
+ const done = (ok) => {
372
+ sock.destroy();
373
+ res(ok);
374
+ };
375
+ sock.setTimeout(timeoutMs, () => done(false));
376
+ sock.once('connect', () => done(true));
377
+ sock.once('error', () => done(false));
378
+ });
379
+ }
380
+ /** First local dev server that accepts a connection, if any. */
381
+ export async function detectDevServer(ports = DEV_PORTS) {
382
+ const open = await Promise.all(ports.map(port => listening(port)));
383
+ const i = open.indexOf(true);
384
+ return i === -1 ? undefined : `http://localhost:${ports[i]}`;
385
+ }
386
+ function cancelled() {
387
+ p.cancel('Cancelled. Nothing was captured.');
388
+ return 130;
389
+ }
390
+ /** `rpc` with no arguments in a terminal. */
391
+ export async function runInteractive(c) {
392
+ printBanner(c);
393
+ intro(c);
394
+ const detected = await detectDevServer();
395
+ if (detected)
396
+ p.log.info(`Found a dev server on ${c.cyan(detected)}`);
397
+ const url = await p.text({
398
+ message: 'Which URL should rpc capture?',
399
+ placeholder: detected ? `${detected} (enter to use)` : 'localhost:3000, example.com or ./page.html',
400
+ ...(detected ? { defaultValue: detected } : {}),
401
+ validate: v => {
402
+ if ((!v || !v.trim()) && detected)
403
+ return undefined;
404
+ if (!v || !v.trim())
405
+ return 'Enter a URL, a local host:port or a path to an .html file';
406
+ try {
407
+ normalizeUrl(v);
408
+ return undefined;
409
+ }
410
+ catch (err) {
411
+ return err.message;
412
+ }
413
+ },
414
+ });
415
+ if (p.isCancel(url))
416
+ return cancelled();
417
+ const options = defaultShotOptions(url.trim() || detected || '');
418
+ if (options.url !== url.trim())
419
+ p.log.message(`${c.dim('URL:')} ${c.cyan(options.url)}`);
420
+ p.log.info(`Found ${c.green(String(DEVICES.length))} devices in ${CATEGORY_ORDER.length} groups`);
421
+ const groups = {};
422
+ for (const cat of CATEGORY_ORDER) {
423
+ groups[GROUP_LABELS[cat]] = DEVICES.filter(d => d.category === cat).map(d => ({
424
+ value: d.id,
425
+ label: d.id,
426
+ hint: viewport(d),
427
+ }));
428
+ }
429
+ const devices = await p.groupMultiselect({
430
+ message: 'Which devices?',
431
+ options: groups,
432
+ initialValues: PRESELECT,
433
+ cursorAt: PRESELECT[0],
434
+ required: true,
435
+ maxItems: Math.max(8, (process.stdout.rows || 30) - 12),
436
+ });
437
+ if (p.isCancel(devices))
438
+ return cancelled();
439
+ // Keep catalog order (phones first), whatever order they were ticked in.
440
+ options.devices = DEVICES.map(d => d.id).filter(id => devices.includes(id));
441
+ const extras = await p.multiselect({
442
+ message: 'Options',
443
+ options: [
444
+ { value: 'bezels', label: 'Device bezels', hint: 'also write a framed copy' },
445
+ { value: 'fullPage', label: 'Full page', hint: 'whole scroll height, stitched' },
446
+ { value: 'dark', label: 'Dark mode', hint: 'prefers-color-scheme: dark' },
447
+ { value: 'reducedMotion', label: 'Reduced motion', hint: 'prefers-reduced-motion: reduce' },
448
+ ],
449
+ initialValues: ['bezels'],
450
+ required: false,
451
+ });
452
+ if (p.isCancel(extras))
453
+ return cancelled();
454
+ options.bezels = extras.includes('bezels');
455
+ options.fullPage = extras.includes('fullPage');
456
+ if (extras.includes('dark'))
457
+ options.colorScheme = 'dark';
458
+ options.reducedMotion = extras.includes('reducedMotion');
459
+ p.note(summaryLines(options, c).join('\n'), 'Capture Summary');
460
+ const go = await p.confirm({ message: 'Proceed with capture?' });
461
+ if (p.isCancel(go))
462
+ return cancelled();
463
+ if (!go) {
464
+ p.outro(c.dim('Nothing captured.'));
465
+ return 0;
466
+ }
467
+ return runShot(options, { open: false, continued: true, showCommand: true }, c);
468
+ }
469
+ // ------------------------------------------------------------------ doctor
470
+ export async function runDoctor(check, c) {
471
+ intro(c);
472
+ const s = p.spinner();
473
+ s.start('Launching Chromium');
474
+ const r = await check();
475
+ s.stop(`Node ${c.dim(r.node)}`);
476
+ p.log.step(`Playwright ${c.dim(r.playwright)}`);
477
+ p.log.step(`Browsers ${c.dim(r.browsersPath)}`);
478
+ if (r.ok) {
479
+ p.log.success(`Chromium ${c.green('launches')} ${c.dim(r.version ?? '')}`);
480
+ p.outro(`${c.green('Ready.')} ${c.dim('Try: rpc shot localhost:3000 --bezels')}`);
481
+ return 0;
482
+ }
483
+ p.log.error(`Chromium ${c.red(r.chromium === 'missing' ? 'is not installed' : 'did not launch')}`);
484
+ const body = r.chromium === 'missing' ? installHint().replace(/^Fix: /, '') : r.error ?? '';
485
+ p.note(wrap(body, textWidth()).join('\n'), c.yellow(r.chromium === 'missing' ? 'Fix' : 'Error'));
486
+ p.outro(`${c.red('Not ready.')} ${c.dim(r.chromium === 'missing' ? 'Run: rpc install' : 'See the error above.')}`);
487
+ return 1;
488
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "rpc-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "Screenshot a URL at device viewports, optionally framed in device bezels. CLI and MCP server for AI coding agents.",
5
+ "keywords": [
6
+ "mcp",
7
+ "mcp-server",
8
+ "playwright",
9
+ "claude-code",
10
+ "cursor",
11
+ "screenshot",
12
+ "responsive",
13
+ "device-mockup",
14
+ "cli"
15
+ ],
16
+ "homepage": "https://github.com/akintola4/rpc-agent#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/akintola4/rpc-agent/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/akintola4/rpc-agent.git"
23
+ },
24
+ "license": "MIT",
25
+ "author": "akintola4",
26
+ "type": "module",
27
+ "bin": {
28
+ "rpc": "dist/cli.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "assets",
33
+ "NOTICE",
34
+ "README.md",
35
+ "LICENSE",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20.12"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "rpc": "tsx src/cli.ts",
46
+ "smoke:mcp": "tsx scripts/mcp-smoke.ts",
47
+ "media": "pnpm build && tsx scripts/media/build.ts",
48
+ "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test"
49
+ },
50
+ "dependencies": {
51
+ "@clack/prompts": "^1.8.1",
52
+ "@modelcontextprotocol/sdk": "^1.32.2",
53
+ "picocolors": "^1.1.1",
54
+ "playwright": "^1.64.1",
55
+ "zod": "^4.6.7"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^22.20.5",
59
+ "@xterm/headless": "^5.5.0",
60
+ "tsx": "^4.23.15",
61
+ "typescript": "^5.9.3",
62
+ "vitest": "^5.0.5"
63
+ },
64
+ "pnpm": {
65
+ "onlyBuiltDependencies": [
66
+ "esbuild"
67
+ ]
68
+ }
69
+ }