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 +8 -2
- package/README.template.md +8 -2
- package/dist/cli/commands/setup.js +1 -1
- package/dist/cli/core/ai-model.js +5 -66
- package/dist/cli/workers/run-integration-runtime.js +2 -2
- package/dist/shared/env/load-env.d.ts +9 -4
- package/dist/shared/env/load-env.js +57 -30
- package/package.json +1 -1
- package/scripts/generate-changelog.ts +9 -6
- package/skills/libretto/SKILL.md +1 -1
- package/skills/libretto/references/configuration-file-reference.md +1 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/setup.ts +1 -1
- package/src/cli/core/ai-model.ts +7 -87
- package/src/cli/workers/run-integration-runtime.ts +2 -2
- package/src/shared/env/load-env.ts +75 -44
package/README.md
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/libretto)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://github.com/saffron-health/libretto/discussions)
|
|
8
|
+
[](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
|
|
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
|
package/README.template.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/libretto)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/saffron-health/libretto/discussions)
|
|
6
|
+
[](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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
3
|
-
* Existing
|
|
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
|
|
11
|
+
declare function loadEnv(): string | null;
|
|
7
12
|
|
|
8
|
-
export {
|
|
13
|
+
export { loadEnv, parseDotEnvAssignment };
|
|
@@ -1,42 +1,69 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
-
|
|
67
|
+
loadEnv,
|
|
68
|
+
parseDotEnvAssignment
|
|
42
69
|
};
|
package/package.json
CHANGED
|
@@ -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:
|
|
31
|
-
|
|
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];
|
package/skills/libretto/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -431,7 +431,7 @@ function copySkills(): void {
|
|
|
431
431
|
|
|
432
432
|
if (agentDirs.length === 0) {
|
|
433
433
|
console.log(
|
|
434
|
-
"\n
|
|
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:",
|
package/src/cli/core/ai-model.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
* Existing
|
|
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
|
|
51
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
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;
|