libretto 0.6.3 → 0.6.5

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/dist/cli/cli.js CHANGED
@@ -16,7 +16,7 @@ function printSetupAudit() {
16
16
  const status = resolveAiSetupStatus();
17
17
  switch (status.kind) {
18
18
  case "ready":
19
- console.log(`\u2713 AI model: ${status.model}`);
19
+ console.log(`\u2713 Snapshot model: ${status.model}`);
20
20
  break;
21
21
  case "configured-missing-credentials":
22
22
  console.log(
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import {
3
- readAiConfig,
4
- writeAiConfig,
5
- clearAiConfig
3
+ readSnapshotModel,
4
+ writeSnapshotModel,
5
+ clearSnapshotModel
6
6
  } from "../core/config.js";
7
7
  import { LIBRETTO_CONFIG_PATH } from "../core/context.js";
8
8
  import { DEFAULT_SNAPSHOT_MODELS } from "../core/ai-model.js";
@@ -21,10 +21,9 @@ const CONFIGURE_PROVIDERS = [
21
21
  function formatConfigureProviders(separator = " | ") {
22
22
  return CONFIGURE_PROVIDERS.join(separator);
23
23
  }
24
- function printAiConfig(config, configPath) {
25
- console.log(`Model: ${config.model}`);
24
+ function printSnapshotModelConfig(model, configPath) {
25
+ console.log(`Snapshot model: ${model}`);
26
26
  console.log(`Config file: ${configPath}`);
27
- console.log(`Updated at: ${config.updatedAt}`);
28
27
  }
29
28
  function resolveModelFromInput(input) {
30
29
  const trimmed = input.trim();
@@ -38,25 +37,25 @@ function runAiConfigure(input, options = {}) {
38
37
  const configPath = options.configPath ?? LIBRETTO_CONFIG_PATH;
39
38
  const presetArg = input.preset?.trim();
40
39
  if (!presetArg && !input.clear) {
41
- const config2 = readAiConfig(configPath);
42
- if (!config2) {
40
+ const model2 = readSnapshotModel(configPath);
41
+ if (!model2) {
43
42
  console.log(
44
- `No AI config set. Choose a default model: ${configureCommandName} ${formatConfigureProviders()}`
43
+ `No snapshot model set. Choose a default model: ${configureCommandName} ${formatConfigureProviders()}`
45
44
  );
46
45
  console.log(
47
46
  "Provider credentials still come from your shell or .env file."
48
47
  );
49
48
  return;
50
49
  }
51
- printAiConfig(config2, configPath);
50
+ printSnapshotModelConfig(model2, configPath);
52
51
  return;
53
52
  }
54
53
  if (input.clear) {
55
- const removed = clearAiConfig(configPath);
54
+ const removed = clearSnapshotModel(configPath);
56
55
  if (removed) {
57
- console.log(`Cleared AI config: ${configPath}`);
56
+ console.log(`Cleared snapshot model config: ${configPath}`);
58
57
  } else {
59
- console.log("No AI config was set.");
58
+ console.log("No snapshot model was set.");
60
59
  }
61
60
  return;
62
61
  }
@@ -71,9 +70,9 @@ function runAiConfigure(input, options = {}) {
71
70
  `Invalid provider or model. Use one of: ${formatConfigureProviders()}, or a full model string like "openai/gpt-4o".`
72
71
  );
73
72
  }
74
- const config = writeAiConfig(model, configPath);
75
- console.log("AI config saved.");
76
- printAiConfig(config, configPath);
73
+ writeSnapshotModel(model, configPath);
74
+ console.log("Snapshot model saved.");
75
+ printSnapshotModelConfig(model, configPath);
77
76
  }
78
77
  const aiConfigureInput = SimpleCLI.input({
79
78
  positionals: [
@@ -1,17 +1,14 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import {
3
- appendFileSync,
4
3
  cpSync,
5
4
  existsSync,
6
5
  readdirSync,
7
- readFileSync,
8
- rmSync,
9
- writeFileSync
6
+ rmSync
10
7
  } from "node:fs";
11
8
  import { spawnSync } from "node:child_process";
12
9
  import { basename, dirname, join } from "node:path";
13
10
  import { fileURLToPath } from "node:url";
14
- import { writeAiConfig } from "../core/config.js";
11
+ import { writeSnapshotModel } from "../core/config.js";
15
12
  import {
16
13
  ensureLibrettoSetup,
17
14
  LIBRETTO_CONFIG_PATH,
@@ -19,10 +16,64 @@ import {
19
16
  } from "../core/context.js";
20
17
  import {
21
18
  DEFAULT_SNAPSHOT_MODELS,
22
- loadSnapshotEnv,
23
19
  resolveAiSetupStatus
24
20
  } from "../core/ai-model.js";
25
21
  import { SimpleCLI } from "../framework/simple-cli.js";
22
+ const PROVIDER_SDK_PACKAGES = {
23
+ openai: "@ai-sdk/openai",
24
+ anthropic: "@ai-sdk/anthropic",
25
+ google: "@ai-sdk/google",
26
+ vertex: "@ai-sdk/google-vertex"
27
+ };
28
+ function detectPackageManager() {
29
+ if (existsSync(join(REPO_ROOT, "pnpm-lock.yaml"))) return "pnpm";
30
+ if (existsSync(join(REPO_ROOT, "yarn.lock"))) return "yarn";
31
+ if (existsSync(join(REPO_ROOT, "bun.lockb"))) return "bun";
32
+ return "npm";
33
+ }
34
+ function installCommand(pkgManager) {
35
+ switch (pkgManager) {
36
+ case "yarn":
37
+ return "yarn add";
38
+ case "bun":
39
+ return "bun add";
40
+ case "pnpm":
41
+ return "pnpm add";
42
+ default:
43
+ return "npm install";
44
+ }
45
+ }
46
+ function isSdkInstalled(sdkPackage) {
47
+ try {
48
+ const result = spawnSync("node", ["-e", `require.resolve("${sdkPackage}")`], {
49
+ cwd: REPO_ROOT,
50
+ stdio: "pipe"
51
+ });
52
+ return result.status === 0;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+ function installSdkIfNeeded(provider) {
58
+ const sdkPackage = PROVIDER_SDK_PACKAGES[provider];
59
+ if (isSdkInstalled(sdkPackage)) return;
60
+ const pkgManager = detectPackageManager();
61
+ const cmd = installCommand(pkgManager);
62
+ console.log(`
63
+ Installing ${sdkPackage}...`);
64
+ const result = spawnSync(cmd, [sdkPackage], {
65
+ cwd: REPO_ROOT,
66
+ stdio: "inherit",
67
+ shell: true
68
+ });
69
+ if (result.status === 0) {
70
+ console.log(`\u2713 Installed ${sdkPackage}`);
71
+ } else {
72
+ console.error(
73
+ `\u2717 Failed to install ${sdkPackage}. Install it manually: ${cmd} ${sdkPackage}`
74
+ );
75
+ }
76
+ }
26
77
  const PROVIDER_CHOICES = [
27
78
  {
28
79
  key: "1",
@@ -70,7 +121,7 @@ function sourceEnvVar(source) {
70
121
  }
71
122
  function ensurePinnedDefaultModel(status) {
72
123
  if (status.source !== "config") {
73
- writeAiConfig(status.model);
124
+ writeSnapshotModel(status.model);
74
125
  return { ...status, source: "config" };
75
126
  }
76
127
  return status;
@@ -103,7 +154,7 @@ function buildRepairPlan(status) {
103
154
  provider: status.provider,
104
155
  model: status.model,
105
156
  envVar: choice?.envVar ?? `${status.provider.toUpperCase()}_API_KEY`,
106
- choices: ["enter-matching-credential", "switch-provider", "skip"]
157
+ choices: ["switch-provider", "skip"]
107
158
  };
108
159
  }
109
160
  if (status.kind === "invalid-config") {
@@ -156,50 +207,7 @@ function printSnapshotApiStatus() {
156
207
  );
157
208
  return false;
158
209
  }
159
- function writeEnvVar(envVar, value, envPath) {
160
- let envContent = "";
161
- if (existsSync(envPath)) {
162
- envContent = readFileSync(envPath, "utf-8");
163
- }
164
- const envLine = `${envVar}=${value}`;
165
- if (envContent.includes(`${envVar}=`)) {
166
- const updated = envContent.replace(
167
- new RegExp(`^${envVar}=.*$`, "m"),
168
- () => envLine
169
- );
170
- writeFileSync(envPath, updated);
171
- console.log(`
172
- \u2713 Updated ${envVar} in ${envPath}`);
173
- } else {
174
- const separator = envContent && !envContent.endsWith("\n") ? "\n" : "";
175
- appendFileSync(envPath, `${separator}${envLine}
176
- `);
177
- console.log(`
178
- \u2713 Added ${envVar} to ${envPath}`);
179
- }
180
- process.env[envVar] = value;
181
- }
182
- async function promptForCredential(rl, choice, envPath, modelOverride) {
183
- console.log(`
184
- ${choice.label} selected.`);
185
- console.log(`${choice.envHint}
186
- `);
187
- const apiKeyValue = await promptUser(rl, `Enter your ${choice.envVar}: `);
188
- if (!apiKeyValue) {
189
- console.log("\nNo value entered. Skipping API key setup.");
190
- return false;
191
- }
192
- writeEnvVar(choice.envVar, apiKeyValue, envPath);
193
- loadSnapshotEnv();
194
- const model = modelOverride ?? DEFAULT_SNAPSHOT_MODELS[choice.provider];
195
- writeAiConfig(model);
196
- console.log(`\u2713 Snapshot API ready: ${model}`);
197
- console.log(
198
- "To change: npx libretto ai configure openai | anthropic | gemini | vertex"
199
- );
200
- return true;
201
- }
202
- async function promptProviderSelection(rl, envPath) {
210
+ async function promptProviderSelection(rl) {
203
211
  console.log(
204
212
  "Which model provider would you like to use for snapshot analysis?\n"
205
213
  );
@@ -218,7 +226,15 @@ async function promptProviderSelection(rl, envPath) {
218
226
  Unknown choice "${answer}". Skipping API setup.`);
219
227
  return false;
220
228
  }
221
- return promptForCredential(rl, selected, envPath);
229
+ const model = DEFAULT_SNAPSHOT_MODELS[selected.provider];
230
+ writeSnapshotModel(model);
231
+ console.log(`
232
+ \u2713 ${selected.label} selected (model: ${model}).`);
233
+ console.log(`
234
+ Add ${selected.envVar} to your .env file:`);
235
+ console.log(` ${selected.envHint}`);
236
+ installSdkIfNeeded(selected.provider);
237
+ return true;
222
238
  }
223
239
  function printSkipMessage() {
224
240
  console.log(
@@ -234,7 +250,6 @@ function printSkipMessage() {
234
250
  }
235
251
  async function runInteractiveApiSetup() {
236
252
  const status = resolveAiSetupStatus();
237
- const envPath = join(REPO_ROOT, ".env");
238
253
  console.log(
239
254
  "\nLibretto uses a sub-agent to analyze DOM snapshots. The model is determined by environment variables."
240
255
  );
@@ -252,23 +267,15 @@ async function runInteractiveApiSetup() {
252
267
  try {
253
268
  if (plan.kind === "repair-missing-credentials") {
254
269
  console.log(formatMissingCredentialsMessage(plan));
270
+ console.log(`
271
+ Add ${plan.envVar} to your .env file to fix this.`);
255
272
  console.log("");
256
- console.log("How would you like to fix this?\n");
257
- console.log(` 1) Enter ${plan.envVar}`);
258
- console.log(" 2) Switch to a different provider");
273
+ console.log("Or switch to a different provider:\n");
274
+ console.log(" 1) Switch to a different provider");
259
275
  console.log(" s) Skip for now\n");
260
276
  const answer = await promptUser(rl, "Choice: ");
261
277
  if (answer === "1") {
262
- const matchingChoice = PROVIDER_CHOICES.find(
263
- (c) => c.provider === plan.provider
264
- );
265
- if (matchingChoice) {
266
- await promptForCredential(rl, matchingChoice, envPath, plan.model);
267
- }
268
- return;
269
- }
270
- if (answer === "2") {
271
- await promptProviderSelection(rl, envPath);
278
+ await promptProviderSelection(rl);
272
279
  return;
273
280
  }
274
281
  printSkipMessage();
@@ -279,11 +286,11 @@ async function runInteractiveApiSetup() {
279
286
  console.log(
280
287
  "\nWould you like to reconfigure with a fresh provider selection?\n"
281
288
  );
282
- await promptProviderSelection(rl, envPath);
289
+ await promptProviderSelection(rl);
283
290
  return;
284
291
  }
285
292
  console.log("\u2717 No snapshot API credentials detected.\n");
286
- await promptProviderSelection(rl, envPath);
293
+ await promptProviderSelection(rl);
287
294
  } finally {
288
295
  rl.close();
289
296
  }
@@ -322,6 +329,13 @@ function detectAgentDirs(root) {
322
329
  function copySkills() {
323
330
  const agentDirs = detectAgentDirs(REPO_ROOT);
324
331
  if (agentDirs.length === 0) {
332
+ console.log(
333
+ "\n\u26A0 No .agents/ or .claude/ directory found. Libretto skills were not installed."
334
+ );
335
+ console.log(
336
+ " Create one of these directories in your repo root and rerun `npx libretto setup` to install skills:"
337
+ );
338
+ console.log(` mkdir ${join(REPO_ROOT, ".claude")}`);
325
339
  return;
326
340
  }
327
341
  let skillsRoot;
@@ -7,7 +7,7 @@ import { readSessionState } from "../core/session.js";
7
7
  import { SimpleCLI } from "../framework/simple-cli.js";
8
8
  import { pageOption, sessionOption, withRequiredSession } from "./shared.js";
9
9
  import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
10
- import { readAiConfig } from "../core/config.js";
10
+ import { readSnapshotModel } from "../core/config.js";
11
11
  import { resolveSnapshotApiModelOrThrow } from "../core/ai-model.js";
12
12
  const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 };
13
13
  function generateSnapshotRunId() {
@@ -187,8 +187,8 @@ async function captureScreenshot(session, logger, pageId) {
187
187
  async function runSnapshot(session, logger, pageId, objective, context) {
188
188
  const normalizedObjective = objective.trim();
189
189
  const normalizedContext = context.trim();
190
- const configuredAi = readAiConfig();
191
- resolveSnapshotApiModelOrThrow(configuredAi);
190
+ const snapshotModel = readSnapshotModel();
191
+ resolveSnapshotApiModelOrThrow(snapshotModel);
192
192
  const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
193
193
  session,
194
194
  logger,
@@ -206,7 +206,7 @@ async function runSnapshot(session, logger, pageId, objective, context) {
206
206
  htmlPath,
207
207
  condensedHtmlPath
208
208
  };
209
- await runApiInterpret(interpretArgs, logger, configuredAi);
209
+ await runApiInterpret(interpretArgs, logger, snapshotModel);
210
210
  }
211
211
  const snapshotInput = SimpleCLI.input({
212
212
  positionals: [],
@@ -6,7 +6,7 @@ function printAiStatus(status) {
6
6
  console.log("AI configuration:");
7
7
  switch (status.kind) {
8
8
  case "ready":
9
- console.log(` \u2713 Model: ${status.model}`);
9
+ console.log(` \u2713 Snapshot model: ${status.model}`);
10
10
  if (status.source === "config") {
11
11
  console.log(` Config: ${LIBRETTO_CONFIG_PATH}`);
12
12
  } else {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { readAiConfig } from "./config.js";
3
+ import { readSnapshotModel } from "./config.js";
4
4
  import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
5
5
  import {
6
6
  hasProviderCredentials,
@@ -155,20 +155,20 @@ function inferAutoSnapshotModel() {
155
155
  }
156
156
  return null;
157
157
  }
158
- function resolveSnapshotApiModel(config = readAiConfig()) {
158
+ function resolveSnapshotApiModel(snapshotModel = readSnapshotModel()) {
159
159
  loadSnapshotEnv();
160
- if (config?.model) {
161
- const { provider } = parseModel(config.model);
160
+ if (snapshotModel) {
161
+ const { provider } = parseModel(snapshotModel);
162
162
  return {
163
- model: config.model,
163
+ model: snapshotModel,
164
164
  provider,
165
165
  source: "config"
166
166
  };
167
167
  }
168
168
  return inferAutoSnapshotModel();
169
169
  }
170
- function resolveSnapshotApiModelOrThrow(config = readAiConfig()) {
171
- const selection = resolveSnapshotApiModel(config);
170
+ function resolveSnapshotApiModelOrThrow(snapshotModel = readSnapshotModel()) {
171
+ const selection = resolveSnapshotApiModel(snapshotModel);
172
172
  if (!selection) {
173
173
  throw new SnapshotApiUnavailableError(noSnapshotApiConfiguredMessage());
174
174
  }
@@ -182,9 +182,9 @@ function resolveSnapshotApiModelOrThrow(config = readAiConfig()) {
182
182
  function isSnapshotApiUnavailableError(error) {
183
183
  return error instanceof SnapshotApiUnavailableError;
184
184
  }
185
- function readAiConfigSafely(configPath) {
185
+ function readSnapshotModelSafely(configPath) {
186
186
  try {
187
- return { ok: true, config: readAiConfig(configPath) };
187
+ return { ok: true, model: readSnapshotModel(configPath) };
188
188
  } catch (err) {
189
189
  return {
190
190
  ok: false,
@@ -194,14 +194,14 @@ function readAiConfigSafely(configPath) {
194
194
  }
195
195
  function resolveAiSetupStatus(configPath = LIBRETTO_CONFIG_PATH) {
196
196
  loadSnapshotEnv();
197
- const configResult = readAiConfigSafely(configPath);
198
- if (!configResult.ok) {
199
- return { kind: "invalid-config", message: configResult.message };
197
+ const result = readSnapshotModelSafely(configPath);
198
+ if (!result.ok) {
199
+ return { kind: "invalid-config", message: result.message };
200
200
  }
201
- if (configResult.config) {
201
+ if (result.model) {
202
202
  let selection;
203
203
  try {
204
- selection = resolveSnapshotApiModel(configResult.config);
204
+ selection = resolveSnapshotApiModel(result.model);
205
205
  } catch (err) {
206
206
  return {
207
207
  kind: "invalid-config",
@@ -7,10 +7,10 @@ import {
7
7
  getMimeType,
8
8
  readFileAsBase64
9
9
  } from "./snapshot-analyzer.js";
10
- import { readAiConfig } from "./config.js";
10
+ import { readSnapshotModel } from "./config.js";
11
11
  import { resolveSnapshotApiModelOrThrow } from "./ai-model.js";
12
- async function runApiInterpret(args, logger, configuredAi = readAiConfig()) {
13
- const selection = resolveSnapshotApiModelOrThrow(configuredAi);
12
+ async function runApiInterpret(args, logger, snapshotModel = readSnapshotModel()) {
13
+ const selection = resolveSnapshotApiModelOrThrow(snapshotModel);
14
14
  logger.info("api-interpret-start", {
15
15
  objective: args.objective,
16
16
  pngPath: args.pngPath,
@@ -4,10 +4,6 @@ import { z } from "zod";
4
4
  import { SessionAccessModeSchema } from "../../shared/state/index.js";
5
5
  import { LIBRETTO_CONFIG_PATH } from "./context.js";
6
6
  const CURRENT_CONFIG_VERSION = 1;
7
- const AiConfigSchema = z.object({
8
- model: z.string().min(1),
9
- updatedAt: z.string()
10
- });
11
7
  const ViewportConfigSchema = z.object({
12
8
  width: z.number().int().min(1),
13
9
  height: z.number().int().min(1)
@@ -18,7 +14,7 @@ const WindowPositionConfigSchema = z.object({
18
14
  });
19
15
  const LibrettoConfigSchema = z.object({
20
16
  version: z.literal(CURRENT_CONFIG_VERSION),
21
- ai: AiConfigSchema.optional(),
17
+ snapshotModel: z.string().min(1).optional(),
22
18
  viewport: ViewportConfigSchema.optional(),
23
19
  windowPosition: WindowPositionConfigSchema.optional(),
24
20
  provider: z.string().optional(),
@@ -31,10 +27,7 @@ function formatExpectedConfigExample() {
31
27
  return JSON.stringify(
32
28
  {
33
29
  version: CURRENT_CONFIG_VERSION,
34
- ai: {
35
- model: "openai/gpt-5.4",
36
- updatedAt: "2026-01-01T00:00:00.000Z"
37
- },
30
+ snapshotModel: "openai/gpt-5.4",
38
31
  viewport: {
39
32
  width: 1280,
40
33
  height: 800
@@ -52,14 +45,14 @@ function formatExpectedConfigExample() {
52
45
  function invalidConfigError(configPath, detail) {
53
46
  return new Error(
54
47
  [
55
- `AI config is invalid at ${configPath}.`,
48
+ `Config is invalid at ${configPath}.`,
56
49
  detail ? `Problems:
57
50
  ${detail}` : null,
58
51
  "Expected config example:",
59
52
  formatExpectedConfigExample(),
60
53
  "Notes:",
61
- ' - "ai", "viewport", "windowPosition", and "sessionMode" are optional.',
62
- ' - "ai.model" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
54
+ ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
55
+ ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
63
56
  "Fix the file to match this shape, or delete it and rerun:",
64
57
  ` npx libretto ai configure openai | anthropic | gemini | vertex`
65
58
  ].filter(Boolean).join("\n")
@@ -93,34 +86,30 @@ function writeLibrettoConfig(config, configPath = LIBRETTO_CONFIG_PATH) {
93
86
  writeFileSync(configPath, JSON.stringify(parsed, null, 2), "utf-8");
94
87
  return parsed;
95
88
  }
96
- function readAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
97
- return readLibrettoConfig(configPath).ai ?? null;
89
+ function readSnapshotModel(configPath = LIBRETTO_CONFIG_PATH) {
90
+ return readLibrettoConfig(configPath).snapshotModel ?? null;
98
91
  }
99
- function writeAiConfig(model, configPath = LIBRETTO_CONFIG_PATH) {
92
+ function writeSnapshotModel(model, configPath = LIBRETTO_CONFIG_PATH) {
100
93
  let librettoConfig;
101
94
  try {
102
95
  librettoConfig = readLibrettoConfig(configPath);
103
96
  } catch {
104
97
  librettoConfig = { version: CURRENT_CONFIG_VERSION };
105
98
  }
106
- const ai = AiConfigSchema.parse({
107
- model,
108
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
109
- });
110
99
  writeLibrettoConfig(
111
100
  {
112
101
  ...librettoConfig,
113
102
  version: CURRENT_CONFIG_VERSION,
114
- ai
103
+ snapshotModel: model
115
104
  },
116
105
  configPath
117
106
  );
118
- return ai;
107
+ return model;
119
108
  }
120
- function clearAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
109
+ function clearSnapshotModel(configPath = LIBRETTO_CONFIG_PATH) {
121
110
  const librettoConfig = readLibrettoConfig(configPath);
122
- if (!librettoConfig.ai) return false;
123
- const { ai: _ai, ...rest } = librettoConfig;
111
+ if (!librettoConfig.snapshotModel) return false;
112
+ const { snapshotModel: _, ...rest } = librettoConfig;
124
113
  writeLibrettoConfig(
125
114
  {
126
115
  ...rest
@@ -130,14 +119,13 @@ function clearAiConfig(configPath = LIBRETTO_CONFIG_PATH) {
130
119
  return true;
131
120
  }
132
121
  export {
133
- AiConfigSchema,
134
122
  CURRENT_CONFIG_VERSION,
135
123
  LibrettoConfigSchema,
136
124
  ViewportConfigSchema,
137
125
  WindowPositionConfigSchema,
138
- clearAiConfig,
139
- readAiConfig,
126
+ clearSnapshotModel,
140
127
  readLibrettoConfig,
141
- writeAiConfig,
142
- writeLibrettoConfig
128
+ readSnapshotModel,
129
+ writeLibrettoConfig,
130
+ writeSnapshotModel
143
131
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "homepage": "https://libretto.sh",
@@ -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.2"
7
+ version: "0.6.4"
8
8
  ---
9
9
 
10
10
  ## How Libretto Works
@@ -19,7 +19,7 @@ Libretto reads workspace config from `.libretto/config.json`.
19
19
 
20
20
  ## Supported Settings
21
21
 
22
- - `ai.model` selects the configured analysis model for `snapshot`.
22
+ - `snapshotModel` selects the configured analysis model for `snapshot`.
23
23
  - `viewport` is an optional top-level setting used by `open` and `run` when you do not pass `--viewport`.
24
24
  - Viewport precedence is: CLI `--viewport`, then `.libretto/config.json`, then the default `1366x768`.
25
25
  - `sessionMode` sets the default session access mode for new sessions created by `open`, `connect`, and `run`. Must be `"read-only"` or `"write-access"`. When omitted, defaults to `"write-access"`. Pass `--read-only` or `--write-access` to `open`, `connect`, or `run` to override when creating a session.
@@ -29,10 +29,7 @@ Example:
29
29
  ```json
30
30
  {
31
31
  "version": 1,
32
- "ai": {
33
- "model": "openai/gpt-5.4",
34
- "updatedAt": "2026-01-01T00:00:00.000Z"
35
- },
32
+ "snapshotModel": "openai/gpt-5.4",
36
33
  "viewport": {
37
34
  "width": 1280,
38
35
  "height": 800
@@ -47,7 +44,7 @@ Example:
47
44
  npx libretto setup # first-time onboarding, auto-pins default model
48
45
  npx libretto status # inspect AI config and open sessions
49
46
  npx libretto ai configure openai # explicitly change provider/model
50
- npx libretto open https://app.example.com --viewport 1440x900
47
+ npx libretto open https://example.com --viewport 1440x900
51
48
  npx libretto run ./integration.ts --viewport 1440x900
52
49
  ```
53
50
 
@@ -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.2"
7
+ version: "0.6.4"
8
8
  ---
9
9
 
10
10
  ## How Libretto Read-Only Works
package/src/cli/cli.ts CHANGED
@@ -19,7 +19,7 @@ function printSetupAudit(): void {
19
19
  const status = resolveAiSetupStatus();
20
20
  switch (status.kind) {
21
21
  case "ready":
22
- console.log(`✓ AI model: ${status.model}`);
22
+ console.log(`✓ Snapshot model: ${status.model}`);
23
23
  break;
24
24
  case "configured-missing-credentials":
25
25
  console.log(
@@ -1,10 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import {
3
3
  CURRENT_CONFIG_VERSION,
4
- readAiConfig,
5
- writeAiConfig,
6
- clearAiConfig,
7
- type AiConfig,
4
+ readSnapshotModel,
5
+ writeSnapshotModel,
6
+ clearSnapshotModel,
8
7
  } from "../core/config.js";
9
8
  import { LIBRETTO_CONFIG_PATH } from "../core/context.js";
10
9
  import { DEFAULT_SNAPSHOT_MODELS } from "../core/ai-model.js";
@@ -27,10 +26,9 @@ function formatConfigureProviders(separator = " | "): string {
27
26
  return CONFIGURE_PROVIDERS.join(separator);
28
27
  }
29
28
 
30
- function printAiConfig(config: AiConfig, configPath: string): void {
31
- console.log(`Model: ${config.model}`);
29
+ function printSnapshotModelConfig(model: string, configPath: string): void {
30
+ console.log(`Snapshot model: ${model}`);
32
31
  console.log(`Config file: ${configPath}`);
33
- console.log(`Updated at: ${config.updatedAt}`);
34
32
  }
35
33
 
36
34
  /**
@@ -71,26 +69,26 @@ export function runAiConfigure(
71
69
  const presetArg = input.preset?.trim();
72
70
 
73
71
  if (!presetArg && !input.clear) {
74
- const config = readAiConfig(configPath);
75
- if (!config) {
72
+ const model = readSnapshotModel(configPath);
73
+ if (!model) {
76
74
  console.log(
77
- `No AI config set. Choose a default model: ${configureCommandName} ${formatConfigureProviders()}`,
75
+ `No snapshot model set. Choose a default model: ${configureCommandName} ${formatConfigureProviders()}`,
78
76
  );
79
77
  console.log(
80
78
  "Provider credentials still come from your shell or .env file.",
81
79
  );
82
80
  return;
83
81
  }
84
- printAiConfig(config, configPath);
82
+ printSnapshotModelConfig(model, configPath);
85
83
  return;
86
84
  }
87
85
 
88
86
  if (input.clear) {
89
- const removed = clearAiConfig(configPath);
87
+ const removed = clearSnapshotModel(configPath);
90
88
  if (removed) {
91
- console.log(`Cleared AI config: ${configPath}`);
89
+ console.log(`Cleared snapshot model config: ${configPath}`);
92
90
  } else {
93
- console.log("No AI config was set.");
91
+ console.log("No snapshot model was set.");
94
92
  }
95
93
  return;
96
94
  }
@@ -107,9 +105,9 @@ export function runAiConfigure(
107
105
  );
108
106
  }
109
107
 
110
- const config = writeAiConfig(model, configPath);
111
- console.log("AI config saved.");
112
- printAiConfig(config, configPath);
108
+ writeSnapshotModel(model, configPath);
109
+ console.log("Snapshot model saved.");
110
+ printSnapshotModelConfig(model, configPath);
113
111
  }
114
112
 
115
113
  export const aiConfigureInput = SimpleCLI.input({
@@ -1,17 +1,14 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import {
3
- appendFileSync,
4
3
  cpSync,
5
4
  existsSync,
6
5
  readdirSync,
7
- readFileSync,
8
6
  rmSync,
9
- writeFileSync,
10
7
  } from "node:fs";
11
8
  import { spawnSync } from "node:child_process";
12
9
  import { basename, dirname, join } from "node:path";
13
10
  import { fileURLToPath } from "node:url";
14
- import { writeAiConfig } from "../core/config.js";
11
+ import { writeSnapshotModel } from "../core/config.js";
15
12
  import {
16
13
  ensureLibrettoSetup,
17
14
  LIBRETTO_CONFIG_PATH,
@@ -20,12 +17,71 @@ import {
20
17
  import {
21
18
  type AiSetupStatus,
22
19
  DEFAULT_SNAPSHOT_MODELS,
23
- loadSnapshotEnv,
24
20
  resolveAiSetupStatus,
25
21
  } from "../core/ai-model.js";
26
22
  import type { Provider } from "../core/resolve-model.js";
27
23
  import { SimpleCLI } from "../framework/simple-cli.js";
28
24
 
25
+ const PROVIDER_SDK_PACKAGES: Record<Provider, string> = {
26
+ openai: "@ai-sdk/openai",
27
+ anthropic: "@ai-sdk/anthropic",
28
+ google: "@ai-sdk/google",
29
+ vertex: "@ai-sdk/google-vertex",
30
+ };
31
+
32
+ function detectPackageManager(): string {
33
+ if (existsSync(join(REPO_ROOT, "pnpm-lock.yaml"))) return "pnpm";
34
+ if (existsSync(join(REPO_ROOT, "yarn.lock"))) return "yarn";
35
+ if (existsSync(join(REPO_ROOT, "bun.lockb"))) return "bun";
36
+ return "npm";
37
+ }
38
+
39
+ function installCommand(pkgManager: string): string {
40
+ switch (pkgManager) {
41
+ case "yarn":
42
+ return "yarn add";
43
+ case "bun":
44
+ return "bun add";
45
+ case "pnpm":
46
+ return "pnpm add";
47
+ default:
48
+ return "npm install";
49
+ }
50
+ }
51
+
52
+ function isSdkInstalled(sdkPackage: string): boolean {
53
+ try {
54
+ const result = spawnSync("node", ["-e", `require.resolve("${sdkPackage}")`], {
55
+ cwd: REPO_ROOT,
56
+ stdio: "pipe",
57
+ });
58
+ return result.status === 0;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function installSdkIfNeeded(provider: Provider): void {
65
+ const sdkPackage = PROVIDER_SDK_PACKAGES[provider];
66
+ if (isSdkInstalled(sdkPackage)) return;
67
+
68
+ const pkgManager = detectPackageManager();
69
+ const cmd = installCommand(pkgManager);
70
+ console.log(`\nInstalling ${sdkPackage}...`);
71
+ const result = spawnSync(cmd, [sdkPackage], {
72
+ cwd: REPO_ROOT,
73
+ stdio: "inherit",
74
+ shell: true,
75
+ });
76
+ if (result.status === 0) {
77
+ console.log(`✓ Installed ${sdkPackage}`);
78
+ } else {
79
+ console.error(
80
+ `✗ Failed to install ${sdkPackage}. Install it manually: ${cmd} ${sdkPackage}`,
81
+ );
82
+ }
83
+ }
84
+
29
85
  export type ProviderChoice = {
30
86
  key: string;
31
87
  label: string;
@@ -97,7 +153,7 @@ function ensurePinnedDefaultModel(
97
153
  status: AiSetupStatus & { kind: "ready" },
98
154
  ): AiSetupStatus & { kind: "ready" } {
99
155
  if (status.source !== "config") {
100
- writeAiConfig(status.model);
156
+ writeSnapshotModel(status.model);
101
157
  return { ...status, source: "config" as const };
102
158
  }
103
159
  return status;
@@ -127,10 +183,7 @@ function printInvalidAiConfigWarning(status: AiSetupStatus): void {
127
183
 
128
184
  // ── Repair plan helpers (exported for testing) ──────────────────────────────
129
185
 
130
- export type RepairChoice =
131
- | "enter-matching-credential"
132
- | "switch-provider"
133
- | "skip";
186
+ export type RepairChoice = "switch-provider" | "skip";
134
187
 
135
188
  export type RepairPlan =
136
189
  | {
@@ -155,7 +208,7 @@ export function buildRepairPlan(status: AiSetupStatus): RepairPlan {
155
208
  provider: status.provider,
156
209
  model: status.model,
157
210
  envVar: choice?.envVar ?? `${status.provider.toUpperCase()}_API_KEY`,
158
- choices: ["enter-matching-credential", "switch-provider", "skip"],
211
+ choices: ["switch-provider", "skip"],
159
212
  };
160
213
  }
161
214
  if (status.kind === "invalid-config") {
@@ -223,72 +276,13 @@ function printSnapshotApiStatus(): boolean {
223
276
  }
224
277
 
225
278
  /**
226
- * Write an env var to the .env file and update process.env.
227
- */
228
- function writeEnvVar(envVar: string, value: string, envPath: string): void {
229
- let envContent = "";
230
- if (existsSync(envPath)) {
231
- envContent = readFileSync(envPath, "utf-8");
232
- }
233
-
234
- const envLine = `${envVar}=${value}`;
235
- if (envContent.includes(`${envVar}=`)) {
236
- const updated = envContent.replace(
237
- new RegExp(`^${envVar}=.*$`, "m"),
238
- () => envLine,
239
- );
240
- writeFileSync(envPath, updated);
241
- console.log(`\n✓ Updated ${envVar} in ${envPath}`);
242
- } else {
243
- const separator = envContent && !envContent.endsWith("\n") ? "\n" : "";
244
- appendFileSync(envPath, `${separator}${envLine}\n`);
245
- console.log(`\n✓ Added ${envVar} to ${envPath}`);
246
- }
247
-
248
- process.env[envVar] = value;
249
- }
250
-
251
- /**
252
- * Prompt the user to enter a credential for a specific provider and pin its model.
253
- * When modelOverride is provided (e.g. during repair), preserves the existing model
254
- * instead of resetting to the provider default.
255
- * Returns true if credential was entered successfully.
256
- */
257
- async function promptForCredential(
258
- rl: ReturnType<typeof createInterface>,
259
- choice: ProviderChoice,
260
- envPath: string,
261
- modelOverride?: string,
262
- ): Promise<boolean> {
263
- console.log(`\n${choice.label} selected.`);
264
- console.log(`${choice.envHint}\n`);
265
-
266
- const apiKeyValue = await promptUser(rl, `Enter your ${choice.envVar}: `);
267
-
268
- if (!apiKeyValue) {
269
- console.log("\nNo value entered. Skipping API key setup.");
270
- return false;
271
- }
272
-
273
- writeEnvVar(choice.envVar, apiKeyValue, envPath);
274
- loadSnapshotEnv();
275
-
276
- const model = modelOverride ?? DEFAULT_SNAPSHOT_MODELS[choice.provider];
277
- writeAiConfig(model);
278
- console.log(`✓ Snapshot API ready: ${model}`);
279
- console.log(
280
- "To change: npx libretto ai configure openai | anthropic | gemini | vertex",
281
- );
282
- return true;
283
- }
284
-
285
- /**
286
- * Run the full provider selection menu and credential entry.
279
+ * Run the full provider selection menu.
280
+ * Pins the selected provider's default model to config and prints
281
+ * instructions for the user to add the credential to .env themselves.
287
282
  * Returns true if a provider was successfully configured.
288
283
  */
289
284
  async function promptProviderSelection(
290
285
  rl: ReturnType<typeof createInterface>,
291
- envPath: string,
292
286
  ): Promise<boolean> {
293
287
  console.log(
294
288
  "Which model provider would you like to use for snapshot analysis?\n",
@@ -311,7 +305,13 @@ async function promptProviderSelection(
311
305
  return false;
312
306
  }
313
307
 
314
- return promptForCredential(rl, selected, envPath);
308
+ const model = DEFAULT_SNAPSHOT_MODELS[selected.provider];
309
+ writeSnapshotModel(model);
310
+ console.log(`\n✓ ${selected.label} selected (model: ${model}).`);
311
+ console.log(`\nAdd ${selected.envVar} to your .env file:`);
312
+ console.log(` ${selected.envHint}`);
313
+ installSdkIfNeeded(selected.provider);
314
+ return true;
315
315
  }
316
316
 
317
317
  function printSkipMessage(): void {
@@ -329,7 +329,6 @@ function printSkipMessage(): void {
329
329
 
330
330
  async function runInteractiveApiSetup(): Promise<void> {
331
331
  const status = resolveAiSetupStatus();
332
- const envPath = join(REPO_ROOT, ".env");
333
332
 
334
333
  console.log(
335
334
  "\nLibretto uses a sub-agent to analyze DOM snapshots. The model is determined by environment variables.",
@@ -353,26 +352,16 @@ async function runInteractiveApiSetup(): Promise<void> {
353
352
  // ── Repair: configured provider with missing credentials ──
354
353
  if (plan.kind === "repair-missing-credentials") {
355
354
  console.log(formatMissingCredentialsMessage(plan));
355
+ console.log(`\nAdd ${plan.envVar} to your .env file to fix this.`);
356
356
  console.log("");
357
- console.log("How would you like to fix this?\n");
358
- console.log(` 1) Enter ${plan.envVar}`);
359
- console.log(" 2) Switch to a different provider");
357
+ console.log("Or switch to a different provider:\n");
358
+ console.log(" 1) Switch to a different provider");
360
359
  console.log(" s) Skip for now\n");
361
360
 
362
361
  const answer = await promptUser(rl, "Choice: ");
363
362
 
364
363
  if (answer === "1") {
365
- const matchingChoice = PROVIDER_CHOICES.find(
366
- (c) => c.provider === plan.provider,
367
- );
368
- if (matchingChoice) {
369
- await promptForCredential(rl, matchingChoice, envPath, plan.model);
370
- }
371
- return;
372
- }
373
-
374
- if (answer === "2") {
375
- await promptProviderSelection(rl, envPath);
364
+ await promptProviderSelection(rl);
376
365
  return;
377
366
  }
378
367
 
@@ -387,13 +376,13 @@ async function runInteractiveApiSetup(): Promise<void> {
387
376
  console.log(
388
377
  "\nWould you like to reconfigure with a fresh provider selection?\n",
389
378
  );
390
- await promptProviderSelection(rl, envPath);
379
+ await promptProviderSelection(rl);
391
380
  return;
392
381
  }
393
382
 
394
383
  // ── Unconfigured: standard first-run flow ──
395
384
  console.log("✗ No snapshot API credentials detected.\n");
396
- await promptProviderSelection(rl, envPath);
385
+ await promptProviderSelection(rl);
397
386
  } finally {
398
387
  rl.close();
399
388
  }
@@ -441,6 +430,13 @@ function copySkills(): void {
441
430
  const agentDirs = detectAgentDirs(REPO_ROOT);
442
431
 
443
432
  if (agentDirs.length === 0) {
433
+ console.log(
434
+ "\n⚠ No .agents/ or .claude/ directory found. Libretto skills were not installed.",
435
+ );
436
+ console.log(
437
+ " Create one of these directories in your repo root and rerun `npx libretto setup` to install skills:",
438
+ );
439
+ console.log(` mkdir ${join(REPO_ROOT, ".claude")}`);
444
440
  return;
445
441
  }
446
442
 
@@ -12,7 +12,7 @@ import {
12
12
  import { SimpleCLI } from "../framework/simple-cli.js";
13
13
  import { pageOption, sessionOption, withRequiredSession } from "./shared.js";
14
14
  import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
15
- import { readAiConfig } from "../core/config.js";
15
+ import { readSnapshotModel } from "../core/config.js";
16
16
  import { resolveSnapshotApiModelOrThrow } from "../core/ai-model.js";
17
17
 
18
18
  const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
@@ -256,8 +256,8 @@ async function runSnapshot(
256
256
  const normalizedObjective = objective.trim();
257
257
  const normalizedContext = context.trim();
258
258
 
259
- const configuredAi = readAiConfig();
260
- resolveSnapshotApiModelOrThrow(configuredAi);
259
+ const snapshotModel = readSnapshotModel();
260
+ resolveSnapshotApiModelOrThrow(snapshotModel);
261
261
 
262
262
  const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
263
263
  session,
@@ -283,7 +283,7 @@ async function runSnapshot(
283
283
  // The legacy CLI-agent path (spawning codex/claude/gemini as a subprocess) is preserved
284
284
  // in snapshot-analyzer.ts — to switch back, replace this call with:
285
285
  // await runInterpret(interpretArgs, logger);
286
- await runApiInterpret(interpretArgs, logger, configuredAi);
286
+ await runApiInterpret(interpretArgs, logger, snapshotModel);
287
287
  }
288
288
 
289
289
  export const snapshotInput = SimpleCLI.input({
@@ -10,7 +10,7 @@ function printAiStatus(status: AiSetupStatus): void {
10
10
 
11
11
  switch (status.kind) {
12
12
  case "ready":
13
- console.log(` ✓ Model: ${status.model}`);
13
+ console.log(` ✓ Snapshot model: ${status.model}`);
14
14
  if (status.source === "config") {
15
15
  console.log(` Config: ${LIBRETTO_CONFIG_PATH}`);
16
16
  } else {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { type AiConfig, readAiConfig } from "./config.js";
3
+ import { readSnapshotModel } from "./config.js";
4
4
  import { LIBRETTO_CONFIG_PATH, REPO_ROOT } from "./context.js";
5
5
  import {
6
6
  hasProviderCredentials,
@@ -221,18 +221,18 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
221
221
  * Resolve which API model to use for snapshot analysis.
222
222
  *
223
223
  * Priority:
224
- * 1. Model from .libretto/config.json ai.model field (set via `ai configure`)
224
+ * 1. snapshotModel from .libretto/config.json (set via `ai configure`)
225
225
  * 2. Auto-detect from available API credentials in env
226
226
  */
227
227
  export function resolveSnapshotApiModel(
228
- config: AiConfig | null = readAiConfig(),
228
+ snapshotModel: string | null = readSnapshotModel(),
229
229
  ): SnapshotApiModelSelection | null {
230
230
  loadSnapshotEnv();
231
231
 
232
- if (config?.model) {
233
- const { provider } = parseModel(config.model);
232
+ if (snapshotModel) {
233
+ const { provider } = parseModel(snapshotModel);
234
234
  return {
235
- model: config.model,
235
+ model: snapshotModel,
236
236
  provider,
237
237
  source: "config",
238
238
  };
@@ -242,9 +242,9 @@ export function resolveSnapshotApiModel(
242
242
  }
243
243
 
244
244
  export function resolveSnapshotApiModelOrThrow(
245
- config: AiConfig | null = readAiConfig(),
245
+ snapshotModel: string | null = readSnapshotModel(),
246
246
  ): SnapshotApiModelSelection {
247
- const selection = resolveSnapshotApiModel(config);
247
+ const selection = resolveSnapshotApiModel(snapshotModel);
248
248
  if (!selection) {
249
249
  throw new SnapshotApiUnavailableError(noSnapshotApiConfiguredMessage());
250
250
  }
@@ -288,14 +288,14 @@ export type AiSetupStatus =
288
288
  | { kind: "unconfigured" };
289
289
 
290
290
  /**
291
- * Read AI config without throwing on invalid files.
292
- * Returns the config or an error message.
291
+ * Read snapshot model without throwing on invalid files.
292
+ * Returns the model string or an error message.
293
293
  */
294
- function readAiConfigSafely(
294
+ function readSnapshotModelSafely(
295
295
  configPath: string,
296
- ): { ok: true; config: AiConfig | null } | { ok: false; message: string } {
296
+ ): { ok: true; model: string | null } | { ok: false; message: string } {
297
297
  try {
298
- return { ok: true, config: readAiConfig(configPath) };
298
+ return { ok: true, model: readSnapshotModel(configPath) };
299
299
  } catch (err) {
300
300
  return {
301
301
  ok: false,
@@ -312,25 +312,25 @@ function readAiConfigSafely(
312
312
  * that the throwing APIs collapse into errors.
313
313
  *
314
314
  * 1. If config read throws → `invalid-config`.
315
- * 2. If config has an `ai` block → check credentials for that provider.
316
- * 3. If no config or no `ai` block → auto-detect from env via existing resolver.
315
+ * 2. If config has a `snapshotModel` → check credentials for that provider.
316
+ * 3. If no `snapshotModel` → auto-detect from env via existing resolver.
317
317
  */
318
318
  export function resolveAiSetupStatus(
319
319
  configPath: string = LIBRETTO_CONFIG_PATH,
320
320
  ): AiSetupStatus {
321
321
  loadSnapshotEnv();
322
322
 
323
- const configResult = readAiConfigSafely(configPath);
323
+ const result = readSnapshotModelSafely(configPath);
324
324
 
325
- if (!configResult.ok) {
326
- return { kind: "invalid-config", message: configResult.message };
325
+ if (!result.ok) {
326
+ return { kind: "invalid-config", message: result.message };
327
327
  }
328
328
 
329
- // Config exists with an ai block — use it directly to check credentials
330
- if (configResult.config) {
329
+ // Config has a snapshotModel — use it directly to check credentials
330
+ if (result.model) {
331
331
  let selection: SnapshotApiModelSelection | null;
332
332
  try {
333
- selection = resolveSnapshotApiModel(configResult.config);
333
+ selection = resolveSnapshotApiModel(result.model);
334
334
  } catch (err) {
335
335
  return {
336
336
  kind: "invalid-config",
@@ -356,7 +356,7 @@ export function resolveAiSetupStatus(
356
356
  };
357
357
  }
358
358
 
359
- // No ai config — fall back to env auto-detect via existing resolver
359
+ // No snapshotModel — fall back to env auto-detect via existing resolver
360
360
  const envSelection = resolveSnapshotApiModel(null);
361
361
  if (envSelection && hasProviderCredentials(envSelection.provider)) {
362
362
  return {
@@ -18,15 +18,15 @@ import {
18
18
  type InterpretResult,
19
19
  type InterpretArgs,
20
20
  } from "./snapshot-analyzer.js";
21
- import { readAiConfig, type AiConfig } from "./config.js";
21
+ import { readSnapshotModel } from "./config.js";
22
22
  import { resolveSnapshotApiModelOrThrow } from "./ai-model.js";
23
23
 
24
24
  export async function runApiInterpret(
25
25
  args: InterpretArgs,
26
26
  logger: LoggerApi,
27
- configuredAi: AiConfig | null = readAiConfig(),
27
+ snapshotModel: string | null = readSnapshotModel(),
28
28
  ): Promise<void> {
29
- const selection = resolveSnapshotApiModelOrThrow(configuredAi);
29
+ const selection = resolveSnapshotApiModelOrThrow(snapshotModel);
30
30
 
31
31
  logger.info("api-interpret-start", {
32
32
  objective: args.objective,
@@ -6,24 +6,6 @@ import { LIBRETTO_CONFIG_PATH } from "./context.js";
6
6
 
7
7
  export const CURRENT_CONFIG_VERSION = 1;
8
8
 
9
- /**
10
- * AI configuration schema.
11
- *
12
- * The `model` field is a provider/model-id string (e.g. "openai/gpt-5.4",
13
- * "anthropic/claude-sonnet-4-6", "google/gemini-3-flash-preview", "vertex/gemini-2.5-pro").
14
- *
15
- * Legacy note: earlier versions stored a `preset` (codex|claude|gemini) and
16
- * `commandPrefix` (CLI args to spawn a sub-agent process). That approach has
17
- * been replaced by direct API calls via the Vercel AI SDK. The legacy CLI-agent
18
- * code is preserved in snapshot-analyzer.ts but is not wired into the snapshot
19
- * command.
20
- */
21
- export const AiConfigSchema = z.object({
22
- model: z.string().min(1),
23
- updatedAt: z.string(),
24
- });
25
- export type AiConfig = z.infer<typeof AiConfigSchema>;
26
-
27
9
  export const ViewportConfigSchema = z.object({
28
10
  width: z.number().int().min(1),
29
11
  height: z.number().int().min(1),
@@ -39,7 +21,7 @@ export type WindowPositionConfig = z.infer<typeof WindowPositionConfigSchema>;
39
21
  export const LibrettoConfigSchema = z
40
22
  .object({
41
23
  version: z.literal(CURRENT_CONFIG_VERSION),
42
- ai: AiConfigSchema.optional(),
24
+ snapshotModel: z.string().min(1).optional(),
43
25
  viewport: ViewportConfigSchema.optional(),
44
26
  windowPosition: WindowPositionConfigSchema.optional(),
45
27
  provider: z.string().optional(),
@@ -58,10 +40,7 @@ function formatExpectedConfigExample(): string {
58
40
  return JSON.stringify(
59
41
  {
60
42
  version: CURRENT_CONFIG_VERSION,
61
- ai: {
62
- model: "openai/gpt-5.4",
63
- updatedAt: "2026-01-01T00:00:00.000Z",
64
- },
43
+ snapshotModel: "openai/gpt-5.4",
65
44
  viewport: {
66
45
  width: 1280,
67
46
  height: 800,
@@ -80,13 +59,13 @@ function formatExpectedConfigExample(): string {
80
59
  function invalidConfigError(configPath: string, detail?: string): Error {
81
60
  return new Error(
82
61
  [
83
- `AI config is invalid at ${configPath}.`,
62
+ `Config is invalid at ${configPath}.`,
84
63
  detail ? `Problems:\n${detail}` : null,
85
64
  "Expected config example:",
86
65
  formatExpectedConfigExample(),
87
66
  "Notes:",
88
- ' - "ai", "viewport", "windowPosition", and "sessionMode" are optional.',
89
- ' - "ai.model" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
67
+ ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
68
+ ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
90
69
  "Fix the file to match this shape, or delete it and rerun:",
91
70
  ` npx libretto ai configure openai | anthropic | gemini | vertex`,
92
71
  ]
@@ -132,16 +111,16 @@ export function writeLibrettoConfig(
132
111
  return parsed;
133
112
  }
134
113
 
135
- export function readAiConfig(
114
+ export function readSnapshotModel(
136
115
  configPath: string = LIBRETTO_CONFIG_PATH,
137
- ): AiConfig | null {
138
- return readLibrettoConfig(configPath).ai ?? null;
116
+ ): string | null {
117
+ return readLibrettoConfig(configPath).snapshotModel ?? null;
139
118
  }
140
119
 
141
- export function writeAiConfig(
120
+ export function writeSnapshotModel(
142
121
  model: string,
143
122
  configPath: string = LIBRETTO_CONFIG_PATH,
144
- ): AiConfig {
123
+ ): string {
145
124
  let librettoConfig: LibrettoConfig;
146
125
  try {
147
126
  librettoConfig = readLibrettoConfig(configPath);
@@ -150,27 +129,23 @@ export function writeAiConfig(
150
129
  // overwrite a broken file instead of throwing.
151
130
  librettoConfig = { version: CURRENT_CONFIG_VERSION };
152
131
  }
153
- const ai = AiConfigSchema.parse({
154
- model,
155
- updatedAt: new Date().toISOString(),
156
- });
157
132
  writeLibrettoConfig(
158
133
  {
159
134
  ...librettoConfig,
160
135
  version: CURRENT_CONFIG_VERSION,
161
- ai,
136
+ snapshotModel: model,
162
137
  },
163
138
  configPath,
164
139
  );
165
- return ai;
140
+ return model;
166
141
  }
167
142
 
168
- export function clearAiConfig(
143
+ export function clearSnapshotModel(
169
144
  configPath: string = LIBRETTO_CONFIG_PATH,
170
145
  ): boolean {
171
146
  const librettoConfig = readLibrettoConfig(configPath);
172
- if (!librettoConfig.ai) return false;
173
- const { ai: _ai, ...rest } = librettoConfig;
147
+ if (!librettoConfig.snapshotModel) return false;
148
+ const { snapshotModel: _, ...rest } = librettoConfig;
174
149
  writeLibrettoConfig(
175
150
  {
176
151
  ...rest,