imprint-mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,24 @@ imprint teach southwest --url https://www.southwest.com
29
29
 
30
30
  A browser opens. You drive the workflow and narrate what you're doing. Imprint records every request and interaction, then compiles a deterministic **MCP tool** your agent can call forever.
31
31
 
32
+ Want to try a finished MCP before recording anything?
33
+
34
+ ```bash
35
+ imprint install google-flights --source examples --platform claude-desktop
36
+ ```
37
+
38
+ That registers the checked-in Google Flights example as an MCP server in your client. Swap `claude-desktop` for `claude-code`, `codex`, `openclaw`, or `hermes`, or add `--print` to see the config without changing anything.
39
+
40
+ On a Hermes agent or Docker host, register the examples directly into Hermes:
41
+
42
+ ```bash
43
+ for site in google-flights google-hotels southwest discoverandgo echo; do
44
+ imprint install "$site" --source examples --platform hermes --no-interactive
45
+ done
46
+ ```
47
+
48
+ When `HERMES_HOME` is set, Imprint writes Hermes MCP entries to `$HERMES_HOME/config.yaml`; outside Hermes it uses `~/.hermes/config.yaml`. For browser-backed MCPs, `imprint install` also installs Playwright Chromium into `$HERMES_HOME/.cache/ms-playwright` and writes `PLAYWRIGHT_BROWSERS_PATH` into the MCP config so Hermes can find it. Use `--skip-browser-install` only for offline builds where you preinstall the browser yourself. In a fresh Linux image that is missing browser system libraries, install those during image build with `bunx playwright install --with-deps chromium`.
49
+
32
50
  ---
33
51
 
34
52
  ## See It in Action
@@ -118,7 +136,7 @@ bun install && bun link
118
136
 
119
137
  <br>
120
138
 
121
- **Browser commands** (`teach`, `record`, `login`, `playbook`) need Playwright's Chromium:
139
+ **Browser commands** (`teach`, `record`, `login`, `playbook`) and browser-backed `imprint install` targets auto-install Playwright Chromium when it is missing. For offline CI or prebuilt Linux images where you pass `--skip-browser-install`, preinstall it ahead of time:
122
140
 
123
141
  ```bash
124
142
  bunx playwright install chromium
@@ -207,6 +225,21 @@ Install any example into your MCP client:
207
225
  imprint install google-flights --source examples --platform claude-desktop
208
226
  ```
209
227
 
228
+ Examples are real generated MCPs, not handwritten SDK samples. `imprint install <site> --source examples` points the MCP server at this repo's `examples/` directory with `IMPRINT_HOME`, ensures Playwright Chromium for browser-backed tools, and lets your client list and call the checked-in tools immediately:
229
+
230
+ ```bash
231
+ imprint install google-hotels --source examples --platform codex
232
+ imprint install southwest --source examples --platform claude-code
233
+ imprint install echo --source examples --platform claude-desktop --print
234
+ ```
235
+
236
+ For your own generated tools, leave off `--source examples`:
237
+
238
+ ```bash
239
+ imprint install mysite --platform claude-code
240
+ imprint install mysite --platform codex
241
+ ```
242
+
210
243
  ---
211
244
 
212
245
  ## CLI Reference
@@ -227,7 +260,14 @@ imprint <command> --help # per-command options
227
260
 
228
261
  ## Sharing Skills
229
262
 
230
- Teach on your laptop, ship to a remote agent. Skill folders contain **zero plaintext credentials** only `${credential.NAME}` placeholders and a manifest listing what the receiver must provision.
263
+ Teach on your laptop, ship to a remote agent. Generated MCP folders contain the portable tool artifacts: `workflow.json`, `playbook.yaml`, `index.ts`, optional shared modules, and cron/backend metadata. Copy `~/.imprint/<site>` into the receiver's `~/.imprint/<site>` or commit it to a private repo, install Imprint there, then register it:
264
+
265
+ ```bash
266
+ bun install -g imprint-mcp
267
+ imprint install mysite --platform claude-code
268
+ ```
269
+
270
+ Credentials stay separate. Skill folders contain **zero plaintext credentials** — only `${credential.NAME}` placeholders and a manifest listing what the receiver must provision.
231
271
 
