libretto 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +109 -35
  2. package/dist/cli/cli.js +22 -97
  3. package/dist/cli/commands/browser.js +86 -59
  4. package/dist/cli/commands/execution.js +199 -86
  5. package/dist/cli/commands/init.js +34 -29
  6. package/dist/cli/commands/logs.js +4 -5
  7. package/dist/cli/commands/shared.js +30 -29
  8. package/dist/cli/commands/snapshot.js +26 -39
  9. package/dist/cli/core/ai-config.js +21 -4
  10. package/dist/cli/core/api-snapshot-analyzer.js +15 -5
  11. package/dist/cli/core/browser.js +207 -37
  12. package/dist/cli/core/context.js +4 -1
  13. package/dist/cli/core/session-telemetry.js +434 -174
  14. package/dist/cli/core/session.js +21 -8
  15. package/dist/cli/core/snapshot-analyzer.js +14 -31
  16. package/dist/cli/core/snapshot-api-config.js +2 -6
  17. package/dist/cli/core/telemetry.js +20 -4
  18. package/dist/cli/framework/simple-cli.js +45 -25
  19. package/dist/cli/router.js +14 -21
  20. package/dist/cli/workers/run-integration-runtime.js +24 -5
  21. package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
  22. package/dist/cli/workers/run-integration-worker.js +1 -4
  23. package/dist/index.d.ts +1 -2
  24. package/dist/index.js +7 -10
  25. package/dist/runtime/download/download.js +5 -1
  26. package/dist/runtime/extract/extract.js +11 -2
  27. package/dist/runtime/network/network.js +8 -1
  28. package/dist/runtime/recovery/agent.js +6 -2
  29. package/dist/runtime/recovery/errors.js +3 -1
  30. package/dist/runtime/recovery/recovery.js +3 -1
  31. package/dist/shared/condense-dom/condense-dom.js +17 -69
  32. package/dist/shared/config/config.d.ts +1 -9
  33. package/dist/shared/config/config.js +0 -18
  34. package/dist/shared/config/index.d.ts +2 -1
  35. package/dist/shared/config/index.js +0 -10
  36. package/dist/shared/debug/pause.js +9 -3
  37. package/dist/shared/dom-semantics.d.ts +8 -0
  38. package/dist/shared/dom-semantics.js +69 -0
  39. package/dist/shared/instrumentation/instrument.js +101 -5
  40. package/dist/shared/llm/ai-sdk-adapter.js +3 -1
  41. package/dist/shared/llm/client.js +3 -1
  42. package/dist/shared/logger/index.js +4 -1
  43. package/dist/shared/run/api.js +3 -1
  44. package/dist/shared/run/browser.js +47 -3
  45. package/dist/shared/state/session-state.d.ts +2 -1
  46. package/dist/shared/state/session-state.js +5 -2
  47. package/dist/shared/visualization/ghost-cursor.js +36 -14
  48. package/dist/shared/visualization/highlight.js +9 -6
  49. package/dist/shared/workflow/workflow.d.ts +4 -5
  50. package/dist/shared/workflow/workflow.js +3 -5
  51. package/package.json +6 -2
  52. package/scripts/check-skills-sync.mjs +25 -0
  53. package/scripts/compare-eval-summary.mjs +47 -0
  54. package/scripts/postinstall.mjs +15 -15
  55. package/scripts/prepare-release.sh +97 -0
  56. package/scripts/skills-libretto.mjs +103 -0
  57. package/scripts/summarize-evals.mjs +135 -0
  58. package/scripts/sync-skills.mjs +12 -0
  59. package/skills/libretto/SKILL.md +132 -54
  60. package/skills/libretto/references/action-logs.md +101 -0
  61. package/skills/libretto/references/auth-profiles.md +1 -2
  62. package/skills/libretto/references/code-generation-rules.md +210 -0
  63. package/skills/libretto/references/configuration-file-reference.md +53 -0
  64. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  65. package/skills/libretto/references/site-security-review.md +143 -0
  66. package/src/cli/cli.ts +23 -110
  67. package/src/cli/commands/browser.ts +94 -70
  68. package/src/cli/commands/execution.ts +233 -102
  69. package/src/cli/commands/init.ts +37 -33
  70. package/src/cli/commands/logs.ts +7 -7
  71. package/src/cli/commands/shared.ts +36 -37
  72. package/src/cli/commands/snapshot.ts +44 -59
  73. package/src/cli/core/ai-config.ts +24 -4
  74. package/src/cli/core/api-snapshot-analyzer.ts +17 -6
  75. package/src/cli/core/browser.ts +260 -49
  76. package/src/cli/core/context.ts +7 -2
  77. package/src/cli/core/session-telemetry.ts +449 -197
  78. package/src/cli/core/session.ts +21 -7
  79. package/src/cli/core/snapshot-analyzer.ts +26 -46
  80. package/src/cli/core/snapshot-api-config.ts +170 -175
  81. package/src/cli/core/telemetry.ts +39 -4
  82. package/src/cli/framework/simple-cli.ts +144 -77
  83. package/src/cli/router.ts +13 -21
  84. package/src/cli/workers/run-integration-runtime.ts +36 -9
  85. package/src/cli/workers/run-integration-worker-protocol.ts +2 -0
  86. package/src/cli/workers/run-integration-worker.ts +1 -4
  87. package/src/index.ts +73 -66
  88. package/src/runtime/download/download.ts +62 -58
  89. package/src/runtime/download/index.ts +5 -5
  90. package/src/runtime/extract/extract.ts +71 -61
  91. package/src/runtime/network/index.ts +3 -3
  92. package/src/runtime/network/network.ts +99 -93
  93. package/src/runtime/recovery/agent.ts +217 -212
  94. package/src/runtime/recovery/errors.ts +107 -104
  95. package/src/runtime/recovery/index.ts +3 -3
  96. package/src/runtime/recovery/recovery.ts +38 -35
  97. package/src/shared/condense-dom/condense-dom.ts +27 -82
  98. package/src/shared/config/config.ts +0 -19
  99. package/src/shared/config/index.ts +0 -5
  100. package/src/shared/debug/pause.ts +57 -51
  101. package/src/shared/dom-semantics.ts +68 -0
  102. package/src/shared/instrumentation/errors.ts +64 -62
  103. package/src/shared/instrumentation/index.ts +5 -5
  104. package/src/shared/instrumentation/instrument.ts +339 -209
  105. package/src/shared/llm/ai-sdk-adapter.ts +58 -55
  106. package/src/shared/llm/client.ts +181 -174
  107. package/src/shared/llm/types.ts +39 -39
  108. package/src/shared/logger/index.ts +11 -4
  109. package/src/shared/logger/logger.ts +312 -306
  110. package/src/shared/logger/sinks.ts +118 -114
  111. package/src/shared/paths/paths.ts +50 -49
  112. package/src/shared/paths/repo-root.ts +17 -17
  113. package/src/shared/run/api.ts +5 -1
  114. package/src/shared/run/browser.ts +65 -3
  115. package/src/shared/state/index.ts +9 -9
  116. package/src/shared/state/session-state.ts +46 -43
  117. package/src/shared/visualization/ghost-cursor.ts +180 -149
  118. package/src/shared/visualization/highlight.ts +89 -86
  119. package/src/shared/visualization/index.ts +13 -13
  120. package/src/shared/workflow/workflow.ts +19 -25
  121. package/skills/libretto/references/reverse-engineering-network-requests.md +0 -39
  122. package/skills/libretto/references/user-action-log.md +0 -31
