libretto 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -13,15 +13,17 @@ Libretto is a toolkit for building robust web integrations. It gives your coding
13
13
 
14
14
  We at [Saffron Health](https://saffron.health) built Libretto to help us maintain our browser integrations to common healthcare software. We're open-sourcing it so other teams have an easier time doing the same thing.
15
15
 
16
+ https://github.com/user-attachments/assets/9b9a0ab3-5133-4b20-b3be-459943349d18
17
+
16
18
  ## Installation
17
19
 
18
20
  ```bash
19
- npm install --save-dev libretto
21
+ npm install libretto
20
22
 
21
23
  # Install skill, download Chromium if not already installed, configure snapshot analysis
22
24
  npx libretto init
23
25
 
24
- # Configure snapshot analysis model (see Configuration section below)
26
+ # Configure or change the snapshot analysis model (see Configuration section below). `npx libretto init` sets this up the first time.
25
27
  npx libretto ai configure <openai | anthropic | gemini | vertex>
26
28
  ```
27
29
 
@@ -58,7 +60,7 @@ Agents can use Libretto to reproduce the failure, pause the workflow at any poin
58
60
  You can also use Libretto directly from the command line. All commands accept `--session <name>` to target a specific session.
59
61
 
60
62
  ```bash
61
- npx libretto init # initialize libretto in the current project
63
+ npx libretto init # interactive; run yourself, not through an agent
62
64
  npx libretto open <url> # launch browser and open a URL (headed by default)
63
65
  npx libretto snapshot --objective "..." --context "..." # capture PNG + HTML and analyze with an LLM
64
66
  npx libretto exec "<code>" # execute Playwright TypeScript against the open page
@@ -134,6 +136,8 @@ Maintained by the team at [Saffron Health](https://saffron.health).
134
136
 
135
137
  ## Development
136
138
 
139
+ For local development in this repository:
140
+
137
141
  ```bash
138
142
  pnpm i
139
143
  pnpm build
@@ -17,8 +17,8 @@ import {
17
17
  loadSnapshotEnv,
18
18
  resolveSnapshotApiModel
19
19
  } from "../core/snapshot-api-config.js";
20
- import { SimpleCLI } from "../framework/simple-cli.js";
21
20
  import { hasProviderCredentials } from "../../shared/llm/client.js";
21
+ import { SimpleCLI } from "../framework/simple-cli.js";
22
22
  const PROVIDER_CHOICES = [
23
23
  {
24
24
  key: "1",
@@ -52,15 +52,6 @@ function promptUser(rl, question) {
52
52
  });
53
53
  });
54
54
  }