232
272
  ```bash
233
273
  # Export (encrypted with libsodium + argon2id)
@@ -75,7 +75,7 @@ const WORKFLOW: Workflow = {
75
75
  {
76
76
  "name": "f_sid",
77
77
  "required": false,
78
- "capability": "ordinary_http",
78
+ "capability": "browser_bootstrap",
79
79
  "source": "html_regex",
80
80
  "pattern": "\"FdrFJe\":\"([^\"]+)\"",
81
81
  "group": 1
@@ -83,7 +83,7 @@ const WORKFLOW: Workflow = {
83
83
  {
84
84
  "name": "bl",
85
85
  "required": false,
86
- "capability": "ordinary_http",
86
+ "capability": "browser_bootstrap",
87
87
  "source": "html_regex",
88
88
  "pattern": "\"cfb2h\":\"([^\"]+)\"",
89
89
  "group": 1
@@ -44,14 +44,16 @@
44
44
  "name": "f_sid",
45
45
  "pattern": "\"FdrFJe\":\"([^\"]+)\"",
46
46
  "group": 1,
47
- "required": false
47
+ "required": false,
48
+ "capability": "browser_bootstrap"
48
49
  },
49
50
  {
50
51
  "source": "html_regex",
51
52
  "name": "bl",
52
53
  "pattern": "\"cfb2h\":\"([^\"]+)\"",
53
54
  "group": 1,
54
- "required": false
55
+ "required": false,
56
+ "capability": "browser_bootstrap"
55
57
  }
56
58
  ]
57
59
  },
@@ -8,13 +8,14 @@
8
8
  - **`probe-backends` skipping the futile rung.** The cached `backends.json` orders the ladder `stealth-fetch → playbook` so cron doesn't burn 200ms on a fetch attempt every tick.
9
9
  - **`notifyWhen: price_below`** pushing only on real drops, with the `pricePath` extracting from real Southwest response shape.
10
10
  - **The fresh-UUID header trick** — Southwest rejects stale `X-User-Experience-ID`; stealth-fetch regenerates per call.
11
+ - **Public bootstrap config capture.** The workflow fetches Southwest's public bootstrap `data.js` and captures the current `api-keys.prod` value, so installed examples do not require a `SOUTHWEST_API_KEY` environment variable.
11
12
  - **Multi-path `pricePath`** — `cron.json`'s notifyWhen lists both the raw API shape (when stealth-fetch wins) and the playbook's reshaped output, so the push fires regardless of which backend produced the result.
12
13
 
13
14
  ## Run it
14
15
 
15
16
  ```bash
16
- # One-time setup (if you haven't already)
17
- bunx playwright install chromium
17
+ # One-time setup: registers the example MCP and installs Playwright Chromium if missing
18
+ imprint install southwest --source examples --platform claude-desktop
18
19
 
19
20
  # Run a single tick (verifies everything still works)
20
21
  imprint cron southwest --once
@@ -55,13 +55,30 @@ const WORKFLOW: Workflow = {
55
55
  }
56
56
  ],
57
57
  "requests": [
58
+ {
59
+ "method": "GET",
60
+ "url": "https://www.southwest.com/swa-ui/bootstrap/landing-home-page-v2/1/data.js",
61
+ "headers": {
62
+ "Accept": "application/javascript, text/javascript, */*; q=0.01"
63
+ },
64
+ "captures": [
65
+ {
66
+ "name": "southwest_api_key",
67
+ "source": "text_regex",
68
+ "pattern": "\"swa-bootstrap-landing-home-page-v2/api-keys\":\\[function\\(require,module,exports\\)\\{\\s*module\\.exports = \\{[^}]*\"prod\":\"([^\"]+)\"",
69
+ "group": 1,
70
+ "required": true,
71
+ "capability": "ordinary_http"
72
+ }
73
+ ]
74
+ },
58
75
  {
59
76
  "method": "POST",
60
77
  "url": "https://www.southwest.com/api/air-booking/v1/air-booking/page/air/booking/shopping",
61
78
  "headers": {
62
79
  "Content-Type": "application/json",
63
80
  "Accept": "application/json, text/javascript, */*; q=0.01",
64
- "X-API-Key": "${env.SOUTHWEST_API_KEY}",
81
+ "X-API-Key": "${state.southwest_api_key}",
65
82
  "X-App-ID": "air-booking",
66
83
  "X-Channel-ID": "southwest"
67
84
  },
@@ -37,13 +37,30 @@
37
37
  }
38
38
  ],
39
39
  "requests": [
40
+ {
41
+ "method": "GET",
42
+ "url": "https://www.southwest.com/swa-ui/bootstrap/landing-home-page-v2/1/data.js",
43
+ "headers": {
44
+ "Accept": "application/javascript, text/javascript, */*; q=0.01"
45
+ },
46
+ "captures": [
47
+ {
48
+ "name": "southwest_api_key",
49
+ "source": "text_regex",
50
+ "pattern": "\"swa-bootstrap-landing-home-page-v2/api-keys\":\\[function\\(require,module,exports\\)\\{\\s*module\\.exports = \\{[^}]*\"prod\":\"([^\"]+)\"",
51
+ "group": 1,
52
+ "required": true,
53
+ "capability": "ordinary_http"
54
+ }
55
+ ]
56
+ },
40
57
  {
41
58
  "method": "POST",
42
59
  "url": "https://www.southwest.com/api/air-booking/v1/air-booking/page/air/booking/shopping",
43
60
  "headers": {
44
61
  "Content-Type": "application/json",
45
62
  "Accept": "application/json, text/javascript, */*; q=0.01",
46
- "X-API-Key": "${env.SOUTHWEST_API_KEY}",
63
+ "X-API-Key": "${state.southwest_api_key}",
47
64
  "X-App-ID": "air-booking",
48
65
  "X-Channel-ID": "southwest"
49
66
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.ts CHANGED
@@ -238,7 +238,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
238
238
  summary:
239
239
  'Install an already-emitted MCP server into Claude Code, Codex, Claude Desktop, OpenClaw, or Hermes.',
240
240
  usage: [
241
- 'imprint install [<site>] [--platform <name>] [--source local|examples] [--print] [--no-interactive]',
241
+ 'imprint install [<site>] [--platform <name>] [--source local|examples] [--print] [--no-interactive] [--skip-browser-install]',
242
242
  ],
243
243
  flags: [
244
244
  {
@@ -257,6 +257,10 @@ export const VERB_HELP: Record<string, VerbHelp> = {
257
257
  name: '--no-interactive',
258
258
  description: 'Do not prompt; requires <site> and --platform.',
259
259
  },
260
+ {
261
+ name: '--skip-browser-install',
262
+ description: 'Do not auto-install Playwright Chromium for browser-backed tools.',
263
+ },
260
264
  ],
261
265
  example: 'imprint install google-flights --source examples --platform claude-desktop',
262
266
  },
@@ -822,6 +826,7 @@ async function main(argv: string[]): Promise<number> {
822
826
  source: { type: 'string' },
823
827
  print: { type: 'boolean' },
824
828
  'no-interactive': { type: 'boolean' },
829
+ 'skip-browser-install': { type: 'boolean' },
825
830
  },
826
831
  allowPositionals: false,
827
832
  });
@@ -850,6 +855,7 @@ async function main(argv: string[]): Promise<number> {
850
855
  source: values.source as (typeof sources)[number] | undefined,
851
856
  print: values.print,
852
857
  noInteractive: values['no-interactive'],
858
+ skipBrowserInstall: values['skip-browser-install'],
853
859
  });