@@ -23,9 +23,17 @@ import {
23
23
 
24
24
  const SESSION_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
25
25
 
26
- export const SESSION_DEFAULT = "default";
27
26
  export const SESSION_DEV_SERVER = "dev-server";
28
27
  export const SESSION_BROWSER_AGENT = "browser-agent";
28
+
29
+ export function generateSessionName(): string {
30
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
31
+ let id = "";
32
+ for (let i = 0; i < 4; i++) {
33
+ id += chars[Math.floor(Math.random() * chars.length)];
34
+ }
35
+ return `ses-${id}`;
36
+ }
29
37
  export { SESSION_STATE_VERSION };
30
38
  export type { SessionStatus, SessionState };
31
39
 
@@ -136,7 +144,10 @@ export function readSessionStateOrThrow(session: string): SessionState {
136
144
  }
137
145
 
138
146
  try {
139
- return parseSessionStateContent(readFileSync(stateFile, "utf-8"), stateFile);
147
+ return parseSessionStateContent(
148
+ readFileSync(stateFile, "utf-8"),
149
+ stateFile,
150
+ );
140
151
  } catch (err) {
141
152
  throw new Error(
142
153
  `Could not read session state for "${session}": ${err instanceof Error ? err.message : String(err)}`,
@@ -186,10 +197,13 @@ export function setSessionStatus(
186
197
  const state = readSessionState(session, logger);
187
198
  if (!state) return;
188
199
  if (state.status === status) return;
189
- writeSessionState({
190
- ...state,
191
- status,
192
- }, logger);
200
+ writeSessionState(
201
+ {
202
+ ...state,
203
+ status,
204
+ },
205
+ logger,
206
+ );
193
207
  }
194
208
 
195
209
  export function assertSessionAvailableForStart(
@@ -198,7 +212,7 @@ export function assertSessionAvailableForStart(
198
212
  ): void {
199
213
  const existingState = readSessionState(session, logger);
200
214
  if (!existingState) return;
201
- if (!isPidRunning(existingState.pid)) {
215
+ if (existingState.pid == null || !isPidRunning(existingState.pid)) {
202
216
  setSessionStatus(session, "exited", logger);
203
217
  return;
204
218
  }
@@ -8,15 +8,10 @@
8
8
  * to the CLI-agent approach if needed.
9
9
  *
10
10
  * Shared types and utilities (InterpretResultSchema, buildInlinePromptSelection,
11
- * formatInterpretationOutput, etc.) are still actively used by the API analyzer.
11
+ * etc.) are still actively used by the API analyzer.
12
12
  */
13
13
 
14
- import {
15
- existsSync,
16
- mkdtempSync,
17
- readFileSync,
18
- rmSync,
19
- } from "node:fs";
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
20
15
  import { extname, isAbsolute, join, resolve } from "node:path";
21
16
  import { spawn } from "node:child_process";
22
17
  import { tmpdir } from "node:os";
@@ -147,7 +142,12 @@ abstract class UserCodingAgent {
147
142
  logger: LoggerApi,
148
143
  stdinText?: string,
149
144
  ): Promise<ExternalCommandResult> {
150
- const result = await runExternalCommand(this.command, args, logger, stdinText);
145
+ const result = await runExternalCommand(
146
+ this.command,
147
+ args,
148
+ logger,
149
+ stdinText,
150
+ );
151
151
  if (result.exitCode !== 0) {
152
152
  throw new Error(
153
153
  `Analyzer command failed (${[this.command, ...args].join(" ")}).\n${stripAnsi(result.stderr).trim() || stripAnsi(result.stdout).trim() || "No error output."}`,
@@ -586,17 +586,18 @@ function estimateTokensFromChars(chars: number): number {
586
586
  return Math.ceil(chars / 4);
587
587
  }
588
588
 
589
- function inferContextWindowTokens(
590
- model: string,
591
- ): { contextWindowTokens: number; source: string } {
589
+ function inferContextWindowTokens(model: string): {
590
+ contextWindowTokens: number;
591
+ source: string;
592
+ } {
592
593
  const normalized = model.trim().toLowerCase();
593
594
  if (normalized.includes("claude")) {
594
595
  return { contextWindowTokens: 200_000, source: "model:claude" };
595
596
  }
596
597
  if (
597
- normalized.includes("gpt-5")
598
- || normalized.includes("o3")
599
- || normalized.includes("o4")
598
+ normalized.includes("gpt-5") ||
599
+ normalized.includes("o3") ||
600
+ normalized.includes("o4")
600
601
  ) {
601
602
  return { contextWindowTokens: 200_000, source: "model:openai" };
602
603
  }
@@ -699,7 +700,9 @@ export function buildInlinePromptSelection(
699
700
  fullDomChars: fullHtmlContent.length,
700
701
  fullDomEstimatedTokens: estimateTokensFromChars(fullHtmlContent.length),
701
702
  condensedDomChars: condensedHtmlContent.length,
702
- condensedDomEstimatedTokens: estimateTokensFromChars(condensedHtmlContent.length),
703
+ condensedDomEstimatedTokens: estimateTokensFromChars(
704
+ condensedHtmlContent.length,
705
+ ),
703
706
  configuredModel: model,
704
707
  };
705
708
 
@@ -740,8 +743,7 @@ export function buildInlinePromptSelection(
740
743
  false,
741
744
  );
742
745
  if (fullCandidate.promptEstimatedTokens <= budget.promptBudgetTokens) {
743
- const selectionReason =
744
- `Full DOM fits within the estimated prompt budget (~${fullCandidate.promptEstimatedTokens.toLocaleString()} <= ${budget.promptBudgetTokens.toLocaleString()} tokens), so the analyzer receives the uncondensed page HTML.`;
746
+ 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.`;
745
747
  const prompt = buildInlineHtmlPrompt(args, {
746
748
  htmlContent: fullHtmlContent,
747
749
  domLabel: "full DOM",
@@ -784,7 +786,10 @@ export function buildInlinePromptSelection(
784
786
  2_000,
785
787
  budget.promptBudgetTokens - estimateTokensFromChars(basePrompt.length),
786
788
  );
787
- const truncatedHtml = truncateText(condensedHtmlContent, availableHtmlTokens * 4);
789
+ const truncatedHtml = truncateText(
790
+ condensedHtmlContent,
791
+ availableHtmlTokens * 4,
792
+ );
788
793
 
789
794
  return buildCandidate(
790
795
  "condensed",
@@ -794,31 +799,6 @@ export function buildInlinePromptSelection(
794
799
  );
795
800
  }
796
801
 
797
- export function formatInterpretationOutput(
798
- parsed: InterpretResult,
799
- header: string = "Interpretation:",
800
- ): string {
801
- const outputLines: string[] = [];
802
- outputLines.push(header);
803
- outputLines.push(`Answer: ${parsed.answer}`);
804
- outputLines.push("");
805
- if (parsed.selectors.length === 0) {
806
- outputLines.push("Selectors: none found.");
807
- } else {
808
- outputLines.push("Selectors:");
809
- parsed.selectors.forEach((selector, index) => {
810
- outputLines.push(` ${index + 1}. ${selector.label}`);
811
- outputLines.push(` selector: ${selector.selector}`);
812
- outputLines.push(` rationale: ${selector.rationale}`);
813
- });
814
- }
815
- if (parsed.notes && parsed.notes.trim()) {
816
- outputLines.push("");
817
- outputLines.push(`Notes: ${parsed.notes.trim()}`);
818
- }
819
- return outputLines.join("\n");
820
- }
821
-
822
802
  export async function runInterpret(
823
803
  args: InterpretArgs,
824
804
  logger: LoggerApi,
@@ -860,14 +840,14 @@ export async function runInterpret(
860
840
  // re-enabled, the caller must supply a valid provider/model-id string.
861
841
  throw new Error(
862
842
  "The CLI-agent snapshot analysis path is not active. " +
863
- "Update your config to the current format with `npx libretto ai configure <provider>`, " +
864
- "or set API credentials in .env for direct API analysis.",
843
+ "Update your config to the current format with `npx libretto ai configure <provider>`, " +
844
+ "or set API credentials in .env for direct API analysis.",
865
845
  );
866
846
 
867
847
  // Preserved for reference — to re-enable, remove the throw above and:
868
848
  // const selection = buildInlinePromptSelection(args, fullHtmlContent, condensedHtmlContent, model);
869
849
  // const parsed = await configuredAgent.analyzeSnapshot(selection.prompt, pngPath, logger);
870
- // console.log(formatInterpretationOutput(parsed));
850
+ // console.log(parsed.answer);
871
851
  }
872
852
 
873
853
  export function canAnalyzeSnapshots(): boolean {
@@ -1,191 +1,188 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import {
4
- type AiConfig,
5
- readAiConfig,
6
- } from "./ai-config.js";
3
+ import { type AiConfig, readAiConfig } from "./ai-config.js";
7
4
  import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
8
5
  import {
9
- hasProviderCredentials,
10
- parseModel,
11
- type Provider,
6
+ hasProviderCredentials,
7
+ parseModel,
8
+ type Provider,
12
9
  } from "../../shared/llm/client.js";
13
10
 
14
11
  const DEFAULT_SNAPSHOT_MODELS = {
15
- openai: "openai/gpt-5.4",
16
- anthropic: "anthropic/claude-sonnet-4-6",
17
- google: "google/gemini-3-flash-preview",
18
- vertex: "vertex/gemini-2.5-pro",
12
+ openai: "openai/gpt-5.4",
13
+ anthropic: "anthropic/claude-sonnet-4-6",
14
+ google: "google/gemini-3-flash-preview",
15
+ vertex: "vertex/gemini-2.5-pro",
19
16
  } as const satisfies Record<Provider, string>;
20
17
 
21
18
  export type SnapshotApiModelSelection = {
22
- model: string;
23
- provider: Provider;
24
- source:
25
- | "config"
26
- | "env:auto-openai"
27
- | "env:auto-anthropic"
28
- | "env:auto-google"
29
- | "env:auto-vertex";
19
+ model: string;
20
+ provider: Provider;
21
+ source:
22
+ | "config"
23
+ | "env:auto-openai"
24
+ | "env:auto-anthropic"
25
+ | "env:auto-google"
26
+ | "env:auto-vertex";
30
27
  };
31
28
 
32
29
  export class SnapshotApiUnavailableError extends Error {
33
- constructor(message: string) {
34
- super(message);
35
- this.name = "SnapshotApiUnavailableError";
36
- }
30
+ constructor(message: string) {
31
+ super(message);
32
+ this.name = "SnapshotApiUnavailableError";
33
+ }
37
34
  }
38
35
 
39
36
  function providerSetupSentence(provider: Provider): string {
40
- switch (provider) {
41
- case "openai":
42
- return "Add OPENAI_API_KEY to .env or as a shell environment variable.";
43
- case "anthropic":
44
- return "Add ANTHROPIC_API_KEY to .env or as a shell environment variable.";
45
- case "google":
46
- return "Add GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY to .env or as a shell environment variable.";
47
- case "vertex":
48
- return "Add GOOGLE_CLOUD_PROJECT or GCLOUD_PROJECT to .env or as a shell environment variable, and make sure application default credentials are configured.";
49
- }
37
+ switch (provider) {
38
+ case "openai":
39
+ return "Add OPENAI_API_KEY to .env or as a shell environment variable.";
40
+ case "anthropic":
41
+ return "Add ANTHROPIC_API_KEY to .env or as a shell environment variable.";
42
+ case "google":
43
+ return "Add GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY to .env or as a shell environment variable.";
44
+ case "vertex":
45
+ return "Add GOOGLE_CLOUD_PROJECT or GCLOUD_PROJECT to .env or as a shell environment variable, and make sure application default credentials are configured.";
46
+ }
50
47
  }
51
48
 
52
49
  function defaultModelCommandLine(): string {
53
- return "npx libretto ai configure openai | anthropic | gemini | vertex";
50
+ return "npx libretto ai configure openai | anthropic | gemini | vertex";
54
51
  }
55
52
 
56
53
  function providerMissingCredentialSummary(provider: Provider): string {
57
- switch (provider) {
58
- case "openai":
59
- return "OPENAI_API_KEY is missing";
60
- case "anthropic":
61
- return "ANTHROPIC_API_KEY is missing";
62
- case "google":
63
- return "GEMINI_API_KEY and GOOGLE_GENERATIVE_AI_API_KEY are missing";
64
- case "vertex":
65
- return "GOOGLE_CLOUD_PROJECT and GCLOUD_PROJECT are missing";
66
- }
54
+ switch (provider) {
55
+ case "openai":
56
+ return "OPENAI_API_KEY is missing";
57
+ case "anthropic":
58
+ return "ANTHROPIC_API_KEY is missing";
59
+ case "google":
60
+ return "GEMINI_API_KEY and GOOGLE_GENERATIVE_AI_API_KEY are missing";
61
+ case "vertex":
62
+ return "GOOGLE_CLOUD_PROJECT and GCLOUD_PROJECT are missing";
63
+ }
67
64
  }
68
65
 
69
66
  function noSnapshotApiConfiguredMessage(): string {
70
- return [
71
- "Failed to analyze snapshot because no snapshot analyzer is configured.",
72
- `Add OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT to .env or as a shell environment variable, or choose a default model with \`${defaultModelCommandLine()}\`.`,
73
- "For more info, run `npx libretto init`.",
74
- ].join(" ");
67
+ return [
68
+ "Failed to analyze snapshot because no snapshot analyzer is configured.",
69
+ `Add OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY, or GOOGLE_CLOUD_PROJECT to .env or as a shell environment variable, or choose a default model with \`${defaultModelCommandLine()}\`.`,
70
+ "For more info, run `npx libretto init`.",
71
+ ].join(" ");
75
72
  }
76
73
 
77
74
  function missingProviderSnapshotMessage(
78
- selection: SnapshotApiModelSelection,
75
+ selection: SnapshotApiModelSelection,
79
76
  ): string {
80
- const configuredSource =
81
- selection.source === "config"
82
- ? ` in ${LIBRETTO_CONFIG_PATH}`
83
- : " from process env or .env";
84
- return [
85
- `Failed to analyze snapshot because ${selection.provider} is configured${configuredSource}, but ${providerMissingCredentialSummary(selection.provider)}.`,
86
- providerSetupSentence(selection.provider),
87
- "For more info, run `npx libretto init`.",
88
- ].join(" ");
77
+ const configuredSource =
78
+ selection.source === "config"
79
+ ? ` in ${LIBRETTO_CONFIG_PATH}`
80
+ : " from process env or .env";
81
+ return [
82
+ `Failed to analyze snapshot because ${selection.provider} is configured${configuredSource}, but ${providerMissingCredentialSummary(selection.provider)}.`,
83
+ providerSetupSentence(selection.provider),
84
+ "For more info, run `npx libretto init`.",
85
+ ].join(" ");
89
86
  }
90
87
 
91
88
  function readWorktreeEnvPath(): string | null {
92
- const gitPath = join(REPO_ROOT, ".git");
93
- if (!existsSync(gitPath)) return null;
94
-
95
- try {
96
- const gitPointer = readFileSync(gitPath, "utf-8").trim();
97
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
98
- if (!match?.[1]) return null;
99
- const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
100
- const commonGitDir = resolve(worktreeGitDir, "..", "..");
101
- return join(dirname(commonGitDir), ".env");
102
- } catch {
103
- return null;
104
- }
89
+ const gitPath = join(REPO_ROOT, ".git");
90
+ if (!existsSync(gitPath)) return null;
91
+
92
+ try {
93
+ const gitPointer = readFileSync(gitPath, "utf-8").trim();
94
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
95
+ if (!match?.[1]) return null;
96
+ const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
97
+ const commonGitDir = resolve(worktreeGitDir, "..", "..");
98
+ return join(dirname(commonGitDir), ".env");
99
+ } catch {
100
+ return null;
101
+ }
105
102
  }
106
103
 
107
104
  export function loadSnapshotEnv(): void {
108
- if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
109
-
110
- const envPathCandidates = [
111
- join(REPO_ROOT, ".env"),
112
- readWorktreeEnvPath(),
113
- ].filter((value): value is string => Boolean(value));
114
-
115
- const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
116
- if (!envPath) return;
117
-
118
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
119
- const parsed = parseDotEnvAssignment(line);
120
- if (!parsed) continue;
121
- if (!(parsed.key in process.env)) {
122
- process.env[parsed.key] = parsed.value;
123
- }
124
- }
105
+ if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
106
+
107
+ const envPathCandidates = [
108
+ join(REPO_ROOT, ".env"),
109
+ readWorktreeEnvPath(),
110
+ ].filter((value): value is string => Boolean(value));
111
+
112
+ const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
113
+ if (!envPath) return;
114
+
115
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
116
+ const parsed = parseDotEnvAssignment(line);
117
+ if (!parsed) continue;
118
+ if (!(parsed.key in process.env)) {
119
+ process.env[parsed.key] = parsed.value;
120
+ }
121
+ }
125
122
  }
126
123
 
127
124
  export function parseDotEnvAssignment(
128
- line: string,
125
+ line: string,
129
126
  ): { key: string; value: string } | null {
130
- const trimmed = line.trim();
131
- if (!trimmed || trimmed.startsWith("#")) return null;
132
-
133
- const withoutExport = trimmed.startsWith("export ")
134
- ? trimmed.slice("export ".length).trimStart()
135
- : trimmed;
136
- const eqIdx = withoutExport.indexOf("=");
137
- if (eqIdx < 1) return null;
138
-
139
- const key = withoutExport.slice(0, eqIdx).trim();
140
- if (!key) return null;
141
-
142
- const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
143
- if (!rawValue) {
144
- return { key, value: "" };
145
- }
146
-
147
- if (rawValue.startsWith('"')) {
148
- const closeIdx = rawValue.indexOf('"', 1);
149
- if (closeIdx > 0) {
150
- return { key, value: rawValue.slice(1, closeIdx) };
151
- }
152
- return { key, value: rawValue.slice(1) };
153
- }
154
-
155
- if (rawValue.startsWith("'")) {
156
- const closeIdx = rawValue.indexOf("'", 1);
157
- if (closeIdx > 0) {
158
- return { key, value: rawValue.slice(1, closeIdx) };
159
- }
160
- return { key, value: rawValue.slice(1) };
161
- }
162
-
163
- const inlineCommentIndex = rawValue.search(/\s#/);
164
- const value =
165
- inlineCommentIndex >= 0
166
- ? rawValue.slice(0, inlineCommentIndex).trimEnd()
167
- : rawValue.trim();
168
- return { key, value };
127
+ const trimmed = line.trim();
128
+ if (!trimmed || trimmed.startsWith("#")) return null;
129
+
130
+ const withoutExport = trimmed.startsWith("export ")
131
+ ? trimmed.slice("export ".length).trimStart()
132
+ : trimmed;
133
+ const eqIdx = withoutExport.indexOf("=");
134
+ if (eqIdx < 1) return null;
135
+
136
+ const key = withoutExport.slice(0, eqIdx).trim();
137
+ if (!key) return null;
138
+
139
+ const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
140
+ if (!rawValue) {
141
+ return { key, value: "" };
142
+ }
143
+
144
+ if (rawValue.startsWith('"')) {
145
+ const closeIdx = rawValue.indexOf('"', 1);
146
+ if (closeIdx > 0) {
147
+ return { key, value: rawValue.slice(1, closeIdx) };
148
+ }
149
+ return { key, value: rawValue.slice(1) };
150
+ }
151
+
152
+ if (rawValue.startsWith("'")) {
153
+ const closeIdx = rawValue.indexOf("'", 1);
154
+ if (closeIdx > 0) {
155
+ return { key, value: rawValue.slice(1, closeIdx) };
156
+ }
157
+ return { key, value: rawValue.slice(1) };
158
+ }
159
+
160
+ const inlineCommentIndex = rawValue.search(/\s#/);
161
+ const value =
162
+ inlineCommentIndex >= 0
163
+ ? rawValue.slice(0, inlineCommentIndex).trimEnd()
164
+ : rawValue.trim();
165
+ return { key, value };
169
166
  }
170
167
 
171
168
  function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
172
- const providersInPriorityOrder: Provider[] = [
173
- "openai",
174
- "anthropic",
175
- "google",
176
- "vertex",
177
- ];
178
-
179
- for (const provider of providersInPriorityOrder) {
180
- if (!hasProviderCredentials(provider)) continue;
181
- return {
182
- model: DEFAULT_SNAPSHOT_MODELS[provider],
183
- provider,
184
- source: `env:auto-${provider}` as SnapshotApiModelSelection["source"],
185
- };
186
- }
187
-
188
- return null;
169
+ const providersInPriorityOrder: Provider[] = [
170
+ "openai",
171
+ "anthropic",
172
+ "google",
173
+ "vertex",
174
+ ];
175
+
176
+ for (const provider of providersInPriorityOrder) {
177
+ if (!hasProviderCredentials(provider)) continue;
178
+ return {
179
+ model: DEFAULT_SNAPSHOT_MODELS[provider],
180
+ provider,
181
+ source: `env:auto-${provider}` as SnapshotApiModelSelection["source"],
182
+ };
183
+ }
184
+
185
+ return null;
189
186
  }
190
187
 
191
188
  /**
@@ -196,41 +193,39 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
196
193
  * 2. Auto-detect from available API credentials in env
197
194
  */
198
195
  export function resolveSnapshotApiModel(
199
- config: AiConfig | null = readAiConfig(),
196
+ config: AiConfig | null = readAiConfig(),
200
197
  ): SnapshotApiModelSelection | null {
201
- loadSnapshotEnv();
202
-
203
- if (config?.model) {
204
- const { provider } = parseModel(config.model);
205
- return {
206
- model: config.model,
207
- provider,
208
- source: "config",
209
- };
210
- }
211
-
212
- return inferAutoSnapshotModel();
198
+ loadSnapshotEnv();
199
+
200
+ if (config?.model) {
201
+ const { provider } = parseModel(config.model);
202
+ return {
203
+ model: config.model,
204
+ provider,
205
+ source: "config",
206
+ };
207
+ }
208
+
209
+ return inferAutoSnapshotModel();
213
210
  }
214
211
 
215
212
  export function resolveSnapshotApiModelOrThrow(
216
- config: AiConfig | null = readAiConfig(),
213
+ config: AiConfig | null = readAiConfig(),
217
214
  ): SnapshotApiModelSelection {
218
- const selection = resolveSnapshotApiModel(config);
219
- if (!selection) {
220
- throw new SnapshotApiUnavailableError(
221
- noSnapshotApiConfiguredMessage(),
222
- );
223
- }
224
-
225
- if (!hasProviderCredentials(selection.provider)) {
226
- throw new SnapshotApiUnavailableError(
227
- missingProviderSnapshotMessage(selection),
228
- );
229
- }
230
-
231
- return selection;
215
+ const selection = resolveSnapshotApiModel(config);
216
+ if (!selection) {
217
+ throw new SnapshotApiUnavailableError(noSnapshotApiConfiguredMessage());
218
+ }
219
+
220
+ if (!hasProviderCredentials(selection.provider)) {
221
+ throw new SnapshotApiUnavailableError(
222
+ missingProviderSnapshotMessage(selection),
223
+ );
224
+ }
225
+
226
+ return selection;
232
227
  }
233
228
 
234
229
  export function isSnapshotApiUnavailableError(error: unknown): boolean {
235
- return error instanceof SnapshotApiUnavailableError;
230
+ return error instanceof SnapshotApiUnavailableError;
236
231
  }