libretto 0.5.2 → 0.5.3-experimental.0

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
@@ -63,8 +63,9 @@ You can also use Libretto directly from the command line. All commands accept `-
63
63
  npx libretto init # interactive; run yourself, not through an agent
64
64
  npx libretto open <url> # launch browser and open a URL (headed by default)
65
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
67
- npx libretto run <file> <export> # run an exported workflow from a file
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
68
69
  npx libretto resume # resume a paused workflow
69
70
  npx libretto network # view captured network requests
70
71
  npx libretto actions # view captured user/agent actions
@@ -445,9 +445,10 @@ async function runIntegrationFromFile(args, logger) {
445
445
  );
446
446
  const payload = JSON.stringify({
447
447
  integrationPath: args.integrationPath,
448
- exportName: args.exportName,
448
+ workflowName: args.workflowName,
449
449
  session: args.session,
450
450
  params: args.params,
451
+ credentials: args.credentials,
451
452
  headless: args.headless,
452
453
  visualize: args.visualize,
453
454
  authProfileDomain: args.authProfileDomain,
@@ -496,11 +497,19 @@ Browser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-ru
496
497
  setSessionStatus(args.session, "completed", logger);
497
498
  console.log("Integration completed.");
498
499
  }
500
+ function readStdinSync() {
501
+ if (process.stdin.isTTY === true) return null;
502
+ try {
503
+ const content = readFileSync(0, "utf8");
504
+ return content.trim().length > 0 ? content : null;
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
499
509
  const execInput = SimpleCLI.input({
500
510
  positionals: [
501
- SimpleCLI.positional("codeParts", z.array(z.string()).default([]), {
502
- help: "Playwright TypeScript code to execute",
503
- variadic: true
511
+ SimpleCLI.positional("code", z.string().optional(), {
512
+ help: "Playwright TypeScript code to execute"
504
513
  })
505
514
  ],
506
515
  named: {
@@ -511,28 +520,36 @@ const execInput = SimpleCLI.input({
511
520
  page: pageOption()
512
521
  }
513
522
  }).refine(
514
- (input) => input.codeParts.length > 0,
515
- `Usage: libretto exec <code> [--session <name>] [--visualize]`
523
+ (input) => input.code !== void 0,
524
+ `Usage: libretto exec <code|-> [--session <name>] [--visualize]
525
+ echo '<code>' | libretto exec - [--session <name>] [--visualize]`
516
526
  );
517
527
  const execCommand = SimpleCLI.command({
518
528
  description: "Execute Playwright TypeScript code"
519
529
  }).input(execInput).use(withRequiredSession()).handle(async ({ input, ctx }) => {
530
+ const code = input.code;
531
+ const codeFromArgsOrStdin = code === "-" ? readStdinSync() : code;
532
+ if (codeFromArgsOrStdin === null) {
533
+ throw new Error(
534
+ "Missing stdin input for `exec -`. Pipe Playwright code into stdin."
535
+ );
536
+ }
520
537
  await runExec(
521
- input.codeParts.join(" "),
538
+ codeFromArgsOrStdin,
522
539
  ctx.session,
523
540
  ctx.logger,
524
541
  input.visualize,
525
542
  input.page
526
543
  );
527
544
  });
528
- const runUsage = `Usage: libretto run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
545
+ const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--credentials <json>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
529
546
  const runInput = SimpleCLI.input({
530
547
  positionals: [
531
548
  SimpleCLI.positional("integrationFile", z.string().optional(), {
532
549
  help: "Path to the integration file"
533
550
  }),
534
- SimpleCLI.positional("integrationExport", z.string().optional(), {
535
- help: "Named workflow export to run"
551
+ SimpleCLI.positional("workflowName", z.string().optional(), {
552
+ help: "Workflow name to run (from workflow(name, handler))"
536
553
  })
537
554
  ],
538
555
  named: {
@@ -544,6 +561,9 @@ const runInput = SimpleCLI.input({
544
561
  name: "params-file",
545
562
  help: "Path to a JSON params file"
546
563
  }),
564
+ credentials: SimpleCLI.option(z.string().optional(), {
565
+ help: "Inline JSON credentials passed to ctx.credentials"
566
+ }),
547
567
  tsconfig: SimpleCLI.option(z.string().optional(), {
548
568
  help: "Path to a tsconfig used for workflow module resolution"
549
569
  }),
@@ -562,7 +582,7 @@ const runInput = SimpleCLI.input({
562
582
  })
563
583
  }
564
584
  }).refine(
565
- (input) => Boolean(input.integrationFile && input.integrationExport),
585
+ (input) => Boolean(input.integrationFile && input.workflowName),
566
586
  runUsage
567
587
  ).refine(
568
588
  (input) => !(input.params && input.paramsFile),
@@ -594,6 +614,11 @@ const runCommand = SimpleCLI.command({
594
614
  await stopExistingFailedRunSession(ctx.session, ctx.logger);
595
615
  assertSessionAvailableForStart(ctx.session, ctx.logger);
596
616
  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;
597
622
  const headlessMode = input.headed ? false : input.headless ? true : void 0;
598
623
  const visualize = !input.noVisualize;
599
624
  const viewport = resolveViewport(
@@ -603,9 +628,10 @@ const runCommand = SimpleCLI.command({
603
628
  await runIntegrationFromFile(
604
629
  {
605
630
  integrationPath: input.integrationFile,
606
- exportName: input.integrationExport,
631
+ workflowName: input.workflowName,
607
632
  session: ctx.session,
608
633
  params,
634
+ credentials,
609
635
  tsconfigPath: input.tsconfig,
610
636
  headless: headlessMode ?? false,
611
637
  visualize,
@@ -270,6 +270,10 @@ class SimpleCLIApp {
270
270
  named["--"] = args.slice(index + 1);
271
271
  break;
272
272
  }
273
+ if (arg === "-") {
274
+ positionals.push(arg);
275
+ continue;
276
+ }
273
277
  if (arg.startsWith("--")) {
274
278
  const [rawName, inlineValue] = splitNamedArg(arg.slice(2));
275
279
  const namedEntry = namedSpecs.get(rawName);
@@ -4,6 +4,8 @@ import { cwd } from "node:process";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import {
7
+ getWorkflowFromModuleExports,
8
+ getWorkflowsFromModuleExports,
7
9
  instrumentContext,
8
10
  launchBrowser
9
11
  } from "../../index.js";
@@ -19,7 +21,6 @@ import {
19
21
  removeSignalIfExists
20
22
  } from "../core/pause-signals.js";
21
23
  import { installSessionTelemetry } from "../core/session-telemetry.js";
22
- const LIBRETTO_WORKFLOW_BRAND = /* @__PURE__ */ Symbol.for("libretto.workflow");
23
24
  const FAILURE_HOLD_POLL_INTERVAL_MS = 250;
24
25
  const TSCONFIG_HINT = "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
25
26
  function isTsxCompileError(error) {
@@ -67,11 +68,6 @@ async function waitForFailureSessionRelease(args) {
67
68
  );
68
69
  }
69
70
  }
70
- function isLoadedLibrettoWorkflow(value) {
71
- if (!value || typeof value !== "object") return false;
72
- const candidate = value;
73
- return candidate[LIBRETTO_WORKFLOW_BRAND] === true && typeof candidate.run === "function";
74
- }
75
71
  function resolveLocalAuthProfilePath(domain) {
76
72
  return getProfilePath(normalizeDomain(domain));
77
73
  }
@@ -93,7 +89,7 @@ function getAbsoluteIntegrationPath(integrationPath) {
93
89
  }
94
90
  return absolutePath;
95
91
  }
96
- async function loadWorkflowExport(absolutePath, exportName) {
92
+ async function loadWorkflowByName(absolutePath, workflowName) {
97
93
  let loadedModule;
98
94
  try {
99
95
  loadedModule = await import(pathToFileURL(absolutePath).href);
@@ -105,37 +101,17 @@ ${TSCONFIG_HINT}` : "";
105
101
  `Failed to import integration module at ${absolutePath}: ${message}${compileHint}`
106
102
  );
107
103
  }
108
- const targetExport = loadedModule[exportName];
109
- if (!targetExport) {
110
- const availableExports = Object.keys(loadedModule);
111
- const detail = availableExports.length > 0 ? ` Available exports: ${availableExports.join(", ")}` : " The module has no exports.";
112
- throw new Error(
113
- `Export "${exportName}" was not found in ${absolutePath}.${detail}`
114
- );
115
- }
116
- if (!isLoadedLibrettoWorkflow(targetExport)) {
117
- throw new Error(
118
- [
119
- `Export "${exportName}" in ${absolutePath} is not a valid Libretto workflow.`,
120
- "",
121
- 'A workflow must be created using the workflow() function from "libretto":',
122
- "",
123
- ' import { workflow } from "libretto";',
124
- "",
125
- ` export const ${exportName} = workflow<InputType, OutputType>(`,
126
- " async (ctx, input) => {",
127
- " // ctx.session \u2014 libretto session name",
128
- " // ctx.page \u2014 Playwright Page instance",
129
- " // ctx.logger \u2014 MinimalLogger",
130
- " // ctx.services \u2014 injected dependencies (generic, default {})",
131
- " // input \u2014 JSON-serializable input matching InputType",
132
- " return output; // must match OutputType",
133
- " },",
134
- " );"
135
- ].join("\n")
136
- );
104
+ const workflow = getWorkflowFromModuleExports(loadedModule, workflowName);
105
+ if (workflow) {
106
+ return workflow;
137
107
  }
138
- return targetExport;
108
+ const availableWorkflows = getWorkflowsFromModuleExports(loadedModule).map(
109
+ (candidate) => candidate.name
110
+ );
111
+ const detail = availableWorkflows.length > 0 ? ` Available workflows: ${availableWorkflows.join(", ")}` : ' No workflows found in this file. Export a workflow() instance from "libretto" directly or via `export const workflows = { ... }`.';
112
+ throw new Error(
113
+ `Workflow "${workflowName}" not found in ${absolutePath}.${detail}`
114
+ );
139
115
  }
140
116
  async function installHeadedWorkflowVisualization(args) {
141
117
  await (args.instrument ?? instrumentContext)(args.context, {
@@ -146,7 +122,7 @@ async function installHeadedWorkflowVisualization(args) {
146
122
  async function runIntegrationInternal(args, options) {
147
123
  const { logger } = options;
148
124
  const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
149
- const workflow = await loadWorkflowExport(absolutePath, args.exportName);
125
+ const workflow = await loadWorkflowByName(absolutePath, args.workflowName);
150
126
  const signalPaths = getPauseSignalPaths(args.session);
151
127
  await removeSignalIfExists(signalPaths.pausedSignalPath);
152
128
  await removeSignalIfExists(signalPaths.resumeSignalPath);
@@ -154,11 +130,11 @@ async function runIntegrationInternal(args, options) {
154
130
  await removeSignalIfExists(signalPaths.failedSignalPath);
155
131
  const restoreStdout = mirrorStdoutToFile(signalPaths.outputSignalPath);
156
132
  console.log(
157
- `Running integration "${args.exportName}" from ${absolutePath} (${args.headless ? "headless" : "headed"})...`
133
+ `Running workflow "${args.workflowName}" from ${absolutePath} (${args.headless ? "headless" : "headed"})...`
158
134
  );
159
135
  const integrationLogger = logger.withScope("integration-run", {
160
136
  integrationPath: absolutePath,
161
- integrationExport: args.exportName,
137
+ workflowName: args.workflowName,
162
138
  session: args.session
163
139
  });
164
140
  const authProfileDomain = args.authProfileDomain;
@@ -201,7 +177,8 @@ async function runIntegrationInternal(args, options) {
201
177
  session: args.session,
202
178
  logger: integrationLogger,
203
179
  page: browserSession.page,
204
- services: {}
180
+ services: {},
181
+ credentials: args.credentials
205
182
  };
206
183
  try {
207
184
  try {
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  const RunIntegrationWorkerRequestSchema = z.object({
3
3
  integrationPath: z.string().min(1),
4
- exportName: z.string().min(1),
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(),
7
8
  headless: z.boolean(),
8
9
  visualize: z.boolean().default(true),
9
10
  authProfileDomain: z.string().optional(),
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { InstrumentationOptions, InstrumentedPage, installInstrumentation, instr
14
14
  export { GhostCursorOptions, ensureGhostCursor, ghostClick, hideGhostCursor, moveGhostCursor } from './shared/visualization/ghost-cursor.js';
15
15
  export { HighlightOptions, clearHighlights, ensureHighlightLayer, showHighlight } from './shared/visualization/highlight.js';
16
16
  export { BrowserSession, LaunchBrowserArgs, launchBrowser } from './shared/run/browser.js';
17
- export { LIBRETTO_WORKFLOW_BRAND, LibrettoWorkflow, LibrettoWorkflowContext, LibrettoWorkflowHandler, workflow } from './shared/workflow/workflow.js';
17
+ export { ExportedLibrettoWorkflow, LIBRETTO_WORKFLOW_BRAND, LibrettoWorkflow, LibrettoWorkflowContext, LibrettoWorkflowHandler, getWorkflowFromModuleExports, getWorkflowsFromModuleExports, isLibrettoWorkflow, workflow } from './shared/workflow/workflow.js';
18
18
  import 'zod';
19
19
  import 'ai';
20
20
  import 'playwright';
package/dist/index.js CHANGED
@@ -54,6 +54,9 @@ import {
54
54
  launchBrowser
55
55
  } from "./shared/run/api.js";
56
56
  import {
57
+ getWorkflowFromModuleExports,
58
+ getWorkflowsFromModuleExports,
59
+ isLibrettoWorkflow,
57
60
  LibrettoWorkflow,
58
61
  LIBRETTO_WORKFLOW_BRAND,
59
62
  workflow
@@ -92,11 +95,14 @@ export {
92
95
  ensureHighlightLayer,
93
96
  executeRecoveryAgent,
94
97
  extractFromPage,
98
+ getWorkflowFromModuleExports,
99
+ getWorkflowsFromModuleExports,
95
100
  ghostClick,
96
101
  hideGhostCursor,
97
102
  installInstrumentation,
98
103
  instrumentContext,
99
104
  instrumentPage,
105
+ isLibrettoWorkflow,
100
106
  jsonlConsoleSink,
101
107
  launchBrowser,
102
108
  moveGhostCursor,
@@ -7,14 +7,25 @@ type LibrettoWorkflowContext<S = {}> = {
7
7
  page: Page;
8
8
  logger: MinimalLogger;
9
9
  services: S;
10
+ credentials?: Record<string, unknown>;
10
11
  };
11
12
  type LibrettoWorkflowHandler<Input = unknown, Output = unknown, S = {}> = (ctx: LibrettoWorkflowContext<S>, input: Input) => Promise<Output>;
12
13
  declare class LibrettoWorkflow<Input = unknown, Output = unknown, S = {}> {
13
14
  readonly [LIBRETTO_WORKFLOW_BRAND] = true;
15
+ readonly name: string;
14
16
  private readonly handler;
15
- constructor(handler: LibrettoWorkflowHandler<Input, Output, S>);
17
+ constructor(name: string, handler: LibrettoWorkflowHandler<Input, Output, S>);
16
18
  run(ctx: LibrettoWorkflowContext<S>, input: Input): Promise<Output>;
17
19
  }
18
- declare function workflow<Input = unknown, Output = unknown, S = {}>(handler: LibrettoWorkflowHandler<Input, Output, S>): LibrettoWorkflow<Input, Output, S>;
20
+ type ExportedLibrettoWorkflow = {
21
+ readonly [LIBRETTO_WORKFLOW_BRAND]: true;
22
+ readonly name: string;
23
+ run: (ctx: LibrettoWorkflowContext, input: unknown) => Promise<unknown>;
24
+ };
25
+ type WorkflowModuleExports = Record<string, unknown>;
26
+ declare function isLibrettoWorkflow(value: unknown): value is ExportedLibrettoWorkflow;
27
+ declare function getWorkflowsFromModuleExports(moduleExports: WorkflowModuleExports): ExportedLibrettoWorkflow[];
28
+ declare function getWorkflowFromModuleExports(moduleExports: WorkflowModuleExports, workflowName: string): ExportedLibrettoWorkflow | null;
29
+ declare function workflow<Input = unknown, Output = unknown, S = {}>(name: string, handler: LibrettoWorkflowHandler<Input, Output, S>): LibrettoWorkflow<Input, Output, S>;
19
30
 
20
- export { LIBRETTO_WORKFLOW_BRAND, LibrettoWorkflow, type LibrettoWorkflowContext, type LibrettoWorkflowHandler, workflow };
31
+ export { type ExportedLibrettoWorkflow, LIBRETTO_WORKFLOW_BRAND, LibrettoWorkflow, type LibrettoWorkflowContext, type LibrettoWorkflowHandler, getWorkflowFromModuleExports, getWorkflowsFromModuleExports, isLibrettoWorkflow, workflow };
@@ -1,19 +1,66 @@
1
1
  const LIBRETTO_WORKFLOW_BRAND = /* @__PURE__ */ Symbol.for("libretto.workflow");
2
2
  class LibrettoWorkflow {
3
3
  [LIBRETTO_WORKFLOW_BRAND] = true;
4
+ name;
4
5
  handler;
5
- constructor(handler) {
6
+ constructor(name, handler) {
7
+ this.name = name;
6
8
  this.handler = handler;
7
9
  }
8
10
  async run(ctx, input) {
9
11
  return this.handler(ctx, input);
10
12
  }
11
13
  }
12
- function workflow(handler) {
13
- return new LibrettoWorkflow(handler);
14
+ function isLibrettoWorkflow(value) {
15
+ if (!value || typeof value !== "object") return false;
16
+ const candidate = value;
17
+ return candidate[LIBRETTO_WORKFLOW_BRAND] === true && typeof candidate.name === "string" && typeof candidate.run === "function";
18
+ }
19
+ function addWorkflowOrThrow(workflowsByName, value) {
20
+ if (!isLibrettoWorkflow(value)) return;
21
+ const existing = workflowsByName.get(value.name);
22
+ if (existing && existing !== value) {
23
+ throw new Error(
24
+ `Duplicate workflow name: "${value.name}". Each workflow() call must use a unique name.`
25
+ );
26
+ }
27
+ workflowsByName.set(value.name, value);
28
+ }
29
+ function getWorkflowsFromModuleExports(moduleExports) {
30
+ const workflowsByName = /* @__PURE__ */ new Map();
31
+ for (const [exportName, value] of Object.entries(moduleExports)) {
32
+ if (exportName === "workflows" && value && typeof value === "object") {
33
+ if (isLibrettoWorkflow(value)) {
34
+ addWorkflowOrThrow(workflowsByName, value);
35
+ } else {
36
+ for (const nestedValue of Object.values(
37
+ value
38
+ )) {
39
+ addWorkflowOrThrow(workflowsByName, nestedValue);
40
+ }
41
+ }
42
+ continue;
43
+ }
44
+ addWorkflowOrThrow(workflowsByName, value);
45
+ }
46
+ return [...workflowsByName.values()];
47
+ }
48
+ function getWorkflowFromModuleExports(moduleExports, workflowName) {
49
+ for (const workflow2 of getWorkflowsFromModuleExports(moduleExports)) {
50
+ if (workflow2.name === workflowName) {
51
+ return workflow2;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ function workflow(name, handler) {
57
+ return new LibrettoWorkflow(name, handler);
14
58
  }
15
59
  export {
16
60
  LIBRETTO_WORKFLOW_BRAND,
17
61
  LibrettoWorkflow,
62
+ getWorkflowFromModuleExports,
63
+ getWorkflowsFromModuleExports,
64
+ isLibrettoWorkflow,
18
65
  workflow
19
66
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.5.2",
3
+ "version": "0.5.3-experimental.0",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,11 +36,9 @@
36
36
  "build": "tsup --config tsup.config.ts",
37
37
  "type-check": "tsc --noEmit",
38
38
  "test": "pnpm run build && vitest run",
39
- "eval": "pnpm run build && vitest run --config vitest.evals.config.ts",
40
- "benchmark": "pnpm run build && tsx benchmarks/run.ts",
41
39
  "test:watch": "vitest",
42
40
  "cli": "node dist/index.js",
43
- "prepare-release": "bash ./scripts/prepare-release.sh",
41
+ "generate-changelog": "tsx scripts/generate-changelog.ts",
44
42
  "prepack": "pnpm run build"
45
43
  },
46
44
  "peerDependencies": {
@@ -69,8 +67,13 @@
69
67
  "@ai-sdk/google-vertex": "^4.0.80",
70
68
  "@ai-sdk/openai": "^3.0.41",
71
69
  "@anthropic-ai/claude-agent-sdk": "^0.2.75",
70
+ "@mariozechner/pi-agent-core": "^0.62.0",
71
+ "@mariozechner/pi-ai": "^0.62.0",
72
+ "@mariozechner/pi-coding-agent": "^0.62.0",
73
+ "@sinclair/typebox": "^0.34.48",
72
74
  "@types/node": "^25.5.0",
73
75
  "glimpseui": "^0.5.1",
76
+ "google-auth-library": "^10.6.1",
74
77
  "openai": "^6.29.0",
75
78
  "tsup": "^8.5.1",
76
79
  "typescript": "^5.9.3",
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { compareSkillDirs, SKILL_DIRS } from "./skills-libretto.mjs";
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(__dirname, "..");
9
+ const repoRoot = join(__dirname, "..", "..", "..");
10
10
  const result = compareSkillDirs(repoRoot);
11
11
 
12
12
  if (result.ok) {
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { Agent, type AgentTool, type AgentEvent } from "@mariozechner/pi-agent-core";
3
+ import { getModel } from "@mariozechner/pi-ai";
4
+ import { Type } from "@sinclair/typebox";
5
+
6
+ const tag = process.argv[2];
7
+ if (!tag) {
8
+ console.error("Usage: generate-changelog.ts <tag>");
9
+ console.error("Example: generate-changelog.ts v0.5.2");
10
+ process.exit(1);
11
+ }
12
+
13
+ if (!process.env.ANTHROPIC_API_KEY) {
14
+ console.error("ANTHROPIC_API_KEY is required.");
15
+ process.exit(1);
16
+ }
17
+
18
+ const ALLOWED_GH_SUBCOMMANDS = new Set(["pr", "release", "repo", "issue"]);
19
+ const ALLOWED_ACTIONS = new Set(["list", "view", "diff", "status", "checks"]);
20
+
21
+ const ghTool: AgentTool = {
22
+ name: "gh",
23
+ label: "GitHub CLI",
24
+ description: [
25
+ "Run a read-only GitHub CLI command. The arguments are passed directly to `gh`.",
26
+ "Examples: 'release list --limit 5', 'pr list --state merged --json number,title',",
27
+ "'pr view 128 --json title,body,files', 'pr diff 128'.",
28
+ "Only read operations are allowed (list, view, diff, etc.). Mutating commands will be rejected.",
29
+ ].join(" "),
30
+ parameters: Type.Object({
31
+ args: Type.String({ description: "Arguments to pass to gh (without the leading 'gh')" }),
32
+ }),
33
+ execute: async (_toolCallId, rawParams) => {
34
+ const params = rawParams as { args: string };
35
+ const args = params.args.trim();
36
+ const parts = args.split(/\s+/);
37
+ const subcommand = parts[0];
38
+
39
+ if (!subcommand || !ALLOWED_GH_SUBCOMMANDS.has(subcommand)) {
40
+ throw new Error(`Subcommand '${subcommand}' is not allowed. Allowed: ${[...ALLOWED_GH_SUBCOMMANDS].join(", ")}`);
41
+ }
42
+
43
+ const action = parts[1];
44
+ if (!action || !ALLOWED_ACTIONS.has(action)) {
45
+ throw new Error(`Action '${action}' is not allowed. Allowed: ${[...ALLOWED_ACTIONS].join(", ")}`);
46
+ }
47
+
48
+ try {
49
+ const output = execFileSync("gh", parts, {
50
+ encoding: "utf8",
51
+ timeout: 300_000,
52
+ maxBuffer: 1024 * 1024,
53
+ });
54
+ return { content: [{ type: "text", text: output }], details: {} };
55
+ } catch (err) {
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ throw new Error(`gh command failed: ${message}`);
58
+ }
59
+ },
60
+ };
61
+
62
+ const agent = new Agent({
63
+ initialState: {
64
+ systemPrompt: [
65
+ `Generate release notes for the ${tag} release of Libretto.`,
66
+ "",
67
+ "Use the gh tool to explore what changed since the previous release.",
68
+ "Useful queries:",
69
+ "- 'release list --limit 5' to find the previous release tag",
70
+ "- 'pr list --state merged --limit 50 --json number,title,body,labels' to find merged PRs",
71
+ "- 'pr diff NUMBER' to see the full diff of a PR (base to head, not individual commits)",
72
+ "- 'pr view NUMBER --json title,body,files' to see PR details",
73
+ "",
74
+ "IMPORTANT: Always read the full PR diff to understand what actually changed.",
75
+ "Do NOT rely solely on PR titles and descriptions — they may be incomplete or misleading.",
76
+ "The diff is the source of truth for what the release note should say.",
77
+ "",
78
+ "Guidelines:",
79
+ "- Write concise, user-facing release notes in markdown.",
80
+ "- Group changes into sections like Features, Fixes, and Improvements. Only include sections that have entries.",
81
+ "- Focus on what changed from the user's perspective, not internal implementation details.",
82
+ "- Do NOT include PR numbers or links.",
83
+ "- Skip PRs labeled 'skip-changelog'.",
84
+ "- Your response must contain ONLY the raw markdown release notes. No preamble like 'Here are the release notes'. No commentary or explanation. No '---' separators. The very first character of your response must be '#'. Example format:",
85
+ "",
86
+ "## Features",
87
+ "",
88
+ "- **Thing**: Description",
89
+ ].join("\n"),
90
+ model: getModel("anthropic", "claude-sonnet-4-6"),
91
+ tools: [ghTool],
92
+ },
93
+ });
94
+
95
+ let finalText = "";
96
+
97
+ agent.subscribe((event: AgentEvent) => {
98
+ if (event.type === "agent_end") {
99
+ const messages = event.messages;
100
+ for (let i = messages.length - 1; i >= 0; i--) {
101
+ const msg = messages[i];
102
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
103
+ for (const block of msg.content) {
104
+ if (typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
105
+ finalText = block.text as string;
106
+ return;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ });
113
+
114
+ await agent.prompt("Generate the release notes now.");
115
+
116
+ if (!finalText) {
117
+ console.error("Changelog generation failed: no text output from agent.");
118
+ process.exit(1);
119
+ }
120
+
121
+ // Strip any preamble before the first markdown heading.
122
+ const headingIndex = finalText.indexOf("\n#");
123
+ if (headingIndex >= 0) {
124
+ finalText = finalText.slice(headingIndex + 1);
125
+ } else if (finalText.startsWith("#")) {
126
+ // Already starts with a heading, keep as-is.
127
+ } else {
128
+ console.error("Changelog generation failed: output does not contain markdown headings.");
129
+ process.exit(1);
130
+ }
131
+
132
+ process.stdout.write(finalText);
@@ -11,7 +11,7 @@ import {
11
11
  import { relative, resolve, join } from "node:path";
12
12
 
13
13
  export const SKILL_DIRS = [
14
- "skills/libretto",
14
+ "packages/libretto/skills/libretto",
15
15
  ".agents/skills/libretto",
16
16
  ".claude/skills/libretto",
17
17
  ];
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { SKILL_DIRS, syncRepoSkills } from "./skills-libretto.mjs";
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(__dirname, "..");
9
+ const repoRoot = join(__dirname, "..", "..", "..");
10
10
 
11
11
  syncRepoSkills(repoRoot);
12
12
  console.log(`libretto: synced skill mirrors across ${SKILL_DIRS.join(", ")}`);
@@ -83,6 +83,7 @@ npx libretto snapshot \
83
83
 
84
84
  - Use `exec` for focused inspection and short-lived interaction experiments.
85
85
  - Use `exec` to validate selectors, inspect data, or prototype a step before you encode it in the workflow file.
86
+ - Use `exec -` to run multi-line scripts from stdin, especially when the code is too long or complex for a command line argument.
86
87
  - Available globals: `page`, `context`, `browser`, `state`, `fetch`, `Buffer`.
87
88
  - Let failures throw. Do not hide `exec` failures with `try/catch` or `.catch()`.
88
89
  - Do not run multiple `exec` commands in parallel.
@@ -91,6 +92,7 @@ npx libretto snapshot \
91
92
  npx libretto exec "return await page.url()"
92
93
  npx libretto exec "return await page.locator('button').count()"
93
94
  npx libretto exec "await page.locator('button:has-text(\"Continue\")').click()"
95
+ echo "return await page.url()" | npx libretto exec - --session debug-example
94
96
  ```
95
97
 
96
98
  ### `pages`
@@ -113,8 +115,8 @@ npx libretto exec --session debug-example --page <page-id> "return await page.ur
113
115
  - Re-run the same workflow after each fix to verify the browser behavior end to end.
114
116
 
115
117
  ```bash
116
- npx libretto run ./integration.ts main --headless --params '{"status":"open"}'
117
- npx libretto run ./integration.ts main --auth-profile app.example.com
118
+ npx libretto run ./integration.ts workflowName --headless --params '{"status":"open"}'
119
+ npx libretto run ./integration.ts workflowName --auth-profile app.example.com
118
120
  ```
119
121
 
120
122
  ### `resume`
@@ -6,7 +6,7 @@ Follow the user's existing codebase conventions, abstractions, and patterns when
6
6
 
7
7
  ## Workflow File Structure
8
8
 
9
- Generated files must export a `workflow()` instance so they can be run via `npx libretto run <file> <exportName>`. Import `workflow` and its types from `"libretto"`:
9
+ Generated files must export a `workflow()` instance so they can be run via `npx libretto run <file> <workflowName>`. Import `workflow` and its types from `"libretto"`:
10
10
 
11
11
  ```typescript
12
12
  import { workflow, pause, type LibrettoWorkflowContext } from "libretto";
@@ -23,7 +23,8 @@ type Output = {
23
23
  };
24
24
 
25
25
  export const myWorkflow = workflow<Input, Output>(
26
- async (ctx, input): Promise<Output> => {
26
+ "myWorkflow",
27
+ async (ctx: LibrettoWorkflowContext, input): Promise<Output> => {
27
28
  const { session, page, logger } = ctx;
28
29
 
29
30
  logger.info("workflow-start", { session, query: input.query });
@@ -37,8 +38,8 @@ export const myWorkflow = workflow<Input, Output>(
37
38
 
38
39
  Key points:
39
40
 
40
- - The named export (e.g., `myWorkflow`) is what you pass as the second arg to `npx libretto run ./file.ts myWorkflow`
41
- - `workflow(handler)` returns a branded workflow object with a `.run(ctx, input)` method. The CLI expects that contract.
41
+ - `workflow(name, handler)` takes a unique workflow name and returns the workflow object that Libretto can run.
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", ...)`.
42
43
  - `ctx` provides `session`, `page`, `logger`, and `services` (generic, default `{}`)
43
44
  - `input` comes from `--params '{"query":"foo"}'` or `--params-file params.json` on the CLI
44
45
  - Use `await pause(ctx.session)` (or `await pause(session)`) to pause the workflow for debugging. It is a no-op in production.
@@ -57,6 +58,7 @@ import { type Transaction } from "./db";
57
58
  type MyServices = { tx?: Transaction };
58
59
 
59
60
  export const myWorkflow = workflow<Input, Output, MyServices>(
61
+ "myWorkflow",
60
62
  async (ctx, input) => {
61
63
  if (ctx.services.tx) {
62
64
  await ctx.services.tx.insert(/* ... */);
@@ -605,9 +605,10 @@ async function runIntegrationFromFile(
605
605
  );
606
606
  const payload = JSON.stringify({
607
607
  integrationPath: args.integrationPath,
608
- exportName: args.exportName,
608
+ workflowName: args.workflowName,
609
609
  session: args.session,
610
610
  params: args.params,
611
+ credentials: args.credentials,
611
612
  headless: args.headless,
612
613
  visualize: args.visualize,
613
614
  authProfileDomain: args.authProfileDomain,
@@ -656,11 +657,20 @@ async function runIntegrationFromFile(
656
657
  console.log("Integration completed.");
657
658
  }
658
659
 
660
+ function readStdinSync(): string | null {
661
+ if (process.stdin.isTTY === true) return null;
662
+ try {
663
+ const content = readFileSync(0, "utf8");
664
+ return content.trim().length > 0 ? content : null;
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+
659
670
  export const execInput = SimpleCLI.input({
660
671
  positionals: [
661
- SimpleCLI.positional("codeParts", z.array(z.string()).default([]), {
672
+ SimpleCLI.positional("code", z.string().optional(), {
662
673
  help: "Playwright TypeScript code to execute",
663
- variadic: true,
664
674
  }),
665
675
  ],
666
676
  named: {
@@ -671,8 +681,8 @@ export const execInput = SimpleCLI.input({
671
681
  page: pageOption(),
672
682
  },
673
683
  }).refine(
674
- (input) => input.codeParts.length > 0,
675
- `Usage: libretto exec <code> [--session <name>] [--visualize]`,
684
+ (input) => input.code !== undefined,
685
+ `Usage: libretto exec <code|-> [--session <name>] [--visualize]\n echo '<code>' | libretto exec - [--session <name>] [--visualize]`,
676
686
  );
677
687
 
678
688
  export const execCommand = SimpleCLI.command({
@@ -681,8 +691,15 @@ export const execCommand = SimpleCLI.command({
681
691
  .input(execInput)
682
692
  .use(withRequiredSession())
683
693
  .handle(async ({ input, ctx }) => {
694
+ const code = input.code!;
695
+ const codeFromArgsOrStdin = code === "-" ? readStdinSync() : code;
696
+ if (codeFromArgsOrStdin === null) {
697
+ throw new Error(
698
+ "Missing stdin input for `exec -`. Pipe Playwright code into stdin.",
699
+ );
700
+ }
684
701
  await runExec(
685
- input.codeParts.join(" "),
702
+ codeFromArgsOrStdin,
686
703
  ctx.session,
687
704
  ctx.logger,
688
705
  input.visualize,
@@ -690,15 +707,15 @@ export const execCommand = SimpleCLI.command({
690
707
  );
691
708
  });
692
709
 
693
- const runUsage = `Usage: libretto run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
710
+ const runUsage = `Usage: libretto run <integrationFile> <workflowName> [--params <json> | --params-file <path>] [--credentials <json>] [--tsconfig <path>] [--headed|--headless] [--no-visualize] [--viewport WxH]`;
694
711
 
695
712
  export const runInput = SimpleCLI.input({
696
713
  positionals: [
697
714
  SimpleCLI.positional("integrationFile", z.string().optional(), {
698
715
  help: "Path to the integration file",
699
716
  }),
700
- SimpleCLI.positional("integrationExport", z.string().optional(), {
701
- help: "Named workflow export to run",
717
+ SimpleCLI.positional("workflowName", z.string().optional(), {
718
+ help: "Workflow name to run (from workflow(name, handler))",
702
719
  }),
703
720
  ],
704
721
  named: {
@@ -710,6 +727,9 @@ export const runInput = SimpleCLI.input({
710
727
  name: "params-file",
711
728
  help: "Path to a JSON params file",
712
729
  }),
730
+ credentials: SimpleCLI.option(z.string().optional(), {
731
+ help: "Inline JSON credentials passed to ctx.credentials",
732
+ }),
713
733
  tsconfig: SimpleCLI.option(z.string().optional(), {
714
734
  help: "Path to a tsconfig used for workflow module resolution",
715
735
  }),
@@ -729,7 +749,7 @@ export const runInput = SimpleCLI.input({
729
749
  },
730
750
  })
731
751
  .refine(
732
- (input) => Boolean(input.integrationFile && input.integrationExport),
752
+ (input) => Boolean(input.integrationFile && input.workflowName),
733
753
  runUsage,
734
754
  )
735
755
  .refine(
@@ -772,6 +792,13 @@ export const runCommand = SimpleCLI.command({
772
792
  assertSessionAvailableForStart(ctx.session, ctx.logger);
773
793
 
774
794
  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;
775
802
  const headlessMode = input.headed
776
803
  ? false
777
804
  : input.headless
@@ -786,9 +813,10 @@ export const runCommand = SimpleCLI.command({
786
813
  await runIntegrationFromFile(
787
814
  {
788
815
  integrationPath: input.integrationFile!,
789
- exportName: input.integrationExport!,
816
+ workflowName: input.workflowName!,
790
817
  session: ctx.session,
791
818
  params,
819
+ credentials,
792
820
  tsconfigPath: input.tsconfig,
793
821
  headless: headlessMode ?? false,
794
822
  visualize,
@@ -579,6 +579,11 @@ export class SimpleCLIApp {
579
579
  break;
580
580
  }
581
581
 
582
+ if (arg === "-") {
583
+ positionals.push(arg);
584
+ continue;
585
+ }
586
+
582
587
  if (arg.startsWith("--")) {
583
588
  const [rawName, inlineValue] = splitNamedArg(arg.slice(2));
584
589
  const namedEntry = namedSpecs.get(rawName);
@@ -5,8 +5,11 @@ import { cwd } from "node:process";
5
5
  import { isAbsolute, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import {
8
+ getWorkflowFromModuleExports,
9
+ getWorkflowsFromModuleExports,
8
10
  instrumentContext,
9
11
  launchBrowser,
12
+ type ExportedLibrettoWorkflow,
10
13
  type LibrettoWorkflowContext,
11
14
  } from "../../index.js";
12
15
  import type { LoggerApi } from "../../shared/logger/index.js";
@@ -25,9 +28,7 @@ import {
25
28
  import { installSessionTelemetry } from "../core/session-telemetry.js";
26
29
  import type { RunIntegrationWorkerRequest } from "./run-integration-worker-protocol.js";
27
30
 
28
- const LIBRETTO_WORKFLOW_BRAND = Symbol.for("libretto.workflow");
29
-
30
- type LoadedLibrettoWorkflow = {
31
+ type LoadedLibrettoWorkflow = ExportedLibrettoWorkflow & {
31
32
  run: (ctx: LibrettoWorkflowContext, input: unknown) => Promise<unknown>;
32
33
  };
33
34
 
@@ -108,17 +109,6 @@ async function waitForFailureSessionRelease(args: {
108
109
  }
109
110
  }
110
111
 
111
- function isLoadedLibrettoWorkflow(
112
- value: unknown,
113
- ): value is LoadedLibrettoWorkflow {
114
- if (!value || typeof value !== "object") return false;
115
- const candidate = value as Record<PropertyKey, unknown>;
116
- return (
117
- candidate[LIBRETTO_WORKFLOW_BRAND] === true &&
118
- typeof candidate.run === "function"
119
- );
120
- }
121
-
122
112
  function resolveLocalAuthProfilePath(domain: string): string {
123
113
  return getProfilePath(normalizeDomain(domain));
124
114
  }
@@ -149,9 +139,9 @@ function getAbsoluteIntegrationPath(integrationPath: string): string {
149
139
  return absolutePath;
150
140
  }
151
141
 
152
- async function loadWorkflowExport(
142
+ async function loadWorkflowByName(
153
143
  absolutePath: string,
154
- exportName: string,
144
+ workflowName: string,
155
145
  ): Promise<LoadedLibrettoWorkflow> {
156
146
  let loadedModule: Record<string, unknown>;
157
147
  try {
@@ -167,42 +157,23 @@ async function loadWorkflowExport(
167
157
  );
168
158
  }
169
159
 
170
- const targetExport = loadedModule[exportName];
171
- if (!targetExport) {
172
- const availableExports = Object.keys(loadedModule);
173
- const detail =
174
- availableExports.length > 0
175
- ? ` Available exports: ${availableExports.join(", ")}`
176
- : " The module has no exports.";
177
- throw new Error(
178
- `Export "${exportName}" was not found in ${absolutePath}.${detail}`,
179
- );
160
+ const workflow = getWorkflowFromModuleExports(loadedModule, workflowName);
161
+ if (workflow) {
162
+ return workflow as LoadedLibrettoWorkflow;
180
163
  }
181
164
 
182
- if (!isLoadedLibrettoWorkflow(targetExport)) {
183
- throw new Error(
184
- [
185
- `Export "${exportName}" in ${absolutePath} is not a valid Libretto workflow.`,
186
- "",
187
- 'A workflow must be created using the workflow() function from "libretto":',
188
- "",
189
- ' import { workflow } from "libretto";',
190
- "",
191
- ` export const ${exportName} = workflow<InputType, OutputType>(`,
192
- " async (ctx, input) => {",
193
- " // ctx.session — libretto session name",
194
- " // ctx.page — Playwright Page instance",
195
- " // ctx.logger — MinimalLogger",
196
- " // ctx.services — injected dependencies (generic, default {})",
197
- " // input — JSON-serializable input matching InputType",
198
- " return output; // must match OutputType",
199
- " },",
200
- " );",
201
- ].join("\n"),
202
- );
203
- }
165
+ const availableWorkflows = getWorkflowsFromModuleExports(loadedModule).map(
166
+ (candidate) => candidate.name,
167
+ );
168
+
169
+ const detail =
170
+ availableWorkflows.length > 0
171
+ ? ` Available workflows: ${availableWorkflows.join(", ")}`
172
+ : ' No workflows found in this file. Export a workflow() instance from "libretto" directly or via `export const workflows = { ... }`.';
204
173
 
205
- return targetExport;
174
+ throw new Error(
175
+ `Workflow "${workflowName}" not found in ${absolutePath}.${detail}`,
176
+ );
206
177
  }
207
178
 
208
179
  export async function installHeadedWorkflowVisualization(args: {
@@ -224,7 +195,7 @@ async function runIntegrationInternal(
224
195
  ): Promise<RunIntegrationOutcome> {
225
196
  const { logger } = options;
226
197
  const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
227
- const workflow = await loadWorkflowExport(absolutePath, args.exportName);
198
+ const workflow = await loadWorkflowByName(absolutePath, args.workflowName);
228
199
  const signalPaths = getPauseSignalPaths(args.session);
229
200
  await removeSignalIfExists(signalPaths.pausedSignalPath);
230
201
  await removeSignalIfExists(signalPaths.resumeSignalPath);
@@ -233,12 +204,12 @@ async function runIntegrationInternal(
233
204
  const restoreStdout = mirrorStdoutToFile(signalPaths.outputSignalPath);
234
205
 
235
206
  console.log(
236
- `Running integration "${args.exportName}" from ${absolutePath} (${args.headless ? "headless" : "headed"})...`,
207
+ `Running workflow "${args.workflowName}" from ${absolutePath} (${args.headless ? "headless" : "headed"})...`,
237
208
  );
238
209
 
239
210
  const integrationLogger = logger.withScope("integration-run", {
240
211
  integrationPath: absolutePath,
241
- integrationExport: args.exportName,
212
+ workflowName: args.workflowName,
242
213
  session: args.session,
243
214
  });
244
215
 
@@ -287,6 +258,7 @@ async function runIntegrationInternal(
287
258
  logger: integrationLogger,
288
259
  page: browserSession.page,
289
260
  services: {},
261
+ credentials: args.credentials,
290
262
  };
291
263
 
292
264
  try {
@@ -2,9 +2,10 @@ import { z } from "zod";
2
2
 
3
3
  export const RunIntegrationWorkerRequestSchema = z.object({
4
4
  integrationPath: z.string().min(1),
5
- exportName: z.string().min(1),
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(),
8
9
  headless: z.boolean(),
9
10
  visualize: z.boolean().default(true),
10
11
  authProfileDomain: z.string().optional(),
package/src/index.ts CHANGED
@@ -102,9 +102,13 @@ export {
102
102
 
103
103
  // Workflow helpers
104
104
  export {
105
+ getWorkflowFromModuleExports,
106
+ getWorkflowsFromModuleExports,
107
+ isLibrettoWorkflow,
105
108
  LibrettoWorkflow,
106
109
  LIBRETTO_WORKFLOW_BRAND,
107
110
  workflow,
111
+ type ExportedLibrettoWorkflow,
108
112
  type LibrettoWorkflowContext,
109
113
  type LibrettoWorkflowHandler,
110
114
  } from "./shared/workflow/workflow.js";
@@ -8,6 +8,7 @@ export type LibrettoWorkflowContext<S = {}> = {
8
8
  page: Page;
9
9
  logger: MinimalLogger;
10
10
  services: S;
11
+ credentials?: Record<string, unknown>;
11
12
  };
12
13
 
13
14
  export type LibrettoWorkflowHandler<
@@ -18,9 +19,14 @@ export type LibrettoWorkflowHandler<
18
19
 
19
20
  export class LibrettoWorkflow<Input = unknown, Output = unknown, S = {}> {
20
21
  public readonly [LIBRETTO_WORKFLOW_BRAND] = true;
22
+ public readonly name: string;
21
23
  private readonly handler: LibrettoWorkflowHandler<Input, Output, S>;
22
24
 
23
- constructor(handler: LibrettoWorkflowHandler<Input, Output, S>) {
25
+ constructor(
26
+ name: string,
27
+ handler: LibrettoWorkflowHandler<Input, Output, S>,
28
+ ) {
29
+ this.name = name;
24
30
  this.handler = handler;
25
31
  }
26
32
 
@@ -29,8 +35,88 @@ export class LibrettoWorkflow<Input = unknown, Output = unknown, S = {}> {
29
35
  }
30
36
  }
31
37
 
38
+ export type ExportedLibrettoWorkflow = {
39
+ readonly [LIBRETTO_WORKFLOW_BRAND]: true;
40
+ readonly name: string;
41
+ run: (ctx: LibrettoWorkflowContext, input: unknown) => Promise<unknown>;
42
+ };
43
+
44
+ type WorkflowModuleExports = Record<string, unknown>;
45
+
46
+ // Use the workflow brand instead of `instanceof` so imported workflows are
47
+ // still recognized after loading the integration module dynamically.
48
+ export function isLibrettoWorkflow(
49
+ value: unknown,
50
+ ): value is ExportedLibrettoWorkflow {
51
+ if (!value || typeof value !== "object") return false;
52
+ const candidate = value as Record<PropertyKey, unknown>;
53
+ return (
54
+ candidate[LIBRETTO_WORKFLOW_BRAND] === true &&
55
+ typeof candidate.name === "string" &&
56
+ typeof candidate.run === "function"
57
+ );
58
+ }
59
+
60
+ function addWorkflowOrThrow(
61
+ workflowsByName: Map<string, ExportedLibrettoWorkflow>,
62
+ value: unknown,
63
+ ): void {
64
+ if (!isLibrettoWorkflow(value)) return;
65
+
66
+ // Re-exporting the same workflow object is fine, but two distinct workflow
67
+ // instances cannot claim the same runtime name.
68
+ const existing = workflowsByName.get(value.name);
69
+ if (existing && existing !== value) {
70
+ throw new Error(
71
+ `Duplicate workflow name: "${value.name}". Each workflow() call must use a unique name.`,
72
+ );
73
+ }
74
+
75
+ workflowsByName.set(value.name, value);
76
+ }
77
+
78
+ export function getWorkflowsFromModuleExports(
79
+ moduleExports: WorkflowModuleExports,
80
+ ): ExportedLibrettoWorkflow[] {
81
+ const workflowsByName = new Map<string, ExportedLibrettoWorkflow>();
82
+
83
+ for (const [exportName, value] of Object.entries(moduleExports)) {
84
+ if (exportName === "workflows" && value && typeof value === "object") {
85
+ // Support both `export const workflows = workflow(...)` and
86
+ // `export const workflows = { myWorkflow }`.
87
+ if (isLibrettoWorkflow(value)) {
88
+ addWorkflowOrThrow(workflowsByName, value);
89
+ } else {
90
+ for (const nestedValue of Object.values(
91
+ value as Record<string, unknown>,
92
+ )) {
93
+ addWorkflowOrThrow(workflowsByName, nestedValue);
94
+ }
95
+ }
96
+ continue;
97
+ }
98
+
99
+ addWorkflowOrThrow(workflowsByName, value);
100
+ }
101
+
102
+ return [...workflowsByName.values()];
103
+ }
104
+
105
+ export function getWorkflowFromModuleExports(
106
+ moduleExports: WorkflowModuleExports,
107
+ workflowName: string,
108
+ ): ExportedLibrettoWorkflow | null {
109
+ for (const workflow of getWorkflowsFromModuleExports(moduleExports)) {
110
+ if (workflow.name === workflowName) {
111
+ return workflow;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
32
117
  export function workflow<Input = unknown, Output = unknown, S = {}>(
118
+ name: string,
33
119
  handler: LibrettoWorkflowHandler<Input, Output, S>,
34
120
  ): LibrettoWorkflow<Input, Output, S> {
35
- return new LibrettoWorkflow(handler);
121
+ return new LibrettoWorkflow(name, handler);
36
122
  }
@@ -1,97 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- usage() {
5
- cat <<'EOF'
6
- Usage: scripts/prepare-release.sh [patch|minor|major]
7
-
8
- Creates a release PR branch from main, bumps package.json, pushes the branch,
9
- and opens a pull request targeting main.
10
- EOF
11
- }
12
-
13
- bump="${1:-patch}"
14
-
15
- case "$bump" in
16
- patch|minor|major)
17
- ;;
18
- -h|--help|help)
19
- usage
20
- exit 0
21
- ;;
22
- *)
23
- echo "Invalid bump type: $bump" >&2
24
- usage >&2
25
- exit 1
26
- ;;
27
- esac
28
-
29
- if ! command -v gh >/dev/null 2>&1; then
30
- echo "gh CLI is required." >&2
31
- exit 1
32
- fi
33
-
34
- if [ -n "$(git status --porcelain)" ]; then
35
- echo "Working tree must be clean before preparing a release." >&2
36
- exit 1
37
- fi
38
-
39
- current_branch="$(git branch --show-current)"
40
- if [ "$current_branch" != "main" ]; then
41
- echo "Switching from $current_branch to main."
42
- fi
43
-
44
- git fetch origin
45
- git checkout main
46
- git pull --ff-only origin main
47
-
48
- pnpm install --frozen-lockfile
49
- pnpm type-check
50
- pnpm test
51
-
52
- current_version="$(node -p "require('./package.json').version")"
53
- next_version="$(node -e '
54
- const [major, minor, patch] = process.argv[1].split(".").map(Number)
55
- const bump = process.argv[2]
56
-
57
- let next
58
- if (bump === "major") next = [major + 1, 0, 0]
59
- else if (bump === "minor") next = [major, minor + 1, 0]
60
- else next = [major, minor, patch + 1]
61
-
62
- process.stdout.write(next.join("."))
63
- ' "$current_version" "$bump")"
64
- branch_name="tk-release-v${next_version}"
65
-
66
- if git show-ref --verify --quiet "refs/heads/${branch_name}"; then
67
- echo "Local branch ${branch_name} already exists." >&2
68
- exit 1
69
- fi
70
-
71
- if git ls-remote --exit-code --heads origin "${branch_name}" >/dev/null 2>&1; then
72
- echo "Remote branch ${branch_name} already exists." >&2
73
- exit 1
74
- fi
75
-
76
- npm version "$next_version" --no-git-tag-version >/dev/null
77
-
78
- git checkout -b "$branch_name"
79
- git add package.json
80
- git commit -m "release: v${next_version}"
81
- git push -u origin "$branch_name"
82
-
83
- gh pr create \
84
- --base main \
85
- --head "$branch_name" \
86
- --title "release: v${next_version}" \
87
- --body "$(cat <<EOF
88
- ## Summary
89
-
90
- - release libretto v${next_version}
91
-
92
- ## Verification
93
-
94
- - pnpm type-check
95
- - pnpm test
96
- EOF
97
- )"