854
860
  console.log(`[imprint] ${result.message}`);
855
861
  if ('source' in result)
@@ -283,7 +283,10 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
283
283
  throw new Error('navigate timeout');
284
284
  }),
285
285
  ]);
286
- await Page.loadEventFired().catch(() => {});
286
+ await Promise.race([
287
+ Page.loadEventFired(),
288
+ sleep(Math.min(abckWaitMs, 5000)).then(() => undefined),
289
+ ]).catch(() => {});
287
290
  } catch (err) {
288
291
  log(`navigation issue (continuing): ${err instanceof Error ? err.message : String(err)}`);
289
292
  }
@@ -2,11 +2,20 @@
2
2
  * bundled Chromium (unmanaged) over system Chrome (corporate policy
3
3
  * often blocks --remote-debugging-port). $CHROMIUM_PATH overrides. */
4
4
 
5
- import { type ChildProcess, spawn } from 'node:child_process';
6
- import { existsSync, readdirSync, statSync } from 'node:fs';
5
+ import { type ChildProcess, spawnSync as nodeSpawnSync, spawn } from 'node:child_process';
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ statSync,
13
+ unlinkSync,
14
+ } from 'node:fs';
15
+ import { createRequire } from 'node:module';
7
16
  import { createServer } from 'node:net';
8
17
  import { homedir, tmpdir } from 'node:os';
9
- import { join as pathJoin } from 'node:path';
18
+ import { dirname as pathDirname, join as pathJoin } from 'node:path';
10
19
  import { setTimeout as sleep } from 'node:timers/promises';
11
20
  import { isDebug } from './log.ts';
12
21
 
@@ -62,6 +71,13 @@ export function chromeProxyArg(proxy: string): string | null {
62
71
  return /^[\w.-]+:\d+$/.test(proxy) ? proxy : null;
63
72
  }
64
73
 
74
+ export function shouldDisableChromiumSandbox(): boolean {
75
+ const override = process.env.IMPRINT_CHROMIUM_NO_SANDBOX?.trim().toLowerCase();
76
+ if (override === '1' || override === 'true' || override === 'yes') return true;
77
+ if (override === '0' || override === 'false' || override === 'no') return false;
78
+ return process.platform === 'linux' && existsSync('/.dockerenv');
79
+ }
80
+
65
81
  interface LaunchedChromium {
66
82
  process: ChildProcess;
67
83
  port: number;
@@ -77,14 +93,36 @@ const LINUX_CANDIDATES = [
77
93
  '/usr/bin/chromium',
78
94
  '/usr/bin/chromium-browser',
79
95
  ];
96
+ const require = createRequire(import.meta.url);
97
+
98
+ export function defaultPlaywrightBrowsersPath(): string | undefined {
99
+ const hermesHome = process.env.HERMES_HOME?.trim();
100
+ if (hermesHome) return pathJoin(hermesHome, '.cache', 'ms-playwright');
101
+ const explicit = process.env.PLAYWRIGHT_BROWSERS_PATH?.trim();
102
+ if (explicit) return explicit;
103
+ return undefined;
104
+ }
105
+
106
+ function playwrightInstallEnv(): NodeJS.ProcessEnv {
107
+ const env = { ...process.env };
108
+ const browsersPath = defaultPlaywrightBrowsersPath();
109
+ if (browsersPath) env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
110
+ return env;
111
+ }
112
+
113
+ function playwrightChromiumCacheRoots(): string[] {
114
+ const roots = [
115
+ defaultPlaywrightBrowsersPath(),
116
+ pathJoin(homedir(), 'Library/Caches/ms-playwright'),
117
+ pathJoin(homedir(), '.cache/ms-playwright'),
118
+ ].filter((root): root is string => Boolean(root));
119
+ return [...new Set(roots)];
120
+ }
80
121
 
81
122
  /** Find Playwright's "Google Chrome for Testing" — newest version wins
82
123
  * if multiple are installed. */
83
124
  function findPlaywrightChromium(): string | null {
84
- const cacheRoots = [
85
- pathJoin(homedir(), 'Library/Caches/ms-playwright'),
86
- pathJoin(homedir(), '.cache/ms-playwright'),
87
- ];
125
+ const cacheRoots = playwrightChromiumCacheRoots();
88
126
  for (const root of cacheRoots) {
89
127
  if (!existsSync(root)) continue;
90
128
  let dirs: string[];
@@ -122,6 +160,7 @@ function findPlaywrightChromium(): string | null {
122
160
  'Google Chrome for Testing',
123
161
  ),
124
162
  // Linux layout
163
+ pathJoin(root, dir, 'chrome-linux64', 'chrome'),
125
164
  pathJoin(root, dir, 'chrome-linux', 'chrome'),
126
165
  ];
127
166
  for (const c of candidates) {
@@ -136,7 +175,236 @@ function findPlaywrightChromium(): string | null {
136
175
  return null;
137
176
  }
138
177
 
178
+ function playwrightInstallCommand(): string[] {
179
+ const playwrightCli = resolvePlaywrightCli();
180
+ if (playwrightCli) return ['node', playwrightCli, 'install', 'chromium'];
181
+ if (process.versions.bun) return [process.execPath, 'x', 'playwright', 'install', 'chromium'];
182
+ return ['bunx', 'playwright', 'install', 'chromium'];
183
+ }
184
+
185
+ function resolvePlaywrightCli(): string | null {
186
+ try {
187
+ const packageJson = require.resolve('playwright/package.json');
188
+ const cli = pathJoin(pathDirname(packageJson), 'cli.js');
189
+ return existsSync(cli) ? cli : null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ function quoteShellArg(value: string): string {
196
+ if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
197
+ return `'${value.replaceAll("'", "'\\''")}'`;
198
+ }
199
+
200
+ function commandText(command: string[], env: NodeJS.ProcessEnv): string {
201
+ const prefix = env.PLAYWRIGHT_BROWSERS_PATH
202
+ ? `PLAYWRIGHT_BROWSERS_PATH=${quoteShellArg(env.PLAYWRIGHT_BROWSERS_PATH)} `
203
+ : '';
204
+ return `${prefix}${command.map(quoteShellArg).join(' ')}`;
205
+ }
206
+
207
+ interface InstallerResult {
208
+ exitCode: number | null;
209
+ stdout?: string;
210
+ stderr?: string;
211
+ signal?: NodeJS.Signals | null;
212
+ error?: string;
213
+ timedOut?: boolean;
214
+ timeoutMs?: number;
215
+ logPath?: string;
216
+ }
217
+
218
+ type ChromiumInstaller = (command: string[], env: NodeJS.ProcessEnv) => InstallerResult;
219
+
220
+ let chromiumInstallerForTest: ChromiumInstaller | null = null;
221
+ let chromiumFinderForTest: (() => string | null) | null = null;
222
+ let verifiedChromiumPath: string | null = null;
223
+
224
+ export function __setPlaywrightChromiumInstallerForTest(installer: ChromiumInstaller | null): void {
225
+ chromiumInstallerForTest = installer;
226
+ }
227
+
228
+ export function __setChromiumFinderForTest(finder: (() => string | null) | null): void {
229
+ chromiumFinderForTest = finder;
230
+ verifiedChromiumPath = null;
231
+ }
232
+
233
+ function runPlaywrightChromiumInstall(command: string[], env: NodeJS.ProcessEnv): InstallerResult {
234
+ if (chromiumInstallerForTest) return chromiumInstallerForTest(command, env);
235
+ const timeoutMs = playwrightInstallTimeoutMs();
236
+ const logPath = pathJoin(tmpdir(), `imprint-playwright-install-${process.pid}-${Date.now()}.log`);
237
+ let logFd: number | null = null;
238
+ try {
239
+ logFd = openSync(logPath, 'w');
240
+ const result = nodeSpawnSync(command[0] ?? '', command.slice(1), {
241
+ env,
242
+ // Playwright emits frequent progress lines. Send them to a file so parent
243
+ // command runners that capture stderr without draining it cannot block.
244
+ stdio: ['ignore', logFd, logFd],
245
+ timeout: timeoutMs,
246
+ });
247
+ const failed = result.status !== 0 || Boolean(result.error);
248
+ if (!failed) unlinkInstallerLog(logPath);
249
+ return formatSpawnResult(
250
+ result,
251
+ timeoutMs,
252
+ failed ? readInstallerLog(logPath) : undefined,
253
+ logPath,
254
+ );
255
+ } finally {
256
+ if (logFd !== null) closeSync(logFd);
257
+ }
258
+ }
259
+
260
+ function formatSpawnResult(
261
+ result: ReturnType<typeof nodeSpawnSync>,
262
+ timeoutMs: number,
263
+ output: string | undefined,
264
+ logPath: string,
265
+ ): InstallerResult {
266
+ const error = result.error as (Error & { code?: string }) | undefined;
267
+ return {
268
+ exitCode: result.status,
269
+ stderr: output,
270
+ signal: result.signal,
271
+ error: error?.message,
272
+ timedOut: error?.code === 'ETIMEDOUT',
273
+ timeoutMs,
274
+ logPath,
275
+ };
276
+ }
277
+
278
+ function readInstallerLog(logPath: string): string | undefined {
279
+ try {
280
+ const output = readFileSync(logPath, 'utf8').trim();
281
+ const maxChars = 50_000;
282
+ if (output.length <= maxChars) return output;
283
+ return `[last ${maxChars} chars of ${logPath}]\n${output.slice(-maxChars)}`;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ function unlinkInstallerLog(logPath: string): void {
290
+ try {
291
+ unlinkSync(logPath);
292
+ } catch {
293
+ // best effort cleanup
294
+ }
295
+ }
296
+
297
+ function playwrightInstallTimeoutMs(): number {
298
+ const raw = process.env.IMPRINT_PLAYWRIGHT_INSTALL_TIMEOUT_MS?.trim();
299
+ if (!raw) return 10 * 60 * 1000;
300
+ const parsed = Number(raw);
301
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 60 * 1000;
302
+ }
303
+
304
+ function formatInstallerFailure(result: InstallerResult): string | undefined {
305
+ const lines: string[] = [];
306
+ if (result.timedOut) {
307
+ lines.push(`Timed out after ${Math.round((result.timeoutMs ?? 0) / 1000)}s.`);
308
+ }
309
+ if (result.signal) lines.push(`Terminated by signal: ${result.signal}`);
310
+ if (result.error) lines.push(`Error: ${result.error}`);
311
+ const output = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
312
+ if (output) lines.push(`Output:\n${output}`);
313
+ else if (result.logPath) lines.push(`Installer log: ${result.logPath}`);
314
+ return lines.length > 0 ? lines.join('\n') : undefined;
315
+ }
316
+
317
+ interface EnsureChromiumResult {
318
+ path: string;
319
+ installed: boolean;
320
+ command?: string;
321
+ }
322
+
323
+ function verifyChromiumExecutable(path: string): void {
324
+ if (verifiedChromiumPath === path) return;
325
+ const result = Bun.spawnSync([path, '--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
326
+ if (result.exitCode === 0) {
327
+ verifiedChromiumPath = path;
328
+ return;
329
+ }
330
+ const output = `${result.stderr.toString()}\n${result.stdout.toString()}`.trim();
331
+ throw new Error(
332
+ [
333
+ `Chromium was found at ${path}, but it could not start.`,
334
+ output ? `Output:\n${output}` : undefined,
335
+ process.platform === 'linux'
336
+ ? 'Install missing Linux browser libraries with: bunx playwright install --with-deps chromium'
337
+ : undefined,
338
+ ]
339
+ .filter((line): line is string => Boolean(line))
340
+ .join('\n'),
341
+ );
342
+ }
343
+
344
+ export function ensurePlaywrightChromiumInstalled(
345
+ opts: {
346
+ log?: (message: string) => void;
347
+ } = {},
348
+ ): EnsureChromiumResult {
349
+ let existingPath: string | null = null;
350
+ try {
351
+ existingPath = findChromium();
352
+ } catch {
353
+ // Install below.
354
+ }
355
+ if (existingPath) {
356
+ verifyChromiumExecutable(existingPath);
357
+ return { path: existingPath, installed: false };
358
+ }
359
+
360
+ const env = playwrightInstallEnv();
361
+ if (env.PLAYWRIGHT_BROWSERS_PATH) {
362
+ process.env.PLAYWRIGHT_BROWSERS_PATH = env.PLAYWRIGHT_BROWSERS_PATH;
363
+ }
364
+ const command = playwrightInstallCommand();
365
+ const displayCommand = commandText(command, env);
366
+ opts.log?.(`Chromium not found; installing Playwright Chromium with: ${displayCommand}`);
367
+ const result = runPlaywrightChromiumInstall(command, env);
368
+ if (result.exitCode !== 0 || result.error) {
369
+ const failure = formatInstallerFailure(result);
370
+ throw new Error(
371
+ [
372
+ 'Could not install Playwright Chromium automatically.',
373
+ `Command: ${displayCommand}`,
374
+ failure,
375
+ '',
376
+ 'Retry manually with the command above.',
377
+ process.platform === 'linux'
378
+ ? 'If Chromium is installed but cannot launch in a fresh Linux image, install OS browser libraries with: bunx playwright install --with-deps chromium'
379
+ : undefined,
380
+ ]
381
+ .filter((line): line is string => Boolean(line))
382
+ .join('\n'),
383
+ );
384
+ }
385
+
386
+ try {
387
+ const path = findChromium();
388
+ verifyChromiumExecutable(path);
389
+ return { path, installed: true, command: displayCommand };
390
+ } catch (err) {
391
+ throw new Error(
392
+ [
393
+ 'Playwright Chromium install completed, but Imprint still could not locate or start the Chromium binary.',
394
+ `Command: ${displayCommand}`,
395
+ err instanceof Error ? err.message : String(err),
396
+ ].join('\n'),
397
+ );
398
+ }
399
+ }
400
+
139
401
  export function findChromium(): string {
402
+ if (chromiumFinderForTest) {
403
+ const path = chromiumFinderForTest();
404
+ if (path) return path;
405
+ throw new Error('Could not locate Chromium.');
406
+ }
407
+
140
408
  const explicit = process.env.CHROMIUM_PATH;
141
409
  if (explicit && existsSync(explicit)) return explicit;
142
410
 
@@ -272,7 +540,9 @@ async function waitForCdp(port: number, timeoutMs = 10_000): Promise<void> {
272
540
  }
273
541
 
274
542
  export async function launchChromium(opts: LaunchOptions = {}): Promise<LaunchedChromium> {
275
- const exe = findChromium();
543
+ const exe = ensurePlaywrightChromiumInstalled({
544
+ log: (message) => process.stderr.write(`[imprint] ${message}\n`),
545
+ }).path;
276
546
  const port = opts.port ?? (await pickFreePort());
277
547
  const userDataDir =
278
548
  opts.userDataDir ?? pathJoin(tmpdir(), `imprint-chrome-${Date.now()}-${process.pid}`);
@@ -286,6 +556,7 @@ export async function launchChromium(opts: LaunchOptions = {}): Promise<Launched
286
556
  '--disable-popup-blocking',
287
557
  '--use-mock-keychain',
288
558
  ];
559
+ if (shouldDisableChromiumSandbox()) args.push('--no-sandbox');
289
560
  if (opts.headless) args.push('--headless=new');
290
561
  const proxy = opts.proxy ?? proxyUrl();
291
562
  if (proxy) {
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
11
18
  import { basename, dirname, join as pathJoin, relative as pathRelative } from 'node:path';
12
19
  import type { AgentTool } from './agent.ts';
13
20
  import { inferAppApiHosts } from './app-api-hosts.ts';
@@ -1234,8 +1241,9 @@ export async function typecheckArtifacts(
1234
1241
  includes: string[],
1235
1242
  ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
1236
1243
  const configPath = pathJoin(dir, '.imprint-typecheck.tsconfig.json');
1237
- const rootTsconfig = pathJoin(REPO_ROOT, 'tsconfig.json');
1238
- const extendsPath = normalizeTsconfigPath(pathRelative(dir, rootTsconfig));
1244
+ const rootTsconfig = realpathSync(pathJoin(REPO_ROOT, 'tsconfig.json'));
1245
+ const configDir = realpathSync(dir);
1246
+ const extendsPath = normalizeTsconfigPath(pathRelative(configDir, rootTsconfig));
1239
1247
 
1240
1248
  writeFileSync(
1241
1249
  configPath,
@@ -77,6 +77,7 @@ async function runOnce(
77
77
  ladder: ConcreteBackend[],
78
78
  assetRoot: string,
79
79
  stealthCache: Map<string, StealthFetch>,
80
+ skipBootstrapSplice: boolean,
80
81
  ): Promise<ToolResult> {
81
82
  const startedAt = new Date();
82
83
  log(
@@ -90,6 +91,7 @@ async function runOnce(
90
91
  params,
91
92
  assetRoot,
92
93
  stealthCache,
94
+ { skipBootstrapSplice },
93
95
  );
94
96
 
95
97
  const elapsed = Date.now() - t0;
@@ -274,6 +276,7 @@ async function runCronImpl(opts: RunCronOptions): Promise<void> {
274
276
  ladder,
275
277
  assetRoot,
276
278
  stealthCache,
279
+ Boolean(cached?.preferredOrder.length),
277
280
  ] as const;
278
281
 
279
282
  if (opts.once) {
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { join as pathJoin } from 'node:path';
8
8
  import { findChromium } from './chromium.ts';
9
+ import { defaultHermesConfigPath } from './install.ts';
9
10
  import { getProviderStatuses } from './llm.ts';
10
11
  import { VERSION } from './version.ts';
11
12
 
@@ -63,9 +64,13 @@ function checkPlaywrightChromium(): CheckResult {
63
64
  // useful as a separate line so users see whether the Playwright path
64
65
  // specifically is set up (matters for stealth-fetch + playbook backends).
65
66
  const cacheRoots = [
67
+ process.env.PLAYWRIGHT_BROWSERS_PATH,
68
+ process.env.HERMES_HOME
69
+ ? pathJoin(process.env.HERMES_HOME, '.cache', 'ms-playwright')
70
+ : undefined,
66
71
  pathJoin(homedir(), 'Library/Caches/ms-playwright'),
67
72
  pathJoin(homedir(), '.cache/ms-playwright'),
68
- ];
73
+ ].filter((root): root is string => Boolean(root));
69
74
  for (const root of cacheRoots) {
70
75
  if (!existsSync(root)) continue;
71
76
  try {
@@ -84,7 +89,8 @@ function checkPlaywrightChromium(): CheckResult {
84
89
  return {
85
90
  name: 'Playwright Chromium',
86
91
  ok: false,
87
- detail: 'no chromium-* install under ~/Library/Caches/ms-playwright or ~/.cache/ms-playwright',
92
+ detail:
93
+ 'no chromium-* install under PLAYWRIGHT_BROWSERS_PATH, $HERMES_HOME/.cache/ms-playwright, ~/Library/Caches/ms-playwright, or ~/.cache/ms-playwright',
88
94
  fix: 'run: bunx playwright install chromium (needed for stealth-fetch + playbook)',
89
95
  };
90
96
  }
@@ -209,7 +215,7 @@ function checkClaudeCode(): CheckResult {
209
215
  }
210
216
 
211
217
  function checkHermes(): CheckResult {
212
- const configPath = pathJoin(homedir(), '.hermes', 'config.yaml');
218
+ const configPath = defaultHermesConfigPath();
213
219
  if (!existsSync(configPath)) {
214
220
  return {
215
221
  name: 'Hermes Agent',
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'node:path';
4
4
  import * as p from '@clack/prompts';
5
5
  import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
6
+ import { defaultPlaywrightBrowsersPath, ensurePlaywrightChromiumInstalled } from './chromium.ts';
6
7
  import {
7
8
  type McpServerConfig,
8
9
  PLATFORMS,
@@ -16,7 +17,7 @@ import {
16
17
  shellQuote,
17
18
  } from './integrations.ts';
18
19
  import { imprintHomeDir } from './paths.ts';
19
- import { discoverTools } from './tool-loader.ts';
20
+ import { type ResolvedTool, discoverTools } from './tool-loader.ts';
20
21
  import type { Workflow } from './types.ts';
21
22
 
22
23
  type InstallSource = 'local' | 'examples';
@@ -28,6 +29,7 @@ interface InstallOptions {
28
29
  source?: InstallSource;
29
30
  print?: boolean;
30
31
  noInteractive?: boolean;
32
+ skipBrowserInstall?: boolean;
31
33
  }
32
34
 
33
35
  interface UninstallOptions {
@@ -70,6 +72,7 @@ interface InstallTarget {
70
72
  source: InstallSource;
71
73
  assetRoot: string;
72
74
  site: string;
75
+ tools: ResolvedTool[];
73
76
  workflows: Workflow[];
74
77
  }
75
78
 
@@ -114,7 +117,11 @@ function defaultOpenClawConfigPath(): string {
114
117
  return pathJoin(homedir(), '.openclaw', 'openclaw.json');
115
118
  }
116
119
 
117
- function defaultHermesConfigPath(): string {
120
+ export function defaultHermesConfigPath(): string {
121
+ const explicit = process.env.HERMES_CONFIG?.trim();
122
+ if (explicit) return explicit;
123
+ const hermesHome = process.env.HERMES_HOME?.trim();
124
+ if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
118
125
  return pathJoin(homedir(), '.hermes', 'config.yaml');
119
126
  }
120
127
 
@@ -255,7 +262,7 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
255
262
  const imprintCommand = configFilePlatform(platform)
256
263
  ? detectDirectBunImprintCommand()
257
264
  : detectImprintCommand();
258
- const env = { IMPRINT_HOME: target.assetRoot };
265
+ const env = buildInstallEnvironment(target);
259
266
  const workflow = target.workflows[0];
260
267
  if (!workflow) {
261
268
  throw new Error(`No emitted workflows found for ${target.site}. Run \`imprint emit\` first.`);
@@ -287,6 +294,10 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
287
294
  };
288
295
  }
289
296
 
297
+ if (!opts.skipBrowserInstall) {
298
+ ensureBrowserRuntimeForInstall(target);
299
+ }
300
+
290
301
  const regCommand = buildRegistrationCommand({
291
302
  site: target.site,
292
303
  platform,
@@ -422,10 +433,68 @@ async function resolveInstallTarget(opts: InstallOptions): Promise<InstallTarget
422
433
  source: selected.source,
423
434
  assetRoot: selected.assetRoot,
424
435
  site: selected.site,
436
+ tools,
425
437
  workflows,
426
438
  };
427
439
  }
428
440
 
441
+ function buildInstallEnvironment(target: InstallTarget): Record<string, string> {
442
+ const env: Record<string, string> = { IMPRINT_HOME: target.assetRoot };
443
+ const browsersPath = defaultPlaywrightBrowsersPath();
444
+ if (browsersPath && installTargetNeedsBrowserRuntime(target)) {
445
+ env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
446
+ }
447
+ return env;
448
+ }
449
+
450
+ function ensureBrowserRuntimeForInstall(target: InstallTarget): void {
451
+ if (!installTargetNeedsBrowserRuntime(target)) return;
452
+ const result = ensurePlaywrightChromiumInstalled({
453
+ log: (message) => process.stderr.write(`[imprint install] ${message}\n`),
454
+ });
455
+ if (result.installed) {
456
+ process.stderr.write(`[imprint install] installed Playwright Chromium at ${result.path}\n`);
457
+ }
458
+ }
459
+
460
+ function installTargetNeedsBrowserRuntime(target: InstallTarget): boolean {
461
+ return target.tools.some(
462
+ (tool) => workflowNeedsBrowserRuntime(tool.workflow) || toolDirNeedsBrowserRuntime(tool.dir),
463
+ );
464
+ }
465
+
466
+ function workflowNeedsBrowserRuntime(workflow: Workflow): boolean {
467
+ if (workflow.bootstrap) return true;
468
+ if (workflow.liveVerified === false && workflow.liveVerifiedWaiver?.kind === 'waived-bot') {
469
+ return true;
470
+ }
471
+ if (workflow.requests.some((request) => request.url.includes('${state.'))) return true;
472
+ return workflow.requests.some((request) =>
473
+ (request.captures ?? []).some((capture) => captureNeedsBrowserRuntime(capture.capability)),
474
+ );
475
+ }
476
+
477
+ function toolDirNeedsBrowserRuntime(toolDir: string): boolean {
478
+ if (existsSync(pathJoin(toolDir, 'playbook.yaml'))) return true;
479
+ const backendsPath = pathJoin(toolDir, 'backends.json');
480
+ if (!existsSync(backendsPath)) return false;
481
+ try {
482
+ const parsed = JSON.parse(readFileSync(backendsPath, 'utf8')) as { preferredOrder?: unknown };
483
+ if (!Array.isArray(parsed.preferredOrder)) return false;
484
+ return parsed.preferredOrder.some(
485
+ (backend) =>
486
+ typeof backend === 'string' &&
487
+ ['fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook'].includes(backend),
488
+ );
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+
494
+ function captureNeedsBrowserRuntime(capability: string | undefined): boolean {
495
+ return capability === 'browser_bootstrap' || capability === 'stealth_bootstrap';
496
+ }
497
+
429
498
  async function resolveInstallPlatform(
430
499
  opts: Pick<InstallOptions, 'platform' | 'noInteractive'>,
431
500
  message = 'Install this MCP server where?',
@@ -658,7 +727,13 @@ function isPlatformDetected(platform: Platform): boolean {
658
727
  case 'openclaw':
659
728
  return commandExists('openclaw') || existsSync(pathJoin(homedir(), '.openclaw'));
660
729
  case 'hermes':
661
- return commandExists('hermes') || existsSync(pathJoin(homedir(), '.hermes'));
730
+ return (
731
+ commandExists('hermes') ||
732
+ Boolean(process.env.HERMES_CONFIG?.trim()) ||
733
+ Boolean(process.env.HERMES_HOME?.trim()) ||
734
+ existsSync(defaultHermesConfigPath()) ||
735
+ existsSync(pathJoin(homedir(), '.hermes'))
736
+ );
662
737
  }
663
738
  }
664
739
 
@@ -105,7 +105,7 @@ export function generatePasteSnippet(opts: {
105
105
  This gives your agent a tool that ${descLower}. Parameters: ${paramList}.`;
106
106
 
107
107
  case 'hermes':
108
- return `Add the ${toolName} tool: add to ~/.hermes/config.yaml under mcp_servers:
108
+ return `Add the ${toolName} tool: add to $HERMES_HOME/config.yaml (or ~/.hermes/config.yaml outside Hermes) under mcp_servers:
109
109
 
110
110
  ${toolName}:
111
111
  command: "${ic.command}"
@@ -144,6 +144,14 @@ function defaultContext(opts: Partial<MaintenanceContext> = {}): MaintenanceCont
144
144
  };
145
145
  }
146
146
 
147
+ function hermesConfigPath(ctx: MaintenanceContext): string {
148
+ const explicit = process.env.HERMES_CONFIG?.trim();
149
+ if (explicit) return explicit;
150
+ const hermesHome = process.env.HERMES_HOME?.trim();
151
+ if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
152
+ return pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
153
+ }
154
+
147
155
  function parseSubArgs(argv: string[]): ParsedArgs {
148
156
  const positionals: string[] = [];
149
157
  const flags: Record<string, string | boolean> = {};
@@ -1419,7 +1427,7 @@ function scanJsonMap(
1419
1427
  }
1420
1428
 
1421
1429
  function scanHermes(ctx: MaintenanceContext): McpRegistration[] {
1422
- const configPath = pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
1430
+ const configPath = hermesConfigPath(ctx);
1423
1431
  if (!existsSync(configPath)) return [];
1424
1432
  const doc = YAML.parseDocument(readFileSync(configPath, 'utf8'));
1425
1433
  const servers = doc.get('mcp_servers', true);
@@ -179,7 +179,7 @@ function buildServer(
179
179
  args,
180
180
  assetRoot,
181
181
  stealthCache,
182
- { cdpPool, winnerCache },
182
+ { cdpPool, winnerCache, skipBootstrapSplice: Boolean(tool.preferredOrder?.length) },
183
183
  );
184
184
  // Reset the idle timer for this site's pooled Chrome.
185
185
  if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
@@ -1,4 +1,4 @@
1
- import { findChromium } from './chromium.ts';
1
+ import { ensurePlaywrightChromiumInstalled } from './chromium.ts';
2
2
 
3
3
  /**
4
4
  * Shared loader for Playwright's chromium with the stealth plugin applied.
@@ -69,13 +69,11 @@ export async function isStealthPluginAvailable(): Promise<boolean> {
69
69
  * replay browser using chrome-headless-shell looks like a bot. Using the
70
70
  * SAME binary for both eliminates the binary asymmetry.
71
71
  *
72
- * Returns `undefined` if no Chromium can be located callers should let
73
- * Playwright fall back to whatever default it finds.
72
+ * Throws if Chromium cannot be installed or started; callers translate the
73
+ * error into their own result shape.
74
74
  */
75
75
  export function getStealthExecutablePath(): string | undefined {
76
- try {
77
- return findChromium();
78
- } catch {
79
- return undefined;
80
- }
76
+ return ensurePlaywrightChromiumInstalled({
77
+ log: (message) => process.stderr.write(`[imprint] ${message}\n`),
78
+ }).path;
81
79
  }