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,998 @@
1
+ /** Multi-provider LLM client — system prompt + JSON-serialized
2
+ * user payload → raw model text. */
3
+
4
+ import Anthropic from '@anthropic-ai/sdk';
5
+ import {
6
+ llmSpanAttributes,
7
+ resolveTraceTokenCount,
8
+ setSpanAttributes,
9
+ traceLlmIoEnabled,
10
+ traceLlmMessages,
11
+ traced,
12
+ } from './tracing.ts';
13
+
14
+ export type ProviderName = 'anthropic-api' | 'claude-cli' | 'codex-cli' | 'cursor-cli';
15
+
16
+ interface AnalyzeResult {
17
+ text: string;
18
+ inputTokens: number | null;
19
+ outputTokens: number | null;
20
+ durationMs: number;
21
+ stopReason: string | null;
22
+ }
23
+
24
+ interface LLMProvider {
25
+ readonly name: ProviderName;
26
+ analyze(systemPrompt: string, userPayload: unknown): Promise<AnalyzeResult>;
27
+ }
28
+
29
+ interface CliProcessWithOutput {
30
+ stdout: ReadableStream<Uint8Array>;
31
+ stderr: ReadableStream<Uint8Array>;
32
+ exited: Promise<number>;
33
+ }
34
+
35
+ interface TraceAnalyzeDetails {
36
+ inputText: string;
37
+ inputMessages: Array<{ role: string; content: string }>;
38
+ invocationParameters?: Record<string, unknown>;
39
+ }
40
+
41
+ /** Subset of providers that support the Anthropic tool-use protocol.
42
+ * anthropic-api qualifies. CLI providers use separate orchestration
43
+ * paths for agentic compile when supported. */
44
+ export interface ToolUseProvider extends LLMProvider {
45
+ messageWithTools(opts: {
46
+ system: string;
47
+ messages: Anthropic.MessageParam[];
48
+ tools: Anthropic.Tool[];
49
+ maxTokens?: number;
50
+ }): Promise<Anthropic.Message>;
51
+ }
52
+
53
+ export function isToolUseProvider(p: LLMProvider): p is ToolUseProvider {
54
+ return typeof (p as Partial<ToolUseProvider>).messageWithTools === 'function';
55
+ }
56
+
57
+ /** Some Claude models (opus-4-7+) reject the `temperature` parameter as
58
+ * deprecated. This returns a fragment to spread into messages.create()
59
+ * that includes temperature only when the model accepts it. */
60
+ function temperatureFragment(model: string, temperature: number): { temperature?: number } {
61
+ if (/claude-opus-4-[7-9]/.test(model) || /claude-opus-[5-9]/.test(model)) return {};
62
+ return { temperature };
63
+ }
64
+
65
+ export interface LLMOptions {
66
+ provider?: ProviderName;
67
+ model?: string;
68
+ temperature?: number;
69
+ maxTokens?: number;
70
+ }
71
+
72
+ class AnthropicApiProvider implements LLMProvider {
73
+ readonly name: ProviderName = 'anthropic-api';
74
+ private client: Anthropic;
75
+ private config: {
76
+ model: string;
77
+ temperature: number;
78
+ maxTokens: number;
79
+ };
80
+
81
+ constructor({
82
+ model,
83
+ temperature,
84
+ maxTokens,
85
+ }: {
86
+ model: string;
87
+ temperature: number;
88
+ maxTokens: number;
89
+ }) {
90
+ this.config = { model, temperature, maxTokens };
91
+ this.client = new Anthropic();
92
+ }
93
+
94
+ async analyze(systemPrompt: string, userPayload: unknown): Promise<AnalyzeResult> {
95
+ const userText = JSON.stringify(userPayload);
96
+ const invocationParameters = {
97
+ max_tokens: this.config.maxTokens,
98
+ ...temperatureFragment(this.config.model, this.config.temperature),
99
+ };
100
+ return await traceAnalyze(
101
+ this.name,
102
+ this.config.model,
103
+ systemPrompt,
104
+ userText.length,
105
+ async () => {
106
+ const t0 = Date.now();
107
+
108
+ let response: Awaited<ReturnType<typeof this.client.messages.create>>;
109
+ try {
110
+ response = await this.client.messages.create({
111
+ model: this.config.model,
112
+ max_tokens: invocationParameters.max_tokens,
113
+ ...(invocationParameters.temperature === undefined
114
+ ? {}
115
+ : { temperature: invocationParameters.temperature }),
116
+ system: systemPrompt,
117
+ messages: [{ role: 'user', content: userText }],
118
+ });
119
+ } catch (err) {
120
+ throw enrichAnthropicApiError(err, this.config);
121
+ }
122
+
123
+ const text = response.content
124
+ .filter((block) => block.type === 'text')
125
+ .map((block) => ('text' in block ? block.text : ''))
126
+ .join('');
127
+
128
+ return {
129
+ text,
130
+ inputTokens: response.usage.input_tokens,
131
+ outputTokens: response.usage.output_tokens,
132
+ durationMs: Date.now() - t0,
133
+ stopReason: response.stop_reason ?? null,
134
+ };
135
+ },
136
+ chatTraceDetails(systemPrompt, userText, invocationParameters),
137
+ );
138
+ }
139
+
140
+ async messageWithTools(opts: {
141
+ system: string;
142
+ messages: Anthropic.MessageParam[];
143
+ tools: Anthropic.Tool[];
144
+ maxTokens?: number;
145
+ }): Promise<Anthropic.Message> {
146
+ return await traceMessageWithTools(this.name, this.config.model, opts, async () => {
147
+ try {
148
+ const response = await this.client.messages.create({
149
+ model: this.config.model,
150
+ max_tokens: opts.maxTokens ?? this.config.maxTokens,
151
+ ...temperatureFragment(this.config.model, this.config.temperature),
152
+ system: opts.system,
153
+ messages: opts.messages,
154
+ tools: opts.tools,
155
+ });
156
+ return response;
157
+ } catch (err) {
158
+ throw enrichAnthropicApiError(err, this.config);
159
+ }
160
+ });
161
+ }
162
+ }
163
+
164
+ function enrichAnthropicApiError(err: unknown, config: { model: string }): Error {
165
+ const msg = err instanceof Error ? err.message : String(err);
166
+ const lc = msg.toLowerCase();
167
+
168
+ if (lc.includes('401') || lc.includes('authentication') || lc.includes('api key')) {
169
+ return new Error(
170
+ 'Anthropic API call failed: invalid API key\n→ check ANTHROPIC_API_KEY is set correctly\n→ get your key at: https://console.anthropic.com/settings/keys',
171
+ { cause: err },
172
+ );
173
+ }
174
+
175
+ if (lc.includes('429') || lc.includes('rate limit')) {
176
+ return new Error(
177
+ 'Anthropic API call failed: rate limit exceeded\n→ wait a moment and retry\n→ check usage limits at: https://console.anthropic.com/settings/limits',
178
+ { cause: err },
179
+ );
180
+ }
181
+
182
+ if (lc.includes('400') || lc.includes('invalid') || lc.includes('model')) {
183
+ return new Error(
184
+ `Anthropic API call failed: bad request (model="${config.model}")\n→ check model ID is valid\n→ see available models at: https://docs.anthropic.com/en/docs/about-claude/models`,
185
+ { cause: err },
186
+ );
187
+ }
188
+
189
+ return new Error(`Anthropic API call failed: ${msg}`, { cause: err });
190
+ }
191
+
192
+ class ClaudeCliProvider implements LLMProvider {
193
+ readonly name: ProviderName = 'claude-cli';
194
+ private model: string;
195
+
196
+ constructor({ model }: { model: string }) {
197
+ this.model = model;
198
+ }
199
+
200
+ async analyze(systemPrompt: string, userPayload: unknown): Promise<AnalyzeResult> {
201
+ const userText = JSON.stringify(userPayload);
202
+ return await traceAnalyze(
203
+ this.name,
204
+ this.model,
205
+ systemPrompt,
206
+ userText.length,
207
+ async () => {
208
+ const t0 = Date.now();
209
+
210
+ // NOTE: no --bare. Without it claude-cli reads OAuth from the keychain,
211
+ // so Pro/Max subscribers spend subscription tokens instead of needing
212
+ // ANTHROPIC_API_KEY. Same rationale as claude-cli-compile.ts.
213
+ const args = [
214
+ 'claude',
215
+ '-p',
216
+ '--system-prompt',
217
+ systemPrompt,
218
+ '--output-format',
219
+ 'json',
220
+ '--model',
221
+ this.model,
222
+ ];
223
+
224
+ let proc: ReturnType<typeof Bun.spawn>;
225
+ try {
226
+ proc = Bun.spawn(args, {
227
+ stdin: new Blob([userText]),
228
+ stdout: 'pipe',
229
+ stderr: 'pipe',
230
+ });
231
+ } catch (err) {
232
+ throw enrichClaudeCliError(err, { model: this.model });
233
+ }
234
+
235
+ if (
236
+ typeof proc.stdout === 'number' ||
237
+ typeof proc.stderr === 'number' ||
238
+ !proc.stdout ||
239
+ !proc.stderr
240
+ ) {
241
+ throw new Error('Failed to capture claude-cli output streams');
242
+ }
243
+
244
+ const { stdout, stderr, exitCode } = await collectCliProcessOutput({
245
+ stdout: proc.stdout,
246
+ stderr: proc.stderr,
247
+ exited: proc.exited,
248
+ });
249
+
250
+ if (exitCode !== 0) {
251
+ throw enrichClaudeCliError(
252
+ new Error(`claude-cli exited with code ${exitCode}\n${stderr}`),
253
+ {
254
+ model: this.model,
255
+ },
256
+ );
257
+ }
258
+
259
+ let parsed: { result?: string; usage?: { input_tokens?: number; output_tokens?: number } };
260
+ try {
261
+ parsed = JSON.parse(stdout);
262
+ } catch (parseErr) {
263
+ throw enrichClaudeCliError(parseErr, { model: this.model });
264
+ }
265
+
266
+ if (!parsed.result) {
267
+ throw new Error(
268
+ 'claude-cli output missing "result" field\n→ ensure you are using a compatible claude CLI version',
269
+ );
270
+ }
271
+
272
+ return {
273
+ text: parsed.result,
274
+ inputTokens: parsed.usage?.input_tokens ?? null,
275
+ outputTokens: parsed.usage?.output_tokens ?? null,
276
+ durationMs: Date.now() - t0,
277
+ stopReason: null,
278
+ };
279
+ },
280
+ chatTraceDetails(systemPrompt, userText, {
281
+ command: 'claude -p',
282
+ output_format: 'json',
283
+ }),
284
+ );
285
+ }
286
+ }
287
+
288
+ function enrichClaudeCliError(err: unknown, _config: { model: string }): Error {
289
+ const msg = err instanceof Error ? err.message : String(err);
290
+ const lc = msg.toLowerCase();
291
+
292
+ if (lc.includes('enoent') || lc.includes('not found') || lc.includes('command not found')) {
293
+ return new Error(
294
+ 'claude-cli not found\n→ install Claude Code CLI: https://docs.anthropic.com/claude/docs/claude-code',
295
+ { cause: err },
296
+ );
297
+ }
298
+
299
+ if (lc.includes('json') || lc.includes('parse')) {
300
+ return new Error(`claude-cli returned invalid JSON: ${msg}`, { cause: err });
301
+ }
302
+
303
+ return new Error(`claude-cli failed: ${msg}`, { cause: err });
304
+ }
305
+
306
+ class CodexCliProvider implements LLMProvider {
307
+ readonly name: ProviderName = 'codex-cli';
308
+ private model: string;
309
+
310
+ constructor({ model }: { model: string }) {
311
+ this.model = model;
312
+ }
313
+
314
+ async analyze(systemPrompt: string, userPayload: unknown): Promise<AnalyzeResult> {
315
+ const combinedPrompt = `<system_instructions>
316
+ ${systemPrompt}
317
+ </system_instructions>
318
+
319
+ <user_payload_json>
320
+ ${JSON.stringify(userPayload)}
321
+ </user_payload_json>
322
+
323
+ ${cliFinalArtifactInstruction()}`;
324
+ return await traceAnalyze(
325
+ this.name,
326
+ this.model,
327
+ systemPrompt,
328
+ combinedPrompt.length,
329
+ async () => {
330
+ const t0 = Date.now();
331
+
332
+ const args = codexAnalyzeArgs(this.model);
333
+
334
+ let proc: ReturnType<typeof Bun.spawn>;
335
+ try {
336
+ proc = Bun.spawn(args, {
337
+ stdin: new Blob([combinedPrompt]),
338
+ stdout: 'pipe',
339
+ stderr: 'pipe',
340
+ });
341
+ } catch (err) {
342
+ throw enrichCodexCliError(err, { model: this.model });
343
+ }
344
+
345
+ if (
346
+ typeof proc.stdout === 'number' ||
347
+ typeof proc.stderr === 'number' ||
348
+ !proc.stdout ||
349
+ !proc.stderr
350
+ ) {
351
+ throw new Error('Failed to capture codex-cli output streams');
352
+ }
353
+
354
+ const { stdout, stderr, exitCode } = await collectCliProcessOutput({
355
+ stdout: proc.stdout,
356
+ stderr: proc.stderr,
357
+ exited: proc.exited,
358
+ });
359
+
360
+ if (exitCode !== 0) {
361
+ throw enrichCodexCliError(
362
+ new Error(`codex-cli exited with code ${exitCode}\n${stderr}`),
363
+ {
364
+ model: this.model,
365
+ },
366
+ );
367
+ }
368
+
369
+ const text = normalizeCliAnalyzeOutput(stdout, systemPrompt);
370
+
371
+ return {
372
+ text,
373
+ inputTokens: null,
374
+ outputTokens: null,
375
+ durationMs: Date.now() - t0,
376
+ stopReason: null,
377
+ };
378
+ },
379
+ promptTraceDetails(combinedPrompt, {
380
+ command: 'codex exec',
381
+ sandbox: 'read-only',
382
+ }),
383
+ );
384
+ }
385
+ }
386
+
387
+ export function codexAnalyzeArgs(model: string): string[] {
388
+ return [
389
+ 'codex',
390
+ '-a',
391
+ 'never',
392
+ 'exec',
393
+ '-m',
394
+ model,
395
+ '-s',
396
+ 'read-only',
397
+ '--ephemeral',
398
+ '--ignore-user-config',
399
+ '--ignore-rules',
400
+ '--skip-git-repo-check',
401
+ ];
402
+ }
403
+
404
+ export function normalizeCliAnalyzeOutput(stdout: string, systemPrompt: string): string {
405
+ if (!promptRequestsJsonObject(systemPrompt)) return stdout;
406
+ return extractJsonObject(stdout) ?? stdout;
407
+ }
408
+
409
+ async function traceAnalyze(
410
+ provider: ProviderName,
411
+ model: string,
412
+ systemPrompt: string,
413
+ payloadChars: number,
414
+ fn: () => Promise<AnalyzeResult>,
415
+ details?: TraceAnalyzeDetails,
416
+ ): Promise<AnalyzeResult> {
417
+ const captureIo = traceLlmIoEnabled();
418
+ return await traced(
419
+ 'llm.analyze',
420
+ 'LLM',
421
+ {
422
+ 'imprint.llm.provider': provider,
423
+ 'imprint.llm.model': model,
424
+ 'imprint.llm.system_prompt_chars': systemPrompt.length,
425
+ 'imprint.llm.payload_chars': payloadChars,
426
+ ...(captureIo
427
+ ? llmSpanAttributes({
428
+ provider,
429
+ model,
430
+ inputMessages: details?.inputMessages
431
+ ? traceLlmMessages(details.inputMessages)
432
+ : undefined,
433
+ inputValue: details?.inputText,
434
+ invocationParameters: details?.invocationParameters,
435
+ })
436
+ : {}),
437
+ },
438
+ async (span) => {
439
+ const result = await fn();
440
+ const inputTokens = resolveTraceTokenCount(result.inputTokens, details?.inputText);
441
+ const outputTokens = resolveTraceTokenCount(result.outputTokens, result.text);
442
+ setSpanAttributes(span, {
443
+ ...llmSpanAttributes({
444
+ provider,
445
+ model,
446
+ inputTokens: inputTokens.tokens,
447
+ outputTokens: outputTokens.tokens,
448
+ tokenCountsEstimated:
449
+ inputTokens.source === 'estimated' || outputTokens.source === 'estimated',
450
+ inputTokenSource: inputTokens.source,
451
+ outputTokenSource: outputTokens.source,
452
+ stopReason: result.stopReason,
453
+ outputMessages: captureIo
454
+ ? traceLlmMessages([{ role: 'assistant', content: result.text }])
455
+ : undefined,
456
+ outputValue: captureIo ? result.text : undefined,
457
+ invocationParameters: details?.invocationParameters,
458
+ }),
459
+ 'imprint.llm.duration_ms': result.durationMs,
460
+ 'imprint.llm.output_chars': result.text.length,
461
+ });
462
+ return result;
463
+ },
464
+ );
465
+ }
466
+
467
+ async function traceMessageWithTools(
468
+ provider: ProviderName,
469
+ model: string,
470
+ opts: {
471
+ system: string;
472
+ messages: Anthropic.MessageParam[];
473
+ tools: Anthropic.Tool[];
474
+ maxTokens?: number;
475
+ },
476
+ fn: () => Promise<Anthropic.Message>,
477
+ ): Promise<Anthropic.Message> {
478
+ const captureIo = traceLlmIoEnabled();
479
+ return await traced(
480
+ 'llm.message_with_tools',
481
+ 'LLM',
482
+ {
483
+ 'imprint.llm.provider': provider,
484
+ 'imprint.llm.model': model,
485
+ 'imprint.llm.message_count': opts.messages.length,
486
+ 'imprint.llm.tool_count': opts.tools.length,
487
+ 'imprint.llm.tool_names': opts.tools.map((t) => t.name).join(', '),
488
+ ...(captureIo
489
+ ? llmSpanAttributes({
490
+ provider,
491
+ model,
492
+ inputMessages: traceLlmMessages(flattenAnthropicMessages(opts.system, opts.messages)),
493
+ inputValue: JSON.stringify({
494
+ system: opts.system,
495
+ messages: opts.messages,
496
+ tools: opts.tools.map((t) => t.name),
497
+ }),
498
+ inputMimeType: 'application/json',
499
+ })
500
+ : {}),
501
+ },
502
+ async (span) => {
503
+ const t0 = Date.now();
504
+ const response = await fn();
505
+ const toolUseNames = response.content
506
+ .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
507
+ .map((b) => b.name);
508
+ const outputText = response.content
509
+ .map((b) => {
510
+ if (b.type === 'text') return b.text;
511
+ if (b.type === 'tool_use') return `[tool_use: ${b.name}]`;
512
+ return `[${b.type}]`;
513
+ })
514
+ .join('\n');
515
+ setSpanAttributes(span, {
516
+ ...llmSpanAttributes({
517
+ provider,
518
+ model,
519
+ inputTokens: response.usage.input_tokens,
520
+ outputTokens: response.usage.output_tokens,
521
+ stopReason: response.stop_reason,
522
+ outputMessages: captureIo
523
+ ? traceLlmMessages([{ role: 'assistant', content: outputText }])
524
+ : undefined,
525
+ outputValue: captureIo ? outputText : undefined,
526
+ }),
527
+ 'imprint.llm.duration_ms': Date.now() - t0,
528
+ 'imprint.llm.tools_called': toolUseNames.join(', '),
529
+ 'imprint.llm.tools_called_count': toolUseNames.length,
530
+ });
531
+ return response;
532
+ },
533
+ );
534
+ }
535
+
536
+ function flattenAnthropicMessages(
537
+ system: string,
538
+ messages: Anthropic.MessageParam[],
539
+ ): Array<{ role: string; content: string }> {
540
+ const out: Array<{ role: string; content: string }> = [{ role: 'system', content: system }];
541
+ for (const msg of messages) {
542
+ const text =
543
+ typeof msg.content === 'string'
544
+ ? msg.content
545
+ : msg.content
546
+ .map((b) => {
547
+ if (b.type === 'text') return b.text;
548
+ if (b.type === 'tool_result') {
549
+ const inner =
550
+ typeof b.content === 'string'
551
+ ? b.content
552
+ : Array.isArray(b.content)
553
+ ? b.content.map((c) => ('text' in c ? c.text : `[${c.type}]`)).join('\n')
554
+ : `[tool_result: ${b.tool_use_id}]`;
555
+ return inner;
556
+ }
557
+ if (b.type === 'tool_use') return `[tool_use: ${b.name}]`;
558
+ return `[${b.type}]`;
559
+ })
560
+ .join('\n');
561
+ out.push({ role: msg.role, content: text });
562
+ }
563
+ return out;
564
+ }
565
+
566
+ function chatTraceDetails(
567
+ systemPrompt: string,
568
+ userText: string,
569
+ invocationParameters?: Record<string, unknown>,
570
+ ): TraceAnalyzeDetails {
571
+ return {
572
+ inputText: JSON.stringify({
573
+ system: systemPrompt,
574
+ messages: [{ role: 'user', content: userText }],
575
+ }),
576
+ inputMessages: [
577
+ { role: 'system', content: systemPrompt },
578
+ { role: 'user', content: userText },
579
+ ],
580
+ invocationParameters,
581
+ };
582
+ }
583
+
584
+ function promptTraceDetails(
585
+ prompt: string,
586
+ invocationParameters?: Record<string, unknown>,
587
+ ): TraceAnalyzeDetails {
588
+ return {
589
+ inputText: prompt,
590
+ inputMessages: [{ role: 'user', content: prompt }],
591
+ invocationParameters,
592
+ };
593
+ }
594
+
595
+ export async function collectCliProcessOutput(proc: CliProcessWithOutput): Promise<{
596
+ stdout: string;
597
+ stderr: string;
598
+ exitCode: number;
599
+ }> {
600
+ const stdoutPromise = Bun.readableStreamToText(proc.stdout);
601
+ const stderrPromise = Bun.readableStreamToText(proc.stderr);
602
+ const exitPromise = proc.exited;
603
+ const [stdout, stderr, exitCode] = await Promise.all([stdoutPromise, stderrPromise, exitPromise]);
604
+ return { stdout, stderr, exitCode };
605
+ }
606
+
607
+ function promptRequestsJsonObject(systemPrompt: string): boolean {
608
+ const lc = systemPrompt.toLowerCase();
609
+ if (/\byaml\b/.test(lc)) return false;
610
+ if (/\bjson\s+array\b/.test(lc) || /\barray\s+of\b/.test(lc)) return false;
611
+ return /\bjson\b/.test(lc) && /\bobject\b/.test(lc);
612
+ }
613
+
614
+ function enrichCodexCliError(err: unknown, _config: { model: string }): Error {
615
+ const msg = err instanceof Error ? err.message : String(err);
616
+ const lc = msg.toLowerCase();
617
+
618
+ if (lc.includes('enoent') || lc.includes('not found') || lc.includes('command not found')) {
619
+ return new Error(
620
+ 'codex-cli not found\n→ install Codex CLI, run `codex login`, and make sure `codex` is on PATH',
621
+ { cause: err },
622
+ );
623
+ }
624
+
625
+ return new Error(`codex-cli failed: ${msg}`, { cause: err });
626
+ }
627
+
628
+ class CursorCliProvider implements LLMProvider {
629
+ readonly name: ProviderName = 'cursor-cli';
630
+ private model: string | undefined;
631
+
632
+ constructor({ model }: { model?: string }) {
633
+ this.model = model;
634
+ }
635
+
636
+ async analyze(systemPrompt: string, userPayload: unknown): Promise<AnalyzeResult> {
637
+ const combinedPrompt = `<system_instructions>
638
+ ${systemPrompt}
639
+ </system_instructions>
640
+
641
+ <user_payload_json>
642
+ ${JSON.stringify(userPayload)}
643
+ </user_payload_json>
644
+
645
+ ${cliFinalArtifactInstruction()}`;
646
+ return await traceAnalyze(
647
+ this.name,
648
+ this.model ?? 'default',
649
+ systemPrompt,
650
+ combinedPrompt.length,
651
+ async () => {
652
+ const t0 = Date.now();
653
+
654
+ const args = ['cursor', 'agent', '-p', '--mode', 'ask'];
655
+ if (this.model) {
656
+ args.push('--model', this.model);
657
+ }
658
+
659
+ let proc: ReturnType<typeof Bun.spawn>;
660
+ try {
661
+ proc = Bun.spawn(args, {
662
+ stdin: new Blob([combinedPrompt]),
663
+ stdout: 'pipe',
664
+ stderr: 'pipe',
665
+ });
666
+ } catch (err) {
667
+ throw enrichCursorCliError(err);
668
+ }
669
+
670
+ if (
671
+ typeof proc.stdout === 'number' ||
672
+ typeof proc.stderr === 'number' ||
673
+ !proc.stdout ||
674
+ !proc.stderr
675
+ ) {
676
+ throw new Error('Failed to capture cursor-cli output streams');
677
+ }
678
+
679
+ const { stdout, stderr, exitCode } = await collectCliProcessOutput({
680
+ stdout: proc.stdout,
681
+ stderr: proc.stderr,
682
+ exited: proc.exited,
683
+ });
684
+
685
+ if (exitCode !== 0) {
686
+ throw enrichCursorCliError(
687
+ new Error(`cursor-cli exited with code ${exitCode}\n${stderr}`),
688
+ );
689
+ }
690
+
691
+ const text = normalizeCliAnalyzeOutput(stdout, systemPrompt);
692
+
693
+ return {
694
+ text,
695
+ inputTokens: null,
696
+ outputTokens: null,
697
+ durationMs: Date.now() - t0,
698
+ stopReason: null,
699
+ };
700
+ },
701
+ promptTraceDetails(combinedPrompt, {
702
+ command: 'cursor agent',
703
+ mode: 'ask',
704
+ }),
705
+ );
706
+ }
707
+ }
708
+
709
+ function enrichCursorCliError(err: unknown): Error {
710
+ const msg = err instanceof Error ? err.message : String(err);
711
+ const lc = msg.toLowerCase();
712
+
713
+ if (lc.includes('enoent') || lc.includes('not found') || lc.includes('command not found')) {
714
+ return new Error(
715
+ 'cursor-cli not found\n→ install Cursor and enable the CLI: https://www.cursor.com',
716
+ { cause: err },
717
+ );
718
+ }
719
+
720
+ return new Error(`cursor-cli failed: ${msg}`, { cause: err });
721
+ }
722
+
723
+ const VALID_PROVIDERS: readonly ProviderName[] = [
724
+ 'anthropic-api',
725
+ 'claude-cli',
726
+ 'codex-cli',
727
+ 'cursor-cli',
728
+ ];
729
+
730
+ export interface ProviderStatus {
731
+ name: ProviderName;
732
+ detected: boolean;
733
+ availableForTeach: boolean;
734
+ reason: string;
735
+ setupHint: string;
736
+ }
737
+
738
+ export function isValidProvider(s: string): s is ProviderName {
739
+ return (VALID_PROVIDERS as readonly string[]).includes(s);
740
+ }
741
+
742
+ export function isTeachCompatibleProvider(name: ProviderName): boolean {
743
+ return name === 'anthropic-api' || name === 'claude-cli' || name === 'codex-cli';
744
+ }
745
+
746
+ export function getProviderStatuses(): ProviderStatus[] {
747
+ const claudePath = Bun.which('claude');
748
+ const codexPath = Bun.which('codex');
749
+ const cursorPath = Bun.which('cursor');
750
+ const hasAnthropicApiKey = !!process.env.ANTHROPIC_API_KEY;
751
+
752
+ const statuses: ProviderStatus[] = [
753
+ {
754
+ name: 'claude-cli',
755
+ detected: !!claudePath,
756
+ availableForTeach: !!claudePath,
757
+ reason: claudePath ? `claude found at ${claudePath}` : 'claude not found on PATH',
758
+ setupHint:
759
+ 'Install Claude Code, run `claude` once to log in, and make sure `claude` is on PATH. Re-run `imprint teach` after `command -v claude` prints a path.',
760
+ },
761
+ {
762
+ name: 'codex-cli',
763
+ detected: !!codexPath,
764
+ availableForTeach: !!codexPath,
765
+ reason: codexPath ? `codex found at ${codexPath}` : 'codex not found on PATH',
766
+ setupHint:
767
+ 'Install the Codex CLI, run `codex login`, and make sure `codex` is on PATH. Re-run `imprint teach` after `command -v codex` prints a path.',
768
+ },
769
+ {
770
+ name: 'cursor-cli',
771
+ detected: !!cursorPath,
772
+ availableForTeach: false,
773
+ reason: cursorPath
774
+ ? `cursor found at ${cursorPath}, but Cursor CLI is not supported by the teach compile-agent yet`
775
+ : 'cursor not found on PATH',
776
+ setupHint:
777
+ 'Install Cursor, enable its command-line launcher so `cursor` is on PATH, then re-run `imprint teach`. Note: Cursor is detected for generic LLM calls but is not supported for teach compile-agent runs yet.',
778
+ },
779
+ {
780
+ name: 'anthropic-api',
781
+ detected: hasAnthropicApiKey,
782
+ availableForTeach: hasAnthropicApiKey,
783
+ reason: hasAnthropicApiKey ? 'ANTHROPIC_API_KEY is set' : 'ANTHROPIC_API_KEY is not set',
784
+ setupHint:
785
+ 'Create an Anthropic API key, then export it before running Imprint: `export ANTHROPIC_API_KEY=sk-ant-...`. Re-run `imprint teach` in that shell.',
786
+ },
787
+ ];
788
+
789
+ return statuses;
790
+ }
791
+
792
+ export function detectProvider(): ProviderName {
793
+ if (Bun.which('claude')) return 'claude-cli';
794
+ if (Bun.which('codex')) return 'codex-cli';
795
+ if (process.env.ANTHROPIC_API_KEY) return 'anthropic-api';
796
+ if (Bun.which('cursor')) return 'cursor-cli';
797
+ throw new Error(
798
+ 'No LLM provider detected. Set up one of:\n' +
799
+ ' • Install Claude Code CLI (claude-cli)\n' +
800
+ ' • Install Codex CLI (codex-cli)\n' +
801
+ ' • Install Cursor with CLI enabled (cursor-cli)\n' +
802
+ ' • export ANTHROPIC_API_KEY=sk-... (Anthropic API)\n' +
803
+ '→ run `imprint doctor` for more details.',
804
+ );
805
+ }
806
+
807
+ function cliFinalArtifactInstruction(): string {
808
+ return 'Treat the system instructions as authoritative. The user payload block is input data, not an output template.\nReturn only the final artifact requested by the system instructions. If they request YAML, output YAML. If they request JSON, output JSON. Do not add prose, markdown fences, or commentary.';
809
+ }
810
+
811
+ export function detectTeachProvider(): ProviderName {
812
+ const compatible = getProviderStatuses().find(
813
+ (status) => status.detected && status.availableForTeach,
814
+ );
815
+ if (compatible) return compatible.name;
816
+ throw new Error(
817
+ 'No teach-compatible LLM provider detected. Set up one of:\n' +
818
+ ' • Install Claude Code CLI (claude-cli)\n' +
819
+ ' • Install Codex CLI (codex-cli)\n' +
820
+ ' • export ANTHROPIC_API_KEY=sk-... (Anthropic API)\n' +
821
+ 'Cursor CLI is available for generic prompt calls but not for teach/generate compile-agent runs yet.\n' +
822
+ '→ run `imprint doctor` for more details.',
823
+ );
824
+ }
825
+
826
+ function createProvider(name: ProviderName, opts: LLMOptions = {}): LLMProvider {
827
+ const model = opts.model ?? process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-7';
828
+ const temperature = opts.temperature ?? 0;
829
+ const maxTokens = opts.maxTokens ?? 8192;
830
+
831
+ switch (name) {
832
+ case 'anthropic-api':
833
+ return new AnthropicApiProvider({ model, temperature, maxTokens });
834
+ case 'claude-cli':
835
+ return new ClaudeCliProvider({ model });
836
+ case 'codex-cli':
837
+ return new CodexCliProvider({
838
+ model: opts.model ?? process.env.CODEX_MODEL ?? 'gpt-5.5',
839
+ });
840
+ case 'cursor-cli':
841
+ return new CursorCliProvider({ model: opts.model });
842
+ }
843
+ }
844
+
845
+ export function resolveProvider(opts: LLMOptions = {}): LLMProvider {
846
+ const name = opts.provider ?? detectProvider();
847
+ return createProvider(name, opts);
848
+ }
849
+
850
+ /** The model to use for the compile-agent (the agentic, tool-using compile
851
+ * loop) on each provider. Defaults to Opus on Claude-capable backends —
852
+ * the iterative reverse-engineering benefits significantly from the stronger
853
+ * model, and Pro/Max claude-cli subscribers already pay for Opus access.
854
+ * Honors $ANTHROPIC_MODEL_AGENT (preferred) or $ANTHROPIC_MODEL (fallback)
855
+ * for explicit overrides. */
856
+ export function preferredAgentModel(provider: ProviderName): string {
857
+ const override =
858
+ provider === 'codex-cli'
859
+ ? (process.env.CODEX_MODEL_AGENT ??
860
+ process.env.CODEX_MODEL ??
861
+ process.env.ANTHROPIC_MODEL_AGENT ??
862
+ process.env.ANTHROPIC_MODEL)
863
+ : (process.env.ANTHROPIC_MODEL_AGENT ?? process.env.ANTHROPIC_MODEL);
864
+ if (override) return override;
865
+ switch (provider) {
866
+ case 'anthropic-api':
867
+ case 'claude-cli':
868
+ return 'claude-opus-4-7';
869
+ case 'codex-cli':
870
+ return 'gpt-5.5';
871
+ case 'cursor-cli':
872
+ return 'claude-opus-4-7'; // best-effort; cursor passes through
873
+ }
874
+ }
875
+
876
+ interface ModelOption {
877
+ model: string;
878
+ isDefault: boolean;
879
+ }
880
+
881
+ export function availableModelsForProvider(provider: ProviderName): ModelOption[] {
882
+ switch (provider) {
883
+ case 'anthropic-api':
884
+ case 'claude-cli':
885
+ return [
886
+ { model: 'claude-opus-4-7', isDefault: true },
887
+ { model: 'claude-sonnet-4-6', isDefault: false },
888
+ { model: 'claude-haiku-4-5', isDefault: false },
889
+ { model: 'claude-opus-4-6', isDefault: false },
890
+ { model: 'claude-sonnet-4-5', isDefault: false },
891
+ { model: 'claude-opus-4-5', isDefault: false },
892
+ ];
893
+ case 'codex-cli':
894
+ return [
895
+ { model: 'gpt-5.5', isDefault: true },
896
+ { model: 'gpt-5.4', isDefault: false },
897
+ { model: 'gpt-5.4-mini', isDefault: false },
898
+ { model: 'gpt-5.2', isDefault: false },
899
+ { model: 'gpt-5.2-pro', isDefault: false },
900
+ { model: 'gpt-5.1', isDefault: false },
901
+ { model: 'gpt-5', isDefault: false },
902
+ { model: 'gpt-4.1', isDefault: false },
903
+ { model: 'gpt-4.1-mini', isDefault: false },
904
+ { model: 'o4-mini', isDefault: false },
905
+ { model: 'o3', isDefault: false },
906
+ { model: 'o3-mini', isDefault: false },
907
+ { model: 'o1', isDefault: false },
908
+ ];
909
+ case 'cursor-cli':
910
+ return [
911
+ { model: 'claude-opus-4-7', isDefault: true },
912
+ { model: 'claude-sonnet-4-6', isDefault: false },
913
+ { model: 'claude-haiku-4-5', isDefault: false },
914
+ { model: 'gpt-5.5', isDefault: false },
915
+ { model: 'gpt-5.4', isDefault: false },
916
+ { model: 'gpt-5.4-mini', isDefault: false },
917
+ { model: 'o3', isDefault: false },
918
+ { model: 'gemini-2.5-pro', isDefault: false },
919
+ { model: 'gemini-2.5-flash', isDefault: false },
920
+ ];
921
+ }
922
+ }
923
+
924
+ /** Extract the first balanced top-level JSON array — handles fenced
925
+ * code blocks and preamble text. Returns null if no array is found. */
926
+ export function extractJsonArray(text: string): string | null {
927
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
928
+ const candidate = fenced?.[1] ?? text;
929
+
930
+ const start = candidate.indexOf('[');
931
+ if (start < 0) return null;
932
+
933
+ let depth = 0;
934
+ let inString = false;
935
+ let escapeNext = false;
936
+ for (let i = start; i < candidate.length; i++) {
937
+ const ch = candidate[i];
938
+ if (escapeNext) {
939
+ escapeNext = false;
940
+ continue;
941
+ }
942
+ if (ch === '\\') {
943
+ escapeNext = true;
944
+ continue;
945
+ }
946
+ if (ch === '"') {
947
+ inString = !inString;
948
+ continue;
949
+ }
950
+ if (inString) continue;
951
+ if (ch === '[') depth++;
952
+ else if (ch === ']') {
953
+ depth--;
954
+ if (depth === 0) {
955
+ return candidate.slice(start, i + 1);
956
+ }
957
+ }
958
+ }
959
+ return null;
960
+ }
961
+
962
+ /** Extract the first balanced top-level JSON object — handles fenced
963
+ * code blocks and preamble text. Returns null if no object is found. */
964
+ export function extractJsonObject(text: string): string | null {
965
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
966
+ const candidate = fenced?.[1] ?? text;
967
+
968
+ const start = candidate.indexOf('{');
969
+ if (start < 0) return null;
970
+
971
+ let depth = 0;
972
+ let inString = false;
973
+ let escapeNext = false;
974
+ for (let i = start; i < candidate.length; i++) {
975
+ const ch = candidate[i];
976
+ if (escapeNext) {
977
+ escapeNext = false;
978
+ continue;
979
+ }
980
+ if (ch === '\\') {
981
+ escapeNext = true;
982
+ continue;
983
+ }
984
+ if (ch === '"') {
985
+ inString = !inString;
986
+ continue;
987
+ }
988
+ if (inString) continue;
989
+ if (ch === '{') depth++;
990
+ else if (ch === '}') {
991
+ depth--;
992
+ if (depth === 0) {
993
+ return candidate.slice(start, i + 1);
994
+ }
995
+ }
996
+ }
997
+ return null;
998
+ }