libretto 0.3.1 → 0.4.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.
@@ -8,13 +8,6 @@ import { extname, isAbsolute, join, resolve } from "node:path";
8
8
  import { spawn } from "node:child_process";
9
9
  import { tmpdir } from "node:os";
10
10
  import { z } from "zod";
11
- import {
12
- formatCommandPrefix,
13
- readAiConfig
14
- } from "./ai-config.js";
15
- import {
16
- getLLMClientFactory
17
- } from "./context.js";
18
11
  const InterpretResultSchema = z.object({
19
12
  answer: z.string(),
20
13
  selectors: z.array(
@@ -23,8 +16,8 @@ const InterpretResultSchema = z.object({
23
16
  selector: z.string(),
24
17
  rationale: z.string()
25
18
  })
26
- ).default([]),
27
- notes: z.string().optional().default("")
19
+ ),
20
+ notes: z.string()
28
21
  });
29
22
  class UserCodingAgent {
30
23
  constructor(config) {
@@ -41,7 +34,7 @@ class UserCodingAgent {
41
34
  }
42
35
  }
43
36
  static readConfiguredConfig() {
44
- return readAiConfig();
37
+ return null;
45
38
  }
46
39
  static getConfigured() {
47
40
  const config = this.readConfiguredConfig();
@@ -66,23 +59,23 @@ class UserCodingAgent {
66
59
  Screenshot file path: ${pngPath}
67
60
  Use the screenshot alongside the HTML snapshot context above.`;
68
61
  }
69
- async runAnalyzer(args, stdinText) {
70
- const result = await runExternalCommand(this.command, args, stdinText);
62
+ async runAnalyzer(args, logger, stdinText) {
63
+ const result = await runExternalCommand(this.command, args, logger, stdinText);
71
64
  if (result.exitCode !== 0) {
72
65
  throw new Error(
73
- `Analyzer command failed (${formatCommandPrefix([this.command, ...args])}).
66
+ `Analyzer command failed (${[this.command, ...args].join(" ")}).
74
67
  ${stripAnsi(result.stderr).trim() || stripAnsi(result.stdout).trim() || "No error output."}`
75
68
  );
76
69
  }
77
70
  return result;
78
71
  }
79
- async runAndParse(args, stdinText) {
80
- const result = await this.runAnalyzer(args, stdinText);
72
+ async runAndParse(args, logger, stdinText) {
73
+ const result = await this.runAnalyzer(args, logger, stdinText);
81
74
  return parseInterpretResultFromText(result.stdout);
82
75
  }
83
76
  }
