@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.
@@ -12,6 +12,7 @@ import {
12
12
  setActiveSession,
13
13
  clearActiveSession,
14
14
  } from '../session-context.js';
15
+ import { expandEqualsFlags } from '../cdp/args.js';
15
16
 
16
17
  const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
17
18
 
@@ -65,11 +66,60 @@ function generateId(): string {
65
66
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
66
67
  }
67
68
 
69
+ function hasHelpFlag(args: string[]): boolean {
70
+ return args.some((arg) => arg === '-h' || arg === '--help');
71
+ }
72
+
73
+ export async function waitForPageLoad(
74
+ client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
75
+ timeoutMs: number,
76
+ ): Promise<boolean> {
77
+ await client.waitReady();
78
+ await client.send('Page.enable');
79
+
80
+ return await new Promise<boolean>((resolve) => {
81
+ const timer = setTimeout(() => resolve(true), timeoutMs);
82
+ client.on('Page.loadEventFired', () => {
83
+ clearTimeout(timer);
84
+ resolve(false);
85
+ });
86
+ });
87
+ }
88
+
89
+ function printSessionHelp(): void {
90
+ console.log(`capture session — manage capture sessions
91
+
92
+ Sub-commands:
93
+ start [--url <url>] Start a session (opens tab, records HAR, sets active context)
94
+ stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
95
+ list List active and stopped sessions
96
+ view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
97
+
98
+ Why sessions: once started, every subsequent capture command auto-fills
99
+ --target (the tab) and --har (the recording). No manual flag threading.
100
+
101
+ Typical flow:
102
+ 1. capture session start --url http://localhost:3000
103
+ 2. Interact — no --target / --har needed:
104
+ capture a11y --interactive
105
+ capture click "Sign in"
106
+ capture type "hi@me.com" --into "Email"
107
+ capture screenshot
108
+ capture navigate https://app.example.com/dashboard
109
+ capture har read --filter-url /api
110
+ 3. capture session stop <session-id>
111
+ 4. capture session view <session-id>
112
+
113
+ Related: capture log <path> [--name label] Tail a log into the active session
114
+ See also: capture --help Full command list`);
115
+ }
116
+
68
117
  // ============================================================================
69
118
  // Session Commands
70
119
  // ============================================================================
71
120
 
