libretto 0.6.6 → 0.6.7

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
@@ -5,6 +5,12 @@
5
5
  [![npm version](https://img.shields.io/npm/v/libretto)](https://www.npmjs.com/package/libretto)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
7
  [![GitHub Discussions](https://img.shields.io/github/discussions/saffron-health/libretto)](https://github.com/saffron-health/libretto/discussions)
8
+ [![Discord](https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/NYrG56hVDt)
9
+
10
+ - Website: [libretto.sh](https://libretto.sh)
11
+ - Repository: [github.com/saffron-health/libretto](https://github.com/saffron-health/libretto)
12
+ - Docs: [libretto.sh/docs](https://libretto.sh/docs)
13
+ - Discord: [discord.gg/NYrG56hVDt](https://discord.gg/NYrG56hVDt)
8
14
 
9
15
  Libretto is a toolkit for building robust web integrations. It gives your coding agent a live browser and a token-efficient CLI to:
10
16
 
@@ -117,7 +123,7 @@ To inspect the current configuration without changing anything:
117
123
  npx libretto status
118
124
  ```
119
125
 
120
- Provider credentials are read from environment variables or a `.env` file at your project root: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`, or `GOOGLE_CLOUD_PROJECT` for Vertex.
126
+ Provider credentials are read from environment variables or a `.env` file at your **repository root** (next to your `.git` directory): `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`, or `GOOGLE_CLOUD_PROJECT` for Vertex. Set `LIBRETTO_DISABLE_DOTENV=1` to skip `.env` loading.
121
127
 
122
128
  The `viewport` field sets the default browser viewport size. Both fields are optional.
123
129
 
@@ -137,7 +143,7 @@ Profiles save browser sessions (cookies, localStorage) so you can reuse authenti
137
143
 
138
144
  ## Community
139
145
 
140
- Have a question, idea, or want to share what you've built? Join the conversation on [GitHub Discussions](https://github.com/saffron-health/libretto/discussions).
146
+ Have a question, idea, or want to share what you've built? Join the conversation on [Discord](https://discord.gg/NYrG56hVDt) for quick help or [GitHub Discussions](https://github.com/saffron-health/libretto/discussions) for longer-form threads.
141
147
 
142
148
  - **[Q&A](https://github.com/saffron-health/libretto/discussions/categories/q-a)** — Ask questions and get help
143
149
  - **[Ideas](https://github.com/saffron-health/libretto/discussions/categories/ideas)** — Suggest new features or improvements
@@ -3,6 +3,12 @@
3
3
  [![npm version](https://img.shields.io/npm/v/libretto)](https://www.npmjs.com/package/libretto)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
5
  [![GitHub Discussions](https://img.shields.io/github/discussions/saffron-health/libretto)](https://github.com/saffron-health/libretto/discussions)
6
+ [![Discord](https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/NYrG56hVDt)
7
+
8
+ - Website: [libretto.sh](https://libretto.sh)
9
+ - Repository: [github.com/saffron-health/libretto](https://github.com/saffron-health/libretto)
10
+ - Docs: [libretto.sh/docs](https://libretto.sh/docs)
11
+ - Discord: [discord.gg/NYrG56hVDt](https://discord.gg/NYrG56hVDt)
6
12
 
7
13
  Libretto is a toolkit for building robust web integrations. It gives your coding agent a live browser and a token-efficient CLI to:
8
14
 
@@ -115,7 +121,7 @@ To inspect the current configuration without changing anything:
115
121
  npx libretto status
116
122
  ```
117
123
 
118
- Provider credentials are read from environment variables or a `.env` file at your project root: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`, or `GOOGLE_CLOUD_PROJECT` for Vertex.
124
+ Provider credentials are read from environment variables or a `.env` file at your **repository root** (next to your `.git` directory): `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`, or `GOOGLE_CLOUD_PROJECT` for Vertex. Set `LIBRETTO_DISABLE_DOTENV=1` to skip `.env` loading.
119
125
 
120
126
  The `viewport` field sets the default browser viewport size. Both fields are optional.
121
127
 
@@ -135,7 +141,7 @@ Profiles save browser sessions (cookies, localStorage) so you can reuse authenti
135
141
 
136
142
  ## Community
137
143
 
138
- Have a question, idea, or want to share what you've built? Join the conversation on [GitHub Discussions](https://github.com/saffron-health/libretto/discussions).
144
+ Have a question, idea, or want to share what you've built? Join the conversation on [Discord](https://discord.gg/NYrG56hVDt) for quick help or [GitHub Discussions](https://github.com/saffron-health/libretto/discussions) for longer-form threads.
139
145
 
140
146
  - **[Q&A](https://github.com/saffron-health/libretto/discussions/categories/q-a)** — Ask questions and get help
141
147
  - **[Ideas](https://github.com/saffron-health/libretto/discussions/categories/ideas)** — Suggest new features or improvements
@@ -330,7 +330,7 @@ function copySkills() {
330
330
  const agentDirs = detectAgentDirs(REPO_ROOT);
331
331
  if (agentDirs.length === 0) {
332
332
  console.log(
333
- "\n\u26A0 No .agents/ or .claude/ directory found. Libretto skills were not installed."
333
+ "\n\u26A0\uFE0F No .agents/ or .claude/ directory found. Libretto skills were not installed."
334
334
  );
335
335
  console.log(
336
336
  " Create one of these directories in your repo root and rerun `npx libretto setup` to install skills:"
@@ -1,11 +1,11 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
1
  import { readSnapshotModel } from "./config.js";
4
- import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
2
+ import { LIBRETTO_CONFIG_PATH } from "./context.js";
5
3
  import {
6
4
  hasProviderCredentials,
7
5
  parseModel
8
6
  } from "./resolve-model.js";
7
+ import { loadEnv } from "../../shared/env/load-env.js";
8
+ import { parseDotEnvAssignment } from "../../shared/env/load-env.js";
9
9
  const DEFAULT_SNAPSHOT_MODELS = {
10
10
  openai: "openai/gpt-5.4",
11
11
  anthropic: "anthropic/claude-sonnet-4-6",
@@ -77,66 +77,6 @@ function missingProviderSnapshotMessage(selection) {
77
77
  "For more info, run `npx libretto setup`."
78
78
  ].join(" ");
79
79
  }
80
- function readWorktreeEnvPath() {
81
- const gitPath = join(REPO_ROOT, ".git");
82
- if (!existsSync(gitPath)) return null;
83
- try {
84
- const gitPointer = readFileSync(gitPath, "utf-8").trim();
85
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
86
- if (!match?.[1]) return null;
87
- const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
88
- const commonGitDir = resolve(worktreeGitDir, "..", "..");
89
- return join(dirname(commonGitDir), ".env");
90
- } catch {
91
- return null;
92
- }
93
- }
94
- function loadSnapshotEnv() {
95
- if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
96
- const envPathCandidates = [
97
- join(REPO_ROOT, ".env"),
98
- readWorktreeEnvPath()
99
- ].filter((value) => Boolean(value));
100
- const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
101
- if (!envPath) return;
102
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
103
- const parsed = parseDotEnvAssignment(line);
104
- if (!parsed) continue;
105
- if (!(parsed.key in process.env)) {
106
- process.env[parsed.key] = parsed.value;
107
- }
108
- }
109
- }
110
- function parseDotEnvAssignment(line) {
111
- const trimmed = line.trim();
112
- if (!trimmed || trimmed.startsWith("#")) return null;
113
- const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
114
- const eqIdx = withoutExport.indexOf("=");
115
- if (eqIdx < 1) return null;
116
- const key = withoutExport.slice(0, eqIdx).trim();
117
- if (!key) return null;
118
- const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
119
- if (!rawValue) {
120
- return { key, value: "" };
121
- }
122
- if (rawValue.startsWith('"')) {
123
- const closeIdx = rawValue.indexOf('"', 1);
124
- if (closeIdx > 0) {
125
- return { key, value: rawValue.slice(1, closeIdx) };
126
- }
127
- return { key, value: rawValue.slice(1) };
128
- }
129
- if (rawValue.startsWith("'")) {
130
- const closeIdx = rawValue.indexOf("'", 1);
131
- if (closeIdx > 0) {
132
- return { key, value: rawValue.slice(1, closeIdx) };
133
- }
134
- return { key, value: rawValue.slice(1) };
135
- }
136
- const inlineCommentIndex = rawValue.search(/\s#/);
137
- const value = inlineCommentIndex >= 0 ? rawValue.slice(0, inlineCommentIndex).trimEnd() : rawValue.trim();
138
- return { key, value };
139
- }
140
80
  function inferAutoSnapshotModel() {
141
81
  const providersInPriorityOrder = [
142
82
  "openai",
@@ -156,7 +96,7 @@ function inferAutoSnapshotModel() {
156
96
  return null;
157
97
  }
158
98
  function resolveSnapshotApiModel(snapshotModel = readSnapshotModel()) {
159
- loadSnapshotEnv();
99
+ loadEnv();
160
100
  if (snapshotModel) {
161
101
  const { provider } = parseModel(snapshotModel);
162
102
  return {
@@ -193,7 +133,7 @@ function readSnapshotModelSafely(configPath) {
193
133
  }
194
134
  }
195
135
  function resolveAiSetupStatus(configPath = LIBRETTO_CONFIG_PATH) {
196
- loadSnapshotEnv();
136
+ loadEnv();
197
137
  const result = readSnapshotModelSafely(configPath);
198
138
  if (!result.ok) {
199
139
  return { kind: "invalid-config", message: result.message };
@@ -240,7 +180,6 @@ export {
240
180
  DEFAULT_SNAPSHOT_MODELS,
241
181
  SnapshotApiUnavailableError,
242
182
  isSnapshotApiUnavailableError,
243
- loadSnapshotEnv,
244
183
  parseDotEnvAssignment,
245
184
  resolveAiSetupStatus,
246
185
  resolveSnapshotApiModel,
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
3
3
  import { cwd } from "node:process";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
- import { loadProjectEnv } from "../../shared/env/load-env.js";
6
+ import { loadEnv } from "../../shared/env/load-env.js";
7
7
  import {
8
8
  getDefaultWorkflowFromModuleExports,
9
9
  getWorkflowsFromModuleExports,
@@ -127,7 +127,7 @@ async function installHeadedWorkflowVisualization(args) {
127
127
  async function runIntegrationInternal(args, options) {
128
128
  const { logger } = options;
129
129
  const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
130
- const envPath = loadProjectEnv(absolutePath);
130
+ const envPath = loadEnv();
131
131
  if (envPath) {
132
132
  logger.info("loaded-env", { path: envPath });
133
133
  }
@@ -1,8 +1,13 @@
1
+ declare function parseDotEnvAssignment(line: string): {
2
+ key: string;
3
+ value: string;
4
+ } | null;
1
5
  /**
2
- * Load the nearest `.env` file above `scriptPath`.
3
- * Existing `process.env` values are never overridden.
6
+ * Load the `.env` file at the repository root into `process.env`.
7
+ * Existing values are never overridden.
8
+ * Respects `LIBRETTO_DISABLE_DOTENV=1` to skip loading entirely.
4
9
  * Returns the path of the loaded `.env`, or `null` if none was found.
5
10
  */
6
- declare function loadProjectEnv(scriptPath: string): string | null;
11
+ declare function loadEnv(): string | null;
7
12
 
8
- export { loadProjectEnv };
13
+ export { loadEnv, parseDotEnvAssignment };
@@ -1,42 +1,69 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- function findNearestEnv(startDir) {
4
- let dir = startDir;
5
- while (true) {
6
- const envPath = join(dir, ".env");
7
- if (existsSync(envPath)) return envPath;
8
- const parent = dirname(dir);
9
- if (parent === dir) return null;
10
- dir = parent;
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { resolveLibrettoRepoRoot } from "../paths/repo-root.js";
4
+ const REPO_ROOT = resolveLibrettoRepoRoot();
5
+ function parseDotEnvAssignment(line) {
6
+ const trimmed = line.trim();
7
+ if (!trimmed || trimmed.startsWith("#")) return null;
8
+ const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
9
+ const eqIdx = withoutExport.indexOf("=");
10
+ if (eqIdx < 1) return null;
11
+ const key = withoutExport.slice(0, eqIdx).trim();
12
+ if (!key) return null;
13
+ const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
14
+ if (!rawValue) {
15
+ return { key, value: "" };
11
16
  }
12
- }
13
- function parseEnvFile(content) {
14
- const vars = {};
15
- for (const raw of content.split("\n")) {
16
- const line = raw.trim();
17
- if (!line || line.startsWith("#")) continue;
18
- const eqIndex = line.indexOf("=");
19
- if (eqIndex === -1) continue;
20
- const key = line.slice(0, eqIndex).trim();
21
- let value = line.slice(eqIndex + 1).trim();
22
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
23
- value = value.slice(1, -1);
17
+ if (rawValue.startsWith('"')) {
18
+ const closeIdx = rawValue.indexOf('"', 1);
19
+ if (closeIdx > 0) {
20
+ return { key, value: rawValue.slice(1, closeIdx) };
21
+ }
22
+ return { key, value: rawValue.slice(1) };
23
+ }
24
+ if (rawValue.startsWith("'")) {
25
+ const closeIdx = rawValue.indexOf("'", 1);
26
+ if (closeIdx > 0) {
27
+ return { key, value: rawValue.slice(1, closeIdx) };
24
28
  }
25
- vars[key] = value;
29
+ return { key, value: rawValue.slice(1) };
30
+ }
31
+ const inlineCommentIndex = rawValue.search(/\s#/);
32
+ const value = inlineCommentIndex >= 0 ? rawValue.slice(0, inlineCommentIndex).trimEnd() : rawValue.trim();
33
+ return { key, value };
34
+ }
35
+ function readWorktreeEnvPath() {
36
+ const gitPath = join(REPO_ROOT, ".git");
37
+ if (!existsSync(gitPath)) return null;
38
+ try {
39
+ const gitPointer = readFileSync(gitPath, "utf-8").trim();
40
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
41
+ if (!match?.[1]) return null;
42
+ const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
43
+ const commonGitDir = resolve(worktreeGitDir, "..", "..");
44
+ return join(dirname(commonGitDir), ".env");
45
+ } catch {
46
+ return null;
26
47
  }
27
- return vars;
28
48
  }
29
- function loadProjectEnv(scriptPath) {
30
- const envPath = findNearestEnv(dirname(scriptPath));
49
+ function loadEnv() {
50
+ if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return null;
51
+ const envPathCandidates = [
52
+ join(REPO_ROOT, ".env"),
53
+ readWorktreeEnvPath()
54
+ ].filter((value) => Boolean(value));
55
+ const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
31
56
  if (!envPath) return null;
32
- const vars = parseEnvFile(readFileSync(envPath, "utf8"));
33
- for (const [key, value] of Object.entries(vars)) {
34
- if (process.env[key] === void 0) {
35
- process.env[key] = value;
57
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
58
+ const parsed = parseDotEnvAssignment(line);
59
+ if (!parsed) continue;
60
+ if (!(parsed.key in process.env)) {
61
+ process.env[parsed.key] = parsed.value;
36
62
  }
37
63
  }
38
64
  return envPath;
39
65
  }
40
66
  export {
41
- loadProjectEnv
67
+ loadEnv,
68
+ parseDotEnvAssignment
42
69
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "homepage": "https://libretto.sh",
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { Agent, type AgentTool, type AgentEvent } from "@mariozechner/pi-agent-core";
3
3
  import { getModel } from "@mariozechner/pi-ai";
4
- import { Type } from "@sinclair/typebox";
4
+ import { Type, type Static } from "@sinclair/typebox";
5
5
 
6
6
  const tag = process.argv[2];
7
7
  if (!tag) {
@@ -17,6 +17,11 @@ if (!process.env.ANTHROPIC_API_KEY) {
17
17
 
18
18
  const ALLOWED_GH_SUBCOMMANDS = new Set(["pr", "release", "repo", "issue"]);
19
19
  const ALLOWED_ACTIONS = new Set(["list", "view", "diff", "status", "checks"]);
20
+ const GhToolParamsSchema = Type.Object({
21
+ args: Type.String({ description: "Arguments to pass to gh (without the leading 'gh')" }),
22
+ });
23
+
24
+ type GhToolParams = Static<typeof GhToolParamsSchema>;
20
25
 
21
26
  const ghTool: AgentTool = {
22
27
  name: "gh",
@@ -27,11 +32,9 @@ const ghTool: AgentTool = {
27
32
  "'pr view 128 --json title,body,files', 'pr diff 128'.",
28
33
  "Only read operations are allowed (list, view, diff, etc.). Mutating commands will be rejected.",
29
34
  ].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
+ parameters: GhToolParamsSchema,
36
+ execute: async (_toolCallId: string, rawParams: unknown) => {
37
+ const params = rawParams as GhToolParams;
35
38
  const args = params.args.trim();
36
39
  const parts = args.split(/\s+/);
37
40
  const subcommand = parts[0];
@@ -4,7 +4,7 @@ description: "Browser automation CLI for building, maintaining, and running brow
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.4"
7
+ version: "0.6.7"
8
8
  ---
9
9
 
10
10
  ## How Libretto Works
@@ -13,7 +13,7 @@ Use this reference when you need to inspect or change the workspace configuratio
13
13
  Libretto reads workspace config from `.libretto/config.json`.
14
14
 
15
15
  - The file is created by `npx libretto setup` during first-time onboarding (auto-pins the default model for the detected provider) or by `npx libretto ai configure ...` for explicit overrides.
16
- - API credentials still come from your shell environment or `.env`. The config file stores the selected model, not the secret itself.
16
+ - API credentials come from your shell environment or a `.env` file **at the repository root** (next to your `.git` directory). The config file stores the selected model, not the secret itself. Set `LIBRETTO_DISABLE_DOTENV=1` to skip `.env` loading (useful in CI).
17
17
  - Use `npx libretto status` to inspect the current AI configuration and open sessions without changing anything.
18
18
  - For first-time setup instructions, follow the main `SKILL.md` flow instead of expanding this reference.
19
19
 
@@ -4,7 +4,7 @@ description: "Read-only Libretto workflow for diagnosing live browser state with
4
4
  license: MIT
5
5
  metadata:
6
6
  author: saffron-health
7
- version: "0.6.4"
7
+ version: "0.6.7"
8
8
  ---
9
9
 
10
10
  ## How Libretto Read-Only Works
@@ -431,7 +431,7 @@ function copySkills(): void {
431
431
 
432
432
  if (agentDirs.length === 0) {
433
433
  console.log(
434
- "\n No .agents/ or .claude/ directory found. Libretto skills were not installed.",
434
+ "\n⚠️ No .agents/ or .claude/ directory found. Libretto skills were not installed.",
435
435
  );
436
436
  console.log(
437
437
  " Create one of these directories in your repo root and rerun `npx libretto setup` to install skills:",
@@ -1,12 +1,14 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
1
  import { readSnapshotModel } from "./config.js";
4
- import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
2
+ import { LIBRETTO_CONFIG_PATH } from "./context.js";
5
3
  import {
6
4
  hasProviderCredentials,
7
5
  parseModel,
8
6
  type Provider,
9
7
  } from "./resolve-model.js";
8
+ import { loadEnv } from "../../shared/env/load-env.js";
9
+
10
+ // Re-export so existing consumers (e.g. tests) don't break.
11
+ export { parseDotEnvAssignment } from "../../shared/env/load-env.js";
10
12
 
11
13
  // ── Default models ──────────────────────────────────────────────────────────
12
14
 
@@ -112,88 +114,6 @@ function missingProviderSnapshotMessage(
112
114
  ].join(" ");
113
115
  }
114
116
 
115
- // ── Dotenv loading ──────────────────────────────────────────────────────────
116
-
117
- function readWorktreeEnvPath(): string | null {
118
- const gitPath = join(REPO_ROOT, ".git");
119
- if (!existsSync(gitPath)) return null;
120
-
121
- try {
122
- const gitPointer = readFileSync(gitPath, "utf-8").trim();
123
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
124
- if (!match?.[1]) return null;
125
- const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
126
- const commonGitDir = resolve(worktreeGitDir, "..", "..");
127
- return join(dirname(commonGitDir), ".env");
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- export function loadSnapshotEnv(): void {
134
- if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return;
135
-
136
- const envPathCandidates = [
137
- join(REPO_ROOT, ".env"),
138
- readWorktreeEnvPath(),
139
- ].filter((value): value is string => Boolean(value));
140
-
141
- const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
142
- if (!envPath) return;
143
-
144
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
145
- const parsed = parseDotEnvAssignment(line);
146
- if (!parsed) continue;
147
- if (!(parsed.key in process.env)) {
148
- process.env[parsed.key] = parsed.value;
149
- }
150
- }
151
- }
152
-
153
- export function parseDotEnvAssignment(
154
- line: string,
155
- ): { key: string; value: string } | null {
156
- const trimmed = line.trim();
157
- if (!trimmed || trimmed.startsWith("#")) return null;
158
-
159
- const withoutExport = trimmed.startsWith("export ")
160
- ? trimmed.slice("export ".length).trimStart()
161
- : trimmed;
162
- const eqIdx = withoutExport.indexOf("=");
163
- if (eqIdx < 1) return null;
164
-
165
- const key = withoutExport.slice(0, eqIdx).trim();
166
- if (!key) return null;
167
-
168
- const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
169
- if (!rawValue) {
170
- return { key, value: "" };
171
- }
172
-
173
- if (rawValue.startsWith('"')) {
174
- const closeIdx = rawValue.indexOf('"', 1);
175
- if (closeIdx > 0) {
176
- return { key, value: rawValue.slice(1, closeIdx) };
177
- }
178
- return { key, value: rawValue.slice(1) };
179
- }
180
-
181
- if (rawValue.startsWith("'")) {
182
- const closeIdx = rawValue.indexOf("'", 1);
183
- if (closeIdx > 0) {
184
- return { key, value: rawValue.slice(1, closeIdx) };
185
- }
186
- return { key, value: rawValue.slice(1) };
187
- }
188
-
189
- const inlineCommentIndex = rawValue.search(/\s#/);
190
- const value =
191
- inlineCommentIndex >= 0
192
- ? rawValue.slice(0, inlineCommentIndex).trimEnd()
193
- : rawValue.trim();
194
- return { key, value };
195
- }
196
-
197
117
  // ── Model resolution ────────────────────────────────────────────────────────
198
118
 
199
119
  function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
@@ -227,7 +147,7 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
227
147
  export function resolveSnapshotApiModel(
228
148
  snapshotModel: string | null = readSnapshotModel(),
229
149
  ): SnapshotApiModelSelection | null {
230
- loadSnapshotEnv();
150
+ loadEnv();
231
151
 
232
152
  if (snapshotModel) {
233
153
  const { provider } = parseModel(snapshotModel);
@@ -318,7 +238,7 @@ function readSnapshotModelSafely(
318
238
  export function resolveAiSetupStatus(
319
239
  configPath: string = LIBRETTO_CONFIG_PATH,
320
240
  ): AiSetupStatus {
321
- loadSnapshotEnv();
241
+ loadEnv();
322
242
 
323
243
  const result = readSnapshotModelSafely(configPath);
324
244
 
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { cwd } from "node:process";
5
5
  import { isAbsolute, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { loadProjectEnv } from "../../shared/env/load-env.js";
7
+ import { loadEnv } from "../../shared/env/load-env.js";
8
8
  import {
9
9
  getDefaultWorkflowFromModuleExports,
10
10
  getWorkflowsFromModuleExports,
@@ -196,7 +196,7 @@ async function runIntegrationInternal(
196
196
  const { logger } = options;
197
197
  const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
198
198
 
199
- const envPath = loadProjectEnv(absolutePath);
199
+ const envPath = loadEnv();
200
200
  if (envPath) {
201
201
  logger.info("loaded-env", { path: envPath });
202
202
  }
@@ -1,60 +1,91 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { resolveLibrettoRepoRoot } from "../paths/repo-root.js";
3
4
 
4
- /**
5
- * Walk up from `startDir` until a `.env` file is found.
6
- * Returns the full path to the `.env`, or `null` if the filesystem root is reached.
7
- */
8
- function findNearestEnv(startDir: string): string | null {
9
- let dir = startDir;
10
- while (true) {
11
- const envPath = join(dir, ".env");
12
- if (existsSync(envPath)) return envPath;
13
- const parent = dirname(dir);
14
- if (parent === dir) return null; // filesystem root
15
- dir = parent;
5
+ const REPO_ROOT = resolveLibrettoRepoRoot();
6
+
7
+ export function parseDotEnvAssignment(
8
+ line: string,
9
+ ): { key: string; value: string } | null {
10
+ const trimmed = line.trim();
11
+ if (!trimmed || trimmed.startsWith("#")) return null;
12
+
13
+ const withoutExport = trimmed.startsWith("export ")
14
+ ? trimmed.slice("export ".length).trimStart()
15
+ : trimmed;
16
+ const eqIdx = withoutExport.indexOf("=");
17
+ if (eqIdx < 1) return null;
18
+
19
+ const key = withoutExport.slice(0, eqIdx).trim();
20
+ if (!key) return null;
21
+
22
+ const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
23
+ if (!rawValue) {
24
+ return { key, value: "" };
16
25
  }
17
- }
18
26
 
19
- /**
20
- * Parse a `.env` file into key-value pairs.
21
- * Handles KEY=VALUE, KEY="VALUE", KEY='VALUE', comments (#), and blank lines.
22
- * Does not support multiline values or variable interpolation.
23
- */
24
- function parseEnvFile(content: string): Record<string, string> {
25
- const vars: Record<string, string> = {};
26
- for (const raw of content.split("\n")) {
27
- const line = raw.trim();
28
- if (!line || line.startsWith("#")) continue;
29
- const eqIndex = line.indexOf("=");
30
- if (eqIndex === -1) continue;
31
- const key = line.slice(0, eqIndex).trim();
32
- let value = line.slice(eqIndex + 1).trim();
33
- // Strip matching quotes
34
- if (
35
- (value.startsWith('"') && value.endsWith('"')) ||
36
- (value.startsWith("'") && value.endsWith("'"))
37
- ) {
38
- value = value.slice(1, -1);
27
+ if (rawValue.startsWith('"')) {
28
+ const closeIdx = rawValue.indexOf('"', 1);
29
+ if (closeIdx > 0) {
30
+ return { key, value: rawValue.slice(1, closeIdx) };
39
31
  }
40
- vars[key] = value;
32
+ return { key, value: rawValue.slice(1) };
33
+ }
34
+
35
+ if (rawValue.startsWith("'")) {
36
+ const closeIdx = rawValue.indexOf("'", 1);
37
+ if (closeIdx > 0) {
38
+ return { key, value: rawValue.slice(1, closeIdx) };
39
+ }
40
+ return { key, value: rawValue.slice(1) };
41
+ }
42
+
43
+ const inlineCommentIndex = rawValue.search(/\s#/);
44
+ const value =
45
+ inlineCommentIndex >= 0
46
+ ? rawValue.slice(0, inlineCommentIndex).trimEnd()
47
+ : rawValue.trim();
48
+ return { key, value };
49
+ }
50
+
51
+ function readWorktreeEnvPath(): string | null {
52
+ const gitPath = join(REPO_ROOT, ".git");
53
+ if (!existsSync(gitPath)) return null;
54
+
55
+ try {
56
+ const gitPointer = readFileSync(gitPath, "utf-8").trim();
57
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
58
+ if (!match?.[1]) return null;
59
+ const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
60
+ const commonGitDir = resolve(worktreeGitDir, "..", "..");
61
+ return join(dirname(commonGitDir), ".env");
62
+ } catch {
63
+ return null;
41
64
  }
42
- return vars;
43
65
  }
44
66
 
45
67
  /**
46
- * Load the nearest `.env` file above `scriptPath`.
47
- * Existing `process.env` values are never overridden.
68
+ * Load the `.env` file at the repository root into `process.env`.
69
+ * Existing values are never overridden.
70
+ * Respects `LIBRETTO_DISABLE_DOTENV=1` to skip loading entirely.
48
71
  * Returns the path of the loaded `.env`, or `null` if none was found.
49
72
  */
50
- export function loadProjectEnv(scriptPath: string): string | null {
51
- const envPath = findNearestEnv(dirname(scriptPath));
73
+ export function loadEnv(): string | null {
74
+ if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return null;
75
+
76
+ const envPathCandidates = [
77
+ join(REPO_ROOT, ".env"),
78
+ readWorktreeEnvPath(),
79
+ ].filter((value): value is string => Boolean(value));
80
+
81
+ const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
52
82
  if (!envPath) return null;
53
83
 
54
- const vars = parseEnvFile(readFileSync(envPath, "utf8"));
55
- for (const [key, value] of Object.entries(vars)) {
56
- if (process.env[key] === undefined) {
57
- process.env[key] = value;
84
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
85
+ const parsed = parseDotEnvAssignment(line);
86
+ if (!parsed) continue;
87
+ if (!(parsed.key in process.env)) {
88
+ process.env[parsed.key] = parsed.value;
58
89
  }
59
90
  }
60
91
  return envPath;