libretto 0.4.0 → 0.4.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.
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import * as moduleBuiltin from "node:module";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { z } from "zod";
5
6
  import { installInstrumentation } from "../../shared/instrumentation/index.js";
6
7
  import {
7
8
  connect,
@@ -12,7 +13,6 @@ import {
12
13
  assertSessionAvailableForStart,
13
14
  clearSessionState,
14
15
  readSessionState,
15
- readSessionStateOrThrow,
16
16
  setSessionStatus
17
17
  } from "../core/session.js";
18
18
  import {
@@ -20,6 +20,13 @@ import {
20
20
  readNetworkLog,
21
21
  wrapPageForActionLogging
22
22
  } from "../core/telemetry.js";
23
+ import { SimpleCLI } from "../framework/simple-cli.js";
24
+ import {
25
+ loadSessionStateMiddleware,
26
+ pageOption,
27
+ resolveSessionMiddleware,
28
+ sessionOption
29
+ } from "./shared.js";
23
30
  const stripTypeScriptTypes = moduleBuiltin.stripTypeScriptTypes;
24
31
  const require2 = moduleBuiltin.createRequire(import.meta.url);
25
32
  const tsxCliPath = require2.resolve("tsx/cli");
@@ -63,7 +70,6 @@ function compileExecFunction(code, helperNames) {
63
70
  return new AsyncFunction(...helperNames, code);
64
71
  }
65
72
  async function runExec(code, session, logger, visualize = false, pageId) {
66
- readSessionStateOrThrow(session);
67
73
  logger.info("exec-start", {
68
74
  session,
69
75
  codeLength: code.length,
@@ -211,25 +217,24 @@ function readJsonFileIfExists(path) {
211
217
  return null;
212
218
  }
213
219
  }
214
- function readFailureSignal(path) {
220
+ function readFailureDetails(path) {
215
221
  const raw = readJsonFileIfExists(path);
216
222
  if (!raw || typeof raw !== "object") return null;
217
223
  const message = raw.message;
218
- if (typeof message !== "string") return null;
219
224
  const phase = raw.phase;
220
225
  return {
221
- message,
226
+ message: typeof message === "string" ? message : void 0,
222
227
  phase: phase === "setup" || phase === "workflow" ? phase : void 0
223
228
  };
224
229
  }
225
- async function waitForFailureSignal(path, timeoutMs = 1e3) {
230
+ async function waitForFailureDetails(path, timeoutMs = 1e3) {
226
231
  const deadline = Date.now() + timeoutMs;
227
232
  while (Date.now() < deadline) {
228
- const failure = readFailureSignal(path);
229
- if (failure) return failure;
233
+ const details = readFailureDetails(path);
234
+ if (details?.message) return details;
230
235
  await new Promise((resolveWait) => setTimeout(resolveWait, 25));
231
236
  }
232
- return readFailureSignal(path);
237
+ return readFailureDetails(path);
233
238
  }
234
239
  function streamOutputSince(path, offset) {
235
240
  if (!existsSync(path)) return offset;
@@ -255,11 +260,11 @@ async function waitForWorkflowOutcome(args) {
255
260
  outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
256
261
  if (existsSync(signalPaths.failedSignalPath)) {
257
262
  outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
258
- const failure = await waitForFailureSignal(signalPaths.failedSignalPath);
263
+ const failureDetails = await waitForFailureDetails(signalPaths.failedSignalPath);
259
264
  return {
260
265
  status: "failed",
261
- message: failure?.message,
262
- failurePhase: failure?.phase
266
+ message: failureDetails?.message,
267
+ phase: failureDetails?.phase
263
268
  };
264
269
  }
265
270
  if (existsSync(signalPaths.completedSignalPath)) {
@@ -277,8 +282,7 @@ async function waitForWorkflowOutcome(args) {
277
282
  await new Promise((resolveWait) => setTimeout(resolveWait, 250));
278
283
  }
279
284
  }
280
- async function runResume(session, logger) {
281
- const state = readSessionStateOrThrow(session);
285
+ async function runResume(session, logger, sessionState) {
282
286
  const {
283
287
  pausedSignalPath,
284
288
  resumeSignalPath,
@@ -291,9 +295,9 @@ async function runResume(session, logger) {
291
295
  `Session "${session}" is not paused. Run "libretto-cli run ... --session ${session}" and call pause() first.`
292
296
  );
293
297
  }
294
- if (!isProcessRunning(state.pid)) {
298
+ if (!isProcessRunning(sessionState.pid)) {
295
299
  throw new Error(
296
- `No active paused workflow found for session "${session}" (worker pid ${state.pid} is not running).`
300
+ `No active paused workflow found for session "${session}" (worker pid ${sessionState.pid} is not running).`
297
301
  );
298
302
  }
299
303
  clearSignalIfExists(pausedSignalPath);
@@ -316,7 +320,7 @@ async function runResume(session, logger) {
316
320
  console.log(`Resume signal sent for session "${session}".`);
317
321
  const outcome = await waitForWorkflowOutcome({
318
322
  session,
319
- pid: state.pid
323
+ pid: sessionState.pid
320
324
  });
321
325
  if (outcome.status === "completed") {
322
326
  setSessionStatus(session, "completed", logger);
@@ -340,7 +344,6 @@ async function runResume(session, logger) {
340
344
  }
341
345
  async function runIntegrationFromFile(args, logger) {
342
346
  await stopExistingFailedRunSession(args.session, logger);
343
- assertSessionAvailableForStart(args.session, logger);
344
347
  const signalPaths = getPauseSignalPaths(args.session);
345
348
  clearSignalIfExists(signalPaths.pausedSignalPath);
346
349
  clearSignalIfExists(signalPaths.resumeSignalPath);
@@ -380,14 +383,13 @@ async function runIntegrationFromFile(args, logger) {
380
383
  }
381
384
  if (outcome.status === "failed") {
382
385
  setSessionStatus(args.session, "failed", logger);
383
- const message = outcome.message ?? "Workflow failed during run.";
384
- if (outcome.failurePhase === "workflow") {
386
+ if (outcome.phase === "workflow") {
385
387
  throw new Error(
386
- `${message}
388
+ `${outcome.message ?? "Workflow failed during run."}
387
389
  Browser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-run the workflow.`
388
390
  );
389
391
  }
390
- throw new Error(message);
392
+ throw new Error(outcome.message ?? "Workflow failed during run.");
391
393
  }
392
394
  if (outcome.status === "exited") {
393
395
  setSessionStatus(args.session, "exited", logger);
@@ -396,95 +398,132 @@ Browser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-ru
396
398
  );
397
399
  }
398
400
  setSessionStatus(args.session, "completed", logger);
401
+ console.log("Integration completed.");
402
+ }
403
+ const execInput = SimpleCLI.input({
404
+ positionals: [
405
+ SimpleCLI.positional("codeParts", z.array(z.string()).default([]), {
406
+ help: "Playwright TypeScript code to execute",
407
+ variadic: true
408
+ })
409
+ ],
410
+ named: {
411
+ session: sessionOption(),
412
+ visualize: SimpleCLI.flag({ help: "Enable ghost cursor + highlight visualization" }),
413
+ page: pageOption()
414
+ }
415
+ }).refine(
416
+ (input) => input.codeParts.length > 0,
417
+ "Usage: libretto-cli exec <code> [--session <name>] [--visualize]"
418
+ );
419
+ function createExecCommand(logger) {
420
+ return SimpleCLI.command({
421
+ description: "Execute Playwright TypeScript code"
422
+ }).input(execInput).use(resolveSessionMiddleware).use(loadSessionStateMiddleware).handle(async ({ input, ctx }) => {
423
+ await runExec(
424
+ input.codeParts.join(" "),
425
+ ctx.session,
426
+ logger,
427
+ input.visualize,
428
+ input.page
429
+ );
430
+ });
399
431
  }
400
- function registerExecutionCommands(yargs, logger) {
401
- return yargs.command(
402
- "exec [code..]",
403
- "Execute Playwright TypeScript code",
404
- (cmd) => cmd.option("visualize", { type: "boolean", default: false }).option("page", { type: "string" }),
405
- async (argv) => {
406
- const codeParts = Array.isArray(argv.code) ? argv.code : argv.code ? [String(argv.code)] : [];
407
- const code = codeParts.join(" ");
408
- if (!code) {
409
- throw new Error(
410
- "Usage: libretto-cli exec <code> [--session <name>] [--visualize]"
411
- );
412
- }
413
- await runExec(
414
- code,
415
- String(argv.session),
416
- logger,
417
- Boolean(argv.visualize),
418
- argv.page ? String(argv.page) : void 0
432
+ const runUsage = "Usage: libretto-cli run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless]";
433
+ const runInput = SimpleCLI.input({
434
+ positionals: [
435
+ SimpleCLI.positional("integrationFile", z.string().optional(), {
436
+ help: "Path to the integration file"
437
+ }),
438
+ SimpleCLI.positional("integrationExport", z.string().optional(), {
439
+ help: "Named workflow export to run"
440
+ })
441
+ ],
442
+ named: {
443
+ session: sessionOption(),
444
+ params: SimpleCLI.option(z.string().optional(), {
445
+ help: "Inline JSON params"
446
+ }),
447
+ paramsFile: SimpleCLI.option(z.string().optional(), {
448
+ name: "params-file",
449
+ help: "Path to a JSON params file"
450
+ }),
451
+ tsconfig: SimpleCLI.option(z.string().optional(), {
452
+ help: "Path to a tsconfig used for workflow module resolution"
453
+ }),
454
+ headed: SimpleCLI.flag({ help: "Run in headed mode" }),
455
+ headless: SimpleCLI.flag({ help: "Run in headless mode" }),
456
+ authProfile: SimpleCLI.option(z.string().optional(), {
457
+ name: "auth-profile",
458
+ help: "Domain for local auth profile (e.g. apps.example.com)"
459
+ })
460
+ }
461
+ }).refine(
462
+ (input) => Boolean(input.integrationFile && input.integrationExport),
463
+ runUsage
464
+ ).refine((input) => !(input.params && input.paramsFile), "Pass either --params or --params-file, not both.").refine((input) => !(input.headed && input.headless), "Cannot pass both --headed and --headless.");
465
+ function resolveRunParams(rawInlineParams, paramsFile) {
466
+ if (paramsFile) {
467
+ let content;
468
+ try {
469
+ content = readFileSync(paramsFile, "utf8");
470
+ } catch {
471
+ throw new Error(
472
+ `Could not read --params-file "${paramsFile}". Ensure the file exists and is readable.`
419
473
  );
420
474
  }
421
- ).command(
422
- "run [integrationFile] [integrationExport]",
423
- "Run an exported Libretto workflow from a file",
424
- (cmd) => cmd.option("params", { type: "string" }).option("params-file", { type: "string" }).option("tsconfig", { type: "string" }).option("headed", { type: "boolean", default: false }).option("headless", { type: "boolean", default: false }).option("auth-profile", { type: "string", describe: "Domain for local auth profile (e.g. apps.example.com)" }),
425
- async (argv) => {
426
- const usage = "Usage: libretto-cli run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless]";
427
- const integrationPath = argv.integrationFile;
428
- const exportName = argv.integrationExport;
429
- const legacyDebug = argv.debug;
430
- if (legacyDebug !== void 0) {
431
- throw new Error(
432
- "The --debug flag has been removed. Run the command without --debug."
433
- );
434
- }
435
- if (!integrationPath || !exportName) {
436
- throw new Error(usage);
437
- }
438
- const session = String(argv.session);
439
- const rawInlineParams = argv.params;
440
- const paramsFile = argv["params-file"];
441
- if (rawInlineParams && paramsFile) {
442
- throw new Error("Pass either --params or --params-file, not both.");
443
- }
444
- const params = (() => {
445
- if (paramsFile) {
446
- let content;
447
- try {
448
- content = readFileSync(paramsFile, "utf8");
449
- } catch {
450
- throw new Error(
451
- `Could not read --params-file "${paramsFile}". Ensure the file exists and is readable.`
452
- );
453
- }
454
- return parseJsonArg("--params-file", content);
455
- }
456
- if (rawInlineParams) {
457
- return parseJsonArg("--params", rawInlineParams);
458
- }
459
- return {};
460
- })();
461
- const hasHeadedFlag = Boolean(argv.headed);
462
- const hasHeadlessFlag = Boolean(argv.headless);
463
- if (hasHeadedFlag && hasHeadlessFlag) {
464
- throw new Error("Cannot pass both --headed and --headless.");
465
- }
466
- const headlessMode = hasHeadedFlag ? false : hasHeadlessFlag ? true : void 0;
467
- const authProfileDomain = argv["auth-profile"];
468
- const tsconfigPath = argv.tsconfig;
469
- await runIntegrationFromFile({
470
- integrationPath,
471
- exportName,
472
- session,
473
- params,
474
- headless: headlessMode ?? false,
475
- authProfileDomain,
476
- tsconfigPath
477
- }, logger);
478
- }
479
- ).command(
480
- "resume",
481
- "Resume a paused workflow for the current session",
482
- (cmd) => cmd,
483
- async (argv) => {
484
- await runResume(String(argv.session), logger);
485
- }
486
- );
475
+ return parseJsonArg("--params-file", content);
476
+ }
477
+ if (rawInlineParams) {
478
+ return parseJsonArg("--params", rawInlineParams);
479
+ }
480
+ return {};
481
+ }
482
+ function createRunCommand(logger) {
483
+ return SimpleCLI.command({
484
+ description: "Run an exported Libretto workflow from a file"
485
+ }).input(runInput).use(resolveSessionMiddleware).handle(async ({ input, ctx }) => {
486
+ await stopExistingFailedRunSession(ctx.session, logger);
487
+ assertSessionAvailableForStart(ctx.session, logger);
488
+ const params = resolveRunParams(input.params, input.paramsFile);
489
+ const headlessMode = input.headed ? false : input.headless ? true : void 0;
490
+ await runIntegrationFromFile({
491
+ integrationPath: input.integrationFile,
492
+ exportName: input.integrationExport,
493
+ session: ctx.session,
494
+ params,
495
+ tsconfigPath: input.tsconfig,
496
+ headless: headlessMode ?? false,
497
+ authProfileDomain: input.authProfile
498
+ }, logger);
499
+ });
500
+ }
501
+ const resumeInput = SimpleCLI.input({
502
+ positionals: [],
503
+ named: {
504
+ session: sessionOption()
505
+ }
506
+ });
507
+ function createResumeCommand(logger) {
508
+ return SimpleCLI.command({
509
+ description: "Resume a paused workflow for the current session"
510
+ }).input(resumeInput).use(resolveSessionMiddleware).use(loadSessionStateMiddleware).handle(async ({ ctx }) => {
511
+ await runResume(ctx.session, logger, ctx.sessionState);
512
+ });
513
+ }
514
+ function createExecutionCommands(logger) {
515
+ return {
516
+ exec: createExecCommand(logger),
517
+ run: createRunCommand(logger),
518
+ resume: createResumeCommand(logger)
519
+ };
487
520
  }
488
521
  export {
489
- registerExecutionCommands
522
+ createExecCommand,
523
+ createExecutionCommands,
524
+ createResumeCommand,
525
+ createRunCommand,
526
+ execInput,
527
+ resumeInput,
528
+ runInput
490
529
  };
@@ -1,15 +1,15 @@
1
1
  import { createInterface } from "node:readline";
2
- import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, cpSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
- import { join } from "node:path";
5
- import {
6
- readAiConfig
7
- } from "../core/ai-config.js";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readAiConfig } from "../core/ai-config.js";
8
7
  import { REPO_ROOT } from "../core/context.js";
9
8
  import {
10
9
  loadSnapshotEnv,
11
10
  resolveSnapshotApiModel
12
11
  } from "../core/snapshot-api-config.js";
12
+ import { SimpleCLI } from "../framework/simple-cli.js";
13
13
  import { hasProviderCredentials } from "../../shared/llm/client.js";
14
14
  const PROVIDER_CHOICES = [
15
15
  {
@@ -44,6 +44,15 @@ function promptUser(rl, question) {
44
44
  });
45
45
  });
46
46
  }
47
+ function askYesNo(question) {
48
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
49
+ return new Promise((resolve) => {
50
+ rl.question(`${question} (y/N) `, (answer) => {
51
+ rl.close();
52
+ resolve(answer.trim().toLowerCase() === "y");
53
+ });
54
+ });
55
+ }
47
56
  function safeReadAiConfig() {
48
57
  try {
49
58
  return readAiConfig();
@@ -61,9 +70,7 @@ function printSnapshotApiStatus() {
61
70
  );
62
71
  console.log(` Credentials are loaded from process env and ${envPath}.`);
63
72
  if (selection && hasProviderCredentials(selection.provider)) {
64
- console.log(
65
- ` \u2713 Ready: ${selection.model} (${selection.source})`
66
- );
73
+ console.log(` \u2713 Ready: ${selection.model} (${selection.source})`);
67
74
  console.log(" Snapshot objectives will use the API analyzer by default.");
68
75
  console.log(" No further action required.");
69
76
  return;
@@ -86,14 +93,10 @@ async function runInteractiveApiSetup() {
86
93
  const selection = resolveSnapshotApiModel(config);
87
94
  const envPath = join(REPO_ROOT, ".env");
88
95
  console.log("\nSnapshot analysis setup:");
89
- console.log(
90
- " Libretto uses direct API calls for snapshot analysis."
91
- );
96
+ console.log(" Libretto uses direct API calls for snapshot analysis.");
92
97
  console.log(` Credentials are loaded from process env and ${envPath}.`);
93
98
  if (selection && hasProviderCredentials(selection.provider)) {
94
- console.log(
95
- ` \u2713 Ready: ${selection.model} (${selection.source})`
96
- );
99
+ console.log(` \u2713 Ready: ${selection.model} (${selection.source})`);
97
100
  console.log(" Snapshot objectives will use the API analyzer by default.");
98
101
  return;
99
102
  }
@@ -120,7 +123,7 @@ async function runInteractiveApiSetup() {
120
123
  );
121
124
  return;
122
125
  }
123
- const selected = PROVIDER_CHOICES.find((c) => c.key === answer);
126
+ const selected = PROVIDER_CHOICES.find((choice) => choice.key === answer);
124
127
  if (!selected) {
125
128
  console.log(`
126
129
  Unknown choice "${answer}". Skipping API setup.`);
@@ -130,7 +133,10 @@ async function runInteractiveApiSetup() {
130
133
  ${selected.label} selected.`);
131
134
  console.log(` ${selected.envHint}
132
135
  `);
133
- const apiKeyValue = await promptUser(rl, ` Enter your ${selected.envVar}: `);
136
+ const apiKeyValue = await promptUser(
137
+ rl,
138
+ ` Enter your ${selected.envVar}: `
139
+ );
134
140
  if (!apiKeyValue) {
135
141
  console.log("\n No value entered. Skipping API key setup.");
136
142
  return;
@@ -179,31 +185,88 @@ function installBrowsers() {
179
185
  );
180
186
  }
181
187
  }
182
- function registerInitCommand(yargs) {
183
- return yargs.command(
184
- "init",
185
- "Initialize libretto in the current project",
186
- (cmd) => cmd.option("skip-browsers", {
187
- type: "boolean",
188
- default: false,
189
- describe: "Skip Playwright Chromium installation"
190
- }),
191
- async (argv) => {
192
- console.log("Initializing libretto...\n");
193
- if (!argv["skip-browsers"]) {
194
- installBrowsers();
195
- } else {
196
- console.log("\nSkipping browser installation (--skip-browsers)");
197
- }
198
- if (process.stdin.isTTY) {
199
- await runInteractiveApiSetup();
200
- } else {
201
- printSnapshotApiStatus();
202
- }
203
- console.log("\n\u2713 libretto init complete");
188
+ function getPackageSkillsDir() {
189
+ const thisFile = fileURLToPath(import.meta.url);
190
+ let dir = dirname(thisFile);
191
+ while (dir !== dirname(dir)) {
192
+ if (existsSync(join(dir, "skills", "libretto"))) {
193
+ return join(dir, "skills", "libretto");
204
194
  }
205
- );
195
+ dir = dirname(dir);
196
+ }
197
+ throw new Error("Could not locate libretto skill files in package");
206
198
  }
199
+ async function copySkills() {
200
+ const cwd = process.cwd();
201
+ const agentDirs = [];
202
+ if (existsSync(join(cwd, ".agents"))) {
203
+ agentDirs.push({
204
+ name: ".agents",
205
+ skillDest: join(cwd, ".agents", "skills", "libretto")
206
+ });
207
+ }
208
+ if (existsSync(join(cwd, ".claude"))) {
209
+ agentDirs.push({
210
+ name: ".claude",
211
+ skillDest: join(cwd, ".claude", "skills", "libretto")
212
+ });
213
+ }
214
+ if (agentDirs.length === 0) {
215
+ console.log("\nSkills: No .agents/ or .claude/ directory found \u2014 skipping skill copy.");
216
+ return;
217
+ }
218
+ const dirNames = agentDirs.map((d) => d.name).join(" and ");
219
+ const existing = agentDirs.filter((d) => existsSync(d.skillDest));
220
+ const verb = existing.length > 0 ? "Overwrite" : "Install";
221
+ const proceed = await askYesNo(`
222
+ ${verb} libretto skills in ${dirNames}?`);
223
+ if (!proceed) {
224
+ console.log(" Skipping skill copy.");
225
+ return;
226
+ }
227
+ let sourceDir;
228
+ try {
229
+ sourceDir = getPackageSkillsDir();
230
+ } catch (e) {
231
+ console.error(` \u2717 ${e instanceof Error ? e.message : String(e)}`);
232
+ return;
233
+ }
234
+ for (const { name, skillDest } of agentDirs) {
235
+ if (existsSync(skillDest)) {
236
+ rmSync(skillDest, { recursive: true });
237
+ }
238
+ cpSync(sourceDir, skillDest, { recursive: true });
239
+ const fileCount = readdirSync(skillDest).length;
240
+ console.log(` \u2713 Copied ${fileCount} skill files to ${name}/skills/libretto/`);
241
+ }
242
+ }
243
+ const initInput = SimpleCLI.input({
244
+ positionals: [],
245
+ named: {
246
+ skipBrowsers: SimpleCLI.flag({
247
+ name: "skip-browsers",
248
+ help: "Skip Playwright Chromium installation"
249
+ })
250
+ }
251
+ });
252
+ const initCommand = SimpleCLI.command({
253
+ description: "Initialize libretto in the current project"
254
+ }).input(initInput).handle(async ({ input }) => {
255
+ console.log("Initializing libretto...\n");
256
+ if (!input.skipBrowsers) {
257
+ installBrowsers();
258
+ } else {
259
+ console.log("\nSkipping browser installation (--skip-browsers)");
260
+ }
261
+ if (process.stdin.isTTY) {
262
+ await copySkills();
263
+ await runInteractiveApiSetup();
264
+ } else {
265
+ printSnapshotApiStatus();
266
+ }
267
+ console.log("\n\u2713 libretto init complete");
268
+ });
207
269
  export {
208
- registerInitCommand
270
+ initCommand,
271
+ initInput
209
272
  };