@crouton-kit/capture 1.3.3 → 1.3.5

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.
@@ -0,0 +1,97 @@
1
+ import WebSocket from 'ws';
2
+
3
+ export class CDPClient {
4
+ private ws: WebSocket;
5
+ private messageId = 0;
6
+ private pendingRequests = new Map<
7
+ number,
8
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
9
+ >();
10
+ private pendingTimeouts = new Map<number, NodeJS.Timeout>();
11
+ private eventHandlers = new Map<string, Array<(params: unknown) => void>>();
12
+ private ready: Promise<void>;
13
+
14
+ constructor(wsUrl: string, connectTimeout = 5000) {
15
+ this.ws = new WebSocket(wsUrl);
16
+ this.ready = new Promise((resolve, reject) => {
17
+ const timeout = setTimeout(() => {
18
+ reject(new Error(`WebSocket connection timeout (${connectTimeout}ms)`));
19
+ }, connectTimeout);
20
+
21
+ this.ws.on('open', () => {
22
+ clearTimeout(timeout);
23
+ resolve();
24
+ });
25
+ this.ws.on('error', (err) => {
26
+ clearTimeout(timeout);
27
+ reject(err);
28
+ });
29
+ });
30
+
31
+ this.ws.on('message', (data: Buffer) => {
32
+ const msg = JSON.parse(data.toString());
33
+ if (msg.id !== undefined) {
34
+ const pending = this.pendingRequests.get(msg.id);
35
+ if (pending) {
36
+ this.pendingRequests.delete(msg.id);
37
+ const timer = this.pendingTimeouts.get(msg.id);
38
+ if (timer) {
39
+ clearTimeout(timer);
40
+ this.pendingTimeouts.delete(msg.id);
41
+ }
42
+ if (msg.error) {
43
+ pending.reject(new Error(msg.error.message));
44
+ } else {
45
+ pending.resolve(msg.result);
46
+ }
47
+ }
48
+ } else if (msg.method) {
49
+ const handlers = this.eventHandlers.get(msg.method);
50
+ handlers?.forEach((h) => h(msg.params));
51
+ }
52
+ });
53
+ }
54
+
55
+ async waitReady(): Promise<void> {
56
+ await this.ready;
57
+ }
58
+
59
+ async send(
60
+ method: string,
61
+ params: Record<string, unknown> = {},
62
+ timeout = 60000,
63
+ ): Promise<unknown> {
64
+ await this.ready;
65
+ const id = ++this.messageId;
66
+
67
+ return new Promise((resolve, reject) => {
68
+ this.pendingRequests.set(id, { resolve, reject });
69
+ this.ws.send(JSON.stringify({ id, method, params }));
70
+
71
+ const timer = setTimeout(() => {
72
+ if (this.pendingRequests.has(id)) {
73
+ this.pendingRequests.delete(id);
74
+ this.pendingTimeouts.delete(id);
75
+ reject(new Error(`CDP request timeout (${timeout}ms): ${method}`));
76
+ }
77
+ }, timeout);
78
+ this.pendingTimeouts.set(id, timer);
79
+ });
80
+ }
81
+
82
+ on(event: string, handler: (params: unknown) => void): void {
83
+ if (!this.eventHandlers.has(event)) {
84
+ this.eventHandlers.set(event, []);
85
+ }
86
+ this.eventHandlers.get(event)!.push(handler);
87
+ }
88
+
89
+ close(): void {
90
+ // Clear all pending timeouts to allow process to exit
91
+ for (const timer of this.pendingTimeouts.values()) {
92
+ clearTimeout(timer);
93
+ }
94
+ this.pendingTimeouts.clear();
95
+ this.ws.close();
96
+ }
97
+ }
@@ -0,0 +1,102 @@
1
+ import * as fs from 'fs';
2
+ import { withConnection } from '../connection.js';
3
+ import { HARRecorder } from '../har-recorder.js';
4
+ import { writeHarAndPrintSummary } from '../har-output.js';
5
+ import { type ParsedArgs } from '../types.js';
6
+ import { hasImports, bundleExec } from '../../vault/bundle.js';
7
+
8
+ export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void> {
9
+ if (parsed.help) {
10
+ console.log(
11
+ 'Usage: capture exec <code> [--target <id>] [--record] [--file <path>] [--har <id>]\n\n' +
12
+ 'Execute JavaScript in a browser tab. Supports await (wrapped in async IIFE).\n\n' +
13
+ 'Examples:\n' +
14
+ ' capture exec "document.title" --target <id>\n' +
15
+ ' capture exec "await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
16
+ ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
17
+ ' capture exec --file /tmp/scrape.js --target <id> --record\n\n' +
18
+ 'Import vault libs (dev checkout only): static imports first, then your code.\n' +
19
+ ' capture exec "import {searchEmails} from \'libs/gmail\'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: \'invoice\' })"',
20
+ );
21
+ process.exit(0);
22
+ }
23
+ let code: string;
24
+ if (parsed.file) {
25
+ code = fs.readFileSync(parsed.file, 'utf-8');
26
+ } else {
27
+ code = parsed.positional[0];
28
+ if (!code) {
29
+ console.error(
30
+ 'ERROR: Missing code to execute.\n\n' +
31
+ 'Usage: capture exec <code> [--target <id>] [--record] [--file <path>]\n\n' +
32
+ 'Examples:\n' +
33
+ ' capture exec "document.title" --target <id>\n' +
34
+ ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
35
+ ' capture exec --file /tmp/scrape.js --target <id> --record',
36
+ );
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ // Import-driven exec: if the code has leading static imports, bundle the
42
+ // forked vault libs on the fly (esbuild) BEFORE opening a tab — fail fast,
43
+ // no wasted CDP connection. Plain exec (no imports) skips this entirely.
44
+ let prebuilt: string | undefined;
45
+ if (hasImports(code)) {
46
+ try {
47
+ prebuilt = await bundleExec(code);
48
+ } catch (e) {
49
+ console.error(`ERROR: ${(e as Error).message}`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ await withConnection(
55
+ parsed,
56
+ async (client) => {
57
+ await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
58
+
59
+ // Standalone HAR recording (--record flag writes its own file)
60
+ let standaloneRecorder: HARRecorder | undefined;
61
+ if (parsed.record) {
62
+ standaloneRecorder = new HARRecorder(client);
63
+ await standaloneRecorder.start();
64
+ }
65
+
66
+ // A prebuilt bundle is already a complete IIFE returning the user's
67
+ // promise. Otherwise fall back to the existing smart wrap: async IIFE
68
+ // only when code contains `await`; bare code lets Runtime.evaluate return
69
+ // the last expression's completion value (multi-statement code works).
70
+ const expression = prebuilt
71
+ ? prebuilt
72
+ : /\bawait\b/.test(code)
73
+ ? `(async () => { ${code} })()`
74
+ : code;
75
+
76
+ const evalResult = (await client.send('Runtime.evaluate', {
77
+ expression,
78
+ awaitPromise: true,
79
+ returnByValue: true,
80
+ })) as {
81
+ result?: { value?: unknown };
82
+ exceptionDetails?: { exception?: { description?: string } };
83
+ };
84
+
85
+ if (standaloneRecorder) {
86
+ const har = await standaloneRecorder.finish();
87
+ writeHarAndPrintSummary(har, parsed.harOut);
88
+ }
89
+
90
+ if (evalResult.exceptionDetails) {
91
+ console.error(
92
+ `ERROR: ${evalResult.exceptionDetails.exception?.description ?? 'Unknown error'}`,
93
+ );
94
+ process.exit(1);
95
+ }
96
+
97
+ console.log(JSON.stringify(evalResult.result?.value));
98
+ },
99
+ { settle: 3000 },
100
+ );
101
+ return;
102
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ createHarRecording,
3
+ readHarRecording as readHarFile,
4
+ deleteHarRecording,
5
+ harFilePath,
6
+ } from '../../har-manager.js';
7
+ import { getActiveSession } from '../../session-context.js';
8
+ import { type ParsedArgs } from '../types.js';
9
+
10
+ export async function cmdHar(parsed: ParsedArgs, args: string[]): Promise<void> {
11
+ if (parsed.help) {
12
+ console.log(
13
+ 'Usage: capture har <create|read|delete>\n\n' +
14
+ ' create Create a new HAR recording (returns id)\n' +
15
+ ' read [id] Read accumulated HAR entries (id optional in active session)\n' +
16
+ ' delete <id> Delete a HAR recording\n\n' +
17
+ 'Filters (read):\n' +
18
+ ' --filter-url <pattern> Substring or regex match on URL\n' +
19
+ ' --filter-status <code> Status code, prefix (e.g. 4), or range (e.g. 400-499)\n' +
20
+ ' --filter-method <method> HTTP method (GET, POST, ...)\n' +
21
+ ' --limit <N> Return only the first N matching entries\n\n' +
22
+ 'Pass --har <id> to exec/navigate commands to append traffic to a recording.',
23
+ );
24
+ process.exit(0);
25
+ }
26
+ const subcommand = args[1];
27
+
28
+ if (subcommand === 'create') {
29
+ // Generate a short random ID
30
+ const { id, path: harPath } = createHarRecording();
31
+ console.log(JSON.stringify({ id, path: harPath }, null, 2));
32
+ console.error(
33
+ `\nPass --har ${id} to subsequent commands to record traffic.`,
34
+ );
35
+ return;
36
+ }
37
+
38
+ if (subcommand === 'read') {
39
+ // ID is optional — fall back to active session's harId
40
+ let id = args[2];
41
+ if (id && id.startsWith('--')) id = undefined as unknown as string;
42
+ if (!id) {
43
+ const session = getActiveSession();
44
+ if (session?.harId) {
45
+ id = session.harId;
46
+ } else {
47
+ console.error('Usage: capture har read [id]\n' +
48
+ ' ID is optional when a capture session is active.');
49
+ process.exit(1);
50
+ }
51
+ }
52
+
53
+ const har = readHarFile(id);
54
+ if (!har) {
55
+ console.error(`No HAR recording found for id: ${id}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ let entries = har.log.entries;
60
+ const total = entries.length;
61
+ const filters: string[] = [];
62
+
63
+ if (parsed.filterUrl) {
64
+ const pattern = parsed.filterUrl;
65
+ let re: RegExp | null = null;
66
+ try { re = new RegExp(pattern, 'i'); } catch { re = null; }
67
+ entries = entries.filter((e) =>
68
+ re ? re.test(e.request.url) : e.request.url.toLowerCase().includes(pattern.toLowerCase()),
69
+ );
70
+ filters.push(`url~${pattern}`);
71
+ }
72
+ if (parsed.filterStatus) {
73
+ const s = parsed.filterStatus;
74
+ let matcher: (code: number) => boolean;
75
+ if (/^\d+-\d+$/.test(s)) {
76
+ const [lo, hi] = s.split('-').map((n) => parseInt(n, 10));
77
+ matcher = (c) => c >= lo && c <= hi;
78
+ } else if (/^\d+$/.test(s)) {
79
+ if (s.length < 3) {
80
+ matcher = (c) => String(c).startsWith(s);
81
+ } else {
82
+ const n = parseInt(s, 10);
83
+ matcher = (c) => c === n;
84
+ }
85
+ } else {
86
+ matcher = () => true;
87
+ }
88
+ entries = entries.filter((e) => matcher(e.response.status));
89
+ filters.push(`status=${s}`);
90
+ }
91
+ if (parsed.filterMethod) {
92
+ const m = parsed.filterMethod;
93
+ entries = entries.filter((e) => e.request.method.toUpperCase() === m);
94
+ filters.push(`method=${m}`);
95
+ }
96
+ if (typeof parsed.limit === 'number' && parsed.limit > 0) {
97
+ entries = entries.slice(0, parsed.limit);
98
+ filters.push(`limit=${parsed.limit}`);
99
+ }
100
+
101
+ const filtersLabel = filters.length > 0 ? ` [${filters.join(', ')}]` : '';
102
+ console.error(
103
+ `HAR: ${harFilePath(id)} (${entries.length}/${total} entries)${filtersLabel}`,
104
+ );
105
+ console.log(JSON.stringify({ log: { entries } }, null, 2));
106
+ return;
107
+ }
108
+
109
+ if (subcommand === 'delete') {
110
+ const id = args[2];
111
+ if (!id) {
112
+ console.error('Usage: capture har delete <id>');
113
+ process.exit(1);
114
+ }
115
+
116
+ deleteHarRecording(id);
117
+ console.log(JSON.stringify({ deleted: true, id }, null, 2));
118
+ return;
119
+ }
120
+
121
+ console.error(
122
+ 'Usage: capture har <create|read|delete>\n' +
123
+ ' create Create a new HAR recording (returns id)\n' +
124
+ ' read <id> Read accumulated HAR entries\n' +
125
+ ' delete <id> Delete a HAR recording',
126
+ );
127
+ process.exit(1);
128
+ }
@@ -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
+ }