@crouton-kit/capture 1.3.4 → 1.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
@@ -10,7 +10,9 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "build": "esbuild src/capture.ts --bundle --platform=node --format=cjs --outfile=bin/capture '--banner:js=#!/usr/bin/env node' \"--define:globalThis.__CAPTURE_VERSION__=\\\"$npm_package_version\\\"\" && chmod +x bin/capture"
13
+ "build": "esbuild src/capture.ts --bundle --platform=node --format=cjs --outfile=bin/capture --external:esbuild '--banner:js=#!/usr/bin/env node' \"--define:globalThis.__CAPTURE_VERSION__=\\\"$npm_package_version\\\"\" && chmod +x bin/capture",
14
+ "dev": "tsx src/capture.ts",
15
+ "test": "node --import tsx --test test/*.test.ts"
14
16
  },
15
17
  "repository": {
16
18
  "type": "git",
@@ -33,7 +35,9 @@
33
35
  "devDependencies": {
34
36
  "@types/ws": "^8.18.1",
35
37
  "esbuild": "^0.24.0",
36
- "typescript": "^5.7.0"
38
+ "tsx": "^4",
39
+ "typescript": "^5.7.0",
40
+ "zod": "^4.3.5"
37
41
  },
38
42
  "dependencies": {
39
43
  "ws": "^8.19.0"
package/src/capture.ts CHANGED
@@ -69,6 +69,7 @@ async function main(): Promise<void> {
69
69
  case "record":
70
70
  case "navigate":
71
71
  case "har":
72
+ case "lib":
72
73
  case "network":
73
74
  case "list":
74
75
  return cdpMain();
@@ -91,7 +92,7 @@ TYPICAL WORKFLOW
91
92
  capture type "hi@me.com" --into "Email"
92
93
  capture screenshot Save current state
93
94
  capture navigate https://... Navigate within the session
94
- capture exec "document.title" Run JS (supports await)
95
+ capture exec "document.title" Run JS (expressions, return, await)
95
96
  capture har read --filter-url /api Inspect recorded traffic
96
97
 
97
98
  3. Bundle and inspect:
@@ -112,7 +113,7 @@ INTERACTION COMMANDS (work inside or outside a session)
112
113
  click "name" [--role <role>] Click by accessible name
113
114
  type "text" [--into "Field"] Type into focused element or named field
114
115
  screenshot [--out <path>] [--full-page] Screenshot (viewport: desktop|desktop-wide|tablet|mobile)
115
- exec <code> | exec --file <path> Evaluate JS; await is supported
116
+ exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
116
117
  navigate <url> [--settle <ms>] Navigate the current tab + record HAR
117
118
 
118
119
  DIAGNOSTICS & ONE-OFFS (no session needed)
@@ -125,11 +126,20 @@ DIAGNOSTICS & ONE-OFFS (no session needed)
125
126
  network <offline|online> Toggle connectivity for a tab
126
127
  har create | read [id] | delete <id> Manage standalone HAR recordings
127
128
 
129
+ LIBRARY (vault libs — dev checkout only)
130
+
131
+ lib list List available libs
132
+ lib search "<query>" [--limit N] Fuzzy-search functions across libs
133
+ lib show <name> Lib + function summaries
134
+ lib read <name> [fn…] Full input/output schemas (+ .ts source path)
135
+ exec "import {fn} from 'libs/<name>'; return await fn({…})" Run a lib in the active tab
136
+
128
137
  TARGETING (only when NOT in a session, or picking a parallel tab)
129
138
 
130
- --target <tabId> Exact id; a prefix of 8 chars is enough (preferred, parallel-safe)
131
- --url <pattern> Fuzzy URL match against open tabs
132
- NOTE: on \`session start\`, --url is a URL to OPEN, not a pattern.
139
+ CDP_PORT / CDP_TARGET Orchestrators can pin the active browser + tab
140
+ --target <tabId> Exact id; a prefix of 8 chars is enough (preferred, parallel-safe)
141
+ --url <pattern> Fuzzy URL match against open tabs
142
+ NOTE: on \`session start\`, --url is a URL to OPEN, not a pattern.
133
143
 
134
144
  HELP
135
145
 
package/src/cdp/args.ts CHANGED
@@ -1,14 +1,33 @@
1
1
  import { type ParsedArgs } from './types.js';
2
2
  import { getActiveSession } from '../session-context.js';
3
3
 
4
+ // Every flag below is matched as `--flag` followed by a separate `next`
5
+ // token (space-separated). To accept the equally-common `--flag=value` form
6
+ // without duplicating every branch, split any `--flag=value` token into two
7
+ // tokens up front so the rest of the parser only ever has to handle one
8
+ // convention.
9
+ export function expandEqualsFlags(argv: string[]): string[] {
10
+ const out: string[] = [];
11
+ for (const arg of argv) {
12
+ if (arg.startsWith('--') && arg.includes('=')) {
13
+ const eq = arg.indexOf('=');
14
+ out.push(arg.slice(0, eq), arg.slice(eq + 1));
15
+ } else {
16
+ out.push(arg);
17
+ }
18
+ }
19
+ return out;
20
+ }
21
+
4
22
  export function parseCliArgs(argv: string[]): ParsedArgs {
5
23
  const command = argv[0] ?? '';
24
+ const rest = expandEqualsFlags(argv.slice(1));
6
25
  const positional: string[] = [];
7
26
  const parsed: Partial<ParsedArgs> = { command, positional };
8
27
 
9
- for (let i = 1; i < argv.length; i++) {
10
- const arg = argv[i];
11
- const next = argv[i + 1];
28
+ for (let i = 0; i < rest.length; i++) {
29
+ const arg = rest[i];
30
+ const next = rest[i + 1];
12
31
 
13
32
  if (arg === '--port' && next) {
14
33
  parsed.port = parseInt(next, 10);
@@ -44,6 +63,9 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
44
63
  } else if (arg === '--target' && next) {
45
64
  parsed.target = next;
46
65
  i++;
66
+ } else if (arg === '--url' && next) {
67
+ parsed.url = next;
68
+ i++;
47
69
  } else if (arg === '--role' && next) {
48
70
  parsed.role = next;
49
71
  i++;
@@ -83,7 +105,15 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
83
105
  }
84
106
 
85
107
  // Fill gaps from environment variables (set by pipeline orchestrators)
86
- if (!parsed.target && process.env.CDP_TARGET) {
108
+ if (!parsed.port && process.env.CDP_PORT) {
109
+ const envPort = Number.parseInt(process.env.CDP_PORT, 10);
110
+ if (Number.isNaN(envPort)) {
111
+ console.error(`Invalid CDP_PORT: ${process.env.CDP_PORT}`);
112
+ process.exit(1);
113
+ }
114
+ parsed.port = envPort;
115
+ }
116
+ if (!parsed.target && !parsed.url && process.env.CDP_TARGET) {
87
117
  parsed.target = process.env.CDP_TARGET;
88
118
  }
89
119
  if (!parsed.har && process.env.CDP_HAR_ID) {
@@ -94,7 +124,9 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
94
124
  const session = getActiveSession();
95
125
  if (session) {
96
126
  if (!parsed.har && session.harId) parsed.har = session.harId;
97
- if (!parsed.target && session.targetId) parsed.target = session.targetId;
127
+ // An explicit --url picks a different (parallel) tab than the session's
128
+ // own — don't let the session's targetId clobber that choice.
129
+ if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
98
130
  }
99
131
 
100
132
  return parsed as ParsedArgs;
@@ -1,19 +1,24 @@
1
1
  import * as fs from 'fs';
2
2
  import { withConnection } from '../connection.js';
3
+ import { buildExecExpression } from '../exec-expression.js';
3
4
  import { HARRecorder } from '../har-recorder.js';
4
5
  import { writeHarAndPrintSummary } from '../har-output.js';
5
6
  import { type ParsedArgs } from '../types.js';
7
+ import { hasImports, bundleExec } from '../../vault/bundle.js';
6
8
 
7
9
  export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void> {
8
10
  if (parsed.help) {
9
11
  console.log(
10
12
  'Usage: capture exec <code> [--target <id>] [--record] [--file <path>] [--har <id>]\n\n' +
11
- 'Execute JavaScript in a browser tab. Supports await (wrapped in async IIFE).\n\n' +
13
+ 'Execute JavaScript in a browser tab. Plain expressions run directly; statement bodies can use top-level return/await and are wrapped in an async IIFE.\n\n' +
12
14
  'Examples:\n' +
13
15
  ' capture exec "document.title" --target <id>\n' +
14
- ' capture exec "await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
16
+ ' capture exec "return document.title" --target <id>\n' +
17
+ ' capture exec "return await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
15
18
  ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
16
- ' capture exec --file /tmp/scrape.js --target <id> --record',
19
+ ' capture exec --file /tmp/scrape.js --target <id> --record\n\n' +
20
+ 'Import vault libs (dev checkout only): static imports first, then your code.\n' +
21
+ ' capture exec "import {searchEmails} from \'libs/gmail\'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: \'invoice\' })"',
17
22
  );
18
23
  process.exit(0);
19
24
  }
@@ -26,8 +31,13 @@ export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void
26
31
  console.error(
27
32
  'ERROR: Missing code to execute.\n\n' +
28
33
  'Usage: capture exec <code> [--target <id>] [--record] [--file <path>]\n\n' +
34
+ 'Supported shapes:\n' +
35
+ ' document.title\n' +
36
+ ' return document.title\n' +
37
+ ' const r = await fetch(\'/api/data\'); return await r.json()\n\n' +
29
38
  'Examples:\n' +
30
39
  ' capture exec "document.title" --target <id>\n' +
40
+ ' capture exec "return document.title" --target <id>\n' +
31
41
  ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
32
42
  ' capture exec --file /tmp/scrape.js --target <id> --record',
33
43
  );
@@ -35,6 +45,19 @@ export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void
35
45
  }
36
46
  }
37
47
 
48
+ // Import-driven exec: if the code has leading static imports, bundle the
49
+ // forked vault libs on the fly (esbuild) BEFORE opening a tab — fail fast,
50
+ // no wasted CDP connection. Plain exec (no imports) skips this entirely.
51
+ let prebuilt: string | undefined;
52
+ if (hasImports(code)) {
53
+ try {
54
+ prebuilt = await bundleExec(code);
55
+ } catch (e) {
56
+ console.error(`ERROR: ${(e as Error).message}`);
57
+ process.exit(1);
58
+ }
59
+ }
60
+
38
61
  await withConnection(
39
62
  parsed,
40
63
  async (client) => {
@@ -47,13 +70,11 @@ export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void
47
70
  await standaloneRecorder.start();
48
71
  }
49
72
 
50
- // Smart wrapping: only use async IIFE when code contains `await`.
51
- // Without wrapping, Runtime.evaluate returns the completion value
52
- // of the last expression, handling multi-statement code naturally.
53
- const needsAsyncWrap = /\bawait\b/.test(code);
54
- const expression = needsAsyncWrap
55
- ? `(async () => { ${code} })()`
56
- : code;
73
+ // A prebuilt bundle is already a complete IIFE returning the user's
74
+ // promise. Otherwise fall back to the exec expression wrapper: bare
75
+ // expressions run directly, while snippets containing top-level `return`
76
+ // or `await` are wrapped in an async IIFE.
77
+ const expression = buildExecExpression(code, prebuilt);
57
78
 
58
79
  const evalResult = (await client.send('Runtime.evaluate', {
59
80
  expression,
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `capture lib <list|search|show|read>` — search the forked vault libs.
3
+ *
4
+ * Pure local reads, NO CDP connection. Mirrors `nl lib`
5
+ * (northlight apps/core/src/cli/families/lib/lib-cli.provider.ts): same search
6
+ * scoring and the discover→schema→run loop, sourced from on-demand doc-gen
7
+ * (`../../vault/docs.ts`) + sha cache. Output contract (capture convention):
8
+ * the machine result → stdout `JSON.stringify(x, null, 2)`; next-step hints →
9
+ * stderr. Every object naming a lib/function carries `srcPath` = the absolute
10
+ * path to that lib's `.ts` source — the agent's pointer to rebuild on the fly.
11
+ */
12
+
13
+ import * as path from "path";
14
+ import { type ParsedArgs } from "../types.js";
15
+ import { vaultLibsDir, listLibs } from "../../vault/paths.js";
16
+ import {
17
+ getDoc,
18
+ listDocs,
19
+ isAgentVisible,
20
+ type LibraryDoc,
21
+ } from "../../vault/docs.js";
22
+
23
+ const USAGE =
24
+ "Usage: capture lib <list|search|show|read> [options]\n\n" +
25
+ " list List available libs (name, fn count, description, srcPath)\n" +
26
+ ' search "<query>" [--limit N] Fuzzy-search functions across all libs (ranked)\n' +
27
+ " show <name> Lib + function summaries (no schemas)\n" +
28
+ " read <name> [fn…] Full input/output JSON schemas (+ .ts source path)\n\n" +
29
+ "Then run a lib in the active authenticated tab:\n" +
30
+ " capture exec \"import {<fn>} from 'libs/<name>'; return await <fn>({…})\"\n\n" +
31
+ "Dev-checkout-only feature (requires vault/ source + esbuild).";
32
+
33
+ // ─── scoring (ported verbatim from lib-cli.provider.ts:71-152) ───────────────
34
+
35
+ const FUNCTION_NAME_EXACT = 100;
36
+ const FUNCTION_NAME_PREFIX = 60;
37
+ const FUNCTION_NAME_SUBSTRING = 40;
38
+ const SERVICE_NAME_MATCH = 50;
39
+ const FUNCTION_DESC_SUBSTRING = 20;
40
+ const SERVICE_DESC_SUBSTRING = 10;
41
+
42
+ function scoreToken(
43
+ token: string,
44
+ service: string,
45
+ fnName: string,
46
+ fnDesc: string,
47
+ serviceDesc: string,
48
+ ): { score: number; match: string } {
49
+ const q = token.toLowerCase();
50
+ const fn = fnName.toLowerCase();
51
+ const svc = service.toLowerCase();
52
+ const fnD = fnDesc.toLowerCase();
53
+ const svcD = serviceDesc.toLowerCase();
54
+
55
+ let score = 0;
56
+ let match = "";
57
+
58
+ if (fn === q) {
59
+ score += FUNCTION_NAME_EXACT;
60
+ match = "function-name (exact)";
61
+ } else if (fn.startsWith(q)) {
62
+ score += FUNCTION_NAME_PREFIX;
63
+ match = "function-name (prefix)";
64
+ } else if (fn.includes(q)) {
65
+ score += FUNCTION_NAME_SUBSTRING;
66
+ match = "function-name";
67
+ }
68
+
69
+ if (svc.includes(q)) {
70
+ score += SERVICE_NAME_MATCH;
71
+ if (!match) match = "service-name";
72
+ }
73
+
74
+ if (fnD.includes(q)) {
75
+ score += FUNCTION_DESC_SUBSTRING;
76
+ if (!match) match = "function-desc";
77
+ }
78
+
79
+ if (svcD.includes(q)) {
80
+ score += SERVICE_DESC_SUBSTRING;
81
+ if (!match) match = "service-desc";
82
+ }
83
+
84
+ return { score, match };
85
+ }
86
+
87
+ /**
88
+ * Every whitespace-separated token must contribute > 0 — without this,
89
+ * `lib search "send email"` would return everything matching "send" alone.
90
+ */
91
+ function scoreHit(
92
+ query: string,
93
+ service: string,
94
+ fnName: string,
95
+ fnDesc: string,
96
+ serviceDesc: string,
97
+ ): { score: number; match: string } {
98
+ const tokens = query.split(/\s+/).filter(Boolean);
99
+ if (tokens.length === 0) return { score: 0, match: "" };
100
+ if (tokens.length === 1) {
101
+ return scoreToken(tokens[0], service, fnName, fnDesc, serviceDesc);
102
+ }
103
+ let total = 0;
104
+ let bestScore = -1;
105
+ let bestMatch = "";
106
+ for (const token of tokens) {
107
+ const r = scoreToken(token, service, fnName, fnDesc, serviceDesc);
108
+ if (r.score === 0) return { score: 0, match: "" };
109
+ total += r.score;
110
+ if (r.score > bestScore) {
111
+ bestScore = r.score;
112
+ bestMatch = r.match;
113
+ }
114
+ }
115
+ return { score: total, match: bestMatch };
116
+ }
117
+
118
+ // ─── helpers ─────────────────────────────────────────────────────────────────
119
+
120
+ const svcName = (doc: LibraryDoc): string =>
121
+ doc.library.replace(/^@vallum\//, "");
122
+
123
+ const srcPathFor = (name: string): string =>
124
+ path.join(vaultLibsDir(), name, "index.ts");
125
+
126
+ /** Resolve a user-supplied name against a candidate set: exact, then ci-prefix. */
127
+ function resolveName(
128
+ query: string,
129
+ names: string[],
130
+ ): { name: string } | { error: string } {
131
+ if (names.includes(query)) return { name: query };
132
+ const lower = query.toLowerCase();
133
+ const prefix = names.filter((n) => n.toLowerCase().startsWith(lower));
134
+ if (prefix.length === 1) return { name: prefix[0] };
135
+ if (prefix.length > 1) {
136
+ return { error: `Ambiguous lib "${query}" — matches: ${prefix.join(", ")}` };
137
+ }
138
+ return { error: `Unknown lib "${query}". Available: ${names.join(", ")}` };
139
+ }
140
+
141
+ // ─── command ─────────────────────────────────────────────────────────────────
142
+
143
+ export async function cmdLib(
144
+ parsed: ParsedArgs,
145
+ _args: string[],
146
+ ): Promise<void> {
147
+ if (parsed.help) {
148
+ console.log(USAGE);
149
+ process.exit(0);
150
+ }
151
+
152
+ const sub = parsed.positional[0];
153
+
154
+ // ── lib list ──────────────────────────────────────────────────────────────
155
+ if (sub === "list") {
156
+ const docs = await listDocs();
157
+ const rows = docs.map((d) => {
158
+ const name = svcName(d);
159
+ return {
160
+ name,
161
+ functionCount: d.functions.length,
162
+ description: d.description,
163
+ srcPath: srcPathFor(name),
164
+ };
165
+ });
166
+ console.log(JSON.stringify(rows, null, 2));
167
+ console.error(
168
+ `\n${rows.length} libs. Next: capture lib show <name> · capture lib read <name> <fn>`,
169
+ );
170
+ return;
171
+ }
172
+
173
+ // ── lib search ──────────────────────────────────────────────────────────────
174
+ if (sub === "search") {
175
+ const query = parsed.positional[1];
176
+ if (!query) {
177
+ console.error('Usage: capture lib search "<query>" [--limit N]');
178
+ process.exit(1);
179
+ }
180
+ const limit = Math.min(
181
+ typeof parsed.limit === "number" && parsed.limit > 0 ? parsed.limit : 20,
182
+ 500,
183
+ );
184
+ const docs = await listDocs();
185
+ const hits: Array<{
186
+ service: string;
187
+ function: string;
188
+ match: string;
189
+ score: number;
190
+ description: string;
191
+ srcPath: string;
192
+ }> = [];
193
+ for (const d of docs) {
194
+ const service = svcName(d);
195
+ for (const fn of d.functions) {
196
+ const { score, match } = scoreHit(
197
+ query,
198
+ service,
199
+ fn.name,
200
+ fn.description ?? "",
201
+ d.description ?? "",
202
+ );
203
+ if (score > 0) {
204
+ hits.push({
205
+ service,
206
+ function: fn.name,
207
+ match,
208
+ score,
209
+ description: fn.description,
210
+ srcPath: srcPathFor(service),
211
+ });
212
+ }
213
+ }
214
+ }
215
+ hits.sort((a, b) => b.score - a.score);
216
+ const top = hits.slice(0, limit);
217
+ console.log(JSON.stringify(top, null, 2));
218
+ console.error(
219
+ `\n${hits.length} hit(s)${hits.length > limit ? ` (showing ${limit})` : ""}.` +
220
+ ` Next: capture lib read <service> <function>`,
221
+ );
222
+ return;
223
+ }
224
+
225
+ // ── lib show ──────────────────────────────────────────────────────────────
226
+ if (sub === "show") {
227
+ const name = parsed.positional[1];
228
+ if (!name) {
229
+ console.error("Usage: capture lib show <name>");
230
+ process.exit(1);
231
+ }
232
+ // Resolve + build ONLY the named lib (design §3.1: show is single-lib, not
233
+ // an all-lib bundle). Mirrors the read path; adds show's visibility filter
234
+ // (a present-but-non-visible lib is treated as Unknown).
235
+ const r = resolveName(name, listLibs());
236
+ if ("error" in r) {
237
+ console.error(r.error);
238
+ process.exit(1);
239
+ }
240
+ let doc: LibraryDoc;
241
+ try {
242
+ doc = await getDoc(r.name);
243
+ } catch (e) {
244
+ console.error((e as Error).message);
245
+ process.exit(1);
246
+ }
247
+ if (!isAgentVisible(doc)) {
248
+ console.error(
249
+ `Unknown lib "${name}". Available: ${listLibs().join(", ")}`,
250
+ );
251
+ process.exit(1);
252
+ }
253
+ const service = r.name;
254
+ const out = {
255
+ service,
256
+ description: doc.description,
257
+ ...(doc.loginUrl ? { loginUrl: doc.loginUrl } : {}),
258
+ ...(doc.notes ? { notes: doc.notes } : {}),
259
+ srcPath: srcPathFor(service),
260
+ functions: doc.functions.map((f) => ({
261
+ name: f.name,
262
+ description: f.description,
263
+ })),
264
+ };
265
+ console.log(JSON.stringify(out, null, 2));
266
+ console.error(
267
+ `\nSchemas required before exec — capture lib read ${service} <fn>`,
268
+ );
269
+ return;
270
+ }
271
+
272
+ // ── lib read ──────────────────────────────────────────────────────────────
273
+ if (sub === "read") {
274
+ const name = parsed.positional[1];
275
+ if (!name) {
276
+ console.error("Usage: capture lib read <name> [fn…]");
277
+ process.exit(1);
278
+ }
279
+ // `read` may read ANY present lib (no visibility filter).
280
+ const r = resolveName(name, listLibs());
281
+ if ("error" in r) {
282
+ console.error(r.error);
283
+ process.exit(1);
284
+ }
285
+ let doc: LibraryDoc;
286
+ try {
287
+ doc = await getDoc(r.name);
288
+ } catch (e) {
289
+ console.error((e as Error).message);
290
+ process.exit(1);
291
+ }
292
+ const service = svcName(doc);
293
+ const fns = parsed.positional.slice(2);
294
+
295
+ // No function names → summary fallback (show-style) + hint to name fns.
296
+ if (fns.length === 0) {
297
+ const out = {
298
+ service,
299
+ description: doc.description,
300
+ ...(doc.notes ? { notes: doc.notes } : {}),
301
+ srcPath: srcPathFor(service),
302
+ functions: doc.functions.map((f) => ({
303
+ name: f.name,
304
+ description: f.description,
305
+ })),
306
+ };
307
+ console.log(JSON.stringify(out, null, 2));
308
+ console.error(
309
+ `\nName functions to load schemas: capture lib read ${service} <fn> [fn…]`,
310
+ );
311
+ return;
312
+ }
313
+
314
+ const byName = new Map(doc.functions.map((f) => [f.name, f]));
315
+ const found: typeof doc.functions = [];
316
+ const missing: string[] = [];
317
+ for (const op of fns) {
318
+ const fn = byName.get(op);
319
+ if (fn) found.push(fn);
320
+ else missing.push(op);
321
+ }
322
+
323
+ if (found.length === 0) {
324
+ const available = doc.functions.map((f) => f.name).join(", ");
325
+ const label =
326
+ missing.length > 1
327
+ ? `Functions "${missing.join('", "')}"`
328
+ : `Function "${missing[0]}"`;
329
+ console.error(`${label} not in ${service}. Available: ${available}`);
330
+ process.exit(1);
331
+ }
332
+
333
+ const out = {
334
+ service,
335
+ srcPath: srcPathFor(service),
336
+ functions: found.map((f) => ({
337
+ name: f.name,
338
+ description: f.description,
339
+ ...(f.notes ? { notes: f.notes } : {}),
340
+ input: f.input,
341
+ output: f.output,
342
+ })),
343
+ ...(missing.length > 0 ? { missing } : {}),
344
+ };
345
+ console.log(JSON.stringify(out, null, 2));
346
+ const firstFn = found[0].name;
347
+ console.error(
348
+ `\nRun it: capture exec "import {${firstFn}} from 'libs/${service}'; return await ${firstFn}({…})"`,
349
+ );
350
+ return;
351
+ }
352
+
353
+ // ── lib (no/unknown subcommand) ─────────────────────────────────────────────
354
+ console.error(USAGE);
355
+ process.exit(1);
356
+ }
@@ -1,4 +1,4 @@
1
- import { detectCdpPortsAsync, detectCdpPort, getDefaultBrowserId } from '../detect.js';
1
+ import { detectCdpPortsAsync, detectCdpPort, getDefaultBrowserId, pickPreferredEndpoint } from '../detect.js';
2
2
  import { listTargets, openTab } from '../targets.js';
3
3
  import { navigateAndWait } from '../record.js';
4
4
  import { CDPClient } from '../client.js';
@@ -25,13 +25,7 @@ export async function cmdDetect(parsed: ParsedArgs, _args: string[]): Promise<vo
25
25
  );
26
26
  process.exit(1);
27
27
  }
28
- const defaultBrowser = getDefaultBrowserId();
29
- const preferred =
30
- endpoints.find(
31
- (p) => p.bundleId === defaultBrowser && !p.isElectron,
32
- ) ??
33
- endpoints.find((p) => !p.isElectron) ??
34
- endpoints[0];
28
+ const preferred = pickPreferredEndpoint(endpoints, getDefaultBrowserId());
35
29
  console.log(
36
30
  JSON.stringify(
37
31
  {
@@ -9,7 +9,7 @@ import { type ParsedArgs } from '../types.js';
9
9
  export async function cmdScreenshot(parsed: ParsedArgs, _args: string[]): Promise<void> {
10
10
  if (parsed.help) {
11
11
  console.log(
12
- 'Usage: capture screenshot [--target <id>] [--out <path>] [--viewport <preset>] [--height <px>] [--full-page]\n\n' +
12
+ 'Usage: capture screenshot [--target <id> | --url <pattern>] [--out <path>] [--viewport <preset>] [--height <px>] [--full-page]\n\n' +
13
13
  'Capture a screenshot of the targeted tab. Saves to --out or auto-generates a path.\n\n' +
14
14
  'Options:\n' +
15
15
  ' --viewport <preset> Viewport preset (default: desktop)\n' +