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
@@ -0,0 +1,2120 @@
1
+ /**
2
+ * `imprint teach` — interactive pipeline that chains record → redact → generate
3
+ * → compile-playbook → emit automatically, then presents a platform picker
4
+ * and outputs paste snippets or runs registration commands.
5
+ *
6
+ * Supports resuming from the last successful step, re-doing from a chosen
7
+ * step, and multiple workflows per site (each in its own subdirectory).
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join as pathJoin, resolve as pathResolve } from 'node:path';
13
+ import * as p from '@clack/prompts';
14
+ import type { OnDeadlineReached } from './agent.ts';
15
+ import {
16
+ type CompileAgentProgress,
17
+ type TriageResult,
18
+ compilePlaybook,
19
+ generate,
20
+ triageRequests,
21
+ } from './compile.ts';
22
+ import {
23
+ type CredentialFinding,
24
+ type Replacement,
25
+ extractCredentials,
26
+ } from './credential-extract.ts';
27
+ import { getCredentialBackend, readSiteManifest, upsertManifestEntry } from './credential-store.ts';
28
+ import { emit } from './emit.ts';
29
+ import {
30
+ type Platform,
31
+ buildRegistrationCommand,
32
+ detectImprintCommand,
33
+ generatePasteSnippet,
34
+ generateSkillMd,
35
+ } from './integrations.ts';
36
+ import {
37
+ type ProviderName,
38
+ type ProviderStatus,
39
+ detectTeachProvider,
40
+ getProviderStatuses,
41
+ isTeachCompatibleProvider,
42
+ } from './llm.ts';
43
+ import { loadJsonFile } from './load-json.ts';
44
+ import { muteLog, unmuteLog } from './log.ts';
45
+ import { MultiProgress } from './multi-progress.ts';
46
+ import { localSiteDir, localToolDir } from './paths.ts';
47
+ import { describeAgentActivity, formatElapsed } from './progress.ts';
48
+ import { record } from './record.ts';
49
+ import { detectPageMintedHeaders, redactSession } from './redact.ts';
50
+ import { loadCredentialStore } from './runtime.ts';
51
+ import type { ClassifiedValue } from './session-diff.ts';
52
+ import { listSiteSessions, mergeSessions, writeCombinedSession } from './session-merge.ts';
53
+ import {
54
+ TEACH_STEPS as STEPS,
55
+ type TeachStep as Step,
56
+ type TeachState,
57
+ type WorkflowState,
58
+ buildTeachStateFromSession,
59
+ discoverCompletedWorkflows,
60
+ discoverOrphanSession,
61
+ friendlySessionTimestamp,
62
+ isExistingTeachFile as isExistingFile,
63
+ loadTeachState,
64
+ nextTeachStep as nextStep,
65
+ resolveTeachStatePath,
66
+ saveTeachState,
67
+ toRelativeTeachStatePath as toRelative,
68
+ } from './teach-state.ts';
69
+ import {
70
+ type SharedCompileContext,
71
+ type ToolCandidate,
72
+ buildSharedCompileContext as buildCandidateSharedCompileContext,
73
+ detectToolCandidates,
74
+ primaryToolCandidate,
75
+ } from './tool-candidates.ts';
76
+ import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
77
+ import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
78
+
79
+ export { buildTeachStateFromSession, resolveTeachStatePath } from './teach-state.ts';
80
+
81
+ // ─── Types ──────────────────────────────────────────────────────────────────
82
+
83
+ interface TeachOptions {
84
+ site?: string;
85
+ url?: string;
86
+ persistProfile?: boolean;
87
+ signal?: AbortSignal;
88
+ noInteractive?: boolean;
89
+ provider?: ProviderName;
90
+ /** Override the compile model (otherwise prompted or auto-detected). */
91
+ model?: string;
92
+ /** Per-tool compile timeout in ms. Default 10 minutes. */
93
+ maxDurationMs?: number;
94
+ fromSession?: string;
95
+ /** Retain parser.test.ts after successful compile-agent verification. */
96
+ keepTest?: boolean;
97
+ /** Non-interactive: compile every detected candidate instead of primary only. */
98
+ allTools?: boolean;
99
+ /** Skip the replay-and-diff stage entirely. */
100
+ skipReplay?: boolean;
101
+ }
102
+
103
+ interface TeachResult {
104
+ sessionPath: string;
105
+ workflowPath: string;
106
+ playbookPath: string;
107
+ indexPath: string;
108
+ workflow: Workflow;
109
+ playbook: Playbook;
110
+ tools: TeachToolResult[];
111
+ }
112
+
113
+ interface TeachToolResult {
114
+ workflowPath: string;
115
+ playbookPath: string;
116
+ indexPath: string;
117
+ workflow: Workflow;
118
+ playbook: Playbook;
119
+ }
120
+
121
+ export function assertCandidateToolName(
122
+ artifact: string,
123
+ actualToolName: string,
124
+ candidate?: ToolCandidate,
125
+ ): void {
126
+ if (!candidate || actualToolName === candidate.toolName) return;
127
+ throw new Error(
128
+ `${artifact} toolName "${actualToolName}" does not match selected candidate "${candidate.toolName}".`,
129
+ );
130
+ }
131
+
132
+ function requireSessionFile(
133
+ path: string | null,
134
+ opts: {
135
+ site: string;
136
+ workflowKey: string;
137
+ startFrom: Step;
138
+ kind: 'raw' | 'redacted' | 'triaged';
139
+ },
140
+ ): string {
141
+ if (isExistingFile(path)) return path;
142
+
143
+ const noun =
144
+ opts.kind === 'raw'
145
+ ? 'original session JSON'
146
+ : opts.kind === 'triaged'
147
+ ? 'triaged session JSON'
148
+ : 'redacted session JSON';
149
+ const redoStep = opts.kind === 'raw' ? 'record' : opts.kind === 'triaged' ? 'triage' : 'redact';
150
+ throw new Error(
151
+ [
152
+ `Cannot redo "${opts.workflowKey}" from ${opts.startFrom}: the ${noun} is missing.`,
153
+ `→ rerun with: imprint teach ${opts.site} --from-session <session.json>`,
154
+ `→ or choose "Redo" from ${redoStep} to rebuild it.`,
155
+ ].join('\n'),
156
+ );
157
+ }
158
+
159
+ // ─── Interactive prompts for missing CLI args ───────────────────────────────
160
+
161
+ function validateSiteName(value: string | undefined): string | undefined {
162
+ const v = (value ?? '').trim();
163
+ if (!v) return 'Site name is required.';
164
+ if (/[\s/\\]/.test(v))
165
+ return 'No spaces or slashes — site becomes a folder name under ~/.imprint/.';
166
+ return undefined;
167
+ }
168
+
169
+ async function resolveSite(opts: TeachOptions): Promise<string> {
170
+ if (opts.site) return opts.site;
171
+ // cli.ts already errors out when --no-interactive is set without a site,
172
+ // so reaching here means we're free to prompt.
173
+ const answer = await p.text({
174
+ message: 'What should we name this site?',
175
+ placeholder: 'google-flights',
176
+ validate: validateSiteName,
177
+ });
178
+ if (p.isCancel(answer)) {
179
+ p.outro('Cancelled.');
180
+ process.exit(0);
181
+ }
182
+ return (answer as string).trim();
183
+ }
184
+
185
+ function validateStartUrl(value: string | undefined): string | undefined {
186
+ const v = (value ?? '').trim();
187
+ if (!v) return undefined; // allow empty → falls back to about:blank
188
+ try {
189
+ const u = new URL(v);
190
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
191
+ return 'URL must start with http:// or https://';
192
+ }
193
+ } catch {
194
+ return 'Not a valid URL.';
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ async function resolveStartUrl(opts: TeachOptions): Promise<string | undefined> {
200
+ if (opts.url) return opts.url;
201
+ if (opts.noInteractive) return undefined;
202
+ const answer = await p.text({
203
+ message: 'Starting URL? (leave blank for about:blank)',
204
+ placeholder: 'https://www.example.com',
205
+ validate: validateStartUrl,
206
+ });
207
+ if (p.isCancel(answer)) {
208
+ p.outro('Cancelled.');
209
+ process.exit(0);
210
+ }
211
+ const trimmed = (answer as string).trim();
212
+ return trimmed.length > 0 ? trimmed : undefined;
213
+ }
214
+
215
+ interface TeachProviderPickerOption {
216
+ value: string;
217
+ label: string;
218
+ hint?: string;
219
+ }
220
+
221
+ interface TeachProviderPickerIO {
222
+ select: (opts: {
223
+ message: string;
224
+ options: TeachProviderPickerOption[];
225
+ }) => Promise<string | symbol>;
226
+ note: (message: string, title?: string) => void;
227
+ isCancel: (value: unknown) => boolean;
228
+ }
229
+
230
+ function assertTeachProvider(name: ProviderName): void {
231
+ if (isTeachCompatibleProvider(name)) return;
232
+ const status = getProviderStatuses().find((s) => s.name === name);
233
+ throw new Error(
234
+ [
235
+ `provider "${name}" is not supported for \`imprint teach\` compile yet.`,
236
+ status?.reason ? `detected status: ${status.reason}` : undefined,
237
+ '→ use one of: claude-cli, codex-cli, anthropic-api',
238
+ ]
239
+ .filter(Boolean)
240
+ .join('\n'),
241
+ );
242
+ }
243
+
244
+ async function resolveTeachProvider(opts: TeachOptions): Promise<ProviderName> {
245
+ if (opts.provider) {
246
+ assertTeachProvider(opts.provider);
247
+ return opts.provider;
248
+ }
249
+
250
+ if (opts.noInteractive) {
251
+ const provider = detectTeachProvider();
252
+ assertTeachProvider(provider);
253
+ return provider;
254
+ }
255
+
256
+ const statuses = getProviderStatuses();
257
+ const detectedCompatible = statuses.filter((s) => s.detected && s.availableForTeach);
258
+ const onlyCompatible = detectedCompatible[0];
259
+ if (detectedCompatible.length === 1 && onlyCompatible) return onlyCompatible.name;
260
+ return await promptForTeachProvider(statuses);
261
+ }
262
+
263
+ export function buildTeachProviderPickerOptions(
264
+ statuses: ProviderStatus[],
265
+ ): TeachProviderPickerOption[] {
266
+ return statuses.map((status) => {
267
+ if (status.detected && status.availableForTeach) {
268
+ return {
269
+ value: `use:${status.name}`,
270
+ label: `${status.name} (detected)`,
271
+ hint: status.reason,
272
+ };
273
+ }
274
+ if (status.detected) {
275
+ return {
276
+ value: `setup:${status.name}`,
277
+ label: `${status.name} (detected, not available for teach)`,
278
+ hint: status.reason,
279
+ };
280
+ }
281
+ return {
282
+ value: `setup:${status.name}`,
283
+ label: `${status.name} (not detected, setup help)`,
284
+ hint: status.reason,
285
+ };
286
+ });
287
+ }
288
+
289
+ export async function promptForTeachProvider(
290
+ statuses: ProviderStatus[],
291
+ io: TeachProviderPickerIO = {
292
+ select: (opts) => p.select({ message: opts.message, options: opts.options }),
293
+ note: (message, title) => p.note(message, title),
294
+ isCancel: p.isCancel,
295
+ },
296
+ ): Promise<ProviderName> {
297
+ while (true) {
298
+ const choice = await io.select({
299
+ message: 'Which LLM provider should compile this workflow?',
300
+ options: buildTeachProviderPickerOptions(statuses),
301
+ });
302
+ if (io.isCancel(choice)) {
303
+ p.outro('Cancelled.');
304
+ process.exit(0);
305
+ }
306
+
307
+ const [action, rawName] = String(choice).split(':') as ['use' | 'setup', ProviderName];
308
+ const status = statuses.find((s) => s.name === rawName);
309
+ if (action === 'use' && status?.availableForTeach) return rawName;
310
+
311
+ if (status) {
312
+ io.note([status.reason, '', status.setupHint].join('\n'), `${status.name} setup`);
313
+ }
314
+ }
315
+ }
316
+
317
+ async function promptForModel(provider: ProviderName): Promise<string> {
318
+ const { availableModelsForProvider } = await import('./llm.ts');
319
+ const models = availableModelsForProvider(provider);
320
+ if (models.length <= 1) return models[0]?.model ?? 'claude-opus-4-7';
321
+
322
+ const choice = await p.select({
323
+ message: 'Which model should compile this workflow?',
324
+ options: models.map((m) => ({
325
+ value: m.model,
326
+ label: m.isDefault ? `${m.model} (default)` : m.model,
327
+ })),
328
+ initialValue: models.find((m) => m.isDefault)?.model,
329
+ });
330
+ if (p.isCancel(choice)) {
331
+ p.outro('Cancelled.');
332
+ process.exit(0);
333
+ }
334
+ return String(choice);
335
+ }
336
+
337
+ // ─── Main teach function ────────────────────────────────────────────────────
338
+
339
+ export async function teach(opts: TeachOptions): Promise<TeachResult> {
340
+ const site = await resolveSite(opts);
341
+ p.intro(`imprint teach — teaching your agent to use ${site}`);
342
+
343
+ const state = loadTeachState(site);
344
+
345
+ // Rename legacy _orphan_ keys to human-readable names.
346
+ for (const key of Object.keys(state.workflows)) {
347
+ if (!key.startsWith('_orphan_')) continue;
348
+ const ws = state.workflows[key];
349
+ if (!ws) continue;
350
+ const newKey = `session from ${friendlySessionTimestamp(ws.sessionPath)}`;
351
+ delete state.workflows[key];
352
+ state.workflows[newKey] = ws;
353
+ }
354
+
355
+ // Pick up sessions that were recorded but never tracked (e.g., old teach
356
+ // runs or manual `imprint record` invocations).
357
+ const orphan = discoverOrphanSession(site, state);
358
+ if (orphan) {
359
+ const key = `session from ${friendlySessionTimestamp(orphan.sessionPath)}`;
360
+ if (!state.workflows[key]) state.workflows[key] = orphan;
361
+ }
362
+
363
+ const completedWorkflows = discoverCompletedWorkflows(site);
364
+ const completedSet = new Set(completedWorkflows);
365
+ const incompleteWorkflows = Object.entries(state.workflows).filter(
366
+ ([name]) => !completedSet.has(name),
367
+ );
368
+
369
+ // Decide what to do: resume, redo, or start fresh.
370
+ let startFrom: Step = 'record';
371
+ let workflowKey: string | null = null;
372
+ let sessionPath: string | null = opts.fromSession ?? null;
373
+ let redactedPath: string | null = null;
374
+ let usingFromSession = false;
375
+
376
+ const hasExisting = completedWorkflows.length > 0 || incompleteWorkflows.length > 0;
377
+
378
+ if (opts.fromSession) {
379
+ startFrom = 'redact';
380
+ sessionPath = pathResolve(opts.fromSession);
381
+ usingFromSession = true;
382
+ } else if (hasExisting && !opts.noInteractive) {
383
+ const choice = await promptResumeChoice(site, completedWorkflows, incompleteWorkflows);
384
+ if (p.isCancel(choice)) {
385
+ p.outro('Cancelled.');
386
+ process.exit(0);
387
+ }
388
+
389
+ if (choice.action === 'new') {
390
+ startFrom = 'record';
391
+ } else if (choice.action === 'continue') {
392
+ workflowKey = choice.workflowKey;
393
+ const ws = state.workflows[workflowKey];
394
+ if (!ws) {
395
+ throw new Error(
396
+ `No state found for workflow "${workflowKey}" — try starting a new workflow.`,
397
+ );
398
+ }
399
+ startFrom = nextStep(ws.completedSteps);
400
+ sessionPath = resolveTeachStatePath(site, ws.sessionPath);
401
+ redactedPath = resolveTeachStatePath(site, ws.redactedPath);
402
+ } else if (choice.action === 'redo') {
403
+ workflowKey = choice.workflowKey;
404
+ startFrom = choice.fromStep;
405
+ const ws = state.workflows[workflowKey];
406
+ if (ws) {
407
+ sessionPath = resolveTeachStatePath(site, ws.sessionPath);
408
+ redactedPath = resolveTeachStatePath(site, ws.redactedPath);
409
+ // If the stored sessionPath is a derived artifact (.triaged.json,
410
+ // .triaged.redacted.json), resolve back to the original recording
411
+ // so redo-from-redact operates on the full session.
412
+ if (sessionPath) {
413
+ const original = sessionPath
414
+ .replace(/\.triaged/g, '')
415
+ .replace(/\.redacted/g, '')
416
+ .replace(/\.json$/, '.json');
417
+ if (original !== sessionPath && isExistingFile(original)) {
418
+ sessionPath = original;
419
+ redactedPath = null;
420
+ }
421
+ }
422
+ }
423
+ if (!sessionPath && startFrom !== 'record') {
424
+ // Completed workflow with no state — find the latest session.
425
+ const orphan = discoverOrphanSession(site, state);
426
+ if (orphan) {
427
+ sessionPath = resolveTeachStatePath(site, orphan.sessionPath);
428
+ redactedPath = resolveTeachStatePath(site, orphan.redactedPath);
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ const startIdx = STEPS.indexOf(startFrom);
435
+ const spinner = p.spinner();
436
+ let resolvedProviderName: ProviderName | null = null;
437
+ const getProviderName = async (): Promise<ProviderName> => {
438
+ resolvedProviderName ??= await resolveTeachProvider(opts);
439
+ return resolvedProviderName;
440
+ };
441
+ let resolvedModel: string | null = null;
442
+ const getModel = async (): Promise<string> => {
443
+ if (resolvedModel) return resolvedModel;
444
+ const providerName = await getProviderName();
445
+ if (opts.model) {
446
+ resolvedModel = opts.model;
447
+ } else if (!opts.noInteractive) {
448
+ resolvedModel = await promptForModel(providerName);
449
+ } else {
450
+ const { resolveCompileAgentModel } = await import('./compile-agent.ts');
451
+ resolvedModel = resolveCompileAgentModel(providerName);
452
+ }
453
+ return resolvedModel;
454
+ };
455
+
456
+ // Temp key for state tracking before we know the toolName.
457
+ if (!workflowKey) {
458
+ workflowKey = `_pending_${new Date().toISOString().replace(/[:.]/g, '-')}`;
459
+ }
460
+
461
+ if (startFrom === 'redact') {
462
+ sessionPath = requireSessionFile(sessionPath, {
463
+ site,
464
+ workflowKey,
465
+ startFrom,
466
+ kind: 'raw',
467
+ });
468
+ } else if (
469
+ startFrom === 'replay-and-diff' ||
470
+ startFrom === 'triage' ||
471
+ startFrom === 'detect-candidates' ||
472
+ startFrom === 'generate' ||
473
+ startFrom === 'compile-playbook'
474
+ ) {
475
+ if (!redactedPath && sessionPath) {
476
+ redactedPath = sessionPath.replace(/\.json$/, '.redacted.json');
477
+ }
478
+ redactedPath = requireSessionFile(redactedPath, {
479
+ site,
480
+ workflowKey,
481
+ startFrom,
482
+ kind: 'redacted',
483
+ });
484
+ }
485
+
486
+ if (usingFromSession && sessionPath) {
487
+ checkpoint(
488
+ site,
489
+ state,
490
+ workflowKey,
491
+ buildTeachStateFromSession(site, sessionPath, redactedPath),
492
+ );
493
+ }
494
+
495
+ if (startIdx <= STEPS.indexOf('compile-playbook')) {
496
+ await getProviderName();
497
+ }
498
+
499
+ // ── 1. Record ──────────────────────────────────────────────────────
500
+ if (startIdx <= STEPS.indexOf('record')) {
501
+ const startUrl = await resolveStartUrl(opts);
502
+
503
+ spinner.start('Recording...');
504
+ spinner.stop('Ready to record.');
505
+ console.log('');
506
+
507
+ const recordResult = await record({
508
+ site: site,
509
+ url: startUrl,
510
+ persistProfile: opts.persistProfile,
511
+ signal: opts.signal,
512
+ });
513
+ sessionPath = recordResult.sessionPath;
514
+
515
+ checkpoint(site, state, workflowKey, {
516
+ sessionPath: toRelative(site, sessionPath),
517
+ completedSteps: ['record'],
518
+ startedAt: new Date().toISOString(),
519
+ updatedAt: new Date().toISOString(),
520
+ });
521
+
522
+ // ── 1b. Combine with past sessions (optional) ────────────────────
523
+ const originalSessionPath = sessionPath;
524
+ sessionPath = await promptSessionCombine({
525
+ site,
526
+ currentSessionPath: sessionPath,
527
+ noInteractive: opts.noInteractive ?? false,
528
+ });
529
+ if (sessionPath !== originalSessionPath) {
530
+ checkpoint(site, state, workflowKey, {
531
+ sessionPath: toRelative(site, sessionPath),
532
+ completedSteps: ['record'],
533
+ startedAt: new Date().toISOString(),
534
+ updatedAt: new Date().toISOString(),
535
+ });
536
+ }
537
+ }
538
+
539
+ // ── 2. Redact ──────────────────────────────────────────────────────
540
+ let teachCredentials: { site: string; values: Record<string, string> } | undefined;
541
+ if (startIdx <= STEPS.indexOf('redact')) {
542
+ sessionPath = requireSessionFile(sessionPath, {
543
+ site,
544
+ workflowKey,
545
+ startFrom,
546
+ kind: 'raw',
547
+ });
548
+
549
+ const session = loadJsonFile(
550
+ sessionPath,
551
+ SessionSchema,
552
+ {
553
+ notFound: 'Session file not found after recording.',
554
+ badSchema: 'Session file is malformed.',
555
+ },
556
+ 'session',
557
+ );
558
+
559
+ // Extract credentials from the raw session BEFORE redaction so we can
560
+ // both stash the values in the credential manager AND swap them for
561
+ // ${credential.X} placeholders in the redacted artifact.
562
+ const { findings, replacements } = extractCredentials(session);
563
+ let confirmedReplacements: Replacement[] = [];
564
+ if (findings.length > 0) {
565
+ const result = await promptAndPersistCredentials({
566
+ site,
567
+ findings,
568
+ replacements,
569
+ noInteractive: opts.noInteractive ?? false,
570
+ });
571
+ confirmedReplacements = result.replacements;
572
+ if (result.confirmedFinding) {
573
+ const f = result.confirmedFinding;
574
+ teachCredentials = {
575
+ site,
576
+ values: {
577
+ [f.usernameName ?? 'username']: f.usernameValue,
578
+ [f.passwordName ?? 'password']: f.passwordValue,
579
+ },
580
+ };
581
+ }
582
+ }
583
+
584
+ spinner.start('Redacting credentials...');
585
+ const pageMintedHeaders = detectPageMintedHeaders(session);
586
+ const { session: scrubbed, stats } = redactSession(session, {
587
+ replacements: confirmedReplacements,
588
+ keepHeaders: pageMintedHeaders,
589
+ });
590
+ redactedPath = sessionPath.replace(/\.json$/, '.redacted.json');
591
+ writeFileSync(redactedPath, `${JSON.stringify(scrubbed, null, 2)}\n`, 'utf8');
592
+ const placeholderNote =
593
+ stats.placeholdersInjected > 0
594
+ ? `, ${stats.placeholdersInjected} replaced with credential placeholders`
595
+ : '';
596
+ const freeformNote =
597
+ stats.freeformRedactions > 0 ? `, ${stats.freeformRedactions} free-form finding(s)` : '';
598
+ spinner.stop(
599
+ `Redacted ${stats.totalRedactions} value(s) across ${stats.requestsRedacted} request(s) and ${stats.cookiesRedacted} cookie(s)${placeholderNote}${freeformNote}.`,
600
+ );
601
+
602
+ updateCheckpoint(site, state, workflowKey, 'redact', {
603
+ redactedPath: toRelative(site, redactedPath),
604
+ });
605
+ }
606
+
607
+ if (!redactedPath) {
608
+ redactedPath = sessionPath ? sessionPath.replace(/\.json$/, '.redacted.json') : null;
609
+ }
610
+
611
+ if (startIdx <= STEPS.indexOf('generate')) {
612
+ redactedPath = requireSessionFile(redactedPath, {
613
+ site,
614
+ workflowKey,
615
+ startFrom,
616
+ kind: 'redacted',
617
+ });
618
+ }
619
+
620
+ // ── 2b+3. Replay || (Triage → Detect → Select) — deep parallelism ──
621
+ //
622
+ // replay-and-diff is slow (~2 min) and only needed at compile time.
623
+ // triage→detect→select is fast (~30s) and independent of replay.
624
+ // Run them in parallel so the user can select tools while replay runs.
625
+ let siteClassifications: ClassifiedValue[] | undefined;
626
+ let triageResult: TriageResult | undefined;
627
+ let triagedPath: string | null = null;
628
+ let plans: CandidateCompilePlan[];
629
+
630
+ let needsReplay = startIdx <= STEPS.indexOf('replay-and-diff') && !opts.skipReplay;
631
+ const needsCandidates = startIdx <= STEPS.indexOf('detect-candidates');
632
+
633
+ if (needsReplay && !opts.noInteractive) {
634
+ const runReplay = await p.confirm({
635
+ message:
636
+ 'Run the replay stage? This replays your flow in a fresh browser session to identify browser-minted tokens, CSRF values, and other ephemeral parameters. It can take a couple of minutes but improves workflow accuracy.',
637
+ initialValue: true,
638
+ });
639
+ if (p.isCancel(runReplay) || !runReplay) {
640
+ needsReplay = false;
641
+ }
642
+ }
643
+
644
+ if (!needsReplay && startIdx <= STEPS.indexOf('replay-and-diff')) {
645
+ p.log.warn(
646
+ "Skipping replay-and-diff stage. The compile agent won't be able to distinguish browser-minted values (timestamps, CSRF tokens) from constants — this may reduce workflow accuracy for sites with ephemeral request parameters.",
647
+ );
648
+ updateCheckpoint(site, state, workflowKey, 'replay-and-diff', {});
649
+ }
650
+
651
+ if (needsReplay || needsCandidates) {
652
+ const replaySessionPath = requireSessionFile(redactedPath, {
653
+ site,
654
+ workflowKey,
655
+ startFrom,
656
+ kind: 'redacted',
657
+ });
658
+
659
+ // Resolve provider eagerly so triage/detect don't block on prompt mid-parallel
660
+ if (needsCandidates) await getProviderName();
661
+
662
+ muteLog();
663
+ try {
664
+ const mp = new MultiProgress();
665
+
666
+ // Branch A: replay-and-diff (slow, ~2 min)
667
+ const replayPromise = (async () => {
668
+ if (!needsReplay) {
669
+ const classPath = pathJoin(localSiteDir(site), '.classifications.json');
670
+ if (existsSync(classPath)) {
671
+ try {
672
+ return JSON.parse(readFileSync(classPath, 'utf8'))
673
+ .classifications as ClassifiedValue[];
674
+ } catch {
675
+ /* proceed without */
676
+ }
677
+ }
678
+ return undefined;
679
+ }
680
+ return siteReplayAndDiff(site, replaySessionPath, mp);
681
+ })();
682
+
683
+ // Branch B: triage → detect-candidates → user selection (fast, ~30s)
684
+ type CandidateChainResult = {
685
+ triageRes?: { result: TriageResult; sessionPath: string };
686
+ plans: CandidateCompilePlan[];
687
+ };
688
+ const candidatePromise = (async (): Promise<CandidateChainResult> => {
689
+ if (!needsCandidates) {
690
+ const ws = state.workflows[workflowKey];
691
+ return {
692
+ plans: [
693
+ {
694
+ workflowKey,
695
+ startFrom,
696
+ candidate: ws?.candidate,
697
+ sharedContext: ws?.sharedContext,
698
+ },
699
+ ],
700
+ };
701
+ }
702
+
703
+ // ── triage ──
704
+ let localTriageResult: TriageResult | undefined;
705
+ let localTriagedPath: string | null = null;
706
+ if (startIdx <= STEPS.indexOf('triage')) {
707
+ const triageSession = loadJsonFile(
708
+ replaySessionPath,
709
+ SessionSchema,
710
+ {
711
+ notFound: 'Redacted session file not found before triage.',
712
+ badSchema: 'Redacted session file is malformed.',
713
+ },
714
+ 'session',
715
+ );
716
+ const providerName = await getProviderName();
717
+ const model = await getModel();
718
+ mp.pause();
719
+ mp.clear();
720
+ spinner.start('Triaging requests...');
721
+ localTriageResult = await triageRequests(triageSession, {
722
+ provider: providerName,
723
+ model,
724
+ });
725
+ spinner.stop(
726
+ `Triaged to ${localTriageResult.selectedSeqs.length} requests (from ${triageSession.requests.length}).`,
727
+ );
728
+ mp.resume();
729
+
730
+ localTriagedPath = replaySessionPath.replace(/\.redacted\.json$/, '.triaged.json');
731
+ writeFileSync(
732
+ localTriagedPath,
733
+ `${JSON.stringify(localTriageResult.session, null, 2)}\n`,
734
+ 'utf8',
735
+ );
736
+ } else {
737
+ const ws = state.workflows[workflowKey];
738
+ if (ws?.triagedPath) {
739
+ localTriagedPath = resolveTeachStatePath(site, ws.triagedPath);
740
+ }
741
+ }
742
+
743
+ // ── detect candidates ──
744
+ const compileSessionPath = requireSessionFile(localTriagedPath ?? redactedPath, {
745
+ site,
746
+ workflowKey,
747
+ startFrom,
748
+ kind: localTriagedPath ? 'triaged' : 'redacted',
749
+ });
750
+ const providerName = await getProviderName();
751
+ const model = await getModel();
752
+ mp.pause();
753
+ mp.clear();
754
+ spinner.start('Detecting candidate tools...');
755
+ const detection = await detectTeachCandidates({
756
+ sessionPath: compileSessionPath,
757
+ providerName,
758
+ model,
759
+ });
760
+ spinner.stop(
761
+ `Detected ${detection.candidates.length} candidate tool${detection.candidates.length === 1 ? '' : 's'}.`,
762
+ );
763
+
764
+ // ── interactive selection — keep mp paused during prompt ──
765
+ const selected = await selectTeachCandidates(detection, opts);
766
+ mp.resume();
767
+
768
+ const sharedContext = buildCandidateSharedCompileContext(detection, selected);
769
+ const pendingKey = workflowKey.startsWith('_pending_') ? workflowKey : null;
770
+ const rawSessionPath = requireSessionFile(sessionPath, {
771
+ site,
772
+ workflowKey,
773
+ startFrom,
774
+ kind: 'raw',
775
+ });
776
+ const baseState = buildTeachStateFromSession(site, rawSessionPath, redactedPath);
777
+ const candidatePlans = selected.map((candidate) => {
778
+ checkpoint(site, state, candidate.toolName, {
779
+ ...baseState,
780
+ completedSteps: ['record', 'redact', 'replay-and-diff', 'triage', 'detect-candidates'],
781
+ candidate,
782
+ sharedContext,
783
+ });
784
+ return {
785
+ workflowKey: candidate.toolName,
786
+ startFrom: 'generate' as Step,
787
+ candidate,
788
+ sharedContext,
789
+ };
790
+ });
791
+
792
+ if (pendingKey && state.workflows[pendingKey]) {
793
+ delete state.workflows[pendingKey];
794
+ saveTeachState(site, state);
795
+ }
796
+
797
+ return {
798
+ triageRes: localTriageResult
799
+ ? { result: localTriageResult, sessionPath: replaySessionPath }
800
+ : undefined,
801
+ plans: candidatePlans,
802
+ };
803
+ })();
804
+
805
+ // Wait for candidate chain (includes user interaction)
806
+ const candidateResult = await candidatePromise;
807
+ plans = candidateResult.plans;
808
+
809
+ if (candidateResult.triageRes) {
810
+ triageResult = candidateResult.triageRes.result;
811
+ triagedPath = candidateResult.triageRes.sessionPath.replace(
812
+ /\.redacted\.json$/,
813
+ '.triaged.json',
814
+ );
815
+ }
816
+
817
+ // Wait for replay — may already be done, or show progress while waiting
818
+ let replaySettled = false;
819
+ replayPromise.then(
820
+ () => {
821
+ replaySettled = true;
822
+ },
823
+ () => {
824
+ replaySettled = true;
825
+ },
826
+ );
827
+ await new Promise((r) => setTimeout(r, 0));
828
+ const showedSpinner = !replaySettled;
829
+ if (showedSpinner) {
830
+ spinner.start('Waiting for replay to finish...');
831
+ }
832
+ siteClassifications = await replayPromise;
833
+ if (showedSpinner) {
834
+ spinner.stop('Replay complete.');
835
+ }
836
+
837
+ mp.clear();
838
+
839
+ // Checkpoints — write sequentially after both complete
840
+ if (needsReplay) {
841
+ updateCheckpoint(site, state, workflowKey, 'replay-and-diff', {
842
+ classificationsPath: siteClassifications
843
+ ? toRelative(site, pathJoin(localSiteDir(site), '.classifications.json'))
844
+ : undefined,
845
+ });
846
+ }
847
+ if (candidateResult.triageRes && triagedPath) {
848
+ updateCheckpoint(site, state, workflowKey, 'triage', {
849
+ triagedPath: toRelative(site, triagedPath),
850
+ });
851
+ }
852
+ } finally {
853
+ unmuteLog();
854
+ }
855
+ } else {
856
+ // Resuming from generate or later — load cached data
857
+ const classPath = pathJoin(localSiteDir(site), '.classifications.json');
858
+ if (existsSync(classPath)) {
859
+ try {
860
+ siteClassifications = JSON.parse(readFileSync(classPath, 'utf8')).classifications;
861
+ } catch {
862
+ /* proceed without */
863
+ }
864
+ }
865
+ const ws = state.workflows[workflowKey];
866
+ if (ws?.triagedPath) {
867
+ triagedPath = resolveTeachStatePath(site, ws.triagedPath);
868
+ }
869
+ plans = [
870
+ {
871
+ workflowKey,
872
+ startFrom,
873
+ candidate: ws?.candidate,
874
+ sharedContext: ws?.sharedContext,
875
+ },
876
+ ];
877
+ }
878
+
879
+ const needsCompileProvider = plans.some(
880
+ (plan) => STEPS.indexOf(plan.startFrom) <= STEPS.indexOf('compile-playbook'),
881
+ );
882
+ const compileProviderName = needsCompileProvider
883
+ ? await getProviderName()
884
+ : ('claude-cli' as ProviderName);
885
+ let compileModel = '';
886
+ if (needsCompileProvider) {
887
+ compileModel = await getModel();
888
+ const timeoutMs = opts.maxDurationMs ?? 10 * 60 * 1000;
889
+ const timeoutDisplay =
890
+ timeoutMs >= 3_600_000
891
+ ? `${Math.round(timeoutMs / 3_600_000)}h`
892
+ : timeoutMs >= 60_000
893
+ ? `${Math.round(timeoutMs / 60_000)}m`
894
+ : `${Math.round(timeoutMs / 1000)}s`;
895
+ p.note(
896
+ [
897
+ `Provider: ${compileProviderName} Model: ${compileModel}`,
898
+ `Timeout: ${timeoutDisplay} per tool`,
899
+ '',
900
+ plans.length === 1
901
+ ? 'An LLM agent will reverse-engineer the API response format.'
902
+ : `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency 3.`,
903
+ `Expect up to ${timeoutDisplay} per tool and moderate to high token use, depending on`,
904
+ 'the complexity of the recording. You can interrupt with Ctrl-C.',
905
+ ].join('\n'),
906
+ 'Compile step',
907
+ );
908
+ }
909
+
910
+ const compileSessionPath = requireSessionFile(redactedPath, {
911
+ site,
912
+ workflowKey: plans[0]?.workflowKey ?? workflowKey,
913
+ startFrom,
914
+ kind: 'redacted',
915
+ });
916
+
917
+ // ── Clean up stale tools from previous teach runs ──
918
+ const incomingToolNames = new Set(plans.map((pl) => pl.candidate?.toolName ?? pl.workflowKey));
919
+ const existingTools = discoverCompletedWorkflows(site);
920
+ const staleTools = existingTools.filter((name) => !incomingToolNames.has(name));
921
+ if (staleTools.length > 0) {
922
+ let shouldReplace = true;
923
+ if (!opts.noInteractive) {
924
+ const answer = await p.confirm({
925
+ message: `Found ${staleTools.length} existing tool${staleTools.length === 1 ? '' : 's'} from previous runs. Replace with the ${incomingToolNames.size} new tool${incomingToolNames.size === 1 ? '' : 's'}?`,
926
+ initialValue: true,
927
+ });
928
+ if (p.isCancel(answer)) throw new Error('Cancelled.');
929
+ shouldReplace = answer;
930
+ }
931
+ if (shouldReplace) {
932
+ for (const name of staleTools) {
933
+ rmSync(localToolDir(site, name), { recursive: true, force: true });
934
+ delete state.workflows[name];
935
+ }
936
+ saveTeachState(site, state);
937
+ }
938
+ }
939
+
940
+ if (plans.length > 1) muteLog();
941
+ let results: TeachToolResult[];
942
+ try {
943
+ results = await compileCandidatePlans({
944
+ plans,
945
+ site,
946
+ state,
947
+ sessionPath: compileSessionPath,
948
+ providerName: compileProviderName,
949
+ compileModel,
950
+ maxDurationMs: opts.maxDurationMs,
951
+ keepTest: opts.keepTest,
952
+ spinner,
953
+ sharedTriageResult: triageResult,
954
+ siteClassifications,
955
+ teachCredentials,
956
+ });
957
+ } finally {
958
+ if (plans.length > 1) unmuteLog();
959
+ }
960
+
961
+ if (results.length === 0) {
962
+ throw new Error('No selected tools were compiled.');
963
+ }
964
+
965
+ for (const result of results) {
966
+ const creds = referencedCredentialNames(result.workflow, result.playbook);
967
+ if (creds.size > 0) {
968
+ const store = await loadCredentialStore(site);
969
+ const storedNames = store ? new Set(Object.keys(store.values)) : new Set<string>();
970
+ const missing = [...creds].filter((name) => !storedNames.has(name));
971
+ if (missing.length > 0) {
972
+ p.log.warn(
973
+ `Tool "${result.workflow.toolName}" needs credentials [${missing.join(', ')}] but they are not in the credential store.\nRun: ${missing.map((n) => `imprint credential set ${site} ${n}`).join(' && ')}`,
974
+ );
975
+ }
976
+ }
977
+ }
978
+
979
+ const primaryResult = results[0] as TeachToolResult;
980
+
981
+ // ── 6. Platform integration ────────────────────────────────────────
982
+ if (startIdx <= STEPS.indexOf('register')) {
983
+ if (opts.noInteractive) {
984
+ const imprintCommand = detectImprintCommand();
985
+ const platforms: Platform[] = [
986
+ 'claude-code',
987
+ 'codex',
988
+ 'claude-desktop',
989
+ 'openclaw',
990
+ 'hermes',
991
+ ];
992
+ console.log('\n── Integration snippets ──\n');
993
+ for (const plat of platforms) {
994
+ console.log(`[${plat}]`);
995
+ console.log(
996
+ generatePasteSnippet({
997
+ site,
998
+ workflow: primaryResult.workflow,
999
+ workflows: results.map((r) => r.workflow),
1000
+ platform: plat,
1001
+ imprintCommand,
1002
+ }),
1003
+ );
1004
+ console.log('');
1005
+ }
1006
+ } else {
1007
+ await interactivePlatformSetup({
1008
+ site,
1009
+ workflowDir: pathResolve(primaryResult.workflowPath, '..'),
1010
+ workflow: primaryResult.workflow,
1011
+ workflows: results.map((r) => r.workflow),
1012
+ playbook: primaryResult.playbook,
1013
+ playbooks: results.map((r) => r.playbook),
1014
+ });
1015
+ }
1016
+ }
1017
+
1018
+ for (const result of results) {
1019
+ updateCheckpoint(site, state, result.workflow.toolName, 'register');
1020
+ }
1021
+
1022
+ p.outro(
1023
+ `Done! ${results.length} tool${results.length === 1 ? '' : 's'} ready: ${results.map((r) => r.workflow.toolName).join(', ')}`,
1024
+ );
1025
+
1026
+ return {
1027
+ sessionPath: sessionPath ?? '',
1028
+ workflowPath: primaryResult.workflowPath,
1029
+ playbookPath: primaryResult.playbookPath,
1030
+ indexPath: primaryResult.indexPath,
1031
+ workflow: primaryResult.workflow,
1032
+ playbook: primaryResult.playbook,
1033
+ tools: results,
1034
+ };
1035
+ }
1036
+
1037
+ // ─── Candidate detection + per-tool compile ────────────────────────────────
1038
+
1039
+ interface CandidateCompilePlan {
1040
+ workflowKey: string;
1041
+ startFrom: Step;
1042
+ candidate?: ToolCandidate;
1043
+ sharedContext?: SharedCompileContext;
1044
+ }
1045
+
1046
+ async function detectTeachCandidates(opts: {
1047
+ sessionPath: string;
1048
+ providerName: ProviderName;
1049
+ model?: string;
1050
+ }): Promise<Awaited<ReturnType<typeof detectToolCandidates>>> {
1051
+ const session = loadJsonFile(
1052
+ opts.sessionPath,
1053
+ SessionSchema,
1054
+ {
1055
+ notFound: 'Redacted session file not found before candidate detection.',
1056
+ badSchema: 'Redacted session file is malformed.',
1057
+ },
1058
+ 'session',
1059
+ );
1060
+ return await detectToolCandidates(session, { provider: opts.providerName, model: opts.model });
1061
+ }
1062
+
1063
+ async function selectTeachCandidates(
1064
+ detection: Awaited<ReturnType<typeof detectToolCandidates>>,
1065
+ opts: TeachOptions,
1066
+ ): Promise<ToolCandidate[]> {
1067
+ if (detection.candidates.length === 1) return [detection.candidates[0] as ToolCandidate];
1068
+
1069
+ if (opts.noInteractive) {
1070
+ if (opts.allTools) return detection.candidates;
1071
+ const primary = primaryToolCandidate(detection);
1072
+ p.log.warn(
1073
+ `Detected ${detection.candidates.length} candidate tools; --no-interactive compiles only primary "${primary.toolName}". Pass --all-tools to compile all.`,
1074
+ );
1075
+ return [primary];
1076
+ }
1077
+
1078
+ const answer = await p.multiselect({
1079
+ message:
1080
+ 'Which tools should Imprint compile from this recording?\n (press [space] to toggle, [enter] to submit)',
1081
+ required: true,
1082
+ initialValues: detection.candidates
1083
+ .filter((candidate) => candidate.primary)
1084
+ .map((c) => c.toolName),
1085
+ options: detection.candidates.map((candidate) => ({
1086
+ value: candidate.toolName,
1087
+ label: `${candidate.toolName}${candidate.primary ? ' (primary)' : ''}`,
1088
+ hint: `${Math.round(candidate.confidence * 100)}% — ${candidate.description}`,
1089
+ })),
1090
+ });
1091
+ if (p.isCancel(answer)) {
1092
+ p.outro('Cancelled.');
1093
+ process.exit(0);
1094
+ }
1095
+
1096
+ const selectedNames = new Set(answer as string[]);
1097
+ const selected = detection.candidates.filter((candidate) =>
1098
+ selectedNames.has(candidate.toolName),
1099
+ );
1100
+ if (selected.length === 0) {
1101
+ throw new Error('At least one tool candidate must be selected.');
1102
+ }
1103
+ return selected;
1104
+ }
1105
+
1106
+ async function compileCandidatePlans(opts: {
1107
+ plans: CandidateCompilePlan[];
1108
+ site: string;
1109
+ state: TeachState;
1110
+ sessionPath: string;
1111
+ providerName: ProviderName;
1112
+ compileModel: string;
1113
+ maxDurationMs?: number;
1114
+ keepTest?: boolean;
1115
+ spinner: ReturnType<typeof p.spinner>;
1116
+ sharedTriageResult?: TriageResult;
1117
+ siteClassifications?: ClassifiedValue[];
1118
+ teachCredentials?: { site: string; values: Record<string, string> };
1119
+ }): Promise<TeachToolResult[]> {
1120
+ const concurrency = opts.plans.length === 1 ? 1 : 3;
1121
+ const mp = opts.plans.length > 1 ? new MultiProgress() : null;
1122
+
1123
+ // Mutex for deadline prompts: concurrent compile agents can hit their
1124
+ // deadline at the same time, but only one p.confirm() can be active on
1125
+ // stdin at a time. Without serialization, a second prompt cancels/steals
1126
+ // input from the first, causing it to auto-resolve as cancelled.
1127
+ let promptLock: Promise<void> = Promise.resolve();
1128
+
1129
+ const outcomes = await mapLimitSettled(opts.plans, concurrency, async (plan) => {
1130
+ const displayName = plan.candidate?.toolName ?? plan.workflowKey;
1131
+ let lastActivity = '';
1132
+ const onProgress = (progress: CompileAgentProgress): void => {
1133
+ const activity = formatCompileProgress(progress);
1134
+ if (activity === lastActivity) return;
1135
+ lastActivity = activity;
1136
+ if (mp) {
1137
+ mp.update(displayName, `[imprint teach] ${displayName}: ${activity}`);
1138
+ } else {
1139
+ opts.spinner.message(activity);
1140
+ }
1141
+ };
1142
+ const compileStart = Date.now();
1143
+ const onDeadlineReached: OnDeadlineReached | undefined = process.stdin.isTTY
1144
+ ? async () => {
1145
+ // Serialize deadline prompts so only one p.confirm() is active at a time.
1146
+ const prev = promptLock;
1147
+ let releaseLock: () => void = () => {};
1148
+ promptLock = new Promise<void>((r) => {
1149
+ releaseLock = r;
1150
+ });
1151
+ await prev;
1152
+
1153
+ try {
1154
+ const elapsed = Math.round((Date.now() - compileStart) / 60000);
1155
+ if (mp) {
1156
+ mp.clear();
1157
+ mp.pause();
1158
+ } else {
1159
+ opts.spinner.stop();
1160
+ }
1161
+ const extend = await p.confirm({
1162
+ message: `${displayName} has been compiling for ${elapsed} minutes. Give it more time?`,
1163
+ });
1164
+ if (mp) {
1165
+ mp.resume();
1166
+ } else {
1167
+ opts.spinner.start(`Compiling ${displayName}...`);
1168
+ }
1169
+ if (p.isCancel(extend) || !extend) return null;
1170
+ return 10 * 60 * 1000;
1171
+ } finally {
1172
+ releaseLock();
1173
+ }
1174
+ }
1175
+ : undefined;
1176
+
1177
+ if (!mp) opts.spinner.start(`Compiling ${displayName}...`);
1178
+ try {
1179
+ const result = await compileSelectedCandidate({
1180
+ ...opts,
1181
+ plan,
1182
+ onProgress,
1183
+ onDeadlineReached,
1184
+ });
1185
+ if (mp) {
1186
+ mp.clear();
1187
+ mp.remove(displayName);
1188
+ p.log.success(`${displayName} compiled.`);
1189
+ mp.render();
1190
+ } else {
1191
+ opts.spinner.stop(`${displayName} compiled.`);
1192
+ }
1193
+ return result;
1194
+ } catch (err) {
1195
+ const ws = opts.state.workflows[plan.workflowKey];
1196
+ if (ws) {
1197
+ ws.error = err instanceof Error ? err.message : String(err);
1198
+ ws.updatedAt = new Date().toISOString();
1199
+ saveTeachState(opts.site, opts.state);
1200
+ }
1201
+ if (mp) {
1202
+ mp.clear();
1203
+ mp.remove(displayName);
1204
+ p.log.warn(`${displayName} failed: ${err instanceof Error ? err.message : String(err)}`);
1205
+ mp.render();
1206
+ } else {
1207
+ opts.spinner.stop(`${displayName} failed.`);
1208
+ p.log.warn(`${err instanceof Error ? err.message : String(err)}`);
1209
+ }
1210
+ throw err;
1211
+ }
1212
+ });
1213
+
1214
+ const successes: TeachToolResult[] = [];
1215
+ const failures: string[] = [];
1216
+ for (let i = 0; i < outcomes.length; i++) {
1217
+ const outcome = outcomes[i];
1218
+ const displayName = opts.plans[i]?.candidate?.toolName ?? opts.plans[i]?.workflowKey ?? '?';
1219
+ if (outcome?.ok) {
1220
+ successes.push(outcome.value);
1221
+ } else {
1222
+ const msg = outcome?.error instanceof Error ? outcome.error.message : String(outcome?.error);
1223
+ failures.push(`${displayName}: ${msg.split('\n')[0]}`);
1224
+ }
1225
+ }
1226
+
1227
+ if (failures.length > 0) {
1228
+ p.log.warn(
1229
+ `${successes.length} of ${outcomes.length} tools compiled. ` +
1230
+ `${failures.length} failed:\n${failures.map((f) => ` • ${f}`).join('\n')}`,
1231
+ );
1232
+ }
1233
+
1234
+ return successes;
1235
+ }
1236
+
1237
+ async function compileSelectedCandidate(opts: {
1238
+ plan: CandidateCompilePlan;
1239
+ site: string;
1240
+ state: TeachState;
1241
+ sessionPath: string;
1242
+ providerName: ProviderName;
1243
+ compileModel: string;
1244
+ maxDurationMs?: number;
1245
+ keepTest?: boolean;
1246
+ onProgress: (progress: CompileAgentProgress) => void;
1247
+ onDeadlineReached?: OnDeadlineReached;
1248
+ sharedTriageResult?: TriageResult;
1249
+ siteClassifications?: ClassifiedValue[];
1250
+ teachCredentials?: { site: string; values: Record<string, string> };
1251
+ }): Promise<TeachToolResult> {
1252
+ const { plan, site, state } = opts;
1253
+ const startIdx = STEPS.indexOf(plan.startFrom);
1254
+ const toolName = plan.candidate?.toolName ?? plan.workflowKey;
1255
+ const workflowDir = localToolDir(site, toolName);
1256
+ mkdirSync(workflowDir, { recursive: true });
1257
+
1258
+ // ── Step 1: generate (workflow.json, enriched with site-level classifications) ──
1259
+ let genResult: { workflow: Workflow; workflowPath: string };
1260
+ if (startIdx <= STEPS.indexOf('generate')) {
1261
+ const result = await generate({
1262
+ sessionPath: opts.sessionPath,
1263
+ outDir: workflowDir,
1264
+ maxDurationMs: opts.maxDurationMs,
1265
+ llmConfig: { provider: opts.providerName, model: opts.compileModel },
1266
+ keepTest: opts.keepTest,
1267
+ candidate: plan.candidate,
1268
+ sharedContext: plan.sharedContext,
1269
+ onProgress: opts.onProgress,
1270
+ onDeadlineReached: opts.onDeadlineReached,
1271
+ classifications: opts.siteClassifications,
1272
+ teachCredentials: opts.teachCredentials,
1273
+ });
1274
+ assertCandidateToolName('Compiled workflow', result.workflow.toolName, plan.candidate);
1275
+ genResult = { workflow: result.workflow, workflowPath: result.workflowPath };
1276
+ updateCheckpoint(site, state, plan.workflowKey, 'generate', {
1277
+ candidate: plan.candidate,
1278
+ sharedContext: plan.sharedContext,
1279
+ });
1280
+ } else {
1281
+ const workflowPath = pathJoin(workflowDir, 'workflow.json');
1282
+ const workflow = loadJsonFile(
1283
+ workflowPath,
1284
+ WorkflowSchema,
1285
+ { notFound: `workflow.json not found at ${workflowPath}` },
1286
+ 'workflow.json',
1287
+ );
1288
+ genResult = { workflow, workflowPath };
1289
+ }
1290
+
1291
+ // ── Step 2: compile-playbook (after generate — runtime artifact, not needed for dual-pass) ──
1292
+ let pbResult: { playbook: Playbook; playbookPath: string };
1293
+ if (startIdx <= STEPS.indexOf('compile-playbook')) {
1294
+ const result = await compilePlaybook({
1295
+ sessionPath: opts.sessionPath,
1296
+ outPath: pathJoin(workflowDir, 'playbook.yaml'),
1297
+ llmConfig: { provider: opts.providerName },
1298
+ candidate: plan.candidate,
1299
+ sharedContext: plan.sharedContext,
1300
+ preTriagedSession: opts.sharedTriageResult,
1301
+ });
1302
+ assertCandidateToolName('Compiled playbook', result.playbook.toolName, plan.candidate);
1303
+ pbResult = { playbook: result.playbook, playbookPath: result.playbookPath };
1304
+ updateCheckpoint(site, state, plan.workflowKey, 'compile-playbook');
1305
+ } else {
1306
+ const playbookPath = pathJoin(workflowDir, 'playbook.yaml');
1307
+ const { parsePlaybook } = await import('./playbook-parser.ts');
1308
+ const playbook = parsePlaybook(readFileSync(playbookPath, 'utf8'));
1309
+ assertCandidateToolName('Stored playbook', playbook.toolName, plan.candidate);
1310
+ pbResult = { playbook, playbookPath };
1311
+ }
1312
+
1313
+ // ── Step 3: emit ──
1314
+ let emitOutPath: string;
1315
+ if (startIdx <= STEPS.indexOf('emit')) {
1316
+ const emitResult = emit({
1317
+ workflowPath: genResult.workflowPath,
1318
+ outDir: workflowDir,
1319
+ force: true,
1320
+ });
1321
+ emitOutPath = emitResult.outPath;
1322
+ updateCheckpoint(site, state, plan.workflowKey, 'emit');
1323
+ } else {
1324
+ emitOutPath = pathJoin(workflowDir, 'index.ts');
1325
+ }
1326
+
1327
+ exportSiteManifest(site, workflowDir, genResult.workflow, pbResult.playbook);
1328
+
1329
+ await writeQuickBackendsCache(workflowDir, genResult.workflow);
1330
+
1331
+ return {
1332
+ workflowPath: genResult.workflowPath,
1333
+ playbookPath: pbResult.playbookPath,
1334
+ indexPath: emitOutPath,
1335
+ workflow: genResult.workflow,
1336
+ playbook: pbResult.playbook,
1337
+ };
1338
+ }
1339
+
1340
+ /**
1341
+ * Site-level replay-and-diff: replay the entire original recording in a fresh
1342
+ * browser, capture all requests, diff against the original to classify values.
1343
+ * Runs once per teach, not per-tool.
1344
+ */
1345
+ async function siteReplayAndDiff(
1346
+ site: string,
1347
+ sessionPath: string,
1348
+ mp: MultiProgress,
1349
+ ): Promise<ClassifiedValue[] | undefined> {
1350
+ try {
1351
+ const { replayRawSession } = await import('./replay-capture.ts');
1352
+ const { diffTriagedSessions, triageByAlignment } = await import('./session-diff.ts');
1353
+
1354
+ const session = loadJsonFile(
1355
+ sessionPath,
1356
+ SessionSchema,
1357
+ { notFound: 'Session not found for replay.' },
1358
+ 'session',
1359
+ );
1360
+
1361
+ mp.update('replay', 'Replaying session in fresh browser...');
1362
+ const replayResult = await replayRawSession({
1363
+ session,
1364
+ site,
1365
+ onProgress: (current, total, captured) => {
1366
+ mp.update('replay', `Replaying event ${current}/${total} (${captured} requests captured)`);
1367
+ },
1368
+ });
1369
+
1370
+ let replayRequests = replayResult.requests;
1371
+
1372
+ if (!replayResult.ok) {
1373
+ mp.clear();
1374
+ mp.remove('replay');
1375
+ p.log.warn(`Automated replay failed: ${replayResult.error}`);
1376
+ p.log.info(
1377
+ 'Recording the same flow again in a fresh browser for dual-pass analysis.\n' +
1378
+ 'No narration needed — just repeat the same actions, then close the browser.',
1379
+ );
1380
+ mp.render();
1381
+
1382
+ const recordResult = await record({ site, url: session.url });
1383
+ const secondSession = loadJsonFile(
1384
+ recordResult.sessionPath,
1385
+ SessionSchema,
1386
+ { notFound: 'Second recording session not found.' },
1387
+ 'session',
1388
+ );
1389
+
1390
+ replayRequests = secondSession.requests;
1391
+ }
1392
+
1393
+ mp.update('replay', 'Diffing replay against original...');
1394
+
1395
+ const triaged2Seqs = triageByAlignment(session.requests, replayRequests);
1396
+ const triaged2Requests = replayRequests.filter((r) => triaged2Seqs.includes(r.seq));
1397
+ const diffResult = diffTriagedSessions(session, { requests: triaged2Requests });
1398
+
1399
+ const classPath = pathJoin(localSiteDir(site), '.classifications.json');
1400
+ writeFileSync(classPath, JSON.stringify(diffResult, null, 2));
1401
+
1402
+ mp.clear();
1403
+ mp.remove('replay');
1404
+
1405
+ const nonConstant = diffResult.classifications.filter((c) => c.classification !== 'constant');
1406
+ if (nonConstant.length > 0) {
1407
+ const counts: Record<string, number> = {};
1408
+ for (const c of nonConstant) counts[c.classification] = (counts[c.classification] ?? 0) + 1;
1409
+ const breakdown = Object.entries(counts)
1410
+ .map(([k, v]) => `${v} ${k}`)
1411
+ .join(', ');
1412
+ p.log.info(
1413
+ `Dual-pass: ${nonConstant.length} ephemeral values (${breakdown}). ${replayRequests.length} requests captured.`,
1414
+ );
1415
+ } else {
1416
+ p.log.info(`Dual-pass: all values constant. ${replayRequests.length} requests captured.`);
1417
+ }
1418
+
1419
+ mp.render();
1420
+ return diffResult.classifications;
1421
+ } catch (err) {
1422
+ mp.clear();
1423
+ mp.remove('replay');
1424
+ p.log.warn(`Dual-pass analysis failed: ${err instanceof Error ? err.message : String(err)}`);
1425
+ mp.render();
1426
+ return undefined;
1427
+ }
1428
+ }
1429
+
1430
+ export async function mapLimit<T, R>(
1431
+ items: T[],
1432
+ concurrency: number,
1433
+ fn: (item: T) => Promise<R>,
1434
+ ): Promise<R[]> {
1435
+ const results = new Array<R>(items.length);
1436
+ let next = 0;
1437
+ let firstError: unknown;
1438
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1439
+ while (next < items.length && firstError === undefined) {
1440
+ const index = next++;
1441
+ const item = items[index];
1442
+ if (item === undefined) continue;
1443
+ try {
1444
+ results[index] = await fn(item);
1445
+ } catch (err) {
1446
+ firstError ??= err;
1447
+ }
1448
+ }
1449
+ });
1450
+ await Promise.allSettled(workers);
1451
+ if (firstError !== undefined) throw firstError;
1452
+ return results;
1453
+ }
1454
+
1455
+ type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
1456
+
1457
+ export async function mapLimitSettled<T, R>(
1458
+ items: T[],
1459
+ concurrency: number,
1460
+ fn: (item: T) => Promise<R>,
1461
+ ): Promise<SettledResult<R>[]> {
1462
+ const results = new Array<SettledResult<R>>(items.length);
1463
+ let next = 0;
1464
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1465
+ while (next < items.length) {
1466
+ const index = next++;
1467
+ const item = items[index];
1468
+ if (item === undefined) continue;
1469
+ try {
1470
+ results[index] = { ok: true, value: await fn(item) };
1471
+ } catch (err) {
1472
+ results[index] = { ok: false, error: err };
1473
+ }
1474
+ }
1475
+ });
1476
+ await Promise.allSettled(workers);
1477
+ return results;
1478
+ }
1479
+
1480
+ // ─── Credential capture (interactive) ───────────────────────────────────────
1481
+
1482
+ interface CredentialPromptResult {
1483
+ replacements: Replacement[];
1484
+ confirmedFinding?: CredentialFinding;
1485
+ }
1486
+
1487
+ async function promptAndPersistCredentials(opts: {
1488
+ site: string;
1489
+ findings: CredentialFinding[];
1490
+ replacements: Replacement[];
1491
+ noInteractive: boolean;
1492
+ }): Promise<CredentialPromptResult> {
1493
+ // De-duplicate findings by username+password value so a re-recorded session
1494
+ // with the same login attempt across multiple seqs only prompts once.
1495
+ const seen = new Set<string>();
1496
+ const unique: CredentialFinding[] = [];
1497
+ for (const f of opts.findings) {
1498
+ const key = `${f.usernameValue}${f.passwordValue}`;
1499
+ if (seen.has(key)) continue;
1500
+ seen.add(key);
1501
+ unique.push(f);
1502
+ }
1503
+ if (unique.length === 0) return { replacements: opts.replacements };
1504
+
1505
+ const summary = unique
1506
+ .map(
1507
+ (f, i) =>
1508
+ ` ${i + 1}. ${f.requestLabel}\n username: ${f.usernameValue}\n password: ${'*'.repeat(Math.min(f.passwordValue.length, 16))}`,
1509
+ )
1510
+ .join('\n');
1511
+ p.note(
1512
+ [
1513
+ `Detected ${unique.length} login form submission(s) in this recording.`,
1514
+ 'Imprint will store the credentials in your local credential manager (OS keychain when',
1515
+ 'available, libsodium-encrypted file otherwise) and rewrite their values to',
1516
+ '${credential.username} / ${credential.password} placeholders before sending the',
1517
+ 'session to the LLM. The plaintext values never enter the workflow artifact.',
1518
+ '',
1519
+ summary,
1520
+ ].join('\n'),
1521
+ 'Credential capture',
1522
+ );
1523
+
1524
+ if (opts.noInteractive) {
1525
+ // Persist silently in non-interactive mode — keeps automated runs working.
1526
+ const finding = unique[0] as CredentialFinding;
1527
+ await persistFinding({ site: opts.site, finding });
1528
+ return { replacements: opts.replacements, confirmedFinding: finding };
1529
+ }
1530
+
1531
+ const proceed = await p.confirm({
1532
+ message: `Save credentials for "${opts.site}" to the credential manager?`,
1533
+ initialValue: true,
1534
+ });
1535
+ if (p.isCancel(proceed) || !proceed) {
1536
+ p.log.warn('Skipping credential save — workflow will not be able to log in.');
1537
+ return { replacements: [] };
1538
+ }
1539
+
1540
+ // For v1 we only support one set of credentials per site (flat
1541
+ // username/password names). If multiple distinct logins were found,
1542
+ // ask which one to persist.
1543
+ let chosen: CredentialFinding | undefined = unique[0];
1544
+ if (unique.length > 1) {
1545
+ const pick = await p.select({
1546
+ message: 'Which login should be stored?',
1547
+ options: unique.map((f, i) => ({
1548
+ value: String(i),
1549
+ label: `${i + 1}. ${f.requestLabel} — ${f.usernameValue}`,
1550
+ })),
1551
+ });
1552
+ if (p.isCancel(pick)) {
1553
+ p.log.warn('Skipped.');
1554
+ return { replacements: [] };
1555
+ }
1556
+ chosen = unique[Number.parseInt(pick as string, 10)];
1557
+ }
1558
+
1559
+ if (!chosen) return { replacements: opts.replacements };
1560
+
1561
+ await persistFinding({ site: opts.site, finding: chosen });
1562
+
1563
+ return {
1564
+ replacements: opts.replacements.filter(
1565
+ (r) => r.originalValue === chosen?.usernameValue || r.originalValue === chosen?.passwordValue,
1566
+ ),
1567
+ confirmedFinding: chosen,
1568
+ };
1569
+ }
1570
+
1571
+ /** Write `<workflowDir>/credentials.manifest.json` so consumers of the
1572
+ * generated tool know what credentials to provision. No values, just names. */
1573
+ function exportSiteManifest(
1574
+ site: string,
1575
+ workflowDir: string,
1576
+ workflow: Workflow,
1577
+ playbook: Playbook,
1578
+ ): void {
1579
+ const m = readSiteManifest(site);
1580
+ if (!m || (m.secrets.length === 0 && (m.storage?.length ?? 0) === 0)) return;
1581
+ const requiredSecrets = referencedCredentialNames(workflow, playbook);
1582
+ const requiredStorageKeys = referencedStorageKeys(workflow, playbook);
1583
+ const secrets = m.secrets.filter((s) => requiredSecrets.has(s.name));
1584
+ const storage = (m.storage ?? []).filter((s) =>
1585
+ requiredStorageKeys.has(`${s.origin}\n${s.kind}\n${s.key}`),
1586
+ );
1587
+ if (secrets.length === 0 && storage.length === 0) return;
1588
+ const out = {
1589
+ site: m.site,
1590
+ secrets: secrets.map((s) => ({
1591
+ name: s.name,
1592
+ kind: s.kind,
1593
+ description: s.description,
1594
+ })),
1595
+ storage: storage.map((s) => ({
1596
+ origin: s.origin,
1597
+ kind: s.kind,
1598
+ key: s.key,
1599
+ })),
1600
+ note: 'Provision these on the consuming agent via `imprint credential set <site> <name>` or by importing an encrypted bundle (`imprint credential import`). Values never travel inside the skill.',
1601
+ };
1602
+ writeFileSync(
1603
+ pathJoin(workflowDir, 'credentials.manifest.json'),
1604
+ `${JSON.stringify(out, null, 2)}\n`,
1605
+ 'utf8',
1606
+ );
1607
+ }
1608
+
1609
+ function referencedCredentialNames(workflow: Workflow, playbook: Playbook): Set<string> {
1610
+ const names = new Set<string>();
1611
+ const text = `${JSON.stringify(workflow)}\n${JSON.stringify(playbook)}`;
1612
+ for (const match of text.matchAll(/\$\{credential\.([^}]+)\}/g)) {
1613
+ if (match[1]) names.add(match[1]);
1614
+ }
1615
+ return names;
1616
+ }
1617
+
1618
+ function referencedStorageKeys(workflow: Workflow, _playbook: Playbook): Set<string> {
1619
+ const refs = new Set<string>();
1620
+ for (const capture of workflow.bootstrap?.captures ?? []) {
1621
+ if (capture.source === 'local_storage') {
1622
+ refs.add(`${capture.origin}\nlocalStorage\n${capture.key}`);
1623
+ } else if (capture.source === 'session_storage') {
1624
+ refs.add(`${capture.origin}\nsessionStorage\n${capture.key}`);
1625
+ }
1626
+ }
1627
+ return refs;
1628
+ }
1629
+
1630
+ async function persistFinding(opts: {
1631
+ site: string;
1632
+ finding: CredentialFinding;
1633
+ }): Promise<void> {
1634
+ const backend = await getCredentialBackend();
1635
+ await backend.setSecret(opts.site, opts.finding.usernameName, opts.finding.usernameValue);
1636
+ await backend.setSecret(opts.site, opts.finding.passwordName, opts.finding.passwordValue);
1637
+ upsertManifestEntry(opts.site, {
1638
+ name: opts.finding.usernameName,
1639
+ kind: 'username',
1640
+ description: 'Login identifier (email or username)',
1641
+ });
1642
+ upsertManifestEntry(opts.site, {
1643
+ name: opts.finding.passwordName,
1644
+ kind: 'password',
1645
+ description: 'Login password',
1646
+ });
1647
+ p.log.success(
1648
+ `Stored credentials for "${opts.site}" — ${opts.finding.usernameName}, ${opts.finding.passwordName} (backend: ${backend.id})`,
1649
+ );
1650
+ }
1651
+
1652
+ // ─── Checkpoint helpers ─────────────────────────────────────────────────────
1653
+
1654
+ function checkpoint(site: string, state: TeachState, key: string, ws: WorkflowState): void {
1655
+ state.workflows[key] = ws;
1656
+ saveTeachState(site, state);
1657
+ }
1658
+
1659
+ function updateCheckpoint(
1660
+ site: string,
1661
+ state: TeachState,
1662
+ key: string,
1663
+ step: Step,
1664
+ extra?: Partial<WorkflowState>,
1665
+ ): void {
1666
+ const ws = state.workflows[key] ?? {
1667
+ sessionPath: '',
1668
+ completedSteps: [],
1669
+ startedAt: new Date().toISOString(),
1670
+ updatedAt: new Date().toISOString(),
1671
+ };
1672
+ if (!ws.completedSteps.includes(step)) {
1673
+ ws.completedSteps.push(step);
1674
+ }
1675
+ ws.updatedAt = new Date().toISOString();
1676
+ ws.error = undefined;
1677
+ if (extra) Object.assign(ws, extra);
1678
+ state.workflows[key] = ws;
1679
+ saveTeachState(site, state);
1680
+ }
1681
+
1682
+ // ─── Resume TUI ─────────────────────────────────────────────────────────────
1683
+
1684
+ interface ResumeChoice {
1685
+ action: 'new' | 'continue' | 'redo';
1686
+ workflowKey: string;
1687
+ fromStep: Step;
1688
+ }
1689
+
1690
+ async function promptResumeChoice(
1691
+ _site: string,
1692
+ completed: string[],
1693
+ incomplete: [string, WorkflowState][],
1694
+ ): Promise<ResumeChoice | symbol> {
1695
+ // Show what exists.
1696
+ if (completed.length > 0 || incomplete.length > 0) {
1697
+ const lines: string[] = [];
1698
+ for (const name of completed) lines.push(` ✓ ${name} (complete)`);
1699
+ for (const [name, ws] of incomplete) {
1700
+ const next = nextStep(ws.completedSteps) ?? 'unknown';
1701
+ const errHint = ws.error ? ` — error: ${ws.error.slice(0, 60)}` : '';
1702
+ lines.push(` ✗ ${name} (stopped at: ${next}${errHint})`);
1703
+ }
1704
+ p.log.info(`Found existing workflows:\n${lines.join('\n')}`);
1705
+ }
1706
+
1707
+ type OptionValue = string;
1708
+ const options: { value: OptionValue; label: string }[] = [];
1709
+
1710
+ // Offer continue for incomplete workflows.
1711
+ for (const [name, ws] of incomplete) {
1712
+ const next = nextStep(ws.completedSteps);
1713
+ if (next) {
1714
+ options.push({
1715
+ value: `continue:${name}`,
1716
+ label: `Continue "${name}" from ${next}`,
1717
+ });
1718
+ }
1719
+ }
1720
+
1721
+ // Offer redo for all workflows (incomplete + completed).
1722
+ for (const [name] of incomplete) {
1723
+ options.push({
1724
+ value: `redo:${name}`,
1725
+ label: `Redo "${name}" from a specific step`,
1726
+ });
1727
+ }
1728
+ for (const name of completed) {
1729
+ options.push({
1730
+ value: `redo:${name}`,
1731
+ label: `Redo "${name}" from a specific step`,
1732
+ });
1733
+ }
1734
+
1735
+ options.push({
1736
+ value: 'new',
1737
+ label: 'Start a new workflow (record a new session)',
1738
+ });
1739
+
1740
+ const choice = await p.select({
1741
+ message: 'What would you like to do?',
1742
+ options,
1743
+ });
1744
+
1745
+ if (p.isCancel(choice)) return choice;
1746
+
1747
+ const choiceStr = choice as string;
1748
+
1749
+ if (choiceStr === 'new') {
1750
+ return { action: 'new', workflowKey: '', fromStep: 'record' };
1751
+ }
1752
+
1753
+ if (choiceStr.startsWith('continue:')) {
1754
+ const key = choiceStr.slice('continue:'.length);
1755
+ const ws = incomplete.find(([n]) => n === key)?.[1];
1756
+ const from = ws ? (nextStep(ws.completedSteps) ?? 'record') : 'record';
1757
+ return { action: 'continue', workflowKey: key, fromStep: from };
1758
+ }
1759
+
1760
+ if (choiceStr.startsWith('redo:')) {
1761
+ const key = choiceStr.slice('redo:'.length);
1762
+
1763
+ const stepChoice = await p.select({
1764
+ message: `Redo "${key}" — start from which step?`,
1765
+ options: STEPS.map((s) => ({ value: s, label: s })),
1766
+ });
1767
+
1768
+ if (p.isCancel(stepChoice)) return stepChoice;
1769
+
1770
+ return { action: 'redo', workflowKey: key, fromStep: stepChoice as Step };
1771
+ }
1772
+
1773
+ return { action: 'new', workflowKey: '', fromStep: 'record' };
1774
+ }
1775
+
1776
+ // ─── Platform integration (unchanged) ───────────────────────────────────────
1777
+
1778
+ async function interactivePlatformSetup(opts: {
1779
+ site: string;
1780
+ workflowDir: string;
1781
+ workflow: Workflow;
1782
+ workflows?: Workflow[];
1783
+ playbook: Playbook;
1784
+ playbooks?: Playbook[];
1785
+ }): Promise<void> {
1786
+ const { site, workflowDir, workflow, workflows, playbook, playbooks } = opts;
1787
+ const imprintCommand = detectImprintCommand();
1788
+
1789
+ const platformChoice = await p.select({
1790
+ message: 'Which platform will use this tool?',
1791
+ options: [
1792
+ { value: 'claude-code' as Platform, label: 'Claude Code' },
1793
+ { value: 'codex' as Platform, label: 'Codex CLI' },
1794
+ { value: 'claude-desktop' as Platform, label: 'Claude Desktop' },
1795
+ { value: 'openclaw' as Platform, label: 'OpenClaw' },
1796
+ { value: 'hermes' as Platform, label: 'Hermes' },
1797
+ { value: 'skip' as const, label: 'Other / manual' },
1798
+ ],
1799
+ });
1800
+
1801
+ if (p.isCancel(platformChoice) || platformChoice === 'skip') return;
1802
+
1803
+ const platform = platformChoice as Platform;
1804
+ const regCommand = buildRegistrationCommand({ site, platform, imprintCommand });
1805
+
1806
+ if (regCommand !== null) {
1807
+ const setupChoice = await p.select({
1808
+ message: 'How would you like to set it up?',
1809
+ options: [
1810
+ { value: 'run' as const, label: 'Run the command now' },
1811
+ { value: 'snippet' as const, label: 'Print paste snippet' },
1812
+ { value: 'skip' as const, label: 'Skip' },
1813
+ ],
1814
+ });
1815
+
1816
+ if (p.isCancel(setupChoice) || setupChoice === 'skip') return;
1817
+
1818
+ if (setupChoice === 'run') {
1819
+ const spinner = p.spinner();
1820
+ const cmdDisplay = regCommand.join(' ');
1821
+ spinner.start(`Running: ${cmdDisplay}`);
1822
+ try {
1823
+ let proc = Bun.spawnSync(regCommand, { stdio: ['ignore', 'pipe', 'pipe'] });
1824
+
1825
+ // If it failed because the server already exists, ask to replace.
1826
+ if (proc.exitCode !== 0 && proc.stderr.toString().includes('already exists')) {
1827
+ spinner.stop(`imprint-${site} is already registered.`);
1828
+ const replace = await p.confirm({
1829
+ message: 'Replace existing registration?',
1830
+ initialValue: true,
1831
+ });
1832
+ if (!p.isCancel(replace) && replace) {
1833
+ const toolName = `imprint-${site}`;
1834
+ if (platform === 'claude-code') {
1835
+ Bun.spawnSync(['claude', 'mcp', 'remove', '--scope', 'user', toolName], {
1836
+ stdio: ['ignore', 'ignore', 'ignore'],
1837
+ });
1838
+ } else if (platform === 'codex') {
1839
+ Bun.spawnSync(['codex', 'mcp', 'remove', toolName], {
1840
+ stdio: ['ignore', 'ignore', 'ignore'],
1841
+ });
1842
+ }
1843
+ spinner.start(`Re-registering: ${cmdDisplay}`);
1844
+ proc = Bun.spawnSync(regCommand, { stdio: ['ignore', 'pipe', 'pipe'] });
1845
+ if (proc.exitCode === 0) {
1846
+ spinner.stop(
1847
+ `imprint-${site} replaced in ${platform === 'claude-code' ? 'Claude Code' : 'Codex'}.`,
1848
+ );
1849
+ } else {
1850
+ const stderr = proc.stderr.toString().trim();
1851
+ spinner.stop(
1852
+ `Command exited with code ${proc.exitCode}${stderr ? `: ${stderr}` : ''}`,
1853
+ );
1854
+ }
1855
+ }
1856
+ } else if (proc.exitCode === 0) {
1857
+ spinner.stop(
1858
+ `imprint-${site} is now available in ${platform === 'claude-code' ? 'Claude Code' : 'Codex'}.`,
1859
+ );
1860
+ } else {
1861
+ const stderr = proc.stderr.toString().trim();
1862
+ spinner.stop(`Command exited with code ${proc.exitCode}${stderr ? `: ${stderr}` : ''}`);
1863
+ console.log('\nRun this manually instead:');
1864
+ console.log(` ${cmdDisplay}\n`);
1865
+ }
1866
+ } catch (err) {
1867
+ spinner.stop(`Failed: ${err instanceof Error ? err.message : String(err)}`);
1868
+ console.log('\nRun this manually instead:');
1869
+ console.log(` ${cmdDisplay}\n`);
1870
+ }
1871
+ } else {
1872
+ const snippet = generatePasteSnippet({
1873
+ site,
1874
+ workflow,
1875
+ workflows,
1876
+ platform,
1877
+ imprintCommand,
1878
+ });
1879
+ console.log('\nPaste this into your terminal or AI tool:\n');
1880
+ console.log(` ${snippet}\n`);
1881
+ }
1882
+ } else {
1883
+ const snippet = generatePasteSnippet({ site, workflow, workflows, platform, imprintCommand });
1884
+ console.log(`\n${snippet}\n`);
1885
+ }
1886
+
1887
+ if (platform === 'openclaw' || platform === 'hermes') {
1888
+ await offerSkillExport({
1889
+ site,
1890
+ workflowDir,
1891
+ workflow,
1892
+ workflows,
1893
+ playbook,
1894
+ playbooks,
1895
+ platform,
1896
+ });
1897
+ }
1898
+ }
1899
+
1900
+ async function offerSkillExport(opts: {
1901
+ site: string;
1902
+ workflowDir: string;
1903
+ workflow: Workflow;
1904
+ workflows?: Workflow[];
1905
+ playbook: Playbook;
1906
+ playbooks?: Playbook[];
1907
+ platform: 'openclaw' | 'hermes';
1908
+ }): Promise<void> {
1909
+ const { site, workflowDir, workflow, workflows, playbook, playbooks, platform } = opts;
1910
+
1911
+ const cronPath = pathResolve(workflowDir, 'cron.json');
1912
+ let cronConfig: CronConfig | undefined;
1913
+ if (existsSync(cronPath)) {
1914
+ try {
1915
+ cronConfig = CronConfigSchema.parse(JSON.parse(readFileSync(cronPath, 'utf8')));
1916
+ } catch {
1917
+ // Ignore malformed cron.json — it's optional context.
1918
+ }
1919
+ }
1920
+
1921
+ const exportConfirm = await p.confirm({
1922
+ message: `Export as SKILL.md for ${platform === 'openclaw' ? 'OpenClaw' : 'Hermes'}?`,
1923
+ initialValue: false,
1924
+ });
1925
+
1926
+ if (p.isCancel(exportConfirm) || !exportConfirm) return;
1927
+
1928
+ const skillContent = generateSkillMd({
1929
+ site,
1930
+ workflow,
1931
+ workflows,
1932
+ playbook,
1933
+ playbooks,
1934
+ cronConfig,
1935
+ platform,
1936
+ });
1937
+
1938
+ let outDir: string;
1939
+ if (platform === 'hermes') {
1940
+ const hermesSkills = pathResolve(homedir(), '.hermes', 'skills', `imprint-${site}`);
1941
+ if (existsSync(pathResolve(homedir(), '.hermes'))) {
1942
+ outDir = hermesSkills;
1943
+ } else {
1944
+ outDir = pathResolve(process.cwd(), `imprint-${site}`);
1945
+ }
1946
+ } else {
1947
+ outDir = pathResolve(process.cwd(), `imprint-${site}`);
1948
+ }
1949
+
1950
+ mkdirSync(outDir, { recursive: true });
1951
+ const outPath = pathJoin(outDir, 'SKILL.md');
1952
+ writeFileSync(outPath, skillContent, 'utf8');
1953
+
1954
+ p.log.success(`SKILL.md → ${outPath}`);
1955
+
1956
+ if (platform === 'openclaw') {
1957
+ p.log.info(`Install: openclaw skill install ${outDir}`);
1958
+ }
1959
+ }
1960
+
1961
+ // ─── Session combination (post-record, pre-redact) ────────────────────────
1962
+
1963
+ async function promptSessionCombine(opts: {
1964
+ site: string;
1965
+ currentSessionPath: string;
1966
+ noInteractive: boolean;
1967
+ }): Promise<string> {
1968
+ if (opts.noInteractive) return opts.currentSessionPath;
1969
+
1970
+ const pastSessions = listSiteSessions(opts.site).filter(
1971
+ (s) => s.absPath !== opts.currentSessionPath,
1972
+ );
1973
+
1974
+ if (pastSessions.length === 0) return opts.currentSessionPath;
1975
+
1976
+ const combine = await p.confirm({
1977
+ message: `Found ${pastSessions.length} past recording session${pastSessions.length === 1 ? '' : 's'} for "${opts.site}". Combine with the new recording?`,
1978
+ initialValue: false,
1979
+ });
1980
+
1981
+ if (p.isCancel(combine) || !combine) return opts.currentSessionPath;
1982
+
1983
+ const selected = await p.multiselect({
1984
+ message:
1985
+ 'Select sessions to combine with the new recording:\n (press [space] to toggle, [enter] to submit)',
1986
+ required: true,
1987
+ initialValues: pastSessions.map((s) => s.absPath),
1988
+ options: pastSessions.map((s) => ({
1989
+ value: s.absPath,
1990
+ label: `${s.friendlyTimestamp} — ${s.url}`,
1991
+ hint: `${s.requestCount} requests, ${s.narrationCount} narrations`,
1992
+ })),
1993
+ });
1994
+
1995
+ if (p.isCancel(selected)) return opts.currentSessionPath;
1996
+
1997
+ const selectedPaths = selected as string[];
1998
+ if (selectedPaths.length === 0) return opts.currentSessionPath;
1999
+
2000
+ const spinner = p.spinner();
2001
+ spinner.start('Combining sessions...');
2002
+
2003
+ const sessions: Session[] = [];
2004
+ for (const path of selectedPaths) {
2005
+ sessions.push(
2006
+ loadJsonFile(
2007
+ path,
2008
+ SessionSchema,
2009
+ { notFound: `Past session not found: ${path}`, badSchema: 'Session file is malformed.' },
2010
+ 'session',
2011
+ ),
2012
+ );
2013
+ }
2014
+ sessions.push(
2015
+ loadJsonFile(
2016
+ opts.currentSessionPath,
2017
+ SessionSchema,
2018
+ { notFound: 'Current session not found.', badSchema: 'Session file is malformed.' },
2019
+ 'session',
2020
+ ),
2021
+ );
2022
+
2023
+ const combined = mergeSessions(sessions);
2024
+ const combinedPath = writeCombinedSession(opts.site, combined);
2025
+
2026
+ spinner.stop(
2027
+ `Combined ${sessions.length} sessions (${combined.requests.length} requests, ${combined.narration.length} narrations).`,
2028
+ );
2029
+
2030
+ return combinedPath;
2031
+ }
2032
+
2033
+ function formatCompileProgress(progress: CompileAgentProgress): string {
2034
+ const activity = describeAgentActivity(progress);
2035
+ const retry = progress.verificationCycle > 1 ? `, retry ${progress.verificationCycle - 1}` : '';
2036
+ return `Compiling • ${activity} (${formatElapsed(progress.elapsedMs)}${retry})`;
2037
+ }
2038
+
2039
+ // ─── Quick backend probe (after emit) ────────────────────────────────────────
2040
+
2041
+ /**
2042
+ * After a workflow is emitted, quickly probe whether plain fetch works.
2043
+ * If it returns FORBIDDEN (bot protection), write a backends.json that
2044
+ * skips fetch so the MCP server goes straight to stealth-fetch → playbook.
2045
+ * This avoids the ~16s wasted on failing backends when the MCP tool is called.
2046
+ */
2047
+ async function writeQuickBackendsCache(workflowDir: string, workflow: Workflow): Promise<void> {
2048
+ const backendsPath = pathJoin(workflowDir, 'backends.json');
2049
+ if (existsSync(backendsPath)) return;
2050
+ const { createHash } = await import('node:crypto');
2051
+
2052
+ const defaults: Record<string, string | number | boolean> = {};
2053
+ for (const param of workflow.parameters) {
2054
+ if (param.default !== undefined) {
2055
+ defaults[param.name] = param.default;
2056
+ } else {
2057
+ defaults[param.name] = param.type === 'number' ? 0 : param.type === 'boolean' ? false : '';
2058
+ }
2059
+ }
2060
+
2061
+ const body = workflow.requests[0]?.body;
2062
+ const url = workflow.requests[0]?.url;
2063
+ if (!url) return;
2064
+
2065
+ const { substituteString } = await import('./runtime.ts');
2066
+ const emptyState = { site: workflow.site ?? '', cookies: [], values: {} };
2067
+ let resolvedUrl: string;
2068
+ let resolvedBody: string | undefined;
2069
+ try {
2070
+ resolvedUrl = substituteString(url, defaults, emptyState, []);
2071
+ resolvedBody = body ? substituteString(body, defaults, emptyState, []) : undefined;
2072
+ } catch {
2073
+ return;
2074
+ }
2075
+
2076
+ const method = workflow.requests[0]?.method ?? 'GET';
2077
+ const headers: Record<string, string> = {};
2078
+ for (const [k, v] of Object.entries(workflow.requests[0]?.headers ?? {})) {
2079
+ if (typeof v === 'string') headers[k] = v;
2080
+ }
2081
+
2082
+ try {
2083
+ const resp = await fetch(resolvedUrl, {
2084
+ method,
2085
+ headers,
2086
+ body: method !== 'GET' ? resolvedBody : undefined,
2087
+ signal: AbortSignal.timeout(5000),
2088
+ });
2089
+
2090
+ const wfHash = createHash('sha256')
2091
+ .update(JSON.stringify(WorkflowSchema.parse(workflow)))
2092
+ .digest('hex');
2093
+
2094
+ const hasPlaybook = existsSync(pathJoin(workflowDir, 'playbook.yaml'));
2095
+
2096
+ if (resp.status === 403) {
2097
+ const preferred = hasPlaybook ? ['stealth-fetch', 'playbook'] : ['stealth-fetch'];
2098
+ const cache = {
2099
+ probedAt: new Date().toISOString(),
2100
+ imprintVersion: '0.1.0',
2101
+ schemaVersion: 2,
2102
+ workflowHash: wfHash,
2103
+ preferredOrder: preferred,
2104
+ results: {
2105
+ fetch: {
2106
+ outcome: 'forbidden' as const,
2107
+ durationMs: 0,
2108
+ detail: `Quick probe during teach: HTTP ${resp.status}`,
2109
+ },
2110
+ },
2111
+ };
2112
+ writeFileSync(backendsPath, `${JSON.stringify(cache, null, 2)}\n`);
2113
+ process.stderr.write(
2114
+ `[imprint teach] backend probe: fetch blocked → wrote ${backendsPath}\n`,
2115
+ );
2116
+ }
2117
+ } catch {
2118
+ // Fetch failed (timeout, network error) — don't write cache, let runtime discover
2119
+ }
2120
+ }