72
- async function start(args: string[]): Promise<void> {
121
+ async function start(rawArgs: string[]): Promise<void> {
122
+ const args = expandEqualsFlags(rawArgs);
73
123
  let url: string | null = null;
74
124
 
75
125
  for (let i = 0; i < args.length; i++) {
@@ -92,16 +142,49 @@ async function start(args: string[]): Promise<void> {
92
142
  console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
93
143
  }
94
144
 
95
- // Open tab if URL provided
145
+ // Open tab if URL provided. Fail fast if the new target cannot attach.
96
146
  let targetId: string | null = null;
147
+ let pageLoadTimedOut = false;
97
148
  if (url) {
98
149
  try {
99
- const { detectCdpPort, navigateAndWait } = await import('../cdp.js');
150
+ const { detectCdpPort, openTab, CDPClient } = await import('../cdp.js');
100
151
  const port = await detectCdpPort();
101
- const tab = await navigateAndWait(port, url);
152
+ const tab = await openTab(port, url);
102
153
  targetId = tab.id;
154
+
155
+ if (!targetId) {
156
+ throw new Error(
157
+ `capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
158
+ );
159
+ }
160
+ if (!tab.webSocketDebuggerUrl) {
161
+ throw new Error(
162
+ `capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
163
+ );
164
+ }
165
+
166
+ const client = new CDPClient(tab.webSocketDebuggerUrl);
167
+ try {
168
+ try {
169
+ await client.send('Runtime.enable', {}, 5_000);
170
+ } catch (err) {
171
+ throw new Error(
172
+ `capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
173
+ );
174
+ }
175
+ pageLoadTimedOut = await waitForPageLoad(client, 10_000);
176
+ } finally {
177
+ client.close();
178
+ }
103
179
  } catch (err) {
104
- console.error(`Warning: could not open tab: ${err instanceof Error ? err.message : err}`);
180
+ if (harId) {
181
+ try {
182
+ deleteHarRecording(harId);
183
+ } catch {
184
+ /* best effort */
185
+ }
186
+ }
187
+ throw err;
105
188
  }
106
189
  }
107
190
 
@@ -132,6 +215,7 @@ async function start(args: string[]): Promise<void> {
132
215
  bundleDir: dir,
133
216
  harId,
134
217
  targetId,
218
+ pageLoadTimedOut,
135
219
  shotsDir: path.join(dir, 'shots'),
136
220
  a11yDir: path.join(dir, 'a11y'),
137
221
  };
@@ -141,11 +225,20 @@ async function start(args: string[]): Promise<void> {
141
225
  console.error(`\nCapture session started: ${id}`);
142
226
  if (targetId) {
143
227
  console.error(`Tab opened — session context active. No need to pass --target or --har.`);
228
+ if (pageLoadTimedOut) {
229
+ console.error(`Page load timed out, but the session is attached to target ${targetId.slice(0, 8)}. Use \`capture exec --target ${targetId.slice(0, 8)}\` or \`capture list\` if you need to recover it.`);
230
+ }
144
231
  }
145
232
  console.error(`\nWhen done: capture session stop ${id}`);
146
233
  }
147
234
 
148
- export function logCommand(args: string[]): void {
235
+ export function logCommand(rawArgs: string[]): void {
236
+ const args = expandEqualsFlags(rawArgs);
237
+ if (hasHelpFlag(args)) {
238
+ console.log('Usage: capture log <path> [--name label] [--session <id>]');
239
+ return;
240
+ }
241
+
149
242
  const sourcePath = args[0];
150
243
  if (!sourcePath) {
151
244
  console.error('Usage: capture log <path> [--name label] [--session <id>]');
@@ -321,7 +414,8 @@ function list(): void {
321
414
  console.log(JSON.stringify(sessions, null, 2));
322
415
  }
323
416
 
324
- function view(args: string[]): void {
417
+ function view(rawArgs: string[]): void {
418
+ const args = expandEqualsFlags(rawArgs);
325
419
  const id = args[0];
326
420
  if (!id) {
327
421
  console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
@@ -350,36 +444,17 @@ function view(args: string[]): void {
350
444
  export async function sessionMain(args: string[]): Promise<void> {
351
445
  const [subcommand, ...rest] = args;
352
446
 
447
+ if (!subcommand || hasHelpFlag(args)) {
448
+ printSessionHelp();
449
+ return;
450
+ }
451
+
353
452
  switch (subcommand) {
354
453
  case 'start': return start(rest);
355
454
  case 'stop': return stop(rest);
356
455
  case 'list': return list();
357
456
  case 'view': return view(rest);
358
457
  default:
359
- console.log(`capture session — manage capture sessions
360
-
361
- Sub-commands:
362
- start [--url <url>] Start a session (opens tab, records HAR, sets active context)
363
- stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
364
- list List active and stopped sessions
365
- view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
366
-
367
- Why sessions: once started, every subsequent capture command auto-fills
368
- --target (the tab) and --har (the recording). No manual flag threading.
369
-
370
- Typical flow:
371
- 1. capture session start --url http://localhost:3000
372
- 2. Interact — no --target / --har needed:
373
- capture a11y --interactive
374
- capture click "Sign in"
375
- capture type "hi@me.com" --into "Email"
376
- capture screenshot
377
- capture navigate https://app.example.com/dashboard
378
- capture har read --filter-url /api
379
- 3. capture session stop <session-id>
380
- 4. capture session view <session-id>
381
-
382
- Related: capture log <path> [--name label] Tail a log into the active session
383
- See also: capture --help Full command list`);
458
+ printSessionHelp();
384
459
  }
385
460
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * vault/bundle — the import-driven exec engine.
3
+ *
4
+ * Ports the bundling mechanism of northlight's
5
+ * `apps/core/src/tool-registry/lib/bundle-agent-code.ts`, DROPPING the telemetry
6
+ * envelope (no `__vallumStartCall`, no `__snapshot`, no `borgEnabled`). When
7
+ * `capture exec` is handed code containing leading static imports, this turns it
8
+ * into a single self-contained IIFE: the user's `libs/<name>` imports are
9
+ * resolved to the forked vault source, esbuild bundles the whole tree on the
10
+ * fly, and the result is a string the exec path evals over CDP.
11
+ *
12
+ * No top-level `esbuild` import — it is loaded lazily via `loadEsbuild()` so the
13
+ * published package (no esbuild) never crashes at startup.
14
+ */
15
+
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import type { Plugin, BuildResult, Message } from "esbuild";
19
+ import {
20
+ isValidLibName,
21
+ loadEsbuild,
22
+ unknownLibMsg,
23
+ vaultLibsDir,
24
+ vaultRepoRoot,
25
+ } from "./paths.js";
26
+
27
+ // Sentinel the post-bundle return-insert anchors on. esbuild with minify:false
28
+ // never renames top-level identifiers, and no first-party lib references
29
+ // __CAPTURE_*, so this literal survives bundling intact and uniquely (the same
30
+ // invariant core relies on for __VALLUM_USER_PROMISE).
31
+ const SENTINEL = "__CAPTURE_RESULT";
32
+
33
+ // Matches a leading run of static import statements (plus any leading comments)
34
+ // per statement: leading ws/comments, then `import` through its single
35
+ // module-specifier string and optional `;`. A normal import has exactly one
36
+ // string literal (the specifier), so this is unambiguous. Ported from
37
+ // bundle-agent-code.ts, with one addition: the `(?!\s*\()` lookahead so a
38
+ // leading dynamic `import(...)` CALL is NOT misclassified as a static import
39
+ // statement (it would otherwise be hoisted and the body run detached, silently
40
+ // returning undefined). Dynamic `import(...)` anywhere in the body is untouched.
41
+ const IMPORT_STMT_RE =
42
+ /^\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*import\b(?!\s*\()[^'"`]*?(['"])(?:[^'"`\\]|\\.)*\1[^\n;]*;?/;
43
+
44
+ function splitLeadingImports(code: string): { imports: string; body: string } {
45
+ const imports: string[] = [];
46
+ let body = code;
47
+ for (;;) {
48
+ const m = IMPORT_STMT_RE.exec(body);
49
+ if (!m) break;
50
+ imports.push(m[0]);
51
+ body = body.slice(m[0].length);
52
+ }
53
+ return { imports: imports.join(""), body };
54
+ }
55
+
56
+ /**
57
+ * True iff `code` contains ≥1 leading static `import` statement. This is the
58
+ * trigger exec.ts uses to preserve the no-import fast path: plain exec has no
59
+ * top-level import, so esbuild never runs for it.
60
+ */
61
+ export function hasImports(code: string): boolean {
62
+ return splitLeadingImports(code).imports.length > 0;
63
+ }
64
+
65
+ // Matches a `libs/<name>` module specifier inside a string literal.
66
+ const LIB_SPECIFIER_RE = /(['"])libs\/((?:[^'"\\]|\\.)*)\1/g;
67
+
68
+ // Validate every `libs/<name>` specifier up front. esbuild with treeShaking:true
69
+ // drops an UNUSED import before its resolver ever runs, so a bogus-lib import
70
+ // whose binding is unreferenced would otherwise bundle silently; this guarantees
71
+ // the `Unknown lib` error deterministically, and before esbuild is even loaded
72
+ // (fail fast, before any CDP). Mirrors vaultResolver's existence check exactly.
73
+ function preflightLibs(imports: string): void {
74
+ const libs = vaultLibsDir();
75
+ LIB_SPECIFIER_RE.lastIndex = 0;
76
+ let m: RegExpExecArray | null;
77
+ while ((m = LIB_SPECIFIER_RE.exec(imports)) !== null) {
78
+ const name = m[2];
79
+ if (!isValidLibName(name) || !fs.existsSync(path.join(libs, name, "index.ts"))) {
80
+ throw new Error(unknownLibMsg(name));
81
+ }
82
+ }
83
+ }
84
+
85
+ // Hoist the imports to module top level (so esbuild resolves & tree-shakes them)
86
+ // and wrap the body in a result-capturing async IIFE bound to the sentinel. The
87
+ // trailing `.then()` is a preserved no-op call → DCE-safe anchor for the
88
+ // post-bundle return insert (a bare `__CAPTURE_RESULT;` statement can be dropped
89
+ // by tree-shaking; a call survives).
90
+ function buildEntry(code: string): string {
91
+ const { imports, body } = splitLeadingImports(code);
92
+ const expr = body.trim().replace(/;+\s*$/, "");
93
+ return [
94
+ imports,
95
+ `const ${SENTINEL} = (async () => { ${expr} })();`,
96
+ `${SENTINEL}.then();`,
97
+ ].join("\n");
98
+ }
99
+
100
+ // Maps the two bare specifier shapes (`@vallum/_runtime`, `libs/<name>`) to
101
+ // absolute source paths in the DEFAULT `file` namespace — no custom namespace,
102
+ // no onLoad. esbuild then natively resolves each file's own relative imports
103
+ // (`./context`, `./messages/send`) and npm imports (`zod`) off disk, pulling the
104
+ // whole nested source tree in for free.
105
+ function vaultResolver(): Plugin {
106
+ const libs = vaultLibsDir();
107
+ const runtime = path.join(libs, "_runtime", "index.ts");
108
+ return {
109
+ name: "vault",
110
+ setup(build) {
111
+ build.onResolve({ filter: /^@vallum\/_runtime$/ }, () => ({
112
+ path: runtime,
113
+ }));
114
+ build.onResolve({ filter: /^libs\// }, (args) => {
115
+ const name = args.path.slice("libs/".length);
116
+ if (!isValidLibName(name)) {
117
+ return { errors: [{ text: unknownLibMsg(name) }] };
118
+ }
119
+ const entry = path.join(libs, name, "index.ts");
120
+ return fs.existsSync(entry)
121
+ ? { path: entry }
122
+ : { errors: [{ text: unknownLibMsg(name) }] };
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ async function runEsbuild(entry: string): Promise<BuildResult<{ write: false }>> {
129
+ const esbuild = await loadEsbuild();
130
+ const opts = {
131
+ stdin: {
132
+ contents: entry,
133
+ resolveDir: vaultRepoRoot(),
134
+ loader: "ts" as const,
135
+ sourcefile: "exec.ts",
136
+ },
137
+ plugins: [vaultResolver()],
138
+ bundle: true,
139
+ write: false as const,
140
+ format: "iife" as const,
141
+ target: "es2020",
142
+ platform: "browser" as const,
143
+ treeShaking: true,
144
+ minify: false,
145
+ logLevel: "silent" as const,
146
+ };
147
+ try {
148
+ return await esbuild.build(opts);
149
+ } catch (error) {
150
+ // esbuild's background process can crash, making all future calls fail with
151
+ // "The service is no longer running". stop() forces a fresh process; retry once.
152
+ if (
153
+ error instanceof Error &&
154
+ error.message.includes("service is no longer running")
155
+ ) {
156
+ esbuild.stop();
157
+ return await esbuild.build(opts);
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ // The only post-bundle edit: esbuild's iife wrapper `(() => { … })()` has no
164
+ // return, so the script completion value is undefined. Insert `return ` before
165
+ // the last `__CAPTURE_RESULT.then(` so the IIFE returns the user's promise.
166
+ function applyReturnInsert(bundled: string): string {
167
+ const anchor = `${SENTINEL}.then(`;
168
+ const i = bundled.lastIndexOf(anchor);
169
+ return i === -1 ? bundled : bundled.slice(0, i) + "return " + bundled.slice(i);
170
+ }
171
+
172
+ function formatBuildErrors(errors: Message[]): string {
173
+ return errors
174
+ .map((e) => {
175
+ const l = e.location;
176
+ const loc = l ? ` (${l.file}:${l.line}:${l.column})` : "";
177
+ return `${e.text}${loc}`;
178
+ })
179
+ .join("\n");
180
+ }
181
+
182
+ /**
183
+ * Bundle import-bearing exec code into a complete IIFE string that returns the
184
+ * user's promise. Throws an Error whose message formats esbuild's `errors[]`
185
+ * (text + file:line:col) on a build/resolve failure — exec.ts prints it and
186
+ * exits before opening a CDP connection.
187
+ */
188
+ export async function bundleExec(code: string): Promise<string> {
189
+ preflightLibs(splitLeadingImports(code).imports);
190
+ const entry = buildEntry(code);
191
+ let result: BuildResult<{ write: false }>;
192
+ try {
193
+ result = await runEsbuild(entry);
194
+ } catch (error) {
195
+ const errs = (error as { errors?: Message[] })?.errors;
196
+ if (Array.isArray(errs) && errs.length > 0) {
197
+ throw new Error(formatBuildErrors(errs));
198
+ }
199
+ throw error;
200
+ }
201
+ if (!result.outputFiles || result.outputFiles.length === 0) {
202
+ throw new Error("esbuild produced no output");
203
+ }
204
+ return applyReturnInsert(result.outputFiles[0].text);
205
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * vault/docs — on-demand library doc generation + sha-keyed cache.
3
+ *
4
+ * Reimplements vault's `generateDocs` (northlight-vault
5
+ * lib-pipeline/build/build-libs.ts:138-184) in-process, dropping the
6
+ * manifest/sha/scripts half: for a lib we esbuild-bundle its `schemas.ts` to a
7
+ * self-contained CJS string (zod inlined from node_modules by `bundle:true`),
8
+ * evaluate it in-memory, then convert each function's Zod input/output to JSON
9
+ * Schema (Zod-4-native `toJSONSchema()`, with the `void` special-case ported
10
+ * verbatim). Results are cached gitignored under `vault/.cache/docs/<svc>.json`
11
+ * with an embedded `_srcSha` = sha256(schemas.ts); a stale/missing sha rebuilds.
12
+ *
13
+ * Nothing here imports `esbuild` at the top level — it is loaded lazily via
14
+ * `loadEsbuild()` so the published package (no `vault/`, no `esbuild`) degrades
15
+ * to a single `DEV_ONLY_MSG` instead of crashing.
16
+ */
17
+
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import { createHash } from "crypto";
21
+ import {
22
+ vaultLibsDir,
23
+ vaultRepoRoot,
24
+ listLibs,
25
+ loadEsbuild,
26
+ isValidLibName,
27
+ unknownLibMsg,
28
+ } from "./paths.js";
29
+
30
+ /** A single library function's doc entry (input/output are JSON Schema). */
31
+ export interface LibraryDocFunction {
32
+ name: string;
33
+ description: string;
34
+ notes?: string;
35
+ input: unknown;
36
+ output: unknown;
37
+ }
38
+
39
+ /** A library's generated doc — same shape `nl lib read --json` returns. */
40
+ export interface LibraryDoc {
41
+ /** `@vallum/<svc>` */
42
+ library: string;
43
+ description: string;
44
+ notes?: string;
45
+ loginUrl?: string;
46
+ /** `'public' | 'chat' | 'hidden'` (default `'public'`). */
47
+ visibility: string;
48
+ functions: LibraryDocFunction[];
49
+ }
50
+
51
+ /** Shape of each entry in a lib's `schemas.ts` exported `allSchemas` array. */
52
+ interface FunctionSchema {
53
+ name: string;
54
+ description: string;
55
+ notes?: string;
56
+ input: unknown;
57
+ output: unknown;
58
+ }
59
+
60
+ /** Mirrors `nl lib`'s agent-visible filter (vault-client.service.ts:49). */
61
+ const AGENT_VISIBLE_VISIBILITIES = new Set(["public", "chat"]);
62
+
63
+ /** True iff a doc is agent-visible (visibility ∈ {public, chat}). */
64
+ export function isAgentVisible(doc: LibraryDoc): boolean {
65
+ return AGENT_VISIBLE_VISIBILITIES.has(doc.visibility);
66
+ }
67
+
68
+ /**
69
+ * A `require` for the in-memory CJS eval below. zod is inlined by `bundle:true`
70
+ * so this is never actually hit — it's passed defensively. Ambient `require` is
71
+ * available under both the built CJS bin (native) and tsx-ESM (injected into a
72
+ * real module). We avoid `createRequire(import.meta.url)` because `import.meta`
73
+ * is empty under the CJS bin build (esbuild) and would throw at module load.
74
+ */
75
+ declare const require: ((id: string) => unknown) | undefined;
76
+ const cjsRequire = (id: string): unknown => {
77
+ if (typeof require === "function") return require(id);
78
+ throw new Error(`cannot require('${id}') — unavailable in this runtime`);
79
+ };
80
+
81
+ function cacheDir(): string {
82
+ return path.join(vaultRepoRoot(), ".cache", "docs");
83
+ }
84
+
85
+ function schemasPathFor(svc: string): string {
86
+ return path.join(vaultLibsDir(), svc, "schemas.ts");
87
+ }
88
+
89
+ function srcSha(svc: string): string {
90
+ return createHash("sha256")
91
+ .update(fs.readFileSync(schemasPathFor(svc), "utf8"))
92
+ .digest("hex");
93
+ }
94
+
95
+ /**
96
+ * Zod → JSON Schema, porting build-libs.ts:155-173 verbatim. `z.void()` has no
97
+ * JSON-Schema representation, so it maps to `{type:'null'}` (carrying its
98
+ * description if present); everything else uses Zod 4's `toJSONSchema()`.
99
+ */
100
+ function toJsonSchema(zodSchema: unknown): unknown {
101
+ const s = zodSchema as {
102
+ _zod?: { def?: { type?: string } };
103
+ description?: string;
104
+ toJSONSchema?: () => unknown;
105
+ };
106
+ if (s?._zod?.def?.type === "void") {
107
+ const desc = s.description;
108
+ return desc ? { type: "null", description: desc } : { type: "null" };
109
+ }
110
+ if (!s?.toJSONSchema) {
111
+ throw new Error(
112
+ "Schema missing toJSONSchema method - ensure Zod 4+ is installed",
113
+ );
114
+ }
115
+ return s.toJSONSchema();
116
+ }
117
+
118
+ /**
119
+ * Build one lib's doc from source: esbuild-bundle its `schemas.ts` to a
120
+ * self-contained CJS string, eval it in-memory, assemble the `LibraryDoc`.
121
+ */
122
+ async function buildDoc(svc: string): Promise<LibraryDoc> {
123
+ const esbuild = await loadEsbuild();
124
+ const result = await esbuild.build({
125
+ entryPoints: [schemasPathFor(svc)],
126
+ bundle: true,
127
+ write: false,
128
+ format: "cjs",
129
+ platform: "node",
130
+ target: "es2020",
131
+ logLevel: "silent",
132
+ });
133
+ const text = result.outputFiles?.[0]?.text;
134
+ if (!text) throw new Error(`esbuild produced no output for ${svc}/schemas.ts`);
135
+
136
+ // In-memory CJS eval (no temp file). zod is inlined by bundle:true, so the
137
+ // `require` arg is never actually hit — passed defensively for any edge.
138
+ const mod = { exports: {} as Record<string, unknown> };
139
+ new Function("module", "exports", "require", text)(
140
+ mod,
141
+ mod.exports,
142
+ cjsRequire,
143
+ );
144
+ const x = mod.exports as {
145
+ allSchemas?: FunctionSchema[];
146
+ libraryDescription?: string;
147
+ libraryNotes?: string;
148
+ loginUrl?: string;
149
+ libraryVisibility?: string;
150
+ };
151
+
152
+ const allSchemas = x.allSchemas ?? [];
153
+ const doc: LibraryDoc = {
154
+ library: `@vallum/${svc}`,
155
+ description: x.libraryDescription ?? `${svc} service library`,
156
+ visibility: x.libraryVisibility ?? "public",
157
+ functions: allSchemas.map((schema) => ({
158
+ name: schema.name,
159
+ description: schema.description,
160
+ ...(schema.notes ? { notes: schema.notes } : {}),
161
+ input: toJsonSchema(schema.input),
162
+ output: toJsonSchema(schema.output),
163
+ })),
164
+ };
165
+ if (x.libraryNotes) doc.notes = x.libraryNotes;
166
+ if (x.loginUrl) doc.loginUrl = x.loginUrl;
167
+ return doc;
168
+ }
169
+
170
+ /**
171
+ * Get one lib's doc — reads the sha-valid cache if present, else (re)builds and
172
+ * writes it. May read ANY present lib (no visibility filter); used by
173
+ * `lib read`. Throws `Unknown lib …` if the named lib has no `schemas.ts`.
174
+ */
175
+ export async function getDoc(name: string): Promise<LibraryDoc> {
176
+ if (!isValidLibName(name) || !fs.existsSync(schemasPathFor(name))) {
177
+ throw new Error(unknownLibMsg(name));
178
+ }
179
+ const sha = srcSha(name);
180
+ const cacheFile = path.join(cacheDir(), `${name}.json`);
181
+ if (fs.existsSync(cacheFile)) {
182
+ try {
183
+ const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")) as
184
+ & LibraryDoc
185
+ & { _srcSha?: string };
186
+ if (cached._srcSha === sha) {
187
+ delete cached._srcSha;
188
+ return cached;
189
+ }
190
+ } catch {
191
+ // corrupt/unreadable cache → fall through and rebuild
192
+ }
193
+ }
194
+ const doc = await buildDoc(name);
195
+ fs.mkdirSync(cacheDir(), { recursive: true });
196
+ fs.writeFileSync(
197
+ cacheFile,
198
+ JSON.stringify({ ...doc, _srcSha: sha }, null, 2),
199
+ );
200
+ return doc;
201
+ }
202
+
203
+ /**
204
+ * All agent-visible libs' docs (visibility ∈ {public, chat}). Lazily builds any
205
+ * missing/stale doc, then filters by visibility. A lib whose `schemas.ts` fails
206
+ * to build is skipped with a stderr warning rather than failing the whole list.
207
+ * Used by `lib list`/`search`/`show`.
208
+ */
209
+ export async function listDocs(): Promise<LibraryDoc[]> {
210
+ const out: LibraryDoc[] = [];
211
+ for (const name of listLibs()) {
212
+ let doc: LibraryDoc;
213
+ try {
214
+ doc = await getDoc(name);
215
+ } catch (e) {
216
+ console.error(
217
+ `WARN: skipping lib "${name}" — ${(e as Error).message}`,
218
+ );
219
+ continue;
220
+ }
221
+ if (isAgentVisible(doc)) out.push(doc);
222
+ }
223
+ return out;
224
+ }