rpc-agent 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/help.js CHANGED
@@ -15,16 +15,57 @@ const SHOT_OPTIONS = [
15
15
  [' --open', 'open the output folder when done'],
16
16
  [' --json', 'print the result as JSON (no colour, no progress)'],
17
17
  ];
18
+ const AUTH_OPTIONS = [
19
+ [' --cookie <c>', '"name=value[; Domain=…; Path=…]", repeatable; target origin only'],
20
+ [' --header <h>', '"Name: value", repeatable; sent to the target origin only'],
21
+ [' --storage-state <file>', 'load a Playwright storageState JSON (cookies + localStorage)'],
22
+ [' --no-session', "don't use the session saved by `rpc login` for this origin"],
23
+ [' --allow-origin <o>', 'also send headers and loaded cookies to this origin, repeatable'],
24
+ ];
25
+ const LOGIN_OPTIONS = [
26
+ [' --save <file>', 'write the storage state here instead of the per-origin session'],
27
+ [' --form', 'headless: fill an email + password form (CI, agents)'],
28
+ [' --email <email>', 'with --form; default $RPC_LOGIN_EMAIL'],
29
+ [' --password <pw>', 'with --form; prefer $RPC_LOGIN_PASSWORD (keeps it out of shell history)'],
30
+ [' --email-selector <css>', `default: input[type=email], input[name=email], …`],
31
+ [' --password-selector <css>', 'default: input[type=password]'],
32
+ [' --submit-selector <css>', 'default: button[type=submit], input[type=submit] (else Enter)'],
33
+ [' --timeout <ms>', 'navigation and form timeout (default: 30000)'],
34
+ [' --json', 'print the result as JSON'],
35
+ ];
18
36
  export const COMMANDS = {
19
37
  shot: {
20
38
  usage: 'rpc shot <url> [options]',
21
39
  summary: 'Capture a URL at device viewports, optionally framed in device bezels.',
22
- options: SHOT_OPTIONS,
40
+ options: [...SHOT_OPTIONS, ...AUTH_OPTIONS],
23
41
  examples: [
24
42
  ['rpc shot localhost:3000 -d iphone-15,ipad,desktop-1440 --bezels', ''],
25
43
  ['rpc shot example.com/pricing --full-page --slices', 'whole page + review slices'],
26
44
  ['rpc shot localhost:5173 --color-scheme dark --open', 'dark mode, open when done'],
27
45
  ['rpc shot localhost:3000 -d pixel --json', 'machine-readable result'],
46
+ ['rpc shot localhost:3000/billing', 'uses the session saved by rpc login'],
47
+ ["rpc shot api.example.com/admin --header 'Authorization: Bearer $TOKEN'", ''],
48
+ ],
49
+ },
50
+ login: {
51
+ usage: 'rpc login <url> [--form] [--save <file>]',
52
+ summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
53
+ 'origin (~/.config/rpc-agent/sessions, mode 0600) and `rpc shot` uses it automatically.',
54
+ options: LOGIN_OPTIONS,
55
+ examples: [
56
+ ['rpc login localhost:3000/sign-in', 'sign in by hand in a window'],
57
+ ['RPC_LOGIN_EMAIL=me@x.test RPC_LOGIN_PASSWORD=… rpc login localhost:3000/sign-in --form', 'CI / agents'],
58
+ ['rpc login example.com --save auth.json', 'write a storageState file instead'],
59
+ ],
60
+ },
61
+ sessions: {
62
+ usage: 'rpc sessions [list] | rpc sessions clear [origin]',
63
+ summary: 'List the sessions saved by `rpc login` (origins, cookie counts, expiry; never values), or delete them.',
64
+ options: [[' --json', 'print as JSON']],
65
+ examples: [
66
+ ['rpc sessions', ''],
67
+ ['rpc sessions clear localhost:3000', 'forget one origin'],
68
+ ['rpc sessions clear', 'forget all'],
28
69
  ],
29
70
  },
30
71
  devices: {
@@ -43,18 +84,24 @@ export const COMMANDS = {
43
84
  examples: [['rpc doctor', 'exits 1 when Chromium is missing']],
44
85
  },
45
86
  install: {
46
- usage: 'rpc install',
87
+ usage: 'rpc install [--headed]',
47
88
  summary: "Download Playwright's Chromium with the Playwright version rpc uses, then run doctor.",
48
- examples: [['rpc install', '']],
89
+ options: [[' --headed', 'also download full Chromium, for the window `rpc login` opens']],
90
+ examples: [
91
+ ['rpc install', 'headless shell only (captures)'],
92
+ ['rpc install --headed', 'for rpc login'],
93
+ ],
49
94
  },
50
95
  mcp: {
51
96
  usage: 'rpc mcp',
52
- summary: 'Run the MCP server on stdio (tools: screenshot, list_devices).',
97
+ summary: 'Run the MCP server on stdio (tools: screenshot, list_devices, list_sessions).',
53
98
  examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
54
99
  },
55
100
  };
56
101
  const TOP = [
57
102
  ['shot <url>', 'capture screenshots'],
103
+ ['login <url>', 'sign in once; later shots of that origin use the session'],
104
+ ['sessions', 'list or clear saved sessions'],
58
105
  ['devices', 'list device ids and aliases'],
59
106
  ['doctor', "check that Playwright's Chromium is installed and launches"],
60
107
  ['install', "download Playwright's Chromium (same version rpc uses)"],
@@ -91,6 +138,9 @@ export function richHelp(c) {
91
138
  c.bold('Shot options:'),
92
139
  ...table(SHOT_OPTIONS, c),
93
140
  '',
141
+ c.bold('Auth options:'),
142
+ ...table(AUTH_OPTIONS, c),
143
+ '',
94
144
  c.bold('Examples:'),
95
145
  ...examples(COMMANDS.shot.examples, c),
96
146
  '',
package/dist/login.js ADDED
@@ -0,0 +1,150 @@
1
+ // `rpc login`: sign in once, save the browser's storage state for the origin.
2
+ import { chromium } from 'playwright';
3
+ import { isLoginUrl, redact, scopeState, toOrigin, writeSession } from './auth.js';
4
+ import { launchBrowser } from './browser.js';
5
+ export const DEFAULT_SELECTORS = {
6
+ email: 'input[type=email], input[autocomplete=username], input[name=email], input[name=username], input#email',
7
+ password: 'input[type=password]',
8
+ submit: 'button[type=submit], input[type=submit]',
9
+ };
10
+ export class HeadedBrowserMissingError extends Error {
11
+ name = 'HeadedBrowserMissingError';
12
+ constructor() {
13
+ super('rpc login opens a browser window, which needs full Chromium (`rpc install` only downloads the headless shell). ' +
14
+ 'Run `rpc install --headed`, or install Google Chrome, or sign in headlessly with `rpc login <url> --form`.');
15
+ }
16
+ }
17
+ /** Headed Chromium; falls back to an installed Google Chrome. */
18
+ export async function launchHeaded() {
19
+ try {
20
+ return await chromium.launch({ headless: false });
21
+ }
22
+ catch (err) {
23
+ if (!/Executable doesn't exist|playwright install/i.test(err.message))
24
+ throw err;
25
+ }
26
+ try {
27
+ return await chromium.launch({ headless: false, channel: 'chrome' });
28
+ }
29
+ catch {
30
+ throw new HeadedBrowserMissingError();
31
+ }
32
+ }
33
+ async function save(state, opts, finalUrl) {
34
+ const origin = toOrigin(opts.url);
35
+ const path = await writeSession(state, { origin, savedAt: new Date().toISOString(), url: opts.url }, opts.save);
36
+ const own = scopeState(state, [origin]);
37
+ return {
38
+ origin,
39
+ path,
40
+ finalUrl,
41
+ cookies: own.cookies.length,
42
+ localStorage: own.origins.length,
43
+ stillOnLogin: isLoginUrl(finalUrl) && new URL(finalUrl).origin === origin,
44
+ };
45
+ }
46
+ /**
47
+ * Open a visible browser at the URL and save the storage state when `done`
48
+ * resolves (the user pressed Enter) or the window is closed.
49
+ */
50
+ export async function interactiveLogin(opts, done, onOpen) {
51
+ const browser = await launchHeaded();
52
+ try {
53
+ const context = await browser.newContext({ viewport: null, ignoreHTTPSErrors: true });
54
+ const page = await context.newPage();
55
+ let lastUrl = opts.url;
56
+ page.on('framenavigated', f => {
57
+ if (f === page.mainFrame())
58
+ lastUrl = f.url();
59
+ });
60
+ await page.goto(opts.url, { timeout: opts.timeoutMs }).catch(() => { });
61
+ onOpen?.();
62
+ // Snapshot regularly: once the window is closed the context can't be read.
63
+ let snapshot = await context.storageState();
64
+ const timer = setInterval(() => {
65
+ context.storageState().then(s => (snapshot = s), () => { });
66
+ }, 1000);
67
+ const closed = new Promise(res => {
68
+ page.on('close', res);
69
+ browser.on('disconnected', res);
70
+ });
71
+ try {
72
+ await Promise.race([done, closed]);
73
+ }
74
+ finally {
75
+ clearInterval(timer);
76
+ }
77
+ const state = await context.storageState().catch(() => snapshot);
78
+ if (!page.isClosed())
79
+ lastUrl = page.url();
80
+ return await save(state, opts, lastUrl);
81
+ }
82
+ finally {
83
+ await browser.close().catch(() => { });
84
+ }
85
+ }
86
+ /**
87
+ * Headless: fill an email + password form, submit, wait to leave the login
88
+ * URL, save. For CI and agents.
89
+ */
90
+ export async function formLogin(opts, form) {
91
+ const browser = await launchBrowser();
92
+ try {
93
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
94
+ const page = await context.newPage();
95
+ await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
96
+ // Let client-side forms hydrate, so submit runs the app's handler rather
97
+ // than a native GET that would put the password in the URL.
98
+ await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
99
+ const start = new URL(page.url());
100
+ const email = page.locator(form.emailSelector ?? DEFAULT_SELECTORS.email).first();
101
+ const password = page.locator(form.passwordSelector ?? DEFAULT_SELECTORS.password).first();
102
+ await waitVisible(email, 'email field', form.emailSelector ? '--email-selector' : undefined, opts.timeoutMs);
103
+ await email.fill(form.email);
104
+ await waitVisible(password, 'password field', form.passwordSelector ? '--password-selector' : undefined, opts.timeoutMs);
105
+ await password.fill(form.password);
106
+ const submit = page.locator(form.submitSelector ?? DEFAULT_SELECTORS.submit).first();
107
+ if (form.submitSelector || (await submit.count()) > 0) {
108
+ await waitVisible(submit, 'submit button', form.submitSelector ? '--submit-selector' : undefined, opts.timeoutMs);
109
+ await submit.click();
110
+ }
111
+ else {
112
+ await password.press('Enter');
113
+ }
114
+ try {
115
+ await page.waitForURL(u => u.origin + u.pathname !== start.origin + start.pathname, { timeout: opts.timeoutMs });
116
+ }
117
+ catch {
118
+ const shown = await pageMessage(page);
119
+ const message = shown && redact(shown, [form.password]);
120
+ throw new Error(`Still on ${start.origin}${start.pathname} ${Math.round(opts.timeoutMs / 1000)} s after submitting the form` +
121
+ `${message ? ` (the page says: "${message}")` : ''}. Check the credentials, or the selectors (--email-selector, --password-selector, --submit-selector).`);
122
+ }
123
+ await page.waitForLoadState('networkidle', { timeout: Math.min(opts.timeoutMs, 10_000) }).catch(() => { });
124
+ return await save(await context.storageState(), opts, page.url());
125
+ }
126
+ finally {
127
+ await browser.close().catch(() => { });
128
+ }
129
+ }
130
+ async function waitVisible(loc, what, flag, timeoutMs) {
131
+ try {
132
+ await loc.waitFor({ state: 'visible', timeout: Math.min(timeoutMs, 15_000) });
133
+ }
134
+ catch {
135
+ throw new Error(`No ${what} found on the page${flag ? ` for ${flag}` : ''}. Pass ${flag ?? `--${what.split(' ')[0]}-selector`} with a CSS selector that matches it.`);
136
+ }
137
+ }
138
+ /** A visible alert or error message, if the login page shows one. */
139
+ async function pageMessage(page) {
140
+ const loc = page.locator('[role=alert], [aria-live=assertive], .error, [data-error]').filter({ hasText: /\S/ }).first();
141
+ try {
142
+ if ((await loc.count()) === 0)
143
+ return undefined;
144
+ const t = (await loc.innerText({ timeout: 1000 })).trim().replace(/\s+/g, ' ');
145
+ return t ? t.slice(0, 160) : undefined;
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ }
package/dist/mcp.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
+ import { describeAuth, listSessions, resolveAuth, sessionsDir, toOrigin } from './auth.js';
5
6
  import { capture, launchBrowser } from './capture.js';
6
7
  import { renderPreview } from './composite.js';
7
8
  import { CATEGORY_LABELS, CATEGORY_ORDER, DEFAULT_DEVICES, DEVICE_ALIASES, DEVICES, resolveDevices, } from './devices.js';
@@ -64,6 +65,33 @@ const screenshotInput = {
64
65
  .boolean()
65
66
  .optional()
66
67
  .describe('Return JPEG previews inline: one per device, or the full-page slices when fullPage. Default true.'),
68
+ cookies: z
69
+ .array(z.object({
70
+ name: z.string().min(1),
71
+ value: z.string(),
72
+ domain: z.string().optional().describe('Default: the target host only. Must match the target host (or an allowOrigins host).'),
73
+ path: z.string().optional().describe('Default /.'),
74
+ }))
75
+ .max(50)
76
+ .optional()
77
+ .describe('Cookies set on the target origin before loading. Values are never echoed back.'),
78
+ headers: z
79
+ .record(z.string(), z.string())
80
+ .optional()
81
+ .describe('Request headers, e.g. {"Authorization": "Bearer …"}. Sent only to the target origin (and allowOrigins), never to third-party requests or redirects. Values are never echoed back.'),
82
+ storageState: z
83
+ .string()
84
+ .optional()
85
+ .describe("Path to a Playwright storageState JSON (cookies + localStorage), relative to the server's cwd. Only the target origin's entries are loaded."),
86
+ useSession: z
87
+ .boolean()
88
+ .optional()
89
+ .describe('Use the session saved by `rpc login <url>` for the target origin. Default true when one exists (see list_sessions). Ignored when storageState is set.'),
90
+ allowOrigins: z
91
+ .array(z.string())
92
+ .max(10)
93
+ .optional()
94
+ .describe('Extra origins (e.g. an API host) that also get the headers and loaded cookies.'),
67
95
  };
68
96
  export function createServer() {
69
97
  const server = new McpServer({ name: 'rpc', version: pkg.version });
@@ -77,6 +105,17 @@ export function createServer() {
77
105
  }, async (args) => {
78
106
  let options;
79
107
  try {
108
+ const auth = {};
109
+ if (args.cookies?.length)
110
+ auth.cookies = args.cookies;
111
+ if (args.headers && Object.keys(args.headers).length)
112
+ auth.headers = args.headers;
113
+ if (args.storageState)
114
+ auth.storageState = args.storageState;
115
+ if (args.useSession === false)
116
+ auth.useSession = false;
117
+ if (args.allowOrigins?.length)
118
+ auth.allowOrigins = args.allowOrigins.map(toOrigin);
80
119
  options = {
81
120
  url: normalizeUrl(args.url),
82
121
  devices: resolveDevices(args.devices ?? DEFAULT_DEVICES).map(d => d.id),
@@ -93,7 +132,9 @@ export function createServer() {
93
132
  saveSlices: args.saveSlices,
94
133
  maxSlices: args.maxSlices ?? DEFAULT_MAX_SLICES,
95
134
  sliceWidth: args.sliceWidth ?? DEFAULT_SLICE_WIDTH,
135
+ ...(Object.keys(auth).length ? { auth } : {}),
96
136
  };
137
+ resolveAuth(options.url, auth); // bad cookies/headers/storage state: a tool error, before launching
97
138
  }
98
139
  catch (err) {
99
140
  return { isError: true, content: [{ type: 'text', text: err.message }] };
@@ -111,9 +152,9 @@ export function createServer() {
111
152
  const content = [
112
153
  {
113
154
  type: 'text',
114
- text: headline(result.shots, options) +
155
+ text: headline(result.shots, options, result.auth) +
115
156
  '\n\n' +
116
- JSON.stringify(summarise(result.url, result.outDir, result.shots), null, 2),
157
+ JSON.stringify(summarise(result.url, result.outDir, result.shots, result.auth), null, 2),
117
158
  },
118
159
  ];
119
160
  if (inline) {
@@ -168,6 +209,29 @@ export function createServer() {
168
209
  lines.push(`${a} -> ${id}`);
169
210
  return { content: [{ type: 'text', text: lines.join('\n') }] };
170
211
  });
212
+ server.registerTool('list_sessions', {
213
+ title: 'List saved login sessions',
214
+ description: 'List the origins that have a session saved by `rpc login` (used automatically by screenshot for that origin): ' +
215
+ 'cookie count, when it was saved, and whether it has expired. Never returns cookie values.',
216
+ inputSchema: {},
217
+ annotations: { readOnlyHint: true },
218
+ }, async () => {
219
+ const list = await listSessions();
220
+ if (list.length === 0) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: 'text',
225
+ text: `No saved sessions in ${sessionsDir()}. The user can create one with \`rpc login <url>\` ` +
226
+ '(a browser window) or `rpc login <url> --form` with RPC_LOGIN_EMAIL / RPC_LOGIN_PASSWORD set.',
227
+ },
228
+ ],
229
+ };
230
+ }
231
+ const lines = list.map(s => `${s.origin}: ${s.cookies} cookie(s)${s.savedAt ? `, saved ${s.savedAt}` : ''}` +
232
+ `${s.expired ? `, EXPIRED ${s.expires} (run \`rpc login ${s.origin}\`)` : s.expires ? `, expires ${s.expires}` : ''}`);
233
+ return { content: [{ type: 'text', text: lines.join('\n') + '\n\n' + JSON.stringify({ dir: sessionsDir(), sessions: list }, null, 2) }] };
234
+ });
171
235
  return server;
172
236
  }
173
237
  /** Framed first screen (the hero), then the first maxSlices slices, each with a label. */
@@ -187,8 +251,10 @@ async function fullPageImages(browser, s, maxSlices) {
187
251
  return out;
188
252
  }
189
253
  /** Plain-language first lines: failures with hints, and what full-page output covers. */
190
- export function headline(shots, options) {
254
+ export function headline(shots, options, auth) {
191
255
  const lines = [];
256
+ if (auth)
257
+ lines.push(`auth: ${describeAuth(auth)}`);
192
258
  const failed = shots.filter(s => s.error);
193
259
  if (failed.length === shots.length)
194
260
  lines.push(`All ${shots.length} device(s) failed.`);
@@ -232,11 +298,12 @@ export function headline(shots, options) {
232
298
  }
233
299
  return lines.join('\n');
234
300
  }
235
- function summarise(url, outDir, shots) {
301
+ function summarise(url, outDir, shots, auth) {
236
302
  return {
237
303
  url,
238
304
  outDir,
239
305
  shots: shots.map(({ buffers: _b, ...s }) => s),
306
+ ...(auth ? { auth } : {}),
240
307
  };
241
308
  }
242
309
  export async function runMcpServer() {
package/dist/term.js CHANGED
@@ -44,3 +44,23 @@ export function openPath(path) {
44
44
  return false;
45
45
  }
46
46
  }
47
+ /**
48
+ * Resolve when a line (Enter) arrives on stdin. EOF never resolves it, so a
49
+ * caller can race it against something else (the login window closing).
50
+ */
51
+ export function waitForEnter(input = process.stdin) {
52
+ let onData = () => { };
53
+ const promise = new Promise(res => {
54
+ onData = chunk => {
55
+ if (/[\r\n]/.test(String(chunk)))
56
+ res();
57
+ };
58
+ input.on('data', onData);
59
+ input.resume();
60
+ });
61
+ const stop = () => {
62
+ input.off('data', onData);
63
+ input.pause();
64
+ };
65
+ return { promise, stop };
66
+ }
package/dist/ui.js CHANGED
@@ -6,11 +6,12 @@ import { connect } from 'node:net';
6
6
  import { relative } from 'node:path';
7
7
  import * as p from '@clack/prompts';
8
8
  import pc from 'picocolors';
9
+ import { clearSessions, listSessions, localTime, MASK, resolveAuth, sessionsDir, } from './auth.js';
9
10
  import { BrowserMissingError, installHint, launchBrowser } from './browser.js';
10
11
  import { capture } from './capture.js';
11
12
  import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
12
13
  import { defaultShotOptions, normalizeUrl } from './options.js';
13
- import { openCommand, openPath } from './term.js';
14
+ import { openCommand, openPath, waitForEnter } from './term.js';
14
15
  export function colors(enabled) {
15
16
  return pc.createColors(enabled);
16
17
  }
@@ -174,7 +175,7 @@ export function printDevices(c) {
174
175
  }
175
176
  // ------------------------------------------------------------------ shot
176
177
  /** Lines for the "Capture Summary" box. */
177
- export function summaryLines(o, c) {
178
+ export function summaryLines(o, c, auth) {
178
179
  const yes = (b) => (b ? c.green('yes') : c.dim('no'));
179
180
  const rows = [
180
181
  ['devices', formatList(o.devices.map(d => c.cyan(d)))],
@@ -188,10 +189,34 @@ export function summaryLines(o, c) {
188
189
  rows.push(['wait for', o.waitFor]);
189
190
  if (!o.emulate)
190
191
  rows.push(['emulation', c.yellow('viewport + DPR only')]);
192
+ if (auth)
193
+ rows.push(...authRows(auth, c));
191
194
  rows.push(['output', `${rel(o.outDir)}/`]);
192
195
  const w = Math.max(...rows.map(([k]) => k.length)) + 1;
193
196
  return [c.cyan(o.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)];
194
197
  }
198
+ /** Auth rows for the summary box: names only, values masked. */
199
+ export function authRows(a, c) {
200
+ const rows = [];
201
+ if (a.session) {
202
+ const saved = a.session.savedAt ? c.dim(` saved ${localTime(a.session.savedAt)}`) : '';
203
+ if (a.session.loaded)
204
+ rows.push(['session', `${c.green(a.session.origin)}${saved}`]);
205
+ else if (a.session.expired)
206
+ rows.push(['session', c.yellow(`${a.session.origin} expired, not loaded`)]);
207
+ else
208
+ rows.push(['session', c.dim(`${a.session.origin} skipped (--no-session)`)]);
209
+ }
210
+ if (a.storageState)
211
+ rows.push(['storage state', rel(a.storageState)]);
212
+ if (a.cookies?.length)
213
+ rows.push(['cookies', formatList(a.cookies.map(x => `${x.name}=${c.dim(MASK)}`))]);
214
+ if (a.headers)
215
+ rows.push(['headers', formatList(Object.keys(a.headers).map(h => `${h}: ${c.dim(MASK)}`))]);
216
+ if (a.allowOrigins?.length)
217
+ rows.push(['also allowed', formatList(a.allowOrigins)]);
218
+ return rows;
219
+ }
195
220
  function statusCell(s, c) {
196
221
  if (s.error)
197
222
  return c.red(s.code ?? 'error');
@@ -279,10 +304,11 @@ export function capturedLines(result, c) {
279
304
  return out;
280
305
  }
281
306
  export async function runShot(options, run, c) {
307
+ const auth = resolveAuth(options.url, options.auth ?? {});
282
308
  if (!run.continued) {
283
309
  printBanner(c);
284
310
  intro(c);
285
- p.note(summaryLines(options, c).join('\n'), 'Capture Summary');
311
+ p.note(summaryLines(options, c, auth.summary).join('\n'), 'Capture Summary');
286
312
  }
287
313
  const s = p.spinner();
288
314
  s.start('Starting Chromium');
@@ -313,6 +339,7 @@ export async function runShot(options, run, c) {
313
339
  try {
314
340
  result = await capture(options, {
315
341
  browser,
342
+ auth,
316
343
  onDeviceStart: (d) => {
317
344
  started = performance.now();
318
345
  current = p.spinner();
@@ -466,6 +493,90 @@ export async function runInteractive(c) {
466
493
  }
467
494
  return runShot(options, { open: false, continued: true, showCommand: true }, c);
468
495
  }
496
+ // ------------------------------------------------------------------ login
497
+ export async function runLogin(cmd, c) {
498
+ intro(c);
499
+ const { formLogin, interactiveLogin, HeadedBrowserMissingError } = await import('./login.js');
500
+ const s = p.spinner();
501
+ let spinning = false;
502
+ try {
503
+ let result;
504
+ if (cmd.form) {
505
+ s.start(`Signing in at ${c.cyan(cmd.url)} ${c.dim('(headless, form)')}`);
506
+ spinning = true;
507
+ result = await formLogin(cmd, cmd.form);
508
+ spinning = false;
509
+ s.stop(`Signed in ${c.dim(`now at ${result.finalUrl}`)}`);
510
+ }
511
+ else {
512
+ s.start('Opening Chromium');
513
+ spinning = true;
514
+ const enter = waitForEnter();
515
+ try {
516
+ result = await interactiveLogin(cmd, enter.promise, () => {
517
+ spinning = false;
518
+ s.stop(`Opened ${c.cyan(cmd.url)}`);
519
+ p.log.info(`Sign in in the browser window, then press ${c.bold('Enter')} here ${c.dim('(or close the window)')}`);
520
+ });
521
+ }
522
+ finally {
523
+ enter.stop();
524
+ }
525
+ }
526
+ if (result.stillOnLogin) {
527
+ p.log.warn(c.yellow(`The page was still on a login URL (${result.finalUrl}). The sign-in may not have finished.`));
528
+ }
529
+ p.note([
530
+ `${c.dim('origin:')} ${c.cyan(result.origin)}`,
531
+ `${c.dim('file:')} ${rel(result.path)} ${c.dim('(mode 0600)')}`,
532
+ `${c.dim('cookies:')} ${result.cookies}`,
533
+ `${c.dim('localStorage:')} ${result.localStorage} origin(s)`,
534
+ ].join('\n'), 'Saved session');
535
+ const next = cmd.save ? `rpc shot <url> --storage-state ${quote(rel(result.path))}` : `rpc shot ${result.origin}/…`;
536
+ p.outro(`${c.green('Done!')} ${c.dim(`Next: ${next}`)}`);
537
+ return 0;
538
+ }
539
+ catch (err) {
540
+ const msg = err.message;
541
+ if (spinning)
542
+ s.error(c.red(err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError ? 'No browser to open' : 'Sign-in failed'));
543
+ if (err instanceof HeadedBrowserMissingError || err instanceof BrowserMissingError) {
544
+ p.note(wrap(msg.replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
545
+ }
546
+ else {
547
+ p.log.error(wrap(msg, textWidth()).join('\n'));
548
+ }
549
+ p.outro(c.red('No session saved.'));
550
+ return 1;
551
+ }
552
+ }
553
+ export async function runSessions(action, origin, c) {
554
+ intro(c);
555
+ if (action === 'clear') {
556
+ const gone = await clearSessions(origin);
557
+ if (gone.length === 0)
558
+ p.log.info(origin ? `No saved session for ${c.cyan(origin)}` : 'No saved sessions');
559
+ for (const o of gone)
560
+ p.log.success(`Removed ${c.cyan(o)}`);
561
+ p.outro(c.dim(`${gone.length} removed`));
562
+ return 0;
563
+ }
564
+ const list = await listSessions();
565
+ if (list.length === 0) {
566
+ p.log.info(`No saved sessions in ${c.dim(sessionsDir())}`);
567
+ p.outro(c.dim('Create one with: rpc login <url>'));
568
+ return 0;
569
+ }
570
+ const when = localTime;
571
+ const originW = Math.max(...list.map(s => s.origin.length));
572
+ const lines = list.map(s => {
573
+ const exp = s.expired ? c.red(`expired ${when(s.expires)}`) : s.expires ? c.dim(`expires ${when(s.expires)}`) : c.dim('no expiry');
574
+ return `${c.cyan(s.origin.padEnd(originW))} ${String(s.cookies).padStart(2)} cookie${s.cookies === 1 ? ' ' : 's'} ${c.dim(`saved ${when(s.savedAt)}`)} ${exp}`;
575
+ });
576
+ p.note(lines.join('\n'), `${list.length} saved session${list.length === 1 ? '' : 's'}`);
577
+ p.outro(c.dim(sessionsDir()));
578
+ return 0;
579
+ }
469
580
  // ------------------------------------------------------------------ doctor
470
581
  export async function runDoctor(check, c) {
471
582
  intro(c);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rpc-agent",
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.",
3
+ "version": "0.4.0",
4
+ "description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not. CLI and MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "mcp-server",
@@ -55,6 +55,7 @@
55
55
  "zod": "^4.6.7"
56
56
  },
57
57
  "devDependencies": {
58
+ "@fontsource/ibm-plex-mono": "^5.3.0",
58
59
  "@types/node": "^22.20.5",
59
60
  "@xterm/headless": "^5.5.0",
60
61
  "tsx": "^4.23.15",