55
- function askYesNo(question) {
56
- const rl = createInterface({ input: process.stdin, output: process.stdout });
57
- return new Promise((resolve) => {
58
- rl.question(`${question} (y/N) `, (answer) => {
59
- rl.close();
60
- resolve(answer.trim().toLowerCase() === "y");
61
- });
62
- });
63
- }
64
55
  function safeReadAiConfig() {
65
56
  try {
66
57
  return readAiConfig();
@@ -233,7 +224,7 @@ function detectAgentDirs(root) {
233
224
  if (existsSync(join(root, ".claude"))) dirs.push(join(root, ".claude"));
234
225
  return dirs;
235
226
  }
236
- async function copySkills() {
227
+ function copySkills() {
237
228
  const agentDirs = detectAgentDirs(REPO_ROOT);
238
229
  if (agentDirs.length === 0) {
239
230
  console.log(
@@ -242,15 +233,6 @@ async function copySkills() {
242
233
  return;
243
234
  }
244
235
  const destinations = agentDirs.map((d) => join(d, "skills", "libretto"));
245
- const dirNames = agentDirs.map((d) => basename(d)).join(" and ");
246
- const existing = destinations.filter((d) => existsSync(d));
247
- const verb = existing.length > 0 ? "Overwrite" : "Install";
248
- const proceed = await askYesNo(`
249
- ${verb} libretto skills in ${dirNames}?`);
250
- if (!proceed) {
251
- console.log(" Skipping skill copy.");
252
- return;
253
- }
254
236
  let sourceDir;
255
237
  try {
256
238
  sourceDir = getPackageSkillsDir();
@@ -289,10 +271,11 @@ const initCommand = SimpleCLI.command({
289
271
  } else {
290
272
  console.log("\nSkipping browser installation (--skip-browsers)");
291
273
  }
274
+ copySkills();
292
275
  if (process.stdin.isTTY) {
293
- await copySkills();
294
276
  await runInteractiveApiSetup();
295
277
  } else {
278
+ loadSnapshotEnv();
296
279
  printSnapshotApiStatus();
297
280
  }
298
281
  console.log("\n\u2713 libretto init complete");
@@ -11,10 +11,15 @@ const ViewportConfigSchema = z.object({
11
11
  width: z.number().int().min(1),
12
12
  height: z.number().int().min(1)
13
13
  });
14
+ const WindowPositionConfigSchema = z.object({
15
+ x: z.number().int(),
16
+ y: z.number().int()
17
+ });
14
18
  const LibrettoConfigSchema = z.object({
15
19
  version: z.literal(CURRENT_CONFIG_VERSION),
16
20
  ai: AiConfigSchema.optional(),
17
- viewport: ViewportConfigSchema.optional()
21
+ viewport: ViewportConfigSchema.optional(),
22
+ windowPosition: WindowPositionConfigSchema.optional()
18
23
  }).passthrough();
19
24
  const DEFAULT_MODELS = {
20
25
  openai: "openai/gpt-5.4",
@@ -49,6 +54,10 @@ function formatExpectedConfigExample() {
49
54
  viewport: {
50
55
  width: 1280,
51
56
  height: 800
57
+ },
58
+ windowPosition: {
59
+ x: 1600,
60
+ y: 120
52
61
  }
53
62
  },
54
63
  null,
@@ -64,7 +73,7 @@ ${detail}` : null,
64
73
  "Expected config example:",
65
74
  formatExpectedConfigExample(),
66
75
  "Notes:",
67
- ' - "ai" and "viewport" are optional.',
76
+ ' - "ai", "viewport", and "windowPosition" are optional.',
68
77
  ' - "ai.model" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
69
78
  "Fix the file to match this shape, or delete it and rerun:",
70
79
  ` npx libretto ai configure ${formatConfigureProviders()}`
@@ -189,6 +198,7 @@ export {
189
198
  CURRENT_CONFIG_VERSION,
190
199
  LibrettoConfigSchema,
191
200
  ViewportConfigSchema,
201
+ WindowPositionConfigSchema,
192
202
  clearAiConfig,
193
203
  readAiConfig,
194
204
  readLibrettoConfig,
@@ -6,6 +6,14 @@ import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createServer } from "node:net";
8
8
  import { spawn } from "node:child_process";
9
+ import {
10
+ filterSemanticClasses,
11
+ INTERACTIVE_ROLE_NAMES,
12
+ INTERACTIVE_TAG_NAMES,
13
+ isObfuscatedClass,
14
+ TEST_ATTRIBUTE_NAMES,
15
+ TRUSTED_ATTRIBUTE_NAMES
16
+ } from "../../shared/dom-semantics.js";
9
17
  import {
10
18
  getSessionActionsLogPath,
11
19
  getSessionNetworkLogPath,
@@ -245,10 +253,22 @@ function resolveViewport(cliViewport, logger) {
245
253
  });
246
254
  return DEFAULT_VIEWPORT;
247
255
  }
256
+ function resolveWindowPosition(logger) {
257
+ const config = readLibrettoConfig();
258
+ if (config.windowPosition) {
259
+ logger.info("window-position-source", {
260
+ source: "config",
261
+ windowPosition: config.windowPosition
262
+ });
263
+ return config.windowPosition;
264
+ }
265
+ return void 0;
266
+ }
248
267
  async function runOpen(rawUrl, headed, session, logger, options) {
249
268
  const url = normalizeUrl(rawUrl);
250
269
  const viewport = resolveViewport(options?.viewport, logger);
251
- logger.info("open-start", { url, headed, session, viewport });
270
+ const windowPosition = headed ? resolveWindowPosition(logger) : void 0;
271
+ logger.info("open-start", { url, headed, session, viewport, windowPosition });
252
272
  assertSessionAvailableForStart(session, logger);
253
273
  const port = await pickFreePort();
254
274
  const runLogPath = logFileForSession(session);
@@ -277,6 +297,45 @@ async function runOpen(rawUrl, headed, session, logger, options) {
277
297
  const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
278
298
  const escapedNetworkLogPath = networkLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
279
299
  const escapedActionsLogPath = actionsLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
300
+ const windowPositionArg = windowPosition ? `, '--window-position=${windowPosition.x},${windowPosition.y}'` : "";
301
+ const windowBoundsSetupCode = windowPosition ? `
302
+ const requestedWindowBounds = { left: ${windowPosition.x}, top: ${windowPosition.y}, windowState: 'normal' };
303
+ const pageCdp = await context.newCDPSession(page);
304
+ let browserCdp;
305
+ try {
306
+ const targetInfo = await pageCdp.send('Target.getTargetInfo');
307
+ const targetId = targetInfo?.targetInfo?.targetId;
308
+ browserCdp = await browser.newBrowserCDPSession();
309
+ const windowResult = await browserCdp.send(
310
+ 'Browser.getWindowForTarget',
311
+ targetId ? { targetId } : {},
312
+ );
313
+ await browserCdp.send('Browser.setWindowBounds', {
314
+ windowId: windowResult.windowId,
315
+ bounds: requestedWindowBounds,
316
+ });
317
+ await new Promise((resolve) => setTimeout(resolve, 250));
318
+ const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
319
+ windowId: windowResult.windowId,
320
+ });
321
+ childLog('info', 'window-bounds-set', {
322
+ windowId: windowResult.windowId,
323
+ requestedBounds: requestedWindowBounds,
324
+ actualBounds: actualWindow.bounds,
325
+ });
326
+ } catch (error) {
327
+ childLog('warn', 'window-bounds-set-failed', {
328
+ requestedBounds: requestedWindowBounds,
329
+ message: error instanceof Error ? error.message : String(error),
330
+ stack: error instanceof Error ? error.stack : undefined,
331
+ });
332
+ } finally {
333
+ await pageCdp.detach().catch(() => {});
334
+ if (browserCdp) {
335
+ await browserCdp.detach().catch(() => {});
336
+ }
337
+ }
338
+ ` : "";
280
339
  const launcherCode = `
281
340
  import { chromium } from 'playwright';
282
341
  import { appendFileSync, mkdirSync } from 'node:fs';
@@ -288,14 +347,21 @@ const ACTIONS_LOG = '${escapedActionsLogPath}';
288
347
  mkdirSync(dirname(NETWORK_LOG), { recursive: true });
289
348
 
290
349
  // tsx/esbuild may emit __name() wrappers in Function#toString output.
291
- const __name = (target, value) =>
292
- Object.defineProperty(target, 'name', { value, configurable: true });
350
+ const __name = (target, value) =>
351
+ Object.defineProperty(target, 'name', { value, configurable: true });
293
352
 
294
- ${installSessionTelemetry.toString()}
353
+ const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
354
+ const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
355
+ const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
356
+ const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
357
+ const filterSemanticClasses = ${filterSemanticClasses.toString()};
358
+ const isObfuscatedClass = ${isObfuscatedClass.toString()};
295
359
 
296
- function logAction(entry) {
297
- appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
298
- }
360
+ ${installSessionTelemetry.toString()}
361
+
362
+ function logAction(entry) {
363
+ appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
364
+ }
299
365
 
300
366
  function logNetwork(entry) {
301
367
  appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
@@ -317,7 +383,7 @@ function childLog(level, event, data = {}) {
317
383
 
318
384
  const browser = await chromium.launch({
319
385
  headless: ${!headed},
320
- args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'],
386
+ args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
321
387
  });
322
388
 
323
389
  browser.on('disconnected', () => {
@@ -331,6 +397,7 @@ const context = await browser.newContext({
331
397
  });
332
398
 
333
399
  const page = await context.newPage();
400
+ ${windowBoundsSetupCode}
334
401
  page.setDefaultTimeout(30000);
335
402
  page.setDefaultNavigationTimeout(45000);
336
403