84
77
  class CodexUserCodingAgent extends UserCodingAgent {
85
- async analyzeSnapshot(prompt, pngPath) {
78
+ async analyzeSnapshot(prompt, pngPath, logger) {
86
79
  const tempDir = mkdtempSync(join(tmpdir(), "libretto-cli-analyzer-"));
87
80
  const outputPath = join(
88
81
  tempDir,
@@ -96,9 +89,21 @@ class CodexUserCodingAgent extends UserCodingAgent {
96
89
  pngPath,
97
90
  "-"
98
91
  ];
99
- const result = await this.runAnalyzer(args, prompt);
92
+ logger.info("interpret-analyzer-codex-start", {
93
+ outputPath,
94
+ pngPath,
95
+ promptChars: prompt.length,
96
+ args
97
+ });
98
+ const result = await this.runAnalyzer(args, logger, prompt);
100
99
  let outputText = result.stdout;
101
100
  try {
101
+ logger.info("interpret-analyzer-codex-finish", {
102
+ outputPath,
103
+ outputFileExists: existsSync(outputPath),
104
+ stdoutChars: result.stdout.length,
105
+ stderrChars: result.stderr.length
106
+ });
102
107
  if (existsSync(outputPath)) {
103
108
  outputText = readFileSync(outputPath, "utf-8");
104
109
  }
@@ -109,31 +114,59 @@ class CodexUserCodingAgent extends UserCodingAgent {
109
114
  }
110
115
  }
111
116
  class ClaudeUserCodingAgent extends UserCodingAgent {
112
- async analyzeSnapshot(prompt, pngPath) {
113
- const args = [...this.baseArgs, `${prompt}${this.screenshotHint(pngPath)}`];
114
- return await this.runAndParse(args);
117
+ async analyzeSnapshot(prompt, pngPath, logger) {
118
+ return await this.runAndParse(
119
+ [...this.baseArgs],
120
+ logger,
121
+ `${prompt}${this.screenshotHint(pngPath)}`
122
+ );
115
123
  }
116
124
  }
117
125
  class GeminiUserCodingAgent extends UserCodingAgent {
118
- async analyzeSnapshot(prompt, pngPath) {
119
- const args = [...this.baseArgs, `${prompt}${this.screenshotHint(pngPath)}`];
120
- return await this.runAndParse(args);
126
+ async analyzeSnapshot(prompt, pngPath, logger) {
127
+ return await this.runAndParse(
128
+ [...this.baseArgs],
129
+ logger,
130
+ `${prompt}${this.screenshotHint(pngPath)}`
131
+ );
121
132
  }
122
133
  }
123
- async function runExternalCommand(command, args, stdinText) {
134
+ async function runExternalCommand(command, args, logger, stdinText) {
124
135
  return await new Promise((resolve2, reject) => {
136
+ const startedAt = Date.now();
137
+ logger.info("interpret-analyzer-spawn-start", {
138
+ command,
139
+ args,
140
+ stdinChars: stdinText?.length ?? 0
141
+ });
125
142
  const child = spawn(command, args, {
126
143
  stdio: ["pipe", "pipe", "pipe"]
127
144
  });
128
145
  let stdout = "";
129
146
  let stderr = "";
147
+ let stdinError = null;
130
148
  child.stdout.on("data", (chunk) => {
131
149
  stdout += chunk.toString();
132
150
  });
133
151
  child.stderr.on("data", (chunk) => {
134
152
  stderr += chunk.toString();
135
153
  });
154
+ child.stdin.on("error", (err) => {
155
+ stdinError = err;
156
+ logger.warn("interpret-analyzer-stdin-pipe-error", {
157
+ command,
158
+ args,
159
+ code: stdinError.code ?? null,
160
+ message: stdinError.message,
161
+ hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
162
+ });
163
+ });
136
164
  child.on("error", (err) => {
165
+ logger.error("interpret-analyzer-spawn-error", {
166
+ command,
167
+ args,
168
+ error: err
169
+ });
137
170
  const error = err;
138
171
  if (error.code === "ENOENT") {
139
172
  reject(
@@ -146,16 +179,41 @@ async function runExternalCommand(command, args, stdinText) {
146
179
  reject(err);
147
180
  });
148
181
  child.on("close", (code) => {
182
+ const stdinNote = formatStdinError(stderr, stdinError);
183
+ const combinedStderr = `${stderr}${stdinNote}`;
184
+ logger.info("interpret-analyzer-spawn-close", {
185
+ command,
186
+ args,
187
+ exitCode: code ?? 1,
188
+ durationMs: Date.now() - startedAt,
189
+ stdoutChars: stdout.length,
190
+ stderrChars: combinedStderr.length,
191
+ stdinErrorCode: stdinError?.code ?? null,
192
+ stdoutPreview: summarizeForLog(stdout),
193
+ stderrPreview: summarizeForLog(combinedStderr)
194
+ });
149
195
  resolve2({
150
196
  exitCode: code ?? 1,
151
197
  stdout,
152
- stderr
198
+ stderr: combinedStderr
153
199
  });
154
200
  });
155
- if (stdinText !== void 0) {
156
- child.stdin.write(stdinText);
201
+ try {
202
+ if (stdinText !== void 0) {
203
+ child.stdin.end(stdinText);
204
+ } else {
205
+ child.stdin.end();
206
+ }
207
+ } catch (err) {
208
+ stdinError = err;
209
+ logger.warn("interpret-analyzer-stdin-write-error", {
210
+ command,
211
+ args,
212
+ code: stdinError.code ?? null,
213
+ message: stdinError.message,
214
+ hint: stdinError.code === "EPIPE" ? "Child process exited before consuming all stdin data" : "Unexpected stdin write error"
215
+ });
157
216
  }
158
- child.stdin.end();
159
217
  });
160
218
  }
161
219
  function stripAnsi(value) {
@@ -164,6 +222,19 @@ function stripAnsi(value) {
164
222
  ""
165
223
  );
166
224
  }
225
+ function summarizeForLog(value, maxChars = 800) {
226
+ const cleaned = stripAnsi(value).trim();
227
+ if (!cleaned) return "";
228
+ if (cleaned.length <= maxChars) return cleaned;
229
+ return `${cleaned.slice(0, maxChars)}\u2026 [truncated ${cleaned.length - maxChars} chars]`;
230
+ }
231
+ function formatStdinError(stderr, error) {
232
+ if (!error) return "";
233
+ const detail = error.code === "EPIPE" ? "Analyzer closed stdin before Libretto finished sending the snapshot prompt." : `Analyzer stdin error: ${error.message}`;
234
+ if (stderr.includes(detail)) return "";
235
+ return `${stderr.endsWith("\n") || stderr.length === 0 ? "" : "\n"}${detail}
236
+ `;
237
+ }
167
238
  function extractJsonObjectCandidates(text) {
168
239
  const candidates = [];
169
240
  const seen = /* @__PURE__ */ new Set();
@@ -349,37 +420,50 @@ function collectSelectorHints(html, limit = 120) {
349
420
  }
350
421
  return candidates;
351
422
  }
352
- async function runInterpret(args, logger) {
353
- logger.info("interpret-start", {
354
- objective: args.objective,
355
- pngPath: args.pngPath,
356
- htmlPath: args.htmlPath
357
- });
358
- process.env.NODE_ENV = "development";
359
- const pngPath = resolvePath(args.pngPath);
360
- const htmlPath = resolvePath(args.htmlPath);
361
- if (!existsSync(pngPath)) {
362
- throw new Error(`PNG file not found: ${pngPath}`);
423
+ function estimateTokensFromChars(chars) {
424
+ return Math.ceil(chars / 4);
425
+ }
426
+ function inferContextWindowTokens(model) {
427
+ const normalized = model.trim().toLowerCase();
428
+ if (normalized.includes("claude")) {
429
+ return { contextWindowTokens: 2e5, source: "model:claude" };
363
430
  }
364
- if (!existsSync(htmlPath)) {
365
- throw new Error(`HTML file not found: ${htmlPath}`);
431
+ if (normalized.includes("gpt-5") || normalized.includes("o3") || normalized.includes("o4")) {
432
+ return { contextWindowTokens: 2e5, source: "model:openai" };
366
433
  }
367
- const htmlContent = readFileSync(htmlPath, "utf-8");
368
- const htmlCharLimit = 5e5;
369
- const { text: trimmedHtml, truncated } = truncateText(
370
- htmlContent,
371
- htmlCharLimit
434
+ if (normalized.includes("gemini")) {
435
+ return { contextWindowTokens: 1e6, source: "model:gemini" };
436
+ }
437
+ if (normalized.startsWith("openai/") || normalized.startsWith("codex/")) {
438
+ return { contextWindowTokens: 2e5, source: "provider:openai" };
439
+ }
440
+ if (normalized.startsWith("anthropic/")) {
441
+ return { contextWindowTokens: 2e5, source: "provider:anthropic" };
442
+ }
443
+ if (normalized.startsWith("google/") || normalized.startsWith("vertex/")) {
444
+ return { contextWindowTokens: 1e6, source: "provider:google" };
445
+ }
446
+ return { contextWindowTokens: 128e3, source: "default" };
447
+ }
448
+ function buildSnapshotBudget(model) {
449
+ const { contextWindowTokens, source } = inferContextWindowTokens(model);
450
+ const outputReserveTokens = Math.min(
451
+ 32e3,
452
+ Math.max(8e3, Math.floor(contextWindowTokens * 0.1))
372
453
  );
373
- const selectorHints = collectSelectorHints(htmlContent, 120);
374
- let prompt = `# Objective
375
- ${args.objective}
376
-
377
- `;
378
- prompt += `# Context
379
- ${args.context}
380
-
381
- `;
382
- prompt += `# Instructions
454
+ const promptBudgetTokens = Math.max(
455
+ 8e3,
456
+ contextWindowTokens - outputReserveTokens - 2e3
457
+ );
458
+ return {
459
+ contextWindowTokens,
460
+ outputReserveTokens,
461
+ promptBudgetTokens,
462
+ source
463
+ };
464
+ }
465
+ function buildInterpretInstructions() {
466
+ let prompt = `# Instructions
383
467
  `;
384
468
  prompt += `You are analyzing a screenshot and HTML snapshot of the same web page on behalf of an automation agent.
385
469
  `;
@@ -405,66 +489,135 @@ ${args.context}
405
489
  prompt += `Selectors should prefer robust attributes: data-testid, data-test, aria-label, name, id, role. Avoid fragile class-based or positional selectors.
406
490
  `;
407
491
  prompt += `Only include selectors that exist in the HTML snapshot.
492
+ `;
493
+ return prompt;
494
+ }
495
+ function buildInlineHtmlPrompt(args, options) {
496
+ const selectorHints = collectSelectorHints(options.htmlContent, 120);
497
+ let prompt = `# Objective
498
+ ${args.objective}
499
+
500
+ `;
501
+ prompt += `# Context
502
+ ${args.context}
408
503
 
409
504
  `;
505
+ prompt += `# Snapshot Selection
506
+ `;
507
+ prompt += `- Selected HTML snapshot: ${options.domLabel}
508
+ `;
509
+ prompt += `- Selection reason: ${options.selectionReason}
510
+
511
+ `;
512
+ prompt += buildInterpretInstructions();
410
513
  if (selectorHints.length > 0) {
411
- prompt += `Selector hints from HTML attributes (use if relevant):
514
+ prompt += `
515
+ Selector hints from HTML attributes (use if relevant):
412
516
  `;
413
517
  prompt += selectorHints.map((hint) => `- ${hint}`).join("\n");
414
- prompt += "\n\n";
518
+ prompt += "\n";
415
519
  }
416
- if (truncated) {
417
- prompt += `HTML content is truncated to fit token limits.
418
-
520
+ if (options.truncated) {
521
+ prompt += `
522
+ HTML content is truncated to fit token limits.
419
523
  `;
420
524
  }
421
- prompt += `HTML snapshot:
525
+ prompt += `
526
+ HTML snapshot (${options.domLabel}):
422
527
 
423
- ${trimmedHtml}`;
528
+ ${options.htmlContent}`;
424
529
  prompt += "\n\nReturn only a JSON object. Do not include markdown code fences or extra commentary.";
425
- let parsed;
426
- const configuredAgent = UserCodingAgent.getConfigured();
427
- if (configuredAgent) {
428
- const configuredAnalyzer = configuredAgent.snapshotAnalyzerConfig;
429
- logger.info("interpret-analyzer-config", {
430
- preset: configuredAnalyzer.preset,
431
- commandPrefix: configuredAnalyzer.commandPrefix
530
+ return prompt;
531
+ }
532
+ function buildInlinePromptSelection(args, fullHtmlContent, condensedHtmlContent, model) {
533
+ const budget = buildSnapshotBudget(model);
534
+ const stats = {
535
+ fullDomChars: fullHtmlContent.length,
536
+ fullDomEstimatedTokens: estimateTokensFromChars(fullHtmlContent.length),
537
+ condensedDomChars: condensedHtmlContent.length,
538
+ condensedDomEstimatedTokens: estimateTokensFromChars(condensedHtmlContent.length),
539
+ configuredModel: model
540
+ };
541
+ const buildCandidate = (domSource, htmlContent, selectionReason, truncated) => {
542
+ const domLabel = domSource === "full" ? "full DOM" : "condensed DOM";
543
+ const prompt = buildInlineHtmlPrompt(args, {
544
+ htmlContent,
545
+ domLabel,
546
+ truncated,
547
+ selectionReason,
548
+ budget,
549
+ stats
432
550
  });
433
- parsed = await configuredAgent.analyzeSnapshot(prompt, pngPath);
434
- } else {
435
- const llmClientFactory = getLLMClientFactory();
436
- if (!llmClientFactory) {
437
- throw new Error(
438
- "No AI config set. Run 'libretto-cli ai configure codex' (or claude/gemini). Library integrations can still set a factory via setLLMClientFactory()."
439
- );
440
- }
441
- logger.info("interpret-analyzer-factory-fallback", {});
442
- const imageBase64 = readFileAsBase64(pngPath);
443
- const client = await llmClientFactory(logger, "google/gemini-3-flash-preview");
444
- const result = await client.generateObjectFromMessages({
445
- schema: InterpretResultSchema,
446
- messages: [
447
- {
448
- role: "user",
449
- content: [
450
- { type: "text", text: prompt },
451
- {
452
- type: "image",
453
- image: `data:${getMimeType(pngPath)};base64,${imageBase64}`
454
- }
455
- ]
456
- }
457
- ],
458
- temperature: 0.1
551
+ return {
552
+ prompt,
553
+ domSource,
554
+ domLabel,
555
+ htmlChars: htmlContent.length,
556
+ htmlEstimatedTokens: estimateTokensFromChars(htmlContent.length),
557
+ promptEstimatedTokens: estimateTokensFromChars(prompt.length),
558
+ truncated,
559
+ selectionReason,
560
+ budget,
561
+ stats
562
+ };
563
+ };
564
+ const fullCandidate = buildCandidate(
565
+ "full",
566
+ fullHtmlContent,
567
+ "placeholder",
568
+ false
569
+ );
570
+ if (fullCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
571
+ const selectionReason = `Full DOM fits within the estimated prompt budget (~${fullCandidate.promptEstimatedTokens.toLocaleString()} <= ${budget.promptBudgetTokens.toLocaleString()} tokens), so the analyzer receives the uncondensed page HTML.`;
572
+ const prompt = buildInlineHtmlPrompt(args, {
573
+ htmlContent: fullHtmlContent,
574
+ domLabel: "full DOM",
575
+ truncated: false,
576
+ selectionReason,
577
+ budget,
578
+ stats
459
579
  });
460
- parsed = InterpretResultSchema.parse(result);
580
+ return {
581
+ ...fullCandidate,
582
+ selectionReason,
583
+ prompt,
584
+ promptEstimatedTokens: estimateTokensFromChars(prompt.length)
585
+ };
586
+ }
587
+ const condensedReason = `Full DOM would exceed the estimated prompt budget (~${fullCandidate.promptEstimatedTokens.toLocaleString()} > ${budget.promptBudgetTokens.toLocaleString()} tokens), so the analyzer receives the condensed DOM instead.`;
588
+ const condensedCandidate = buildCandidate(
589
+ "condensed",
590
+ condensedHtmlContent,
591
+ condensedReason,
592
+ false
593
+ );
594
+ if (condensedCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
595
+ return condensedCandidate;
461
596
  }
462
- logger.info("interpret-success", {
463
- selectorCount: parsed.selectors.length,
464
- answer: parsed.answer.slice(0, 200)
597
+ const truncateReason = `Both full and condensed DOM snapshots exceed the estimated prompt budget (full ~${fullCandidate.promptEstimatedTokens.toLocaleString()}, condensed ~${condensedCandidate.promptEstimatedTokens.toLocaleString()}, budget ${budget.promptBudgetTokens.toLocaleString()} tokens), so the condensed DOM is truncated to fit.`;
598
+ const basePrompt = buildInlineHtmlPrompt(args, {
599
+ htmlContent: "",
600
+ domLabel: "condensed DOM",
601
+ truncated: true,
602
+ selectionReason: truncateReason,
603
+ budget,
604
+ stats
465
605
  });
606
+ const availableHtmlTokens = Math.max(
607
+ 2e3,
608
+ budget.promptBudgetTokens - estimateTokensFromChars(basePrompt.length)
609
+ );
610
+ const truncatedHtml = truncateText(condensedHtmlContent, availableHtmlTokens * 4);
611
+ return buildCandidate(
612
+ "condensed",
613
+ truncatedHtml.text,
614
+ truncateReason,
615
+ truncatedHtml.truncated
616
+ );
617
+ }
618
+ function formatInterpretationOutput(parsed, header = "Interpretation:") {
466
619
  const outputLines = [];
467
- outputLines.push("Interpretation:");
620
+ outputLines.push(header);
468
621
  outputLines.push(`Answer: ${parsed.answer}`);
469
622
  outputLines.push("");
470
623
  if (parsed.selectors.length === 0) {
@@ -477,16 +630,54 @@ ${trimmedHtml}`;
477
630
  outputLines.push(` rationale: ${selector.rationale}`);
478
631
  });
479
632
  }
480
- if (parsed.notes.trim()) {
633
+ if (parsed.notes && parsed.notes.trim()) {
481
634
  outputLines.push("");
482
635
  outputLines.push(`Notes: ${parsed.notes.trim()}`);
483
636
  }
484
- console.log(outputLines.join("\n"));
637
+ return outputLines.join("\n");
638
+ }
639
+ async function runInterpret(args, logger) {
640
+ logger.info("interpret-start", {
641
+ objective: args.objective,
642
+ pngPath: args.pngPath,
643
+ htmlPath: args.htmlPath,
644
+ condensedHtmlPath: args.condensedHtmlPath
645
+ });
646
+ process.env.NODE_ENV = "development";
647
+ const pngPath = resolvePath(args.pngPath);
648
+ const htmlPath = resolvePath(args.htmlPath);
649
+ const condensedHtmlPath = resolvePath(args.condensedHtmlPath);
650
+ if (!existsSync(pngPath)) {
651
+ throw new Error(`PNG file not found: ${pngPath}`);
652
+ }
653
+ if (!existsSync(htmlPath)) {
654
+ throw new Error(`HTML file not found: ${htmlPath}`);
655
+ }
656
+ if (!existsSync(condensedHtmlPath)) {
657
+ throw new Error(`Condensed HTML file not found: ${condensedHtmlPath}`);
658
+ }
659
+ const fullHtmlContent = readFileSync(htmlPath, "utf-8");
660
+ const condensedHtmlContent = readFileSync(condensedHtmlPath, "utf-8");
661
+ const configuredAgent = UserCodingAgent.getConfigured();
662
+ if (!configuredAgent) {
663
+ throw new Error(
664
+ "No AI config set. Run 'npx libretto ai configure codex' (or claude/gemini), or set API credentials in your .env file for direct API analysis."
665
+ );
666
+ }
667
+ const configuredAnalyzer = configuredAgent.snapshotAnalyzerConfig;
668
+ throw new Error(
669
+ "The CLI-agent snapshot analysis path is not active. Update your config to the current format with `npx libretto ai configure <provider>`, or set API credentials in .env for direct API analysis."
670
+ );
485
671
  }
486
672
  function canAnalyzeSnapshots() {
487
- return UserCodingAgent.getConfigured() !== null || getLLMClientFactory() !== null;
673
+ return UserCodingAgent.getConfigured() !== null;
488
674
  }
489
675
  export {
676
+ InterpretResultSchema,
677
+ buildInlinePromptSelection,
490
678
  canAnalyzeSnapshots,
679
+ formatInterpretationOutput,
680
+ getMimeType,
681
+ readFileAsBase64,
491
682
  runInterpret
492
683
  };
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import {
4
+ readAiConfig
5
+ } from "./ai-config.js";
6
+ import { REPO_ROOT } from "./context.js";
7
+ import {
8
+ hasProviderCredentials,
9
+ missingProviderCredentialsMessage,
10
+ parseModel
11
+ } from "../../shared/llm/client.js";
12
+ const DEFAULT_SNAPSHOT_MODELS = {
13
+ openai: "openai/gpt-5.4",
14
+ anthropic: "anthropic/claude-sonnet-4-6",
15
+ google: "google/gemini-2.5-flash",
16
+ vertex: "vertex/gemini-2.5-pro"
17
+ };
18
+ class SnapshotApiUnavailableError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "SnapshotApiUnavailableError";
22
+ }
23
+ }
24
+ function readWorktreeEnvPath() {
25
+ const gitPath = join(REPO_ROOT, ".git");
26
+ if (!existsSync(gitPath)) return null;
27
+ try {
28
+ const gitPointer = readFileSync(gitPath, "utf-8").trim();
29
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
30
+ if (!match?.[1]) return null;
31
+ const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
32
+ const commonGitDir = resolve(worktreeGitDir, "..", "..");
33
+ return join(dirname(commonGitDir), ".env");
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ function loadSnapshotEnv() {
39
+ if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
40
+ const envPathCandidates = [
41
+ join(REPO_ROOT, ".env"),
42
+ readWorktreeEnvPath()
43
+ ].filter((value) => Boolean(value));
44
+ const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
45
+ if (!envPath) return;
46
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
47
+ const parsed = parseDotEnvAssignment(line);
48
+ if (!parsed) continue;
49
+ if (!(parsed.key in process.env)) {
50
+ process.env[parsed.key] = parsed.value;
51
+ }
52
+ }
53
+ }
54
+ function parseDotEnvAssignment(line) {
55
+ const trimmed = line.trim();
56
+ if (!trimmed || trimmed.startsWith("#")) return null;
57
+ const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
58
+ const eqIdx = withoutExport.indexOf("=");
59
+ if (eqIdx < 1) return null;
60
+ const key = withoutExport.slice(0, eqIdx).trim();
61
+ if (!key) return null;
62
+ const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
63
+ if (!rawValue) {
64
+ return { key, value: "" };
65
+ }
66
+ if (rawValue.startsWith('"')) {
67
+ const closeIdx = rawValue.indexOf('"', 1);
68
+ if (closeIdx > 0) {
69
+ return { key, value: rawValue.slice(1, closeIdx) };
70
+ }
71
+ return { key, value: rawValue.slice(1) };
72
+ }
73
+ if (rawValue.startsWith("'")) {
74
+ const closeIdx = rawValue.indexOf("'", 1);
75
+ if (closeIdx > 0) {
76
+ return { key, value: rawValue.slice(1, closeIdx) };
77
+ }
78
+ return { key, value: rawValue.slice(1) };
79
+ }
80
+ const inlineCommentIndex = rawValue.search(/\s#/);
81
+ const value = inlineCommentIndex >= 0 ? rawValue.slice(0, inlineCommentIndex).trimEnd() : rawValue.trim();
82
+ return { key, value };
83
+ }
84
+ function inferAutoSnapshotModel() {
85
+ const providersInPriorityOrder = [
86
+ "openai",
87
+ "anthropic",
88
+ "google",
89
+ "vertex"
90
+ ];
91
+ for (const provider of providersInPriorityOrder) {
92
+ if (!hasProviderCredentials(provider)) continue;
93
+ return {
94
+ model: DEFAULT_SNAPSHOT_MODELS[provider],
95
+ provider,
96
+ source: `env:auto-${provider}`
97
+ };
98
+ }
99
+ return null;
100
+ }
101
+ function resolveSnapshotApiModel(config = readAiConfig()) {
102
+ loadSnapshotEnv();
103
+ if (config?.model) {
104
+ const { provider } = parseModel(config.model);
105
+ return {
106
+ model: config.model,
107
+ provider,
108
+ source: "config"
109
+ };
110
+ }
111
+ return inferAutoSnapshotModel();
112
+ }
113
+ function resolveSnapshotApiModelOrThrow(config = readAiConfig()) {
114
+ const selection = resolveSnapshotApiModel(config);
115
+ if (!selection) {
116
+ throw new SnapshotApiUnavailableError(
117
+ "No API snapshot analyzer is available. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY/GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT, or run `npx libretto ai configure <provider>` to set a default model."
118
+ );
119
+ }
120
+ if (!hasProviderCredentials(selection.provider)) {
121
+ throw new SnapshotApiUnavailableError(
122
+ missingProviderCredentialsMessage(selection.provider)
123
+ );
124
+ }
125
+ return selection;
126
+ }
127
+ function isSnapshotApiUnavailableError(error) {
128
+ return error instanceof SnapshotApiUnavailableError;
129
+ }
130
+ export {
131
+ SnapshotApiUnavailableError,
132
+ isSnapshotApiUnavailableError,
133
+ loadSnapshotEnv,
134
+ parseDotEnvAssignment,
135
+ resolveSnapshotApiModel,
136
+ resolveSnapshotApiModelOrThrow
137
+ };
package/dist/cli/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { runLibrettoCLI } from "./cli.js";
2
3
  import {
3
4
  maybeConfigureLLMClientFactoryFromEnv,