libretto 0.6.7-experimental.0 → 0.6.8
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 +27 -69
- package/README.template.md +27 -69
- package/dist/cli/commands/setup.js +12 -4
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/core/ai-model.js +17 -70
- package/dist/cli/core/browser-daemon.js +122 -0
- package/dist/cli/core/browser.js +31 -176
- package/dist/cli/core/config.js +1 -1
- package/dist/cli/core/providers/libretto-cloud.js +5 -3
- package/dist/cli/core/resolve-model.js +20 -2
- package/dist/cli/workers/run-integration-runtime.js +5 -2
- package/dist/shared/dom-semantics.js +0 -1
- package/dist/shared/env/load-env.d.ts +9 -4
- package/dist/shared/env/load-env.js +57 -36
- package/package.json +1 -1
- package/scripts/generate-changelog.ts +9 -6
- package/skills/libretto/SKILL.md +14 -3
- package/skills/libretto/references/configuration-file-reference.md +1 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/setup.ts +11 -3
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/core/ai-model.ts +17 -89
- package/src/cli/core/browser-daemon.ts +198 -0
- package/src/cli/core/browser.ts +27 -187
- package/src/cli/core/config.ts +1 -1
- package/src/cli/core/providers/libretto-cloud.ts +8 -5
- package/src/cli/core/resolve-model.ts +20 -2
- package/src/cli/workers/run-integration-runtime.ts +12 -2
- package/src/shared/dom-semantics.ts +0 -1
- package/src/shared/env/load-env.ts +75 -53
package/dist/cli/core/browser.js
CHANGED
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
chromium
|
|
3
3
|
} from "playwright";
|
|
4
|
-
import { openSync, existsSync } from "node:fs";
|
|
5
|
-
import { dirname, join
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { openSync, closeSync, existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
7
8
|
import { createServer } from "node:net";
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
|
-
import {
|
|
10
|
-
filterSemanticClasses,
|
|
11
|
-
INTERACTIVE_ROLE_NAMES,
|
|
12
|
-
INTERACTIVE_TAG_NAMES,
|
|
13
|
-
isObfuscatedClass,
|
|
14
|
-
TEST_ATTRIBUTE_NAMES,
|
|
15
|
-
TRUSTED_ATTRIBUTE_NAMES
|
|
16
|
-
} from "../../shared/dom-semantics.js";
|
|
17
|
-
import {
|
|
18
|
-
getSessionActionsLogPath,
|
|
19
|
-
getSessionNetworkLogPath,
|
|
20
|
-
PROFILES_DIR
|
|
21
|
-
} from "./context.js";
|
|
10
|
+
import { PROFILES_DIR } from "./context.js";
|
|
22
11
|
import { readLibrettoConfig } from "./config.js";
|
|
23
12
|
import {
|
|
24
13
|
assertSessionAvailableForStart,
|
|
@@ -31,11 +20,10 @@ import {
|
|
|
31
20
|
writeSessionState
|
|
32
21
|
} from "./session.js";
|
|
33
22
|
import { getCloudProviderApi } from "./providers/index.js";
|
|
34
|
-
import { installSessionTelemetry } from "./session-telemetry.js";
|
|
35
23
|
const CLOSE_WAIT_MS = 1500;
|
|
36
24
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
37
25
|
async function pickFreePort() {
|
|
38
|
-
return await new Promise((
|
|
26
|
+
return await new Promise((resolve, reject) => {
|
|
39
27
|
const server = createServer();
|
|
40
28
|
server.listen(0, "127.0.0.1", () => {
|
|
41
29
|
const address = server.address();
|
|
@@ -44,7 +32,7 @@ async function pickFreePort() {
|
|
|
44
32
|
return;
|
|
45
33
|
}
|
|
46
34
|
const port = address.port;
|
|
47
|
-
server.close(() =>
|
|
35
|
+
server.close(() => resolve(port));
|
|
48
36
|
});
|
|
49
37
|
server.on("error", reject);
|
|
50
38
|
});
|
|
@@ -99,7 +87,7 @@ async function tryConnectToCDP(endpoint, logger, timeoutMs = 5e3) {
|
|
|
99
87
|
try {
|
|
100
88
|
const connectPromise = chromium.connectOverCDP(endpoint);
|
|
101
89
|
const timeoutPromise = new Promise(
|
|
102
|
-
(
|
|
90
|
+
(resolve) => setTimeout(() => resolve(null), timeoutMs)
|
|
103
91
|
);
|
|
104
92
|
const browser = await Promise.race([connectPromise, timeoutPromise]);
|
|
105
93
|
if (browser) {
|
|
@@ -313,8 +301,6 @@ async function runOpen(rawUrl, headed, session, logger, options) {
|
|
|
313
301
|
assertSessionAvailableForStart(session, logger);
|
|
314
302
|
const port = await pickFreePort();
|
|
315
303
|
const runLogPath = logFileForSession(session);
|
|
316
|
-
const networkLogPath = getSessionNetworkLogPath(session);
|
|
317
|
-
const actionsLogPath = getSessionActionsLogPath(session);
|
|
318
304
|
const browserMode = headed ? "headed" : "headless";
|
|
319
305
|
const supportsSavedProfile = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
|
|
320
306
|
const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : void 0;
|
|
@@ -333,162 +319,31 @@ async function runOpen(rawUrl, headed, session, logger, options) {
|
|
|
333
319
|
console.log(`Loading saved profile for ${domain}`);
|
|
334
320
|
}
|
|
335
321
|
console.log(`Launching ${browserMode} browser (session: ${session})...`);
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const windowResult = await browserCdp.send(
|
|
351
|
-
'Browser.getWindowForTarget',
|
|
352
|
-
targetId ? { targetId } : {},
|
|
353
|
-
);
|
|
354
|
-
await browserCdp.send('Browser.setWindowBounds', {
|
|
355
|
-
windowId: windowResult.windowId,
|
|
356
|
-
bounds: requestedWindowBounds,
|
|
357
|
-
});
|
|
358
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
359
|
-
const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
|
|
360
|
-
windowId: windowResult.windowId,
|
|
361
|
-
});
|
|
362
|
-
childLog('info', 'window-bounds-set', {
|
|
363
|
-
windowId: windowResult.windowId,
|
|
364
|
-
requestedBounds: requestedWindowBounds,
|
|
365
|
-
actualBounds: actualWindow.bounds,
|
|
366
|
-
});
|
|
367
|
-
} catch (error) {
|
|
368
|
-
childLog('warn', 'window-bounds-set-failed', {
|
|
369
|
-
requestedBounds: requestedWindowBounds,
|
|
370
|
-
message: error instanceof Error ? error.message : String(error),
|
|
371
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
372
|
-
});
|
|
373
|
-
} finally {
|
|
374
|
-
await pageCdp.detach().catch(() => {});
|
|
375
|
-
if (browserCdp) {
|
|
376
|
-
await browserCdp.detach().catch(() => {});
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
` : "";
|
|
380
|
-
const launcherCode = `
|
|
381
|
-
import { chromium } from 'playwright';
|
|
382
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
383
|
-
import { dirname } from 'node:path';
|
|
384
|
-
|
|
385
|
-
const LOG_FILE = '${escapedLogPath}';
|
|
386
|
-
const NETWORK_LOG = '${escapedNetworkLogPath}';
|
|
387
|
-
const ACTIONS_LOG = '${escapedActionsLogPath}';
|
|
388
|
-
mkdirSync(dirname(NETWORK_LOG), { recursive: true });
|
|
389
|
-
|
|
390
|
-
// tsx/esbuild may emit __name() wrappers in Function#toString output.
|
|
391
|
-
const __name = (target, value) =>
|
|
392
|
-
Object.defineProperty(target, 'name', { value, configurable: true });
|
|
393
|
-
|
|
394
|
-
const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
|
|
395
|
-
const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
|
|
396
|
-
const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
|
|
397
|
-
const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
|
|
398
|
-
const filterSemanticClasses = ${filterSemanticClasses.toString()};
|
|
399
|
-
const isObfuscatedClass = ${isObfuscatedClass.toString()};
|
|
400
|
-
|
|
401
|
-
${installSessionTelemetry.toString()}
|
|
402
|
-
|
|
403
|
-
function logAction(entry) {
|
|
404
|
-
appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function logNetwork(entry) {
|
|
408
|
-
appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function childLog(level, event, data = {}) {
|
|
412
|
-
try {
|
|
413
|
-
const entry = JSON.stringify({
|
|
414
|
-
timestamp: new Date().toISOString(),
|
|
415
|
-
id: Math.random().toString(36).slice(2, 10),
|
|
416
|
-
level,
|
|
417
|
-
scope: 'libretto.child',
|
|
418
|
-
event,
|
|
419
|
-
data,
|
|
420
|
-
});
|
|
421
|
-
appendFileSync(LOG_FILE, entry + '\\n');
|
|
422
|
-
} catch {}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const browser = await chromium.launch({
|
|
426
|
-
headless: ${!headed},
|
|
427
|
-
args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
browser.on('disconnected', () => {
|
|
431
|
-
childLog('warn', 'browser-disconnected', { port: ${port} });
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
const context = await browser.newContext({
|
|
435
|
-
${storageStateCode}
|
|
436
|
-
viewport: { width: ${viewport.width}, height: ${viewport.height} },
|
|
437
|
-
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
const page = await context.newPage();
|
|
441
|
-
${windowBoundsSetupCode}
|
|
442
|
-
page.setDefaultTimeout(30000);
|
|
443
|
-
page.setDefaultNavigationTimeout(45000);
|
|
444
|
-
|
|
445
|
-
await installSessionTelemetry({
|
|
446
|
-
context,
|
|
447
|
-
initialPage: page,
|
|
448
|
-
includeUserDomActions: true,
|
|
449
|
-
logAction,
|
|
450
|
-
logNetwork,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
await page.goto('${escapedUrl}');
|
|
455
|
-
|
|
456
|
-
process.on('SIGTERM', async () => {
|
|
457
|
-
childLog('info', 'child-sigterm');
|
|
458
|
-
await browser.close();
|
|
459
|
-
process.exit(0);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
process.on('SIGINT', async () => {
|
|
463
|
-
childLog('info', 'child-sigint');
|
|
464
|
-
await browser.close();
|
|
465
|
-
process.exit(0);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
process.on('uncaughtException', (err) => {
|
|
469
|
-
childLog('error', 'uncaught-exception', { message: err.message, stack: err.stack });
|
|
470
|
-
process.exit(1);
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
process.on('unhandledRejection', (reason) => {
|
|
474
|
-
childLog('warn', 'unhandled-rejection', { reason: String(reason) });
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
process.on('exit', (code) => {
|
|
478
|
-
childLog('info', 'child-exit', { code, pid: process.pid, port: ${port} });
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
childLog('info', 'child-launched', { port: ${port}, pid: process.pid, session: '${session}' });
|
|
482
|
-
|
|
483
|
-
await new Promise(() => {});
|
|
484
|
-
`;
|
|
322
|
+
const daemonEntryPath = fileURLToPath(
|
|
323
|
+
new URL("./browser-daemon.js", import.meta.url)
|
|
324
|
+
);
|
|
325
|
+
const require2 = createRequire(import.meta.url);
|
|
326
|
+
const tsxImportPath = pathToFileURL(require2.resolve("tsx/esm")).href;
|
|
327
|
+
const daemonConfig = {
|
|
328
|
+
port,
|
|
329
|
+
url,
|
|
330
|
+
session,
|
|
331
|
+
headed,
|
|
332
|
+
viewport,
|
|
333
|
+
storageStatePath: useProfile ? profilePath : void 0,
|
|
334
|
+
windowPosition
|
|
335
|
+
};
|
|
485
336
|
const childStderrFd = openSync(runLogPath, "a");
|
|
486
|
-
const child = spawn(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
337
|
+
const child = spawn(
|
|
338
|
+
process.execPath,
|
|
339
|
+
["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
|
|
340
|
+
{
|
|
341
|
+
detached: true,
|
|
342
|
+
stdio: ["ignore", "ignore", childStderrFd]
|
|
343
|
+
}
|
|
344
|
+
);
|
|
491
345
|
child.unref();
|
|
346
|
+
closeSync(childStderrFd);
|
|
492
347
|
logger.info("open-child-spawned", { pid: child.pid, port, session });
|
|
493
348
|
let childSpawnError = null;
|
|
494
349
|
let childEarlyExit = null;
|
package/dist/cli/core/config.js
CHANGED
|
@@ -54,7 +54,7 @@ ${detail}` : null,
|
|
|
54
54
|
' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
|
|
55
55
|
' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
|
|
56
56
|
"Fix the file to match this shape, or delete it and rerun:",
|
|
57
|
-
` npx libretto ai configure openai | anthropic | gemini | vertex`
|
|
57
|
+
` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`
|
|
58
58
|
].filter(Boolean).join("\n")
|
|
59
59
|
);
|
|
60
60
|
}
|
|
@@ -21,7 +21,9 @@ function createLibrettoCloudProvider() {
|
|
|
21
21
|
"x-api-key": apiKey,
|
|
22
22
|
"Content-Type": "application/json"
|
|
23
23
|
},
|
|
24
|
-
body: JSON.stringify({
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
json: { timeout_seconds: timeoutSeconds }
|
|
26
|
+
})
|
|
25
27
|
});
|
|
26
28
|
if (!resp.ok) {
|
|
27
29
|
const body = await resp.text();
|
|
@@ -29,7 +31,7 @@ function createLibrettoCloudProvider() {
|
|
|
29
31
|
`Libretto Cloud API error (${resp.status}): ${body}`
|
|
30
32
|
);
|
|
31
33
|
}
|
|
32
|
-
const json = await resp.json();
|
|
34
|
+
const { json } = await resp.json();
|
|
33
35
|
return {
|
|
34
36
|
sessionId: json.session_id,
|
|
35
37
|
cdpEndpoint: json.cdp_url
|
|
@@ -42,7 +44,7 @@ function createLibrettoCloudProvider() {
|
|
|
42
44
|
"x-api-key": apiKey,
|
|
43
45
|
"Content-Type": "application/json"
|
|
44
46
|
},
|
|
45
|
-
body: JSON.stringify({ session_id: sessionId })
|
|
47
|
+
body: JSON.stringify({ json: { session_id: sessionId } })
|
|
46
48
|
});
|
|
47
49
|
if (!resp.ok) {
|
|
48
50
|
const body = await resp.text();
|
|
@@ -12,7 +12,8 @@ const SUPPORTED_PROVIDER_ALIASES = {
|
|
|
12
12
|
vertex: "vertex",
|
|
13
13
|
anthropic: "anthropic",
|
|
14
14
|
codex: "openai",
|
|
15
|
-
openai: "openai"
|
|
15
|
+
openai: "openai",
|
|
16
|
+
openrouter: "openrouter"
|
|
16
17
|
};
|
|
17
18
|
function readFirstEnvValue(env, names) {
|
|
18
19
|
for (const name of names) {
|
|
@@ -33,7 +34,7 @@ function parseModel(model) {
|
|
|
33
34
|
const modelId = model.slice(slashIndex + 1);
|
|
34
35
|
if (!provider) {
|
|
35
36
|
throw new Error(
|
|
36
|
-
`Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), and
|
|
37
|
+
`Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), vertex, and openrouter.`
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
return { provider, modelId };
|
|
@@ -48,6 +49,8 @@ function hasProviderCredentials(provider, env = process.env) {
|
|
|
48
49
|
return Boolean(env.ANTHROPIC_API_KEY?.trim());
|
|
49
50
|
case "openai":
|
|
50
51
|
return Boolean(env.OPENAI_API_KEY?.trim());
|
|
52
|
+
case "openrouter":
|
|
53
|
+
return Boolean(env.OPENROUTER_API_KEY?.trim());
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
function missingProviderCredentialsMessage(provider) {
|
|
@@ -62,6 +65,9 @@ function missingProviderCredentialsMessage(provider) {
|
|
|
62
65
|
case "openai": {
|
|
63
66
|
return "OpenAI API key is missing. Set OPENAI_API_KEY.";
|
|
64
67
|
}
|
|
68
|
+
case "openrouter": {
|
|
69
|
+
return "OpenRouter API key is missing. Set OPENROUTER_API_KEY.";
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
async function getProviderModel(provider, modelId) {
|
|
@@ -105,6 +111,18 @@ async function getProviderModel(provider, modelId) {
|
|
|
105
111
|
const openai = createOpenAI({ apiKey });
|
|
106
112
|
return openai(modelId);
|
|
107
113
|
}
|
|
114
|
+
case "openrouter": {
|
|
115
|
+
const apiKey = process.env.OPENROUTER_API_KEY?.trim();
|
|
116
|
+
if (!apiKey) {
|
|
117
|
+
throw new Error(missingProviderCredentialsMessage(provider));
|
|
118
|
+
}
|
|
119
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
120
|
+
const openrouter = createOpenAI({
|
|
121
|
+
apiKey,
|
|
122
|
+
baseURL: "https://openrouter.ai/api/v1"
|
|
123
|
+
});
|
|
124
|
+
return openrouter(modelId);
|
|
125
|
+
}
|
|
108
126
|
}
|
|
109
127
|
}
|
|
110
128
|
async function resolveModel(model) {
|
|
@@ -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
|
}
|
|
@@ -186,6 +186,9 @@ async function runIntegrationInternal(args, options) {
|
|
|
186
186
|
appendFileSync(networkLogPath, JSON.stringify(entry) + "\n");
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
|
+
await browserSession.context.addInitScript(() => {
|
|
190
|
+
globalThis.__name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true });
|
|
191
|
+
});
|
|
189
192
|
const workflowContext = {
|
|
190
193
|
session: args.session,
|
|
191
194
|
page: browserSession.page
|
|
@@ -53,7 +53,6 @@ function isObfuscatedClass(cls) {
|
|
|
53
53
|
if (cls.length > 80) return true;
|
|
54
54
|
if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
|
|
55
55
|
if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
|
|
56
|
-
if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
|
|
57
56
|
const digits = (cls.match(/[0-9]/g) || []).length;
|
|
58
57
|
const letters = (cls.match(/[a-zA-Z]/g) || []).length;
|
|
59
58
|
if (cls.length >= 6 && digits >= letters * 0.5 && digits >= 2) return true;
|
|
@@ -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,48 +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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
24
|
-
value = value.slice(1, -1);
|
|
25
|
-
} else {
|
|
26
|
-
const commentIndex = value.search(/\s#/);
|
|
27
|
-
if (commentIndex >= 0) {
|
|
28
|
-
value = value.slice(0, commentIndex).trimEnd();
|
|
29
|
-
}
|
|
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) };
|
|
30
28
|
}
|
|
31
|
-
|
|
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;
|
|
32
47
|
}
|
|
33
|
-
return vars;
|
|
34
48
|
}
|
|
35
|
-
function
|
|
36
|
-
|
|
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));
|
|
37
56
|
if (!envPath) return null;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
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;
|
|
42
62
|
}
|
|
43
63
|
}
|
|
44
64
|
return envPath;
|
|
45
65
|
}
|
|
46
66
|
export {
|
|
47
|
-
|
|
67
|
+
loadEnv,
|
|
68
|
+
parseDotEnvAssignment
|
|
48
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
|
@@ -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.
|
|
7
|
+
version: "0.6.8"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## How Libretto Works
|
|
@@ -34,6 +34,7 @@ metadata:
|
|
|
34
34
|
- Defer repo/code review until you begin generating code, unless the user explicitly asks for it earlier.
|
|
35
35
|
- Read and follow guidelines in `references/code-generation-rules.md` before generating or editing production workflow code.
|
|
36
36
|
- Validation requires a successful clean `run --headless` with confirmation of the actual returned output, not just process success. If the user wants to watch the finished workflow, do a final headed `run` after headless validation succeeds.
|
|
37
|
+
- After validation, always show the user: (1) the output/results from the headless validation run, and (2) a headed version of the same command so they can re-run it themselves and watch the browser (e.g. replace `--headless` with `--headed`). Include any `--params` or `--auth-profile` flags the workflow needs.
|
|
37
38
|
- Treat exploration sessions as disposable unless the user explicitly wants one kept open.
|
|
38
39
|
- Get explicit user confirmation before mutating actions or replaying network requests that may have side effects.
|
|
39
40
|
- Never run multiple `exec` commands at the same time.
|
|
@@ -214,7 +215,12 @@ Assistant: [Reads `references/site-security-review.md` before choosing between p
|
|
|
214
215
|
Assistant: [Runs `npx libretto snapshot --objective "Find the next required action" --context "We are starting the workflow from the landing page and need the first meaningful step."`]
|
|
215
216
|
Assistant: [Uses `snapshot` and `exec` as needed to understand the site and decide the implementation path]
|
|
216
217
|
Assistant: [Reads `references/code-generation-rules.md` before writing production workflow code]
|
|
217
|
-
Assistant: I found the working path. I'll now
|
|
218
|
+
Assistant: I found the working path. I'll now create the workflow file and verify it.
|
|
219
|
+
Assistant: [Creates or edits `target-workflow.ts` following `references/code-generation-rules.md`]
|
|
220
|
+
Assistant: [Runs `npx libretto run ./target-workflow.ts --headless --params '{"status":"open"}'` to validate]
|
|
221
|
+
Assistant: Validation passed. Here are the results:
|
|
222
|
+
[Shows the output/results from the headless run]
|
|
223
|
+
To watch it yourself in a browser, run: npx libretto run ./target-workflow.ts --headed --params '{"status":"open"}'
|
|
218
224
|
</example>
|
|
219
225
|
```
|
|
220
226
|
|
|
@@ -229,7 +235,12 @@ Assistant: The workflow failed and Libretto kept the browser open. I'll inspect
|
|
|
229
235
|
Assistant: [Runs `npx libretto snapshot --session debug-flow --objective "Find the blocking error or broken selector target" --context "The workflow just failed after trying to continue from the review step, and I need to identify the visible blocker on the current page."`]
|
|
230
236
|
Assistant: [Runs `npx libretto exec --session debug-flow "...focused inspection or prototype..."`]
|
|
231
237
|
Assistant: [Reads `references/code-generation-rules.md` before patching the workflow file]
|
|
232
|
-
Assistant: I found the issue. I'll patch the workflow code
|
|
238
|
+
Assistant: I found the issue. I'll patch the workflow code and verify.
|
|
239
|
+
Assistant: [Edits `integration.ts` following `references/code-generation-rules.md`]
|
|
240
|
+
Assistant: [Runs `npx libretto run ./integration.ts --headless` to validate the fix]
|
|
241
|
+
Assistant: Fix verified. Here are the results:
|
|
242
|
+
[Shows the output/results from the headless run]
|
|
243
|
+
To watch it yourself in a browser, run: npx libretto run ./integration.ts --headed
|
|
233
244
|
</example>
|
|
234
245
|
```
|
|
235
246
|
|
|
@@ -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
|
|