libretto 0.5.3-experimental.6 → 0.5.3

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
@@ -1,3 +1,5 @@
1
+ <!-- Generated from packages/libretto/README.template.md by `pnpm sync:mirrors`. Do not edit directly. -->
2
+
1
3
  # Libretto
2
4
 
3
5
  [![npm version](https://img.shields.io/npm/v/libretto)](https://www.npmjs.com/package/libretto)
@@ -152,6 +154,9 @@ Source layout:
152
154
  - `src/runtime/` — browser runtime (network, recovery, downloads, extraction)
153
155
  - `src/shared/` — shared utilities (config, LLM client, logging, state)
154
156
  - `test/` — test files (`*.spec.ts`)
155
- - `skills/libretto/` — source of truth for the Libretto skill; mirrors are synced on `pnpm i`
157
+ - `README.template.md` — source of truth for the repo and package READMEs
158
+ - `skills/libretto/` — source of truth for the Libretto skill
159
+
160
+ Run `pnpm sync:mirrors` after editing `README.template.md` or anything under `skills/libretto/`. `pnpm i` also resyncs the skill mirrors through `postinstall`.
156
161
 
157
- To check that skill mirrors are in sync without fixing them, run `pnpm check:skills`. To release, run `pnpm prepare-release`.
162
+ To check that generated READMEs, skill mirrors, and skill version metadata are in sync without fixing them, run `pnpm check:mirrors`. To release, run `pnpm prepare-release`.
@@ -0,0 +1,160 @@
1
+ # Libretto
2
+
3
+ [![npm version](https://img.shields.io/npm/v/libretto)](https://www.npmjs.com/package/libretto)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ [![GitHub Discussions](https://img.shields.io/github/discussions/saffron-health/libretto)](https://github.com/saffron-health/libretto/discussions)
6
+
7
+ Libretto is a toolkit for building robust web integrations. It gives your coding agent a live browser and a token-efficient CLI to:
8
+
9
+ - Inspect live pages with minimal context overhead
10
+ - Capture network traffic to reverse-engineer site APIs
11
+ - Record user actions and replay them as automation scripts
12
+ - Debug broken workflows interactively against the real site
13
+
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
+
16
+ https://github.com/user-attachments/assets/9b9a0ab3-5133-4b20-b3be-459943349d18
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install libretto
22
+
23
+ # Install skill, download Chromium if not already installed, configure snapshot analysis
24
+ npx libretto init
25
+
26
+ # Configure or change the snapshot analysis model (see Configuration section below). `npx libretto init` sets this up the first time.
27
+ npx libretto ai configure <openai | anthropic | gemini | vertex>
28
+ ```
29
+
30
+ ## Use cases
31
+
32
+ Libretto is designed to be used as a skill through your coding agent. Here are some example prompts:
33
+
34
+ ### One-shot script generation
35
+
36
+ > Use the Libretto skill. Go on LinkedIn and scrape the first 10 posts for content, who posted it, the number of reactions, the first 25 comments, and the first 25 reposts.
37
+
38
+ Your coding agent will open a window for you to log into LinkedIn, and then automatically start exploring.
39
+
40
+ ### Interactive script building
41
+
42
+ > I'm gonna show you a workflow in the eclinicalworks EHR to get a patient's primary insurance ID. Use libretto skill to turn it into a playwright script that takes patient name and dob as input to get back the insurance ID. URL is ...
43
+
44
+ Libretto can read your actions you perform in the browser, so you can perform a workflow, then ask it to use your actions to rebuild the workflow.
45
+
46
+ ### Convert browser automation to network requests
47
+
48
+ > We have a browser script at ./integration.ts that automates going to Hacker News and getting the first 10 posts. Convert it to direct network scripts instead. Use the Libretto skill.
49
+
50
+ Libretto can read network requests from the browser, which it can use to reverse engineer the API and create a script that directly calls those requests. Directly making API calls is faster, and more reliable, than UI automation. You can also ask Libretto to conduct a security analysis which analyzes the requests for common security cookies, so you can understand whether a network request approach will be safe.
51
+
52
+ ### Fix broken integrations
53
+
54
+ > We have a browser script at ./integration.ts that is supposed to go to Availity and perform an eligibility check for a patient. But I'm getting a broken selector error when I run it. Fix it. Use the Libretto skill.
55
+
56
+ Agents can use Libretto to reproduce the failure, pause the workflow at any point, inspect the live page, and fix issues, all autonomously.
57
+
58
+ ### CLI usage
59
+
60
+ You can also use Libretto directly from the command line. All commands accept `--session <name>` to target a specific session.
61
+
62
+ ```bash
63
+ npx libretto init # interactive; run yourself, not through an agent
64
+ npx libretto open <url> # launch browser and open a URL (headed by default)
65
+ npx libretto snapshot --objective "..." --context "..." # capture PNG + HTML and analyze with an LLM
66
+ npx libretto exec "<code>" # execute Playwright TypeScript against the open page (single quoted argument)
67
+ echo "<code>" | npx libretto exec - # intentionally read Playwright TypeScript from stdin
68
+ npx libretto run <file> <workflowName> # run an exported workflow from a file
69
+ npx libretto resume # resume a paused workflow
70
+ npx libretto network # view captured network requests
71
+ npx libretto actions # view captured user/agent actions
72
+ npx libretto pages # list open pages in the session
73
+ npx libretto save <domain> # save browser session (cookies, localStorage) for reuse
74
+ npx libretto close # close the browser
75
+ npx libretto ai configure <provider> # configure snapshot analysis model
76
+ ```
77
+
78
+ ## Configuration
79
+
80
+ All Libretto state lives in a `.libretto/` directory at your project root. Configuration is stored in `.libretto/config.json`.
81
+
82
+ ### Config file
83
+
84
+ `.libretto/config.json` controls snapshot analysis and viewport settings:
85
+
86
+ ```json
87
+ {
88
+ "version": 1,
89
+ "ai": {
90
+ "model": "openai/gpt-5.4",
91
+ "updatedAt": "2026-01-01T00:00:00.000Z"
92
+ },
93
+ "viewport": { "width": 1280, "height": 800 }
94
+ }
95
+ ```
96
+
97
+ The `ai` field configures which model Libretto uses for snapshot analysis — extracting selectors, identifying interactive elements, or diagnosing why a step failed. This keeps heavy visual context out of your coding agent's context window. Snapshot analysis is required.
98
+
99
+ The easiest way to set the model is through the CLI:
100
+
101
+ ```bash
102
+ npx libretto ai configure <openai | anthropic | gemini | vertex>
103
+ ```
104
+
105
+ Provider credentials are read from environment variables or a `.env` file at your project root: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`, or `GOOGLE_CLOUD_PROJECT` for Vertex.
106
+
107
+ The `viewport` field sets the default browser viewport size. Both fields are optional.
108
+
109
+ ### Sessions
110
+
111
+ Each Libretto session gets its own directory under `.libretto/sessions/<name>/` containing runtime state. Sessions are git-ignored.
112
+
113
+ - `state.json` — session metadata (debug port, PID, status)
114
+ - `logs.jsonl` — structured session logs
115
+ - `network.jsonl` — captured network requests
116
+ - `actions.jsonl` — recorded user actions
117
+ - `snapshots/` — screenshot PNGs and HTML snapshots
118
+
119
+ ### Profiles
120
+
121
+ Profiles save browser sessions (cookies, localStorage) so you can reuse authenticated state across runs. They are stored in `.libretto/profiles/<domain>.json`, created via `npx libretto save <domain>`. Profiles are machine-local and git-ignored.
122
+
123
+ ## Community
124
+
125
+ Have a question, idea, or want to share what you've built? Join the conversation on [GitHub Discussions](https://github.com/saffron-health/libretto/discussions).
126
+
127
+ - **[Q&A](https://github.com/saffron-health/libretto/discussions/categories/q-a)** — Ask questions and get help
128
+ - **[Ideas](https://github.com/saffron-health/libretto/discussions/categories/ideas)** — Suggest new features or improvements
129
+ - **[Show and tell](https://github.com/saffron-health/libretto/discussions/categories/show-and-tell)** — Share your workflows and automations
130
+ - **[General](https://github.com/saffron-health/libretto/discussions/categories/general)** — Chat about anything Libretto-related
131
+
132
+ Found a bug? Please [open an issue](https://github.com/saffron-health/libretto/issues/new).
133
+
134
+ ## Authors
135
+
136
+ Maintained by the team at [Saffron Health](https://saffron.health).
137
+
138
+ ## Development
139
+
140
+ For local development in this repository:
141
+
142
+ ```bash
143
+ pnpm i
144
+ pnpm build
145
+ pnpm type-check
146
+ pnpm test
147
+ ```
148
+
149
+ Source layout:
150
+
151
+ - `{{LIBRETTO_PATH_PREFIX}}src/cli/` — CLI commands
152
+ - `{{LIBRETTO_PATH_PREFIX}}src/runtime/` — browser runtime (network, recovery, downloads, extraction)
153
+ - `{{LIBRETTO_PATH_PREFIX}}src/shared/` — shared utilities (config, LLM client, logging, state)
154
+ - `{{LIBRETTO_PATH_PREFIX}}test/` — test files (`*.spec.ts`)
155
+ - `{{LIBRETTO_PATH_PREFIX}}README.template.md` — source of truth for the repo and package READMEs
156
+ - `{{LIBRETTO_PATH_PREFIX}}skills/libretto/` — source of truth for the Libretto skill
157
+
158
+ Run `pnpm sync:mirrors` after editing `{{LIBRETTO_PATH_PREFIX}}README.template.md` or anything under `{{LIBRETTO_PATH_PREFIX}}skills/libretto/`. `pnpm i` also resyncs the skill mirrors through `postinstall`.
159
+
160
+ To check that generated READMEs, skill mirrors, and skill version metadata are in sync without fixing them, run `pnpm check:mirrors`. To release, run `pnpm prepare-release`.
@@ -80,7 +80,7 @@ const deployInput = SimpleCLI.input({
80
80
  }
81
81
  });
82
82
  const deployCommand = SimpleCLI.command({
83
- description: "[experimental] Deploy workflows to the hosted platform",
83
+ description: "Deploy workflows to the hosted platform",
84
84
  experimental: true
85
85
  }).input(deployInput).handle(async ({ input }) => {
86
86
  const { apiUrl, apiKey } = getConfig();
@@ -448,7 +448,6 @@ async function runIntegrationFromFile(args, logger) {
448
448
  workflowName: args.workflowName,
449
449
  session: args.session,
450
450
  params: args.params,
451
- credentials: args.credentials,
452
451
  headless: args.headless,
453
452
  visualize: args.visualize,
454
453
  authProfileDomain: args.authProfileDomain,
@@ -542,7 +541,7 @@ const execCommand = SimpleCLI.command({
542
541
  input.page
543
542
  );
544
543
  });
545
- const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--credentials <json>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
544
+ const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
546
545
  const runInput = SimpleCLI.input({
547
546
  positionals: [
548
547
  SimpleCLI.positional("integrationFile", z.string().optional(), {
@@ -561,9 +560,6 @@ const runInput = SimpleCLI.input({
561
560
  name: "params-file",
562
561
  help: "Path to a JSON params file"
563
562
  }),
564
- credentials: SimpleCLI.option(z.string().optional(), {
565
- help: "Inline JSON credentials passed to ctx.credentials"
566
- }),
567
563
  tsconfig: SimpleCLI.option(z.string().optional(), {
568
564
  help: "Path to a tsconfig used for workflow module resolution"
569
565
  }),
@@ -614,11 +610,6 @@ const runCommand = SimpleCLI.command({
614
610
  await stopExistingFailedRunSession(ctx.session, ctx.logger);
615
611
  assertSessionAvailableForStart(ctx.session, ctx.logger);
616
612
  const params = resolveRunParams(input.params, input.paramsFile);
617
- const rawCredentials = input.credentials ? parseJsonArg("--credentials", input.credentials) : void 0;
618
- if (rawCredentials !== void 0 && (typeof rawCredentials !== "object" || rawCredentials === null || Array.isArray(rawCredentials))) {
619
- throw new Error(`--credentials must be a JSON object (e.g., '{"key": "value"}').`);
620
- }
621
- const credentials = rawCredentials;
622
613
  const headlessMode = input.headed ? false : input.headless ? true : void 0;
623
614
  const visualize = !input.noVisualize;
624
615
  const viewport = resolveViewport(
@@ -631,7 +622,6 @@ const runCommand = SimpleCLI.command({
631
622
  workflowName: input.workflowName,
632
623
  session: ctx.session,
633
624
  params,
634
- credentials,
635
625
  tsconfigPath: input.tsconfig,
636
626
  headless: headlessMode ?? false,
637
627
  visualize,
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ const EXPERIMENTAL_COMMAND_PREFIX = "experimental";
3
+ const EXPERIMENTAL_GROUP_DESCRIPTION = "Experimental commands";
2
4
  function toCamelCase(input2) {
3
5
  return input2.replace(
4
6
  /-([a-zA-Z0-9])/g,
@@ -420,7 +422,7 @@ class SimpleCLIApp {
420
422
  }
421
423
  renderRootHelp() {
422
424
  const lines = [`Usage: ${this.name} <command>`, "", "Commands:"];
423
- for (const entry of this.getImmediateRouteEntries([])) {
425
+ for (const entry of this.getRootHelpEntries()) {
424
426
  lines.push(formatListEntry(entry.label, entry.description));
425
427
  }
426
428
  return lines.join("\n");
@@ -493,7 +495,6 @@ class SimpleCLIApp {
493
495
  continue;
494
496
  }
495
497
  const command2 = this.findCommandByPath(routeEntry.path);
496
- if (command2?.experimental) continue;
497
498
  entries.push({
498
499
  label: token,
499
500
  description: command2?.description
@@ -501,6 +502,41 @@ class SimpleCLIApp {
501
502
  }
502
503
  return entries;
503
504
  }
505
+ getRootHelpEntries() {
506
+ return this.getImmediateRouteEntries([]).filter((entry) => {
507
+ const token = entry.label.replace(/\s+<subcommand>$/, "");
508
+ const group2 = this.findGroupByPath([token]);
509
+ if (!group2) {
510
+ return true;
511
+ }
512
+ if (token === EXPERIMENTAL_COMMAND_PREFIX) {
513
+ return this.groupHasExperimentalCommand(group2.path);
514
+ }
515
+ return this.groupHasVisibleNonExperimentalCommand(group2.path);
516
+ });
517
+ }
518
+ groupHasVisibleNonExperimentalCommand(path) {
519
+ for (const routeEntry of this.routeEntries) {
520
+ if (routeEntry.kind !== "command") continue;
521
+ if (!pathStartsWith(routeEntry.path, path)) continue;
522
+ const command2 = this.findCommandByPath(routeEntry.path);
523
+ if (command2 && !command2.experimental) {
524
+ return true;
525
+ }
526
+ }
527
+ return false;
528
+ }
529
+ groupHasExperimentalCommand(path) {
530
+ for (const routeEntry of this.routeEntries) {
531
+ if (routeEntry.kind !== "command") continue;
532
+ if (!pathStartsWith(routeEntry.path, path)) continue;
533
+ const command2 = this.findCommandByPath(routeEntry.path);
534
+ if (command2?.experimental) {
535
+ return true;
536
+ }
537
+ }
538
+ return false;
539
+ }
504
540
  findBestMatchingCommand(args) {
505
541
  let bestMatch = null;
506
542
  for (const command2 of this.resolvedCommands.values()) {
@@ -600,31 +636,38 @@ function validateRequiredNamedArgs(definitions, named) {
600
636
  throw new Error(`Missing required option --${flagName}.`);
601
637
  }
602
638
  }
603
- function resolveRouteTree(routes, parentPath = [], parentMiddlewares = []) {
639
+ function resolveRouteTree(routes) {
640
+ const collected = collectRouteTree(routes);
641
+ const { groups, routeEntries } = buildResolvedRouteEntries(
642
+ collected.commands,
643
+ collected.groupDescriptions
644
+ );
645
+ return {
646
+ commands: collected.commands,
647
+ groups,
648
+ routeEntries
649
+ };
650
+ }
651
+ function collectRouteTree(routes, parentPath = [], parentMiddlewares = []) {
604
652
  const resolved = {
605
653
  commands: [],
606
- groups: [],
607
- routeEntries: []
654
+ groupDescriptions: /* @__PURE__ */ new Map()
608
655
  };
609
656
  for (const [token, routeValue] of Object.entries(routes)) {
610
657
  if (isGroup(routeValue)) {
611
658
  const groupPath = [...parentPath, token];
612
- resolved.groups.push({
613
- routeKey: pathToRouteKey(groupPath),
614
- path: groupPath,
615
- description: routeValue.description
616
- });
617
- resolved.routeEntries.push({
618
- kind: "group",
619
- path: groupPath
620
- });
621
- const nested = resolveRouteTree(routeValue.routes, groupPath, [
659
+ resolved.groupDescriptions.set(
660
+ pathToRouteKey(groupPath),
661
+ routeValue.description
662
+ );
663
+ const nested = collectRouteTree(routeValue.routes, groupPath, [
622
664
  ...parentMiddlewares,
623
665
  ...routeValue.middlewares
624
666
  ]);
625
667
  resolved.commands.push(...nested.commands);
626
- resolved.groups.push(...nested.groups);
627
- resolved.routeEntries.push(...nested.routeEntries);
668
+ for (const [routeKey, description] of nested.groupDescriptions) {
669
+ resolved.groupDescriptions.set(routeKey, description);
670
+ }
628
671
  continue;
629
672
  }
630
673
  const command2 = routeValue.getDefinition();
@@ -633,7 +676,8 @@ function resolveRouteTree(routes, parentPath = [], parentMiddlewares = []) {
633
676
  `Command "${[...parentPath, token].join(" ")}" is missing a handler.`
634
677
  );
635
678
  }
636
- const path = [...parentPath, token];
679
+ const rawPath = [...parentPath, token];
680
+ const path = command2.config.experimental ? [EXPERIMENTAL_COMMAND_PREFIX, ...rawPath] : rawPath;
637
681
  resolved.commands.push({
638
682
  routeKey: pathToRouteKey(path),
639
683
  path,
@@ -646,12 +690,43 @@ function resolveRouteTree(routes, parentPath = [], parentMiddlewares = []) {
646
690
  ),
647
691
  handler: command2.handler
648
692
  });
649
- resolved.routeEntries.push({
693
+ }
694
+ return resolved;
695
+ }
696
+ function buildResolvedRouteEntries(commands, groupDescriptions) {
697
+ const groups = /* @__PURE__ */ new Map();
698
+ const routeEntries = [];
699
+ for (const command2 of commands) {
700
+ for (let depth = 1; depth < command2.path.length; depth += 1) {
701
+ const path = command2.path.slice(0, depth);
702
+ const routeKey = pathToRouteKey(path);
703
+ if (groups.has(routeKey)) continue;
704
+ groups.set(routeKey, {
705
+ routeKey,
706
+ path,
707
+ description: resolveGroupDescription(path, groupDescriptions)
708
+ });
709
+ routeEntries.push({
710
+ kind: "group",
711
+ path
712
+ });
713
+ }
714
+ routeEntries.push({
650
715
  kind: "command",
651
- path
716
+ path: command2.path
652
717
  });
653
718
  }
654
- return resolved;
719
+ return {
720
+ groups: [...groups.values()],
721
+ routeEntries
722
+ };
723
+ }
724
+ function resolveGroupDescription(path, groupDescriptions) {
725
+ if (path.length === 1 && path[0] === EXPERIMENTAL_COMMAND_PREFIX) {
726
+ return EXPERIMENTAL_GROUP_DESCRIPTION;
727
+ }
728
+ const originalPath = path[0] === EXPERIMENTAL_COMMAND_PREFIX ? path.slice(1) : path;
729
+ return groupDescriptions.get(pathToRouteKey(originalPath));
655
730
  }
656
731
  function mergeInheritedMiddlewares(parentMiddlewares, commandMiddlewares) {
657
732
  if (parentMiddlewares.length === 0) {
@@ -8,12 +8,12 @@ import { snapshotCommand } from "./commands/snapshot.js";
8
8
  import { SimpleCLI } from "./framework/simple-cli.js";
9
9
  const cliRoutes = {
10
10
  ...browserCommands,
11
+ deploy: deployCommand,
11
12
  ...executionCommands,
12
13
  ...logCommands,
13
14
  ai: aiCommands,
14
15
  init: initCommand,
15
- snapshot: snapshotCommand,
16
- deploy: deployCommand
16
+ snapshot: snapshotCommand
17
17
  };
18
18
  function createCLIApp() {
19
19
  return SimpleCLI.define("libretto", cliRoutes);
@@ -176,8 +176,7 @@ async function runIntegrationInternal(args, options) {
176
176
  const workflowContext = {
177
177
  session: args.session,
178
178
  logger: integrationLogger,
179
- page: browserSession.page,
180
- credentials: args.credentials
179
+ page: browserSession.page
181
180
  };
182
181
  try {
183
182
  try {
@@ -4,7 +4,6 @@ const RunIntegrationWorkerRequestSchema = z.object({
4
4
  workflowName: z.string().min(1),
5
5
  session: z.string().min(1),
6
6
  params: z.unknown(),
7
- credentials: z.record(z.string(), z.unknown()).optional(),
8
7
  headless: z.boolean(),
9
8
  visualize: z.boolean().default(true),
10
9
  authProfileDomain: z.string().optional(),
@@ -6,7 +6,6 @@ type LibrettoWorkflowContext = {
6
6
  session: string;
7
7
  page: Page;
8
8
  logger: MinimalLogger;
9
- credentials?: Record<string, unknown>;
10
9
  };
11
10
  type LibrettoWorkflowHandler<Input = unknown, Output = unknown> = (ctx: LibrettoWorkflowContext, input: Input) => Promise<Output>;
12
11
  declare class LibrettoWorkflow<Input = unknown, Output = unknown> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.5.3-experimental.6",
3
+ "version": "0.5.3",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,8 +31,10 @@
31
31
  },
32
32
  "scripts": {
33
33
  "postinstall": "node scripts/postinstall.mjs",
34
- "sync-skills": "node scripts/sync-skills.mjs",
35
- "check:skills": "node scripts/check-skills-sync.mjs",
34
+ "sync:mirrors": "node ../dev-tools/scripts/sync-mirrors.mjs",
35
+ "check:mirrors": "node ../dev-tools/scripts/check-mirrors-sync.mjs",
36
+ "sync-skills": "pnpm run sync:mirrors",
37
+ "check:skills": "pnpm run check:mirrors",
36
38
  "build": "tsup --config tsup.config.ts",
37
39
  "type-check": "tsc --noEmit",
38
40
  "test": "pnpm run build && vitest run",
@@ -1,14 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import {
4
- cpSync,
5
- existsSync,
6
- mkdirSync,
7
- readFileSync,
8
- readdirSync,
9
- rmSync,
10
- } from "node:fs";
11
- import { relative, resolve, join } from "node:path";
3
+ import { cpSync, mkdirSync, rmSync } from "node:fs";
12
4
 
13
5
  export const SKILL_DIRS = [
14
6
  "packages/libretto/skills/libretto",
@@ -16,88 +8,8 @@ export const SKILL_DIRS = [
16
8
  ".claude/skills/libretto",
17
9
  ];
18
10
 
19
- function walkFiles(dir, baseDir = dir) {
20
- const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
21
- a.name.localeCompare(b.name),
22
- );
23
- const files = [];
24
-
25
- for (const entry of entries) {
26
- const fullPath = join(dir, entry.name);
27
- if (entry.isDirectory()) {
28
- files.push(...walkFiles(fullPath, baseDir));
29
- continue;
30
- }
31
- if (entry.isFile()) files.push(relative(baseDir, fullPath));
32
- }
33
-
34
- return files;
35
- }
36
-
37
11
  export function syncSkillDir(sourceDir, destDir) {
38
12
  rmSync(destDir, { recursive: true, force: true });
39
13
  mkdirSync(destDir, { recursive: true });
40
14
  cpSync(sourceDir, destDir, { recursive: true });
41
15
  }
42
-
43
- export function syncRepoSkills(repoRoot) {
44
- const sourceDir = resolve(repoRoot, "skills/libretto");
45
- for (const dir of SKILL_DIRS.slice(1)) {
46
- syncSkillDir(sourceDir, resolve(repoRoot, dir));
47
- }
48
- }
49
-
50
- export function compareSkillDirs(repoRoot) {
51
- const roots = SKILL_DIRS.map((dir) => ({
52
- label: dir,
53
- absPath: resolve(repoRoot, dir),
54
- }));
55
- const missing = roots.filter(({ absPath }) => !existsSync(absPath));
56
- const mismatches = [];
57
-
58
- if (missing.length > 0) {
59
- return {
60
- ok: false,
61
- issues: missing.map(({ label }) => `missing directory: ${label}`),
62
- };
63
- }
64
-
65
- const expectedFiles = walkFiles(roots[0].absPath);
66
- const expectedFileSet = new Set(expectedFiles);
67
-
68
- for (const root of roots.slice(1)) {
69
- const actualFiles = walkFiles(root.absPath);
70
- const actualFileSet = new Set(actualFiles);
71
-
72
- for (const file of expectedFiles) {
73
- if (!actualFileSet.has(file)) {
74
- mismatches.push(`${root.label} is missing file: ${file}`);
75
- }
76
- }
77
-
78
- for (const file of actualFiles) {
79
- if (!expectedFileSet.has(file)) {
80
- mismatches.push(`${root.label} has unexpected file: ${file}`);
81
- }
82
- }
83
- }
84
-
85
- for (const file of expectedFiles) {
86
- const expectedContent = readFileSync(join(roots[0].absPath, file));
87
- for (const root of roots.slice(1)) {
88
- const targetPath = join(root.absPath, file);
89
- if (!existsSync(targetPath)) continue;
90
- const actualContent = readFileSync(targetPath);
91
- if (!expectedContent.equals(actualContent)) {
92
- mismatches.push(
93
- `${root.label} differs from ${roots[0].label}: ${file}`,
94
- );
95
- }
96
- }
97
- }
98
-
99
- return {
100
- ok: mismatches.length === 0,
101
- issues: mismatches,
102
- };
103
- }
@@ -4,7 +4,7 @@ description: "Browser automation CLI for building, maintaining, and running brow
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.4.2"
7
+ version: "0.5.3"
8
8
  ---
9
9
 
10
10
  ## How Libretto Works
@@ -40,7 +40,7 @@ Key points:
40
40
 
41
41
  - `workflow(name, handler)` takes a unique workflow name and returns the workflow object that Libretto can run.
42
42
  - `npx libretto run ./file.ts myWorkflow` resolves `myWorkflow` from the workflows exported by `./file.ts`, so export or re-export the workflow from that file directly or through a `workflows` object, and make sure the run argument matches the name passed to `workflow("myWorkflow", ...)`.
43
- - `ctx` provides `session`, `page`, `logger`, and optional `credentials`
43
+ - `ctx` provides `session`, `page`, and `logger`
44
44
  - `input` comes from `--params '{"query":"foo"}'` or `--params-file params.json` on the CLI
45
45
  - Use `await pause(ctx.session)` (or `await pause(session)`) to pause the workflow for debugging. It is a no-op in production.
46
46
  - After validation is complete and the workflow is confirmed working end to end, remove all `pause()` calls and pause-only workflow params unless the user explicitly says to keep them.
@@ -121,7 +121,7 @@ export const deployInput = SimpleCLI.input({
121
121
  });
122
122
 
123
123
  export const deployCommand = SimpleCLI.command({
124
- description: "[experimental] Deploy workflows to the hosted platform",
124
+ description: "Deploy workflows to the hosted platform",
125
125
  experimental: true,
126
126
  })
127
127
  .input(deployInput)
@@ -608,7 +608,6 @@ async function runIntegrationFromFile(
608
608
  workflowName: args.workflowName,
609
609
  session: args.session,
610
610
  params: args.params,
611
- credentials: args.credentials,
612
611
  headless: args.headless,
613
612
  visualize: args.visualize,
614
613
  authProfileDomain: args.authProfileDomain,
@@ -707,7 +706,7 @@ export const execCommand = SimpleCLI.command({
707
706
  );
708
707
  });
709
708
 
710
- const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--credentials <json>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
709
+ const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
711
710
 
712
711
  export const runInput = SimpleCLI.input({
713
712
  positionals: [
@@ -727,9 +726,6 @@ export const runInput = SimpleCLI.input({
727
726
  name: "params-file",
728
727
  help: "Path to a JSON params file",
729
728
  }),
730
- credentials: SimpleCLI.option(z.string().optional(), {
731
- help: "Inline JSON credentials passed to ctx.credentials",
732
- }),
733
729
  tsconfig: SimpleCLI.option(z.string().optional(), {
734
730
  help: "Path to a tsconfig used for workflow module resolution",
735
731
  }),
@@ -792,13 +788,6 @@ export const runCommand = SimpleCLI.command({
792
788
  assertSessionAvailableForStart(ctx.session, ctx.logger);
793
789
 
794
790
  const params = resolveRunParams(input.params, input.paramsFile);
795
- const rawCredentials = input.credentials
796
- ? parseJsonArg("--credentials", input.credentials)
797
- : undefined;
798
- if (rawCredentials !== undefined && (typeof rawCredentials !== "object" || rawCredentials === null || Array.isArray(rawCredentials))) {
799
- throw new Error("--credentials must be a JSON object (e.g., '{\"key\": \"value\"}').");
800
- }
801
- const credentials = rawCredentials as Record<string, unknown> | undefined;
802
791
  const headlessMode = input.headed
803
792
  ? false
804
793
  : input.headless
@@ -816,7 +805,6 @@ export const runCommand = SimpleCLI.command({
816
805
  workflowName: input.workflowName!,
817
806
  session: ctx.session,
818
807
  params,
819
- credentials,
820
808
  tsconfigPath: input.tsconfig,
821
809
  headless: headlessMode ?? false,
822
810
  visualize,
@@ -129,6 +129,11 @@ type InternalResolvedRouteEntry = {
129
129
  path: readonly string[];
130
130
  };
131
131
 
132
+ type CollectedRouteTreeResult = {
133
+ commands: InternalResolvedCommand[];
134
+ groupDescriptions: Map<string, string | undefined>;
135
+ };
136
+
132
137
  type ResolveRouteTreeResult = {
133
138
  commands: InternalResolvedCommand[];
134
139
  groups: InternalResolvedGroup[];
@@ -150,6 +155,9 @@ type ExtractedGlobalArgs = {
150
155
  named: Readonly<Record<string, unknown>>;
151
156
  };
152
157
 
158
+ const EXPERIMENTAL_COMMAND_PREFIX = "experimental";
159
+ const EXPERIMENTAL_GROUP_DESCRIPTION = "Experimental commands";
160
+
153
161
  function toCamelCase(input: string): string {
154
162
  return input.replace(/-([a-zA-Z0-9])/g, (_match, letter: string) =>
155
163
  letter.toUpperCase(),
@@ -761,7 +769,7 @@ export class SimpleCLIApp {
761
769
 
762
770
  private renderRootHelp(): string {
763
771
  const lines = [`Usage: ${this.name} <command>`, "", "Commands:"];
764
- for (const entry of this.getImmediateRouteEntries([])) {
772
+ for (const entry of this.getRootHelpEntries()) {
765
773
  lines.push(formatListEntry(entry.label, entry.description));
766
774
  }
767
775
  return lines.join("\n");
@@ -860,7 +868,6 @@ export class SimpleCLIApp {
860
868
  }
861
869
 
862
870
  const command = this.findCommandByPath(routeEntry.path);
863
- if (command?.experimental) continue;
864
871
  entries.push({
865
872
  label: token,
866
873
  description: command?.description,
@@ -870,6 +877,49 @@ export class SimpleCLIApp {
870
877
  return entries;
871
878
  }
872
879
 
880
+ private getRootHelpEntries(): Array<{
881
+ label: string;
882
+ description?: string;
883
+ }> {
884
+ return this.getImmediateRouteEntries([]).filter((entry) => {
885
+ const token = entry.label.replace(/\s+<subcommand>$/, "");
886
+ const group = this.findGroupByPath([token]);
887
+ if (!group) {
888
+ return true;
889
+ }
890
+ if (token === EXPERIMENTAL_COMMAND_PREFIX) {
891
+ return this.groupHasExperimentalCommand(group.path);
892
+ }
893
+ return this.groupHasVisibleNonExperimentalCommand(group.path);
894
+ });
895
+ }
896
+
897
+ private groupHasVisibleNonExperimentalCommand(path: readonly string[]): boolean {
898
+ for (const routeEntry of this.routeEntries) {
899
+ if (routeEntry.kind !== "command") continue;
900
+ if (!pathStartsWith(routeEntry.path, path)) continue;
901
+ const command = this.findCommandByPath(routeEntry.path);
902
+ if (command && !command.experimental) {
903
+ return true;
904
+ }
905
+ }
906
+
907
+ return false;
908
+ }
909
+
910
+ private groupHasExperimentalCommand(path: readonly string[]): boolean {
911
+ for (const routeEntry of this.routeEntries) {
912
+ if (routeEntry.kind !== "command") continue;
913
+ if (!pathStartsWith(routeEntry.path, path)) continue;
914
+ const command = this.findCommandByPath(routeEntry.path);
915
+ if (command?.experimental) {
916
+ return true;
917
+ }
918
+ }
919
+
920
+ return false;
921
+ }
922
+
873
923
  private findBestMatchingCommand(
874
924
  args: readonly string[],
875
925
  ): InternalResolvedCommand | null {
@@ -1034,37 +1084,46 @@ function validateRequiredNamedArgs(
1034
1084
  }
1035
1085
  }
1036
1086
 
1037
- function resolveRouteTree(
1087
+ function resolveRouteTree(routes: SimpleCLIRouteTree<any>): ResolveRouteTreeResult {
1088
+ const collected = collectRouteTree(routes);
1089
+ const { groups, routeEntries } = buildResolvedRouteEntries(
1090
+ collected.commands,
1091
+ collected.groupDescriptions,
1092
+ );
1093
+
1094
+ return {
1095
+ commands: collected.commands,
1096
+ groups,
1097
+ routeEntries,
1098
+ };
1099
+ }
1100
+
1101
+ function collectRouteTree(
1038
1102
  routes: SimpleCLIRouteTree<any>,
1039
1103
  parentPath: readonly string[] = [],
1040
1104
  parentMiddlewares: readonly AnySimpleCLIMiddleware[] = [],
1041
- ): ResolveRouteTreeResult {
1042
- const resolved: ResolveRouteTreeResult = {
1105
+ ): CollectedRouteTreeResult {
1106
+ const resolved: CollectedRouteTreeResult = {
1043
1107
  commands: [],
1044
- groups: [],
1045
- routeEntries: [],
1108
+ groupDescriptions: new Map<string, string | undefined>(),
1046
1109
  };
1047
1110
 
1048
1111
  for (const [token, routeValue] of Object.entries(routes)) {
1049
1112
  if (isGroup(routeValue)) {
1050
1113
  const groupPath = [...parentPath, token];
1051
- resolved.groups.push({
1052
- routeKey: pathToRouteKey(groupPath),
1053
- path: groupPath,
1054
- description: routeValue.description,
1055
- });
1056
- resolved.routeEntries.push({
1057
- kind: "group",
1058
- path: groupPath,
1059
- });
1114
+ resolved.groupDescriptions.set(
1115
+ pathToRouteKey(groupPath),
1116
+ routeValue.description,
1117
+ );
1060
1118
 
1061
- const nested = resolveRouteTree(routeValue.routes, groupPath, [
1119
+ const nested = collectRouteTree(routeValue.routes, groupPath, [
1062
1120
  ...parentMiddlewares,
1063
1121
  ...routeValue.middlewares,
1064
1122
  ]);
1065
1123
  resolved.commands.push(...nested.commands);
1066
- resolved.groups.push(...nested.groups);
1067
- resolved.routeEntries.push(...nested.routeEntries);
1124
+ for (const [routeKey, description] of nested.groupDescriptions) {
1125
+ resolved.groupDescriptions.set(routeKey, description);
1126
+ }
1068
1127
  continue;
1069
1128
  }
1070
1129
 
@@ -1075,7 +1134,10 @@ function resolveRouteTree(
1075
1134
  );
1076
1135
  }
1077
1136
 
1078
- const path = [...parentPath, token];
1137
+ const rawPath = [...parentPath, token];
1138
+ const path = command.config.experimental
1139
+ ? [EXPERIMENTAL_COMMAND_PREFIX, ...rawPath]
1140
+ : rawPath;
1079
1141
  resolved.commands.push({
1080
1142
  routeKey: pathToRouteKey(path),
1081
1143
  path,
@@ -1092,13 +1154,58 @@ function resolveRouteTree(
1092
1154
  unknown
1093
1155
  >,
1094
1156
  });
1095
- resolved.routeEntries.push({
1157
+ }
1158
+
1159
+ return resolved;
1160
+ }
1161
+
1162
+ function buildResolvedRouteEntries(
1163
+ commands: readonly InternalResolvedCommand[],
1164
+ groupDescriptions: ReadonlyMap<string, string | undefined>,
1165
+ ): Pick<ResolveRouteTreeResult, "groups" | "routeEntries"> {
1166
+ const groups = new Map<string, InternalResolvedGroup>();
1167
+ const routeEntries: InternalResolvedRouteEntry[] = [];
1168
+
1169
+ for (const command of commands) {
1170
+ for (let depth = 1; depth < command.path.length; depth += 1) {
1171
+ const path = command.path.slice(0, depth);
1172
+ const routeKey = pathToRouteKey(path);
1173
+ if (groups.has(routeKey)) continue;
1174
+
1175
+ groups.set(routeKey, {
1176
+ routeKey,
1177
+ path,
1178
+ description: resolveGroupDescription(path, groupDescriptions),
1179
+ });
1180
+ routeEntries.push({
1181
+ kind: "group",
1182
+ path,
1183
+ });
1184
+ }
1185
+
1186
+ routeEntries.push({
1096
1187
  kind: "command",
1097
- path,
1188
+ path: command.path,
1098
1189
  });
1099
1190
  }
1100
1191
 
1101
- return resolved;
1192
+ return {
1193
+ groups: [...groups.values()],
1194
+ routeEntries,
1195
+ };
1196
+ }
1197
+
1198
+ function resolveGroupDescription(
1199
+ path: readonly string[],
1200
+ groupDescriptions: ReadonlyMap<string, string | undefined>,
1201
+ ): string | undefined {
1202
+ if (path.length === 1 && path[0] === EXPERIMENTAL_COMMAND_PREFIX) {
1203
+ return EXPERIMENTAL_GROUP_DESCRIPTION;
1204
+ }
1205
+
1206
+ const originalPath =
1207
+ path[0] === EXPERIMENTAL_COMMAND_PREFIX ? path.slice(1) : path;
1208
+ return groupDescriptions.get(pathToRouteKey(originalPath));
1102
1209
  }
1103
1210
 
1104
1211
  function mergeInheritedMiddlewares(
package/src/cli/router.ts CHANGED
@@ -9,12 +9,12 @@ import { SimpleCLI } from "./framework/simple-cli.js";
9
9
 
10
10
  export const cliRoutes = {
11
11
  ...browserCommands,
12
+ deploy: deployCommand,
12
13
  ...executionCommands,
13
14
  ...logCommands,
14
15
  ai: aiCommands,
15
16
  init: initCommand,
16
17
  snapshot: snapshotCommand,
17
- deploy: deployCommand,
18
18
  };
19
19
 
20
20
  export function createCLIApp() {
@@ -257,7 +257,6 @@ async function runIntegrationInternal(
257
257
  session: args.session,
258
258
  logger: integrationLogger,
259
259
  page: browserSession.page,
260
- credentials: args.credentials,
261
260
  };
262
261
 
263
262
  try {
@@ -5,7 +5,6 @@ export const RunIntegrationWorkerRequestSchema = z.object({
5
5
  workflowName: z.string().min(1),
6
6
  session: z.string().min(1),
7
7
  params: z.unknown(),
8
- credentials: z.record(z.string(), z.unknown()).optional(),
9
8
  headless: z.boolean(),
10
9
  visualize: z.boolean().default(true),
11
10
  authProfileDomain: z.string().optional(),
@@ -7,7 +7,6 @@ export type LibrettoWorkflowContext = {
7
7
  session: string;
8
8
  page: Page;
9
9
  logger: MinimalLogger;
10
- credentials?: Record<string, unknown>;
11
10
  };
12
11
 
13
12
  export type LibrettoWorkflowHandler<Input = unknown, Output = unknown> = (
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- import { compareSkillDirs, SKILL_DIRS } from "./skills-libretto.mjs";
7
-
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(__dirname, "..", "..", "..");
10
- const result = compareSkillDirs(repoRoot);
11
-
12
- if (result.ok) {
13
- console.log(
14
- `libretto: verified identical skill mirrors across ${SKILL_DIRS.join(", ")}`,
15
- );
16
- process.exit(0);
17
- }
18
-
19
- console.error("libretto: skill directories must be identical:");
20
- for (const issue of result.issues) {
21
- console.error(`- ${issue}`);
22
- }
23
- console.error("");
24
- console.error("Run `pnpm i` to resync the mirrors in this repository.");
25
- process.exit(1);
@@ -1,12 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- import { SKILL_DIRS, syncRepoSkills } from "./skills-libretto.mjs";
7
-
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(__dirname, "..", "..", "..");
10
-
11
- syncRepoSkills(repoRoot);
12
- console.log(`libretto: synced skill mirrors across ${SKILL_DIRS.join(", ")}`);