imprint-mcp 0.2.0

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1287 @@
1
+ #!/usr/bin/env bun
2
+ /** CLI entry point. Run `imprint --help` for the verb list. */
3
+
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { basename, dirname, resolve } from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+ import { IS_COMPILED_BINARY } from './imprint/is-compiled.ts';
8
+ import type { ProviderName } from './imprint/llm.ts';
9
+ import { isDebug } from './imprint/log.ts';
10
+ import { shutdownTracing, traced } from './imprint/tracing.ts';
11
+ import { VERSION } from './imprint/version.ts';
12
+
13
+ /** Load .env from the project root (next to src/) if present.
14
+ * Bun auto-loads .env from CWD, but this covers running from other directories. */
15
+ function loadDotenv(): void {
16
+ const envPath = resolve(import.meta.dir, '..', '.env');
17
+ if (!existsSync(envPath)) return;
18
+ for (const line of readFileSync(envPath, 'utf8').split('\n')) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed || trimmed.startsWith('#')) continue;
21
+ const eq = trimmed.indexOf('=');
22
+ if (eq === -1) continue;
23
+ const key = trimmed.slice(0, eq);
24
+ const value = trimmed.slice(eq + 1);
25
+ if (!(key in process.env)) process.env[key] = value;
26
+ }
27
+ }
28
+
29
+ loadDotenv();
30
+
31
+ /** Parse a duration string: "5m" → 300000, "1h" → 3600000, "30s" → 30000, "5000" → 5000.
32
+ * Returns null if the format is invalid. */
33
+ export function parseDuration(dur: string): number | null {
34
+ const match = dur.match(/^(\d+)(m|h|s|ms)?$/);
35
+ if (!match) return null;
36
+ const num = Number.parseInt(match[1] ?? '0', 10);
37
+ const unit = match[2] ?? 'ms';
38
+ if (unit === 'h') return num * 60 * 60 * 1000;
39
+ if (unit === 'm') return num * 60 * 1000;
40
+ if (unit === 's') return num * 1000;
41
+ return num;
42
+ }
43
+
44
+ const HELP = `imprint v${VERSION} — teach an AI agent to use any website. Once.
45
+
46
+ USAGE
47
+ imprint <verb> [args]
48
+ imprint <verb> --help Per-verb help with flags and examples.
49
+
50
+ CAPTURE
51
+ record <site> Drive a workflow in Chromium, capture session.
52
+ teach <site> Record + compile + emit in one flow. <site> is a label you pick.
53
+ redact <session.json> Scrub credentials + PII before LLM analysis.
54
+
55
+ COMPILE
56
+ generate <session> Session → workflow.json (API replay).
57
+ compile-playbook <sess> Session → playbook.yaml (DOM replay).
58
+ emit <workflow.json> workflow.json → ~/.imprint/<site>/<toolName>/index.ts.
59
+ probe-backends <site> Try each backend once, cache the working order.
60
+
61
+ INSTALL
62
+ install [<site>] Install an emitted MCP server into an AI platform.
63
+ uninstall [<site>] Remove an installed Imprint MCP server from an AI platform.
64
+
65
+ RUN
66
+ mcp-server <site> Serve one site's tools as MCP (stdio default).
67
+ cron <site> Polling daemon for ~/.imprint/<site>/<toolName>/cron.json.
68
+ playbook <site> Run a playbook directly (debugging).
69
+
70
+ OTHER
71
+ doctor Check that the environment is set up correctly.
72
+ mcp Audit/disable/delete Imprint MCP registrations.
73
+ assemble <session.jsonl> Recover session.json from a partial JSONL.
74
+ check <session> Sanity-check a captured session.
75
+ login <site> Persist cookies for <site> from a session.
76
+ credential <subcmd> Manage stored credentials (list/get/set/delete/export/import/migrate).
77
+
78
+ GLOBAL
79
+ --help, -h Show this help.
80
+ --version, -v Print version.
81
+
82
+ Quick start: docs/getting-started.md
83
+ Full docs: docs/architecture.md, docs/glossary.md, docs/decisions.md
84
+ `;
85
+
86
+ export interface VerbHelp {
87
+ summary: string;
88
+ usage: string[];
89
+ flags?: Array<{ name: string; description: string }>;
90
+ example: string;
91
+ }
92
+
93
+ export const VERB_HELP: Record<string, VerbHelp> = {
94
+ record: {
95
+ summary: 'Drive a workflow in Chromium and stream the session to JSONL.',
96
+ usage: ['imprint record <site> [--url <url>] [--persist-profile] [--out <path>]'],
97
+ flags: [
98
+ { name: '--url <url>', description: 'Starting URL (else about:blank — navigate manually).' },
99
+ { name: '--out <path>', description: 'Override the JSONL output path.' },
100
+ {
101
+ name: '--persist-profile',
102
+ description: 'Reuse a stable Chrome profile for this site (preserves login state).',
103
+ },
104
+ ],
105
+ example: 'imprint record southwest --url https://www.southwest.com',
106
+ },
107
+ teach: {
108
+ summary:
109
+ 'Record a workflow, compile both artifacts, emit the tool, and connect to your AI platform — all in one interactive flow. Supports resuming incomplete runs and multiple workflows per site.',
110
+ usage: [
111
+ 'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--all-tools] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay]',
112
+ ],
113
+ flags: [
114
+ { name: '--url <url>', description: 'Starting URL (else about:blank).' },
115
+ {
116
+ name: '--from-session <path>',
117
+ description: 'Skip recording; use an existing session file to compile from.',
118
+ },
119
+ { name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
120
+ {
121
+ name: '--no-interactive',
122
+ description:
123
+ 'Run without prompts; compile the primary detected tool and print integration snippets.',
124
+ },
125
+ {
126
+ name: '--all-tools',
127
+ description:
128
+ 'With --no-interactive, compile every detected candidate tool instead of only the primary.',
129
+ },
130
+ {
131
+ name: '--provider <name>',
132
+ description:
133
+ 'Compile-agent provider: anthropic-api, claude-cli, codex-cli (auto-detected if omitted).',
134
+ },
135
+ {
136
+ name: '--model <name>',
137
+ description:
138
+ 'Override the compile-agent model (e.g. claude-sonnet-4-6). Default is prompted interactively or auto-selected per provider.',
139
+ },
140
+ {
141
+ name: '--timeout <duration>',
142
+ description: 'Per-tool compile timeout. Accepts 10m, 1h, 300s, or plain ms. Default 10m.',
143
+ },
144
+ {
145
+ name: '--keep-test',
146
+ description:
147
+ 'Retain the agent-generated parser.test.ts after compile (debug). Default deletes it; the test reads the gitignored redacted session via $IMPRINT_SESSION_PATH and is not portable. Also settable via IMPRINT_KEEP_TEST=1.',
148
+ },
149
+ {
150
+ name: '--skip-replay',
151
+ description:
152
+ "Skip the replay-and-diff stage. Faster, but the compile agent won't be able to distinguish browser-minted values from constants, which may reduce workflow accuracy.",
153
+ },
154
+ ],
155
+ example: 'imprint teach google-flights --url https://flights.google.com',
156
+ },
157
+ doctor: {
158
+ summary: 'Check that the environment is set up correctly (Bun, Chromium, LLM providers, push).',
159
+ usage: ['imprint doctor'],
160
+ example: 'imprint doctor',
161
+ },
162
+ assemble: {
163
+ summary: 'Reconstruct session.json from a partial session.jsonl.',
164
+ usage: ['imprint assemble <session.jsonl>'],
165
+ example: 'imprint assemble ~/.imprint/mysite/sessions/2026-05-03T22-00-00Z.jsonl',
166
+ },
167
+ check: {
168
+ summary: 'Sanity-check a captured session for completeness.',
169
+ usage: ['imprint check <session.json | session.jsonl>'],
170
+ example: 'imprint check ~/.imprint/acmecorp/sessions/2026-05-03T22-00-00Z.json',
171
+ },
172
+ redact: {
173
+ summary: 'Scrub credentials + PII; write <session>.redacted.json.',
174
+ usage: ['imprint redact <session.json> [--keep-header <name>]…'],
175
+ flags: [
176
+ {
177
+ name: '--keep-header <name>',
178
+ description:
179
+ 'Keep this header un-redacted (repeatable). Use when a non-credential header has a "secret" name.',
180
+ },
181
+ ],
182
+ example: 'imprint redact ~/.imprint/acmecorp/sessions/<ts>.json',
183
+ },
184
+ generate: {
185
+ summary: 'LLM-compile a session into workflow.json (API replay artifact).',
186
+ usage: [
187
+ 'imprint generate <session.json> [--out <path>] [--max-duration <time>] [--provider <name>] [--keep-test]',
188
+ ],
189
+ flags: [
190
+ { name: '--out <path>', description: 'Override the workflow.json output path.' },
191
+ {
192
+ name: '--max-duration <time>',
193
+ description: 'Agent timeout (e.g., "10m", "1h", "300s"). Default 10m.',
194
+ },
195
+ {
196
+ name: '--provider <name>',
197
+ description:
198
+ 'Compile-agent provider: anthropic-api, claude-cli, codex-cli (auto-detected if omitted).',
199
+ },
200
+ {
201
+ name: '--keep-test',
202
+ description:
203
+ 'Retain the agent-generated parser.test.ts after compile (debug). Default deletes it; the test reads the gitignored redacted session via $IMPRINT_SESSION_PATH and is not portable. Also settable via IMPRINT_KEEP_TEST=1.',
204
+ },
205
+ ],
206
+ example: 'imprint generate ~/.imprint/acmecorp/sessions/<ts>.redacted.json',
207
+ },
208
+ 'compile-playbook': {
209
+ summary: 'LLM-compile a session into playbook.yaml (DOM replay artifact).',
210
+ usage: [
211
+ 'imprint compile-playbook <session.json> [--out <path>] [--no-shrink] [--provider <name>]',
212
+ ],
213
+ flags: [
214
+ { name: '--out <path>', description: 'Override the playbook.yaml output path.' },
215
+ {
216
+ name: '--no-shrink',
217
+ description: 'Skip LLM-based triage; send all XHR/Fetch requests (debugging).',
218
+ },
219
+ {
220
+ name: '--provider <name>',
221
+ description:
222
+ 'LLM provider: anthropic-api, claude-cli, codex-cli, cursor-cli (auto-detected if omitted).',
223
+ },
224
+ ],
225
+ example: 'imprint compile-playbook ~/.imprint/acmecorp/sessions/<ts>.redacted.json',
226
+ },
227
+ emit: {
228
+ summary: 'Generate the executable TS module from workflow.json.',
229
+ usage: ['imprint emit <workflow.json> [--out-dir <dir>] [--force]'],
230
+ flags: [
231
+ { name: '--out-dir <dir>', description: 'Override the output directory.' },
232
+ { name: '--force', description: 'Overwrite an existing index.ts.' },
233
+ ],
234
+ example: 'imprint emit ~/.imprint/acmecorp/my-workflow/workflow.json',
235
+ },
236
+ install: {
237
+ summary:
238
+ 'Install an already-emitted MCP server into Claude Code, Codex, Claude Desktop, OpenClaw, or Hermes.',
239
+ usage: [
240
+ 'imprint install [<site>] [--platform <name>] [--source local|examples] [--print] [--no-interactive]',
241
+ ],
242
+ flags: [
243
+ {
244
+ name: '--platform <name>',
245
+ description: 'Target platform: claude-code, codex, claude-desktop, openclaw, hermes.',
246
+ },
247
+ {
248
+ name: '--source <source>',
249
+ description: 'Install generated tools from local IMPRINT_HOME or checked-in examples.',
250
+ },
251
+ {
252
+ name: '--print',
253
+ description: 'Print the platform config/command instead of writing or running it.',
254
+ },
255
+ {
256
+ name: '--no-interactive',
257
+ description: 'Do not prompt; requires <site> and --platform.',
258
+ },
259
+ ],
260
+ example: 'imprint install google-flights --source examples --platform claude-desktop',
261
+ },
262
+ uninstall: {
263
+ summary:
264
+ 'Remove an installed Imprint MCP server from Claude Code, Codex, Claude Desktop, OpenClaw, or Hermes.',
265
+ usage: ['imprint uninstall [<site>] [--platform <name>] [--print] [--no-interactive]'],
266
+ flags: [
267
+ {
268
+ name: '--platform <name>',
269
+ description: 'Target platform: claude-code, codex, claude-desktop, openclaw, hermes.',
270
+ },
271
+ {
272
+ name: '--print',
273
+ description: 'Print the platform remove command/config edit instead of applying it.',
274
+ },
275
+ {
276
+ name: '--no-interactive',
277
+ description: 'Do not prompt; requires <site> and --platform.',
278
+ },
279
+ ],
280
+ example: 'imprint uninstall google-flights --platform claude-desktop',
281
+ },
282
+ login: {
283
+ summary: 'Persist auth cookies for <site> from a captured session.',
284
+ usage: ['imprint login <site> --from-session <session.json>'],
285
+ flags: [
286
+ { name: '--from-session <path>', description: 'Source session.json (required in v0.1).' },
287
+ ],
288
+ example:
289
+ 'imprint login discoverandgo --from-session ~/.imprint/discoverandgo/sessions/<ts>.json',
290
+ },
291
+ credential: {
292
+ summary:
293
+ 'Manage local credential storage. Subcommands: list, get, set, delete, export, import, migrate.',
294
+ usage: [
295
+ 'imprint credential list [<site>]',
296
+ 'imprint credential get <site> <name> --reveal',
297
+ 'imprint credential set <site> <name>',
298
+ 'imprint credential delete <site> <name>',
299
+ 'imprint credential export <site> [--out <path>]',
300
+ 'imprint credential import <site> <bundle-path>',
301
+ 'imprint credential migrate',
302
+ ],
303
+ example: 'imprint credential set southwest-seats password',
304
+ },
305
+ 'probe-backends': {
306
+ summary: 'Try each backend once and cache the working order to backends.json.',
307
+ usage: ['imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…'],
308
+ flags: [
309
+ { name: '--tool <toolName>', description: 'Select a generated tool for multi-tool sites.' },
310
+ { name: '--out <path>', description: 'Override backends.json output path.' },
311
+ { name: '--param k=v', description: 'Override a workflow parameter (repeatable).' },
312
+ ],
313
+ example: 'imprint probe-backends southwest --tool search_flights',
314
+ },
315
+ playbook: {
316
+ summary: 'Run a playbook against a real Chromium (debugging).',
317
+ usage: ['imprint playbook <site> [--headed] [--trace] [--path <yaml>] [--param k=v]…'],
318
+ flags: [
319
+ { name: '--headed', description: 'Show the browser window (default headless).' },
320
+ { name: '--trace', description: 'Screenshot after every step.' },
321
+ { name: '--path <yaml>', description: 'Override the playbook.yaml path.' },
322
+ { name: '--param k=v', description: 'Set a playbook parameter (repeatable).' },
323
+ ],
324
+ example:
325
+ 'imprint playbook southwest --param origin_airport_code=SJC --param destination_airport_code=SAN',
326
+ },
327
+ cron: {
328
+ summary:
329
+ 'Polling daemon for cron.json next to a generated tool at ~/.imprint/<site>/<toolName>/cron.json.',
330
+ usage: [
331
+ 'imprint cron <site> [--tool <toolName>] [--once | --run-now] [--config <path>] [--quiet]',
332
+ ],
333
+ flags: [
334
+ { name: '--tool <toolName>', description: 'Select a generated tool for multi-tool sites.' },
335
+ { name: '--once', description: 'Run a single tick and exit (for OS schedulers).' },
336
+ { name: '--run-now', description: 'Run once immediately, then continue scheduling.' },
337
+ { name: '--config <path>', description: 'Override the cron.json path.' },
338
+ {
339
+ name: '--quiet',
340
+ description:
341
+ 'Suppress logs on successful runs (errors still surface). For OS schedulers that mail on stderr.',
342
+ },
343
+ ],
344
+ example: 'imprint cron southwest --tool search_flights --once --quiet',
345
+ },
346
+ 'mcp-server': {
347
+ summary: "Serve one site's generated tools as MCP (stdio default).",
348
+ usage: ['imprint mcp-server <site> [--http] [--port <num>]'],
349
+ flags: [
350
+ { name: '--http', description: 'Use Streamable HTTP transport instead of stdio.' },
351
+ { name: '--port <num>', description: 'Port for HTTP transport (default 8765).' },
352
+ ],
353
+ example: 'imprint mcp-server southwest',
354
+ },
355
+ mcp: {
356
+ summary:
357
+ 'Audit, disable, re-enable, and delete Imprint MCP registrations and stale teach state.',
358
+ usage: [
359
+ 'imprint mcp',
360
+ 'imprint mcp status [--site <site>] [--json]',
361
+ 'imprint mcp disable <server-or-site> [--client <name|all>] [--yes]',
362
+ 'imprint mcp enable <server-or-site> [--client <name|all>] [--yes]',
363
+ 'imprint mcp delete <server-or-site> [--client <name|all>] [--local none|tool|site] [--yes]',
364
+ 'imprint mcp prune-state [--site <site>] [--missing-session] [--incomplete] [--yes]',
365
+ ],
366
+ flags: [
367
+ { name: '--site <site>', description: 'Limit status/prune-state to one Imprint site.' },
368
+ {
369
+ name: '--client <name|all>',
370
+ description:
371
+ 'Limit mutations to one client (claude-code, codex, claude-desktop, openclaw, hermes) or all.',
372
+ },
373
+ {
374
+ name: '--local none|tool|site',
375
+ description:
376
+ 'For delete: also remove local generated tools or the full local site directory.',
377
+ },
378
+ { name: '--yes', description: 'Required for direct mutating subcommands.' },
379
+ { name: '--json', description: 'Print machine-readable status output.' },
380
+ ],
381
+ example: 'imprint mcp status',
382
+ },
383
+ };
384
+
385
+ function printVerbHelp(verb: string): void {
386
+ const h = VERB_HELP[verb];
387
+ if (!h) {
388
+ console.error(`No help for unknown verb: ${verb}`);
389
+ return;
390
+ }
391
+ console.log(`imprint ${verb} — ${h.summary}\n`);
392
+ console.log('USAGE');
393
+ for (const u of h.usage) console.log(` ${u}`);
394
+ if (h.flags && h.flags.length > 0) {
395
+ console.log('\nFLAGS');
396
+ const pad = Math.max(...h.flags.map((f) => f.name.length));
397
+ for (const f of h.flags) console.log(` ${f.name.padEnd(pad)} ${f.description}`);
398
+ }
399
+ console.log('\nEXAMPLE');
400
+ console.log(` ${h.example}\n`);
401
+ }
402
+
403
+ function isVerbHelpRequest(argv: string[]): boolean {
404
+ return argv.includes('--help') || argv.includes('-h');
405
+ }
406
+
407
+ /** Pull `argv[1]` or print a uniform error and return null for early-return. */
408
+ function requirePositional(argv: string[], verb: string, label: string): string | null {
409
+ const v = argv[1];
410
+ if (!v) {
411
+ console.error(
412
+ `error: \`imprint ${verb}\` requires ${label}\n→ run \`imprint ${verb} --help\` for usage.`,
413
+ );
414
+ return null;
415
+ }
416
+ if (v.startsWith('-')) {
417
+ console.error(
418
+ `error: \`imprint ${verb}\` requires a <site> name before any flags.\n <site> is a label you choose — it names the output folder under ~/.imprint/.\n\n example: imprint ${verb} google-flights --url https://flights.google.com\n→ run \`imprint ${verb} --help\` for usage.`,
419
+ );
420
+ return null;
421
+ }
422
+ return v;
423
+ }
424
+
425
+ /** Parse `--param k=v` entries; coerces only well-formed decimal numbers
426
+ * and booleans, leaves everything else as strings. Returns null and prints
427
+ * an error on malformed input — caller returns its own exit code.
428
+ *
429
+ * Numeric coercion is intentionally stricter than `Number(v)`:
430
+ * - Leading zeros stay strings ("0123" → "0123", not 123) so airport / ZIP /
431
+ * library-card codes survive.
432
+ * - "Infinity" / "-Infinity" / "NaN" stay strings (Number() accepts them).
433
+ * - Empty / whitespace stays as the literal string.
434
+ * - Hex / binary / octal literals stay strings.
435
+ * Pattern matches: optional minus, single 0 or non-zero-leading digits,
436
+ * optional .digits, optional eN exponent. */
437
+ const NUMERIC_PARAM_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
438
+
439
+ export function tryParseParamKV(
440
+ entries: string[] | undefined,
441
+ ): Record<string, string | number | boolean> | null {
442
+ const out: Record<string, string | number | boolean> = {};
443
+ for (const kv of entries ?? []) {
444
+ const eq = kv.indexOf('=');
445
+ if (eq === -1) {
446
+ console.error(
447
+ `error: --param requires k=v form, got "${kv}"\n→ example: --param origin_airport_code=SJC`,
448
+ );
449
+ return null;
450
+ }
451
+ const k = kv.slice(0, eq);
452
+ const v = kv.slice(eq + 1);
453
+ if (v === 'true' || v === 'false') out[k] = v === 'true';
454
+ else if (NUMERIC_PARAM_RE.test(v)) out[k] = Number(v);
455
+ else out[k] = v;
456
+ }
457
+ return out;
458
+ }
459
+
460
+ export function inferPlaybookSiteForSmokeCommand(playbookPath: string, toolName: string): string {
461
+ const parent = basename(dirname(playbookPath));
462
+ if (!parent) return '<site>';
463
+ if (parent === toolName) {
464
+ const grandparent = basename(dirname(dirname(playbookPath)));
465
+ return grandparent || '<site>';
466
+ }
467
+ return parent;
468
+ }
469
+
470
+ async function main(argv: string[]): Promise<number> {
471
+ const verb = argv[0];
472
+
473
+ if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
474
+ console.log(HELP);
475
+ return 0;
476
+ }
477
+ if (verb === '--version' || verb === '-v') {
478
+ console.log(VERSION);
479
+ return 0;
480
+ }
481
+
482
+ // Per-verb help: `imprint <verb> --help` or `-h`.
483
+ if (verb in VERB_HELP && isVerbHelpRequest(argv.slice(1))) {
484
+ printVerbHelp(verb);
485
+ return 0;
486
+ }
487
+
488
+ const BINARY_BLOCKED_COMMANDS = new Set([
489
+ 'teach',
490
+ 'record',
491
+ 'login',
492
+ 'playbook',
493
+ 'generate',
494
+ 'compile-playbook',
495
+ ]);
496
+ if (IS_COMPILED_BINARY && BINARY_BLOCKED_COMMANDS.has(verb)) {
497
+ const rest = process.argv.slice(2).join(' ');
498
+ const reason =
499
+ verb === 'generate' || verb === 'compile-playbook'
500
+ ? `The \`${verb}\` command spawns \`bun test\` for verification and requires the Bun runtime on PATH.`
501
+ : `The \`${verb}\` command requires Playwright, which isn't included in the standalone binary.`;
502
+ console.error(
503
+ [
504
+ reason,
505
+ '',
506
+ 'If you have Bun installed, run it directly:',
507
+ ` bunx imprint-mcp ${rest}`,
508
+ '',
509
+ "If you don't have Bun yet:",
510
+ ' curl -fsSL https://bun.sh/install | bash',
511
+ ` bunx imprint-mcp ${rest}`,
512
+ ].join('\n'),
513
+ );
514
+ return 1;
515
+ }
516
+
517
+ switch (verb) {
518
+ case 'record': {
519
+ const site = requirePositional(argv, 'record', 'a <site> argument');
520
+ if (site === null) return 2;
521
+ const { record } = await import('./imprint/record.ts');
522
+ const { values } = parseArgs({
523
+ args: argv.slice(2),
524
+ options: {
525
+ url: { type: 'string' },
526
+ out: { type: 'string' },
527
+ 'persist-profile': { type: 'boolean' },
528
+ },
529
+ allowPositionals: false,
530
+ });
531
+
532
+ // SIGINT → AbortController so the recorder flushes files before exit.
533
+ const ctrl = new AbortController();
534
+ const onSigint = (): void => ctrl.abort();
535
+ process.once('SIGINT', onSigint);
536
+
537
+ try {
538
+ await record({
539
+ site,
540
+ url: values.url,
541
+ outPath: values.out,
542
+ persistProfile: values['persist-profile'],
543
+ signal: ctrl.signal,
544
+ });
545
+ } finally {
546
+ process.removeListener('SIGINT', onSigint);
547
+ }
548
+ return 0;
549
+ }
550
+
551
+ case 'doctor': {
552
+ const { doctor, reportDoctor } = await import('./imprint/doctor.ts');
553
+ const report = reportDoctor(doctor());
554
+ for (const line of report.lines) console.log(line);
555
+ return report.ok ? 0 : 1;
556
+ }
557
+
558
+ case 'assemble': {
559
+ const jsonlPath = requirePositional(argv, 'assemble', 'a <session.jsonl> argument');
560
+ if (jsonlPath === null) return 2;
561
+ const { assembleFromJsonl } = await import('./imprint/session-writer.ts');
562
+ const { writeFileSync } = await import('node:fs');
563
+ const session = assembleFromJsonl(jsonlPath);
564
+ const outPath = jsonlPath.replace(/\.jsonl$/, '.json');
565
+ writeFileSync(outPath, `${JSON.stringify(session, null, 2)}\n`, 'utf8');
566
+ console.log(`[imprint] assembled → ${outPath}`);
567
+ console.log(
568
+ `[imprint] ${session.requests.length} requests, ${session.events.length} events, ${session.narration.length} narration lines`,
569
+ );
570
+ console.log('');
571
+ console.log('next step:');
572
+ console.log(` imprint check ${outPath} # sanity-check what was captured`);
573
+ return 0;
574
+ }
575
+
576
+ case 'check': {
577
+ const sessionPath = requirePositional(
578
+ argv,
579
+ 'check',
580
+ 'a <session.json> or <session.jsonl> argument',
581
+ );
582
+ if (sessionPath === null) return 2;
583
+ const { checkSession, reportCheck } = await import('./imprint/check.ts');
584
+ const result = checkSession(sessionPath);
585
+ reportCheck(sessionPath, result);
586
+ return result.ok ? 0 : 1;
587
+ }
588
+
589
+ case 'redact': {
590
+ const sessionPath = requirePositional(argv, 'redact', 'a <session.json> argument');
591
+ if (sessionPath === null) return 2;
592
+ const { values } = parseArgs({
593
+ args: argv.slice(2),
594
+ options: { 'keep-header': { type: 'string', multiple: true } },
595
+ allowPositionals: false,
596
+ });
597
+ const { writeFileSync } = await import('node:fs');
598
+ const { SessionSchema } = await import('./imprint/types.ts');
599
+ const { redactSession } = await import('./imprint/redact.ts');
600
+ const { loadJsonFile } = await import('./imprint/load-json.ts');
601
+ let session: ReturnType<typeof SessionSchema.parse>;
602
+ try {
603
+ session = loadJsonFile(
604
+ sessionPath,
605
+ SessionSchema,
606
+ {
607
+ notFound: '→ run `imprint record <site>` to capture one.',
608
+ notJson: `→ if this is a .jsonl from a crashed recording, run \`imprint assemble ${sessionPath}\` first.`,
609
+ badSchema: '→ hand-edited session files often drift; re-record if needed.',
610
+ },
611
+ 'session',
612
+ );
613
+ } catch (err) {
614
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
615
+ return 2;
616
+ }
617
+ const keepHeaders = values['keep-header'] ?? [];
618
+ const { session: scrubbed, stats } = redactSession(session, { keepHeaders });
619
+ const outPath = sessionPath.replace(/\.json$/, '.redacted.json');
620
+ writeFileSync(outPath, `${JSON.stringify(scrubbed, null, 2)}\n`, 'utf8');
621
+ console.log(`[imprint] redacted → ${outPath}`);
622
+ const freeformNote =
623
+ stats.freeformRedactions > 0
624
+ ? ` (${stats.freeformRedactions} free-form finding${stats.freeformRedactions === 1 ? '' : 's'})`
625
+ : '';
626
+ console.log(
627
+ `[imprint] ${stats.totalRedactions} value${stats.totalRedactions === 1 ? '' : 's'} replaced across ${stats.requestsRedacted} request${stats.requestsRedacted === 1 ? '' : 's'} and ${stats.cookiesRedacted} cookie${stats.cookiesRedacted === 1 ? '' : 's'}${freeformNote}`,
628
+ );
629
+ if (keepHeaders.length > 0) {
630
+ console.log(`[imprint] kept (not redacted): ${keepHeaders.join(', ')}`);
631
+ }
632
+ for (const w of stats.warnings) {
633
+ console.log(`[imprint] ⚠ ${w}`);
634
+ }
635
+ console.log('');
636
+ console.log('next step:');
637
+ console.log(` imprint generate ${outPath} # LLM → workflow.json`);
638
+ return 0;
639
+ }
640
+
641
+ case 'generate': {
642
+ const sessionPath = requirePositional(argv, 'generate', 'a <session.json> argument');
643
+ if (sessionPath === null) return 2;
644
+ const { values } = parseArgs({
645
+ args: argv.slice(2),
646
+ options: {
647
+ out: { type: 'string' },
648
+ 'max-duration': { type: 'string' },
649
+ provider: { type: 'string' },
650
+ 'keep-test': { type: 'boolean' },
651
+ },
652
+ allowPositionals: false,
653
+ });
654
+
655
+ if (values.provider) {
656
+ const { isTeachCompatibleProvider, isValidProvider } = await import('./imprint/llm.ts');
657
+ if (!isValidProvider(values.provider)) {
658
+ console.error(
659
+ `error: unknown provider '${values.provider}' — valid: anthropic-api, claude-cli, codex-cli, cursor-cli`,
660
+ );
661
+ return 2;
662
+ }
663
+ if (!isTeachCompatibleProvider(values.provider)) {
664
+ console.error(
665
+ `error: provider '${values.provider}' is not supported for generate — use anthropic-api, claude-cli, or codex-cli`,
666
+ );
667
+ return 2;
668
+ }
669
+ }
670
+
671
+ let maxDurationMs: number | undefined;
672
+ if (values['max-duration']) {
673
+ maxDurationMs = parseDuration(values['max-duration']) ?? undefined;
674
+ if (maxDurationMs === undefined) {
675
+ console.error(
676
+ `error: invalid --max-duration "${values['max-duration']}"\n→ use format: 30m, 1h, 300s, or plain milliseconds`,
677
+ );
678
+ return 2;
679
+ }
680
+ }
681
+
682
+ const { generate } = await import('./imprint/compile.ts');
683
+ const { detectTeachProvider } = await import('./imprint/llm.ts');
684
+ const { resolveCompileAgentModel } = await import('./imprint/compile-agent.ts');
685
+ const { describeAgentActivity, formatElapsed } = await import('./imprint/progress.ts');
686
+
687
+ // Resolve provider + model NOW so we can tell the user before silence
688
+ // sets in (the agent loop typically runs 3-5 min with no other output).
689
+ const providerName = (values.provider as ProviderName | undefined) ?? detectTeachProvider();
690
+ const compileModel = resolveCompileAgentModel(providerName);
691
+ console.error('');
692
+ console.error(`[imprint compile] provider: ${providerName} model: ${compileModel}`);
693
+ console.error(
694
+ '[imprint compile] An LLM agent will reverse-engineer the API response format.',
695
+ );
696
+ console.error(
697
+ '[imprint compile] Expect ~3-5 minutes and moderate to high token use, depending on',
698
+ );
699
+ console.error('[imprint compile] the complexity of the recording.');
700
+ console.error('');
701
+
702
+ // Stream one stderr line per *changed* activity so non-TTY runs (CI,
703
+ // piped, backgrounded) get visibility instead of silence.
704
+ let lastActivity = '';
705
+ const compileStart = Date.now();
706
+ const onDeadlineReached = process.stdin.isTTY
707
+ ? async (): Promise<number | null> => {
708
+ const { createInterface } = await import('node:readline');
709
+ const elapsed = Math.round((Date.now() - compileStart) / 60000);
710
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
711
+ const answer = await new Promise<string>((resolve) => {
712
+ rl.question(
713
+ `[imprint compile] Timeout reached after ${elapsed} minutes. Give it 10 more minutes? [Y/n] `,
714
+ resolve,
715
+ );
716
+ });
717
+ rl.close();
718
+ return answer.trim().toLowerCase().startsWith('n') ? null : 10 * 60 * 1000;
719
+ }
720
+ : undefined;
721
+ const result = await generate({
722
+ sessionPath,
723
+ outPath: values.out,
724
+ maxDurationMs,
725
+ llmConfig: { provider: providerName, model: compileModel },
726
+ keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
727
+ onDeadlineReached,
728
+ onProgress: (p) => {
729
+ const activity = describeAgentActivity(p);
730
+ if (activity === lastActivity) return;
731
+ lastActivity = activity;
732
+ const retry = p.verificationCycle > 1 ? ` (retry ${p.verificationCycle - 1})` : '';
733
+ process.stderr.write(
734
+ `[imprint compile] ${formatElapsed(p.elapsedMs)} — ${activity}${retry}\n`,
735
+ );
736
+ },
737
+ });
738
+ console.log('');
739
+ console.log(`[imprint] workflow → ${result.workflowPath}`);
740
+ console.log(
741
+ `[imprint] tool: ${result.workflow.toolName} (${result.workflow.requests.length} request${result.workflow.requests.length === 1 ? '' : 's'}, ${result.workflow.parameters.length} parameter${result.workflow.parameters.length === 1 ? '' : 's'})`,
742
+ );
743
+ console.log(
744
+ `[imprint] tokens: ${result.inputTokens ?? 'N/A'} in, ${result.outputTokens ?? 'N/A'} out — ${(result.durationMs / 1000).toFixed(1)}s`,
745
+ );
746
+ console.log('');
747
+ console.log('next step:');
748
+ console.log(` imprint emit ${result.workflowPath} # codegen the runtime tool`);
749
+ return 0;
750
+ }
751
+
752
+ case 'emit': {
753
+ const workflowPath = requirePositional(argv, 'emit', 'a <workflow.json> argument');
754
+ if (workflowPath === null) return 2;
755
+ const { values } = parseArgs({
756
+ args: argv.slice(2),
757
+ options: { force: { type: 'boolean' }, 'out-dir': { type: 'string' } },
758
+ allowPositionals: false,
759
+ });
760
+ const { emit } = await import('./imprint/emit.ts');
761
+ const result = emit({
762
+ workflowPath,
763
+ outDir: values['out-dir'],
764
+ force: values.force,
765
+ });
766
+ console.log(`[imprint] generated → ${result.outPath}`);
767
+ console.log(
768
+ `[imprint] tool: ${result.toolName} (${result.parameters.length} parameter${result.parameters.length === 1 ? '' : 's'})`,
769
+ );
770
+ // Surface what to do next so users don't have to alt-tab to docs.
771
+ const site = result.outPath.split('/').slice(-3, -2)[0] ?? '<site>';
772
+ console.log('');
773
+ console.log('next steps:');
774
+ console.log(
775
+ ` imprint probe-backends ${site} --tool ${result.toolName} # cache the working backend order`,
776
+ );
777
+ console.log(` imprint mcp-server ${site} # expose this site's tool as MCP`);
778
+ console.log(
779
+ ` imprint cron ${site} --tool ${result.toolName} --once # one-shot test (after creating cron.json)`,
780
+ );
781
+ return 0;
782
+ }
783
+
784
+ case 'install': {
785
+ const rawSite = argv[1];
786
+ let site: string | undefined;
787
+ if (rawSite && !rawSite.startsWith('-')) site = rawSite;
788
+ const { values } = parseArgs({
789
+ args: argv.slice(site ? 2 : 1),
790
+ options: {
791
+ platform: { type: 'string' },
792
+ source: { type: 'string' },
793
+ print: { type: 'boolean' },
794
+ 'no-interactive': { type: 'boolean' },
795
+ },
796
+ allowPositionals: false,
797
+ });
798
+
799
+ const { PLATFORMS } = await import('./imprint/integrations.ts');
800
+ if (values.platform && !PLATFORMS.includes(values.platform as (typeof PLATFORMS)[number])) {
801
+ console.error(
802
+ `error: unknown platform '${values.platform}' — valid: ${PLATFORMS.join(', ')}`,
803
+ );
804
+ return 2;
805
+ }
806
+ const sources = ['local', 'examples'] as const;
807
+ if (values.source && !sources.includes(values.source as (typeof sources)[number])) {
808
+ console.error(`error: unknown source '${values.source}' — valid: ${sources.join(', ')}`);
809
+ return 2;
810
+ }
811
+
812
+ const { install, installTui } = await import('./imprint/install.ts');
813
+ const useTui =
814
+ !site && !values.platform && !values.source && !values.print && !values['no-interactive'];
815
+ const result = useTui
816
+ ? await installTui()
817
+ : await install({
818
+ site,
819
+ platform: values.platform as (typeof PLATFORMS)[number] | undefined,
820
+ source: values.source as (typeof sources)[number] | undefined,
821
+ print: values.print,
822
+ noInteractive: values['no-interactive'],
823
+ });
824
+ console.log(`[imprint] ${result.message}`);
825
+ if ('source' in result)
826
+ console.log(`[imprint] source: ${result.source} (${result.assetRoot})`);
827
+ return 0;
828
+ }
829
+
830
+ case 'uninstall': {
831
+ const rawSite = argv[1];
832
+ let site: string | undefined;
833
+ if (rawSite && !rawSite.startsWith('-')) site = rawSite;
834
+ const { values } = parseArgs({
835
+ args: argv.slice(site ? 2 : 1),
836
+ options: {
837
+ platform: { type: 'string' },
838
+ print: { type: 'boolean' },
839
+ 'no-interactive': { type: 'boolean' },
840
+ },
841
+ allowPositionals: false,
842
+ });
843
+
844
+ const { PLATFORMS } = await import('./imprint/integrations.ts');
845
+ if (values.platform && !PLATFORMS.includes(values.platform as (typeof PLATFORMS)[number])) {
846
+ console.error(
847
+ `error: unknown platform '${values.platform}' — valid: ${PLATFORMS.join(', ')}`,
848
+ );
849
+ return 2;
850
+ }
851
+
852
+ const { uninstall } = await import('./imprint/install.ts');
853
+ const result = await uninstall({
854
+ site,
855
+ platform: values.platform as (typeof PLATFORMS)[number] | undefined,
856
+ print: values.print,
857
+ noInteractive: values['no-interactive'],
858
+ });
859
+ console.log(`[imprint] ${result.message}`);
860
+ return 0;
861
+ }
862
+
863
+ case 'login': {
864
+ const site = requirePositional(argv, 'login', 'a <site> argument');
865
+ if (site === null) return 2;
866
+ const { values } = parseArgs({
867
+ args: argv.slice(2),
868
+ options: { 'from-session': { type: 'string' } },
869
+ allowPositionals: false,
870
+ });
871
+ if (!values['from-session']) {
872
+ console.error(
873
+ 'error: v0.1 of `imprint login` requires --from-session <session.json>. Capture a session via `imprint record` first, then point login at it.',
874
+ );
875
+ return 2;
876
+ }
877
+ const { login } = await import('./imprint/login.ts');
878
+ const result = await login({
879
+ site,
880
+ fromSession: values['from-session'],
881
+ });
882
+ console.log(`[imprint] credentials → backend: ${result.backend}`);
883
+ console.log(
884
+ `[imprint] ${result.cookieCount} cookie${result.cookieCount === 1 ? '' : 's'} stored`,
885
+ );
886
+ console.log(
887
+ `[imprint] ${Object.keys(result.values).length} value${Object.keys(result.values).length === 1 ? '' : 's'} extracted: ${Object.keys(result.values).join(', ') || '(none)'}`,
888
+ );
889
+ if (result.matchedExtractors.length > 0) {
890
+ console.log(`[imprint] extractors matched: ${result.matchedExtractors.join(', ')}`);
891
+ }
892
+ console.log('');
893
+ console.log(
894
+ `[imprint] credentials are loaded automatically by \`imprint cron ${site}\` and \`imprint mcp-server\` — no extra wiring needed.`,
895
+ );
896
+ return 0;
897
+ }
898
+
899
+ case 'mcp-server': {
900
+ const site = requirePositional(argv, 'mcp-server', 'a <site> argument');
901
+ if (site === null) return 2;
902
+ const { values } = parseArgs({
903
+ args: argv.slice(2),
904
+ options: {
905
+ http: { type: 'boolean' },
906
+ port: { type: 'string' },
907
+ },
908
+ allowPositionals: false,
909
+ });
910
+ const { runMcpServer } = await import('./imprint/mcp-server.ts');
911
+ await runMcpServer({
912
+ site,
913
+ http: values.http,
914
+ port: values.port ? Number(values.port) : undefined,
915
+ });
916
+ return 0;
917
+ }
918
+
919
+ case 'cron': {
920
+ const site = requirePositional(argv, 'cron', 'a <site> argument');
921
+ if (site === null) return 2;
922
+ const { values } = parseArgs({
923
+ args: argv.slice(2),
924
+ options: {
925
+ config: { type: 'string' },
926
+ tool: { type: 'string' },
927
+ once: { type: 'boolean' },
928
+ 'run-now': { type: 'boolean' },
929
+ quiet: { type: 'boolean' },
930
+ },
931
+ allowPositionals: false,
932
+ });
933
+ const { runCron } = await import('./imprint/cron.ts');
934
+ await runCron({
935
+ site,
936
+ configPath: values.config,
937
+ toolName: values.tool,
938
+ once: values.once,
939
+ runNow: values['run-now'],
940
+ // --quiet suppresses successful-run logs so OS schedulers
941
+ // (cron, systemd, launchd) don't mail noise on green runs.
942
+ // runCron scopes the env mutation to its own lifetime.
943
+ quiet: values.quiet,
944
+ });
945
+ return 0;
946
+ }
947
+
948
+ case 'probe-backends': {
949
+ const site = requirePositional(argv, 'probe-backends', 'a <site> argument');
950
+ if (site === null) return 2;
951
+ const { values } = parseArgs({
952
+ args: argv.slice(2),
953
+ options: {
954
+ out: { type: 'string' },
955
+ tool: { type: 'string' },
956
+ param: { type: 'string', multiple: true },
957
+ },
958
+ allowPositionals: false,
959
+ });
960
+ const overrides = tryParseParamKV(values.param);
961
+ if (overrides === null) return 2;
962
+ const { probeBackends } = await import('./imprint/probe-backends.ts');
963
+ const result = await probeBackends({
964
+ site,
965
+ outPath: values.out,
966
+ toolName: values.tool,
967
+ paramOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
968
+ });
969
+ console.log(`[imprint] probed → ${result.outPath}`);
970
+ console.log(`[imprint] preferred order: ${result.cache.preferredOrder.join(' → ')}`);
971
+ console.log('');
972
+ console.log('[imprint] cron + mcp-server now skip futile rungs at startup using this cache.');
973
+ return 0;
974
+ }
975
+
976
+ case 'compile-playbook': {
977
+ const sessionPath = requirePositional(argv, 'compile-playbook', 'a <session.json> argument');
978
+ if (sessionPath === null) return 2;
979
+ const { values } = parseArgs({
980
+ args: argv.slice(2),
981
+ options: {
982
+ out: { type: 'string' },
983
+ 'no-shrink': { type: 'boolean' },
984
+ provider: { type: 'string' },
985
+ },
986
+ allowPositionals: false,
987
+ });
988
+
989
+ if (values.provider) {
990
+ const { isValidProvider } = await import('./imprint/llm.ts');
991
+ if (!isValidProvider(values.provider)) {
992
+ console.error(
993
+ `error: unknown provider '${values.provider}' — valid: anthropic-api, claude-cli, codex-cli, cursor-cli`,
994
+ );
995
+ return 2;
996
+ }
997
+ }
998
+
999
+ const { compilePlaybook } = await import('./imprint/compile.ts');
1000
+ const result = await compilePlaybook({
1001
+ sessionPath,
1002
+ outPath: values.out,
1003
+ noShrink: values['no-shrink'],
1004
+ llmConfig: values.provider ? { provider: values.provider as ProviderName } : undefined,
1005
+ });
1006
+ console.log(`[imprint] playbook → ${result.playbookPath}`);
1007
+ console.log(
1008
+ `[imprint] tool: ${result.playbook.toolName} (${result.playbook.steps.length} step${result.playbook.steps.length === 1 ? '' : 's'}, ${result.playbook.parameters.length} parameter${result.playbook.parameters.length === 1 ? '' : 's'})`,
1009
+ );
1010
+ console.log(
1011
+ `[imprint] tokens: ${result.inputTokens ?? 'N/A'} in, ${result.outputTokens ?? 'N/A'} out — ${(result.durationMs / 1000).toFixed(1)}s`,
1012
+ );
1013
+ // Suggest a smoke run; the playbook is most useful behind the cron ladder.
1014
+ const playbookSite = inferPlaybookSiteForSmokeCommand(
1015
+ result.playbookPath,
1016
+ result.playbook.toolName,
1017
+ );
1018
+ console.log('');
1019
+ console.log('next step:');
1020
+ console.log(
1021
+ ` imprint playbook ${playbookSite} --param k=v # smoke-test the playbook directly`,
1022
+ );
1023
+ return 0;
1024
+ }
1025
+
1026
+ case 'playbook': {
1027
+ const site = requirePositional(argv, 'playbook', 'a <site> argument');
1028
+ if (site === null) return 2;
1029
+ const { values } = parseArgs({
1030
+ args: argv.slice(2),
1031
+ options: {
1032
+ headed: { type: 'boolean' },
1033
+ trace: { type: 'boolean' },
1034
+ param: { type: 'string', multiple: true },
1035
+ path: { type: 'string' },
1036
+ },
1037
+ allowPositionals: false,
1038
+ });
1039
+ const { resolve: pathResolve } = await import('node:path');
1040
+ let playbookPath: string;
1041
+ if (values.path) {
1042
+ playbookPath = pathResolve(values.path);
1043
+ } else {
1044
+ const { discoverTools } = await import('./imprint/tool-loader.ts');
1045
+ const { imprintHomeDir, localToolDir } = await import('./imprint/paths.ts');
1046
+ const tools = await discoverTools(imprintHomeDir(), site);
1047
+ if (tools.length > 1) {
1048
+ console.error(
1049
+ `error: site "${site}" has ${tools.length} workflows — specify which with --path:\n${tools.map((t) => ` --path ${pathResolve(t.dir, 'playbook.yaml')}`).join('\n')}`,
1050
+ );
1051
+ return 2;
1052
+ }
1053
+ const tool = tools[0];
1054
+ playbookPath = tool
1055
+ ? pathResolve(tool.dir, 'playbook.yaml')
1056
+ : pathResolve(localToolDir(site, '<toolName>'), 'playbook.yaml');
1057
+ }
1058
+ const params = tryParseParamKV(values.param);
1059
+ if (params === null) return 2;
1060
+ const { runPlaybook } = await import('./imprint/playbook-runner.ts');
1061
+ const result = await runPlaybook({
1062
+ playbook: playbookPath,
1063
+ params,
1064
+ headed: values.headed,
1065
+ trace: values.trace,
1066
+ site,
1067
+ });
1068
+ if (result.ok) {
1069
+ console.log('[imprint] OK');
1070
+ console.log(JSON.stringify(result.data, null, 2));
1071
+ return 0;
1072
+ }
1073
+ console.error(`[imprint] ${result.error}: ${result.message}`);
1074
+ return 1;
1075
+ }
1076
+
1077
+ case 'teach': {
1078
+ const rawSite = argv[1];
1079
+ let site: string | undefined;
1080
+ if (rawSite?.startsWith('-')) {
1081
+ // Looks like a flag — can't tell from a missing site, so error out
1082
+ // with the explanation regardless of interactive mode.
1083
+ console.error(
1084
+ 'error: `imprint teach` requires a <site> name before any flags.\n <site> is a label you choose — it names the output folder under ~/.imprint/.\n\n example: imprint teach google-flights --url https://flights.google.com\n→ run `imprint teach --help` for usage.',
1085
+ );
1086
+ return 2;
1087
+ }
1088
+ if (rawSite) site = rawSite;
1089
+
1090
+ const { values } = parseArgs({
1091
+ args: argv.slice(rawSite ? 2 : 1),
1092
+ options: {
1093
+ url: { type: 'string' },
1094
+ 'from-session': { type: 'string' },
1095
+ 'persist-profile': { type: 'boolean' },
1096
+ 'no-interactive': { type: 'boolean' },
1097
+ 'all-tools': { type: 'boolean' },
1098
+ provider: { type: 'string' },
1099
+ model: { type: 'string' },
1100
+ timeout: { type: 'string' },
1101
+ 'keep-test': { type: 'boolean' },
1102
+ 'skip-replay': { type: 'boolean' },
1103
+ },
1104
+ allowPositionals: false,
1105
+ });
1106
+
1107
+ if (!site && values['no-interactive']) {
1108
+ console.error(
1109
+ 'error: `imprint teach` requires a <site> argument in --no-interactive mode.\n <site> is a label you choose — it names the output folder under ~/.imprint/.\n\n example: imprint teach google-flights --url https://flights.google.com\n→ run `imprint teach --help` for usage.',
1110
+ );
1111
+ return 2;
1112
+ }
1113
+
1114
+ if (values.provider) {
1115
+ const { isValidProvider } = await import('./imprint/llm.ts');
1116
+ if (!isValidProvider(values.provider)) {
1117
+ console.error(
1118
+ `error: unknown provider '${values.provider}' — valid: anthropic-api, claude-cli, codex-cli, cursor-cli`,
1119
+ );
1120
+ return 2;
1121
+ }
1122
+ }
1123
+
1124
+ let teachTimeoutMs: number | undefined;
1125
+ if (values.timeout) {
1126
+ teachTimeoutMs = parseDuration(values.timeout) ?? undefined;
1127
+ if (teachTimeoutMs === undefined) {
1128
+ console.error(
1129
+ `error: invalid --timeout "${values.timeout}"\n→ use format: 5m, 1h, 300s, or plain milliseconds`,
1130
+ );
1131
+ return 2;
1132
+ }
1133
+ }
1134
+
1135
+ const ctrl = new AbortController();
1136
+ const onSigint = (): void => ctrl.abort();
1137
+ process.once('SIGINT', onSigint);
1138
+
1139
+ try {
1140
+ const { teach } = await import('./imprint/teach.ts');
1141
+ await traced(
1142
+ 'cli.teach',
1143
+ 'AGENT',
1144
+ {
1145
+ 'imprint.site': site,
1146
+ 'imprint.url': values.url,
1147
+ 'imprint.from_session': values['from-session'],
1148
+ 'imprint.provider': values.provider ?? 'auto',
1149
+ 'imprint.model': values.model ?? 'auto',
1150
+ 'imprint.timeout_ms': teachTimeoutMs ?? 'default',
1151
+ 'imprint.all_tools': values['all-tools'] ?? false,
1152
+ 'imprint.no_interactive': values['no-interactive'] ?? false,
1153
+ 'imprint.skip_replay': values['skip-replay'] ?? false,
1154
+ },
1155
+ () =>
1156
+ teach({
1157
+ site,
1158
+ url: values.url,
1159
+ fromSession: values['from-session'],
1160
+ persistProfile: values['persist-profile'],
1161
+ signal: ctrl.signal,
1162
+ noInteractive: values['no-interactive'],
1163
+ provider: values.provider as ProviderName | undefined,
1164
+ model: values.model,
1165
+ maxDurationMs: teachTimeoutMs,
1166
+ keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
1167
+ allTools: values['all-tools'],
1168
+ skipReplay: values['skip-replay'],
1169
+ }),
1170
+ );
1171
+ } finally {
1172
+ process.removeListener('SIGINT', onSigint);
1173
+ }
1174
+ return 0;
1175
+ }
1176
+
1177
+ case 'credential': {
1178
+ const { runCredentialCommand } = await import('./imprint/cli-credential.ts');
1179
+ return await runCredentialCommand(argv.slice(1));
1180
+ }
1181
+
1182
+ case 'mcp': {
1183
+ const { runMcpCommand } = await import('./imprint/mcp-maintenance.ts');
1184
+ return await runMcpCommand(argv.slice(1));
1185
+ }
1186
+
1187
+ // Hidden verb: spawned by claude-cli-compile.ts via --mcp-config. Not in
1188
+ // VERB_HELP, not advertised. Double-underscore prefix marks it as internal.
1189
+ case '__mcp-compile-server': {
1190
+ const { values } = parseArgs({
1191
+ args: argv.slice(1),
1192
+ options: {
1193
+ 'session-path': { type: 'string' },
1194
+ 'tool-dir': { type: 'string' },
1195
+ 'example-dir': { type: 'string' },
1196
+ 'candidate-json': { type: 'string' },
1197
+ 'shared-context-json': { type: 'string' },
1198
+ },
1199
+ allowPositionals: false,
1200
+ });
1201
+ const toolDir = values['tool-dir'] ?? values['example-dir'];
1202
+ if (!values['session-path'] || !toolDir) {
1203
+ console.error(
1204
+ 'error: __mcp-compile-server requires --session-path <path> and --tool-dir <path>',
1205
+ );
1206
+ return 2;
1207
+ }
1208
+ const { runCompileMcpServer } = await import('./imprint/mcp-compile-server.ts');
1209
+ const { ToolCandidateSchema, SharedCompileContextSchema } = await import(
1210
+ './imprint/tool-candidates.ts'
1211
+ );
1212
+ const candidate = values['candidate-json']
1213
+ ? ToolCandidateSchema.parse(JSON.parse(values['candidate-json']))
1214
+ : undefined;
1215
+ const sharedContext = values['shared-context-json']
1216
+ ? SharedCompileContextSchema.parse(JSON.parse(values['shared-context-json']))
1217
+ : undefined;
1218
+ await runCompileMcpServer({
1219
+ sessionPath: values['session-path'],
1220
+ toolDir,
1221
+ candidate,
1222
+ sharedContext,
1223
+ });
1224
+ return 0;
1225
+ }
1226
+
1227
+ default: {
1228
+ const suggestion = closestVerb(verb);
1229
+ const tail = suggestion ? `did you mean \`imprint ${suggestion}\`?` : 'run `imprint --help`';
1230
+ console.error(`error: unknown verb '${verb}' — ${tail}`);
1231
+ return 2;
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ /** Suggest the closest known verb to a typo via Levenshtein distance.
1237
+ * Returns the suggestion only if it's plausibly close (≤ 3 edits). */
1238
+ export function closestVerb(input: string): string | null {
1239
+ const verbs = Object.keys(VERB_HELP);
1240
+ let best: { verb: string; dist: number } | null = null;
1241
+ for (const v of verbs) {
1242
+ const d = levenshtein(input, v);
1243
+ if (best === null || d < best.dist) best = { verb: v, dist: d };
1244
+ }
1245
+ if (best === null) return null;
1246
+ // Require absolute distance ≤ 3 AND ≤ half the longer string's length —
1247
+ // catches typos and short truncations without suggesting wildly different verbs.
1248
+ const maxLen = Math.max(input.length, best.verb.length);
1249
+ if (best.dist > 3 || best.dist > Math.floor(maxLen / 2)) return null;
1250
+ return best.verb;
1251
+ }
1252
+
1253
+ function levenshtein(a: string, b: string): number {
1254
+ if (a === b) return 0;
1255
+ if (a.length === 0) return b.length;
1256
+ if (b.length === 0) return a.length;
1257
+ const prev = new Array<number>(b.length + 1);
1258
+ const curr = new Array<number>(b.length + 1);
1259
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
1260
+ for (let i = 1; i <= a.length; i++) {
1261
+ curr[0] = i;
1262
+ for (let j = 1; j <= b.length; j++) {
1263
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1264
+ curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
1265
+ }
1266
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j] ?? 0;
1267
+ }
1268
+ return prev[b.length] ?? a.length;
1269
+ }
1270
+
1271
+ // Only run when invoked as the entry point — importing this module
1272
+ // (e.g. for VERB_HELP from tests) must not trigger the CLI dispatch.
1273
+ if (import.meta.main) {
1274
+ main(process.argv.slice(2))
1275
+ .then(async (code) => {
1276
+ await shutdownTracing();
1277
+ process.exit(code);
1278
+ })
1279
+ .catch(async (err) => {
1280
+ console.error('imprint: fatal:', err instanceof Error ? err.message : String(err));
1281
+ if (isDebug()) {
1282
+ console.error(err);
1283
+ }
1284
+ await shutdownTracing();
1285
+ process.exit(1);
1286
+ });
1287
+ }