@maintainer-pro/ai-cli 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1057 @@
1
+ // src/detect.ts
2
+ import { execa as execa2 } from "execa";
3
+
4
+ // src/prompt.ts
5
+ function buildConversationPrompt(messages) {
6
+ if (messages.length === 0) return "";
7
+ const lines = messages.map(
8
+ (m) => `${m.role === "user" ? "Human" : "Assistant"}: ${m.content}`
9
+ );
10
+ return lines.join("\n\n");
11
+ }
12
+ function formatClientContext(context) {
13
+ if (!context) return "";
14
+ const lines = [
15
+ "Client context (live UI snapshot):",
16
+ `- Route: ${context.route}`,
17
+ `- Page title: ${context.pageTitle}`
18
+ ];
19
+ if (context.visiblePanels?.length) {
20
+ lines.push(`- Visible panels: ${context.visiblePanels.join(", ")}`);
21
+ }
22
+ if (context.focusedElement) {
23
+ lines.push(`- Focused element: ${context.focusedElement}`);
24
+ }
25
+ if (context.relevantFiles?.length) {
26
+ lines.push(`- Relevant files: ${context.relevantFiles.join(", ")}`);
27
+ }
28
+ if (context.data && Object.keys(context.data).length > 0) {
29
+ lines.push("- App state:");
30
+ lines.push(JSON.stringify(context.data, null, 2));
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+ function splitMessages(messages) {
35
+ let latestUserIndex = -1;
36
+ for (let i = messages.length - 1; i >= 0; i--) {
37
+ if (messages[i].role === "user") {
38
+ latestUserIndex = i;
39
+ break;
40
+ }
41
+ }
42
+ const request = latestUserIndex >= 0 ? messages[latestUserIndex].content.trim() : "";
43
+ const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
44
+ return { request, history };
45
+ }
46
+ function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
47
+ const { request, history } = splitMessages(messages);
48
+ const historyBlock = history.length > 0 ? `Conversation so far:
49
+ ${buildConversationPrompt(history)}
50
+
51
+ ` : "";
52
+ const contextBlock = formatClientContext(context);
53
+ const contextSection = contextBlock ? `${contextBlock}
54
+
55
+ ` : "";
56
+ const systemSection = systemPrompt ? `${systemPrompt}
57
+
58
+ ` : "";
59
+ const priorSection = priorConversationsContext?.trim() ? `${priorConversationsContext.trim()}
60
+
61
+ ` : "";
62
+ const attachmentsSection = attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files):
63
+ ${attachmentPaths.map((p) => `- ${p}`).join("\n")}
64
+
65
+ ` : "";
66
+ return `${systemSection}${contextSection}${priorSection}${historyBlock}${attachmentsSection}Current user request:
67
+ ${request || "(See the attached screenshot(s).)"}
68
+
69
+ Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.`;
70
+ }
71
+ function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext) {
72
+ return buildUserFacingPrompt(
73
+ systemPrompt,
74
+ messages,
75
+ context,
76
+ attachmentPaths,
77
+ priorConversationsContext
78
+ );
79
+ }
80
+ function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext) {
81
+ return buildUserFacingPrompt(
82
+ null,
83
+ messages,
84
+ context,
85
+ attachmentPaths,
86
+ priorConversationsContext
87
+ );
88
+ }
89
+
90
+ // src/parser.ts
91
+ function parseAiResponse(raw, provider) {
92
+ const toolCalls = [];
93
+ const jsonBlockRegex = /```json\s*\n?([\s\S]*?)\n?```/g;
94
+ let match;
95
+ while ((match = jsonBlockRegex.exec(raw)) !== null) {
96
+ try {
97
+ const parsed = JSON.parse(match[1].trim());
98
+ if (parsed !== null && typeof parsed === "object" && "tool" in parsed && typeof parsed.tool === "string") {
99
+ toolCalls.push(parsed);
100
+ }
101
+ } catch {
102
+ }
103
+ }
104
+ const text = raw.replace(/```json\s*\n?[\s\S]*?\n?```/g, "").trim();
105
+ return { text, toolCalls, provider };
106
+ }
107
+
108
+ // src/resolve-command.ts
109
+ import fs from "fs";
110
+ import path from "path";
111
+ function parseVersionRank(versionName) {
112
+ const datePart = versionName.split("-")[0] ?? "";
113
+ const parts = datePart.split(".");
114
+ if (parts.length !== 3) return 0;
115
+ const [year, month, day] = parts;
116
+ return Number(`${year}${month.padStart(2, "0")}${day.padStart(2, "0")}`);
117
+ }
118
+ function findLatestCursorVersionDir(installDir) {
119
+ const versionsRoot = path.join(installDir, "versions");
120
+ if (!fs.existsSync(versionsRoot)) return null;
121
+ const dirs = fs.readdirSync(versionsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter(
122
+ (name) => /^\d{4}\.\d{1,2}\.\d{1,2}(-\d{2}-\d{2}-\d{2})?-[a-f0-9]+$/.test(name)
123
+ ).sort((a, b) => parseVersionRank(b) - parseVersionRank(a));
124
+ return dirs[0] ? path.join(versionsRoot, dirs[0]) : null;
125
+ }
126
+ function cursorInstallDirFromCommand(command) {
127
+ const normalized = command.replace(/\//g, path.sep);
128
+ if (path.isAbsolute(normalized) || normalized.includes(path.sep)) {
129
+ const dir = path.dirname(normalized);
130
+ if (fs.existsSync(path.join(dir, "versions"))) return dir;
131
+ }
132
+ const localAppData = process.env.LOCALAPPDATA;
133
+ if (localAppData) {
134
+ const candidate = path.join(localAppData, "cursor-agent");
135
+ if (fs.existsSync(path.join(candidate, "versions"))) return candidate;
136
+ }
137
+ return null;
138
+ }
139
+ function resolveCliCommand(provider, command) {
140
+ if (provider === "cursor" && process.platform === "win32") {
141
+ const installDir = cursorInstallDirFromCommand(command);
142
+ if (installDir) {
143
+ const versionDir = findLatestCursorVersionDir(installDir);
144
+ if (versionDir) {
145
+ const nodePath = path.join(versionDir, "node.exe");
146
+ const indexPath = path.join(versionDir, "index.js");
147
+ if (fs.existsSync(nodePath) && fs.existsSync(indexPath)) {
148
+ return {
149
+ file: nodePath,
150
+ argsPrefix: [indexPath],
151
+ shell: false
152
+ };
153
+ }
154
+ }
155
+ }
156
+ }
157
+ const isWinBatch = process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
158
+ const isBare = process.platform === "win32" && !command.includes("\\") && !command.includes("/");
159
+ const isAbsoluteExe = process.platform === "win32" && path.isAbsolute(command) && /\.exe$/i.test(command);
160
+ return {
161
+ file: command,
162
+ argsPrefix: [],
163
+ shell: isAbsoluteExe ? false : isWinBatch || isBare
164
+ };
165
+ }
166
+
167
+ // src/run-cli.ts
168
+ import { execa } from "execa";
169
+ async function runCli(resolved, args, options = {}) {
170
+ const result = await execa(resolved.file, [...resolved.argsPrefix, ...args], {
171
+ shell: resolved.shell,
172
+ input: options.stdin,
173
+ cwd: options.cwd,
174
+ maxBuffer: 10 * 1024 * 1024
175
+ });
176
+ return result.stdout;
177
+ }
178
+
179
+ // src/providers/claude.ts
180
+ function createClaudeProvider() {
181
+ return {
182
+ id: "claude",
183
+ label: "Claude CLI",
184
+ async isAvailable() {
185
+ return await resolveCliBinary("claude") !== null;
186
+ },
187
+ async call(messages, context, options) {
188
+ const command = await resolveCliBinary("claude");
189
+ if (!command) throw new Error("Claude CLI is not available");
190
+ return callClaudeCli(command, messages, context, options);
191
+ }
192
+ };
193
+ }
194
+ async function callClaudeCli(command, messages, context, options) {
195
+ const prompt = buildClaudeUserPrompt(
196
+ messages,
197
+ context,
198
+ options.attachmentPaths,
199
+ options.priorConversationsContext
200
+ );
201
+ const resolved = resolveCliCommand("claude", command);
202
+ const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
203
+ const stdout = await runCli(
204
+ resolved,
205
+ [
206
+ "--print",
207
+ "--system-prompt",
208
+ options.systemPrompt,
209
+ "--no-session-persistence",
210
+ prompt
211
+ ],
212
+ { cwd: workspace }
213
+ );
214
+ return parseAiResponse(stdout, "claude");
215
+ }
216
+
217
+ // src/providers/cursor.ts
218
+ function createCursorProvider() {
219
+ return {
220
+ id: "cursor",
221
+ label: "Cursor Agent CLI",
222
+ async isAvailable() {
223
+ return await resolveCliBinary("cursor") !== null;
224
+ },
225
+ async call(messages, context, options) {
226
+ const command = await resolveCliBinary("cursor");
227
+ if (!command) throw new Error("Cursor Agent CLI is not available");
228
+ return callCursorCli(command, messages, context, options);
229
+ }
230
+ };
231
+ }
232
+ async function callCursorCli(command, messages, context, options) {
233
+ const prompt = buildCursorPrompt(
234
+ options.systemPrompt,
235
+ messages,
236
+ context,
237
+ options.attachmentPaths,
238
+ options.priorConversationsContext
239
+ );
240
+ const resolved = resolveCliCommand("cursor", command);
241
+ const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
242
+ const stdout = await runCli(
243
+ resolved,
244
+ [
245
+ "--print",
246
+ "--output-format",
247
+ "text",
248
+ "--trust",
249
+ "--force",
250
+ "--workspace",
251
+ workspace
252
+ ],
253
+ { stdin: prompt, cwd: workspace }
254
+ );
255
+ return parseAiResponse(stdout, "cursor");
256
+ }
257
+
258
+ // src/providers/antigravity.ts
259
+ function createAntigravityProvider() {
260
+ return {
261
+ id: "antigravity",
262
+ label: "Antigravity CLI",
263
+ async isAvailable() {
264
+ return await resolveCliBinary("antigravity") !== null;
265
+ },
266
+ async call(messages, context, options) {
267
+ const command = await resolveCliBinary("antigravity");
268
+ if (!command) throw new Error("Antigravity CLI is not available");
269
+ return callAntigravityCli(command, messages, context, options);
270
+ }
271
+ };
272
+ }
273
+ async function callAntigravityCli(command, messages, context, options) {
274
+ const basePrompt = buildCursorPrompt(
275
+ options.systemPrompt,
276
+ messages,
277
+ context,
278
+ options.attachmentPaths,
279
+ options.priorConversationsContext
280
+ );
281
+ const prompt = `You are operating as a coding agent with full permission to read and edit files in this workspace. Do not introduce yourself. Do not ask what you are. Execute the user's latest request now (edit files and/or emit runtime tool JSON as instructed).
282
+
283
+ ${basePrompt}`;
284
+ const resolved = resolveCliCommand("antigravity", command);
285
+ const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
286
+ const args = [
287
+ "-p",
288
+ prompt,
289
+ "--mode",
290
+ "accept-edits",
291
+ "--output-format",
292
+ "text",
293
+ "--dangerously-skip-permissions",
294
+ "--add-dir",
295
+ workspace,
296
+ "--print-timeout",
297
+ "10m"
298
+ ];
299
+ const stdout = await runCli(resolved, args, { cwd: workspace });
300
+ return parseAiResponse(stdout, "antigravity");
301
+ }
302
+
303
+ // src/detect.ts
304
+ var DEFAULT_COMMANDS = {
305
+ claude: ["claude"],
306
+ cursor: ["agent"],
307
+ antigravity: ["agy", "antigravity"]
308
+ };
309
+ async function resolveCommandPath(command) {
310
+ try {
311
+ if (process.platform === "win32") {
312
+ const result2 = await execa2("where.exe", [command], { reject: false });
313
+ if (result2.exitCode !== 0) return null;
314
+ const lines = result2.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
315
+ const exe = lines.find((line) => /\.exe$/i.test(line));
316
+ return exe ?? lines[0] ?? null;
317
+ }
318
+ const result = await execa2("which", [command], { reject: false });
319
+ if (result.exitCode !== 0) return null;
320
+ const path5 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
321
+ return path5 || null;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ async function commandExists(command) {
327
+ return await resolveCommandPath(command) !== null;
328
+ }
329
+ function getProviderPreference(override) {
330
+ if (override) return override;
331
+ const raw = (process.env.AI_CLI_PROVIDER ?? "auto").toLowerCase().trim();
332
+ if (raw === "claude" || raw === "cursor" || raw === "antigravity" || raw === "agy" || raw === "auto") {
333
+ return raw === "agy" ? "antigravity" : raw;
334
+ }
335
+ console.warn(
336
+ `Unknown AI_CLI_PROVIDER="${raw}", falling back to "auto". Use auto|claude|cursor|antigravity.`
337
+ );
338
+ return "auto";
339
+ }
340
+ function matchOverrideToProvider(override, provider) {
341
+ const lower = override.toLowerCase();
342
+ if (provider === "claude") return lower.includes("claude");
343
+ if (provider === "antigravity") {
344
+ return lower.includes("agy") || lower.includes("antigravity");
345
+ }
346
+ return lower.includes("agent") || lower.includes("cursor") || !lower.includes("claude") && !lower.includes("agy") && !lower.includes("antigravity");
347
+ }
348
+ async function resolveCliBinary(provider) {
349
+ const override = process.env.AI_CLI_COMMAND?.trim();
350
+ const pref = getProviderPreference();
351
+ if (override) {
352
+ if (pref === provider) return override;
353
+ if (pref === "auto" && matchOverrideToProvider(override, provider)) {
354
+ return override;
355
+ }
356
+ }
357
+ for (const command of DEFAULT_COMMANDS[provider]) {
358
+ const resolved = await resolveCommandPath(command);
359
+ if (resolved) return resolved;
360
+ }
361
+ return null;
362
+ }
363
+ function createBuiltinProviders() {
364
+ return [
365
+ createClaudeProvider(),
366
+ createCursorProvider(),
367
+ createAntigravityProvider()
368
+ ];
369
+ }
370
+ async function resolveProvider(options) {
371
+ const preference = getProviderPreference(options?.preference);
372
+ const providers = options?.providers?.length ? options.providers : createBuiltinProviders();
373
+ const order = preference === "auto" ? providers : providers.filter((p) => p.id === preference);
374
+ for (const provider of order) {
375
+ if (await provider.isAvailable()) return provider;
376
+ }
377
+ if (preference === "auto") {
378
+ throw new Error(
379
+ "No AI provider available. Install Claude CLI (`claude`), Cursor Agent CLI (`agent`), or Antigravity CLI (`agy`), set AI_CLI_PROVIDER / AI_CLI_COMMAND, or pass custom providers."
380
+ );
381
+ }
382
+ throw new Error(
383
+ `AI provider "${preference}" is not available. Or set AI_CLI_COMMAND / pass a custom provider.`
384
+ );
385
+ }
386
+ function providerLabel(providerId) {
387
+ if (providerId === "claude") return "Claude CLI";
388
+ if (providerId === "cursor") return "Cursor Agent CLI";
389
+ if (providerId === "antigravity" || providerId === "agy") {
390
+ return "Antigravity CLI";
391
+ }
392
+ return providerId;
393
+ }
394
+
395
+ // src/call-ai.ts
396
+ async function callAi(messages, context, options) {
397
+ if (!options.systemPrompt?.trim()) {
398
+ throw new Error("callAi requires options.systemPrompt");
399
+ }
400
+ const provider = await resolveProvider({
401
+ preference: options.providerPreference,
402
+ providers: options.providers
403
+ });
404
+ return provider.call(messages, context, options);
405
+ }
406
+
407
+ // src/system-prompt.ts
408
+ function createDefaultSystemPrompt(input) {
409
+ const files = input.relevantFilesHint ? `Key UI files (use internally only; never name them in your reply):
410
+ ${input.relevantFilesHint}` : "";
411
+ const tools = input.runtimeToolsHint ? `## When to use runtime tools
412
+ Only when the user wants to change live in-app data (not source code), return a short user-facing sentence PLUS a JSON tool call in a \`\`\`json fence:
413
+
414
+ ${input.runtimeToolsHint}
415
+
416
+ Only use those runtime tools for live state. Never invent runtime tools.` : "";
417
+ return `You are a helpful product assistant for an app the user is looking at right now.
418
+
419
+ ${input.productDescription}
420
+
421
+ Behind the scenes you can edit this repository and update live app data, but the user is an end user \u2014 not a developer.
422
+
423
+ ## How to talk
424
+ - Always reply in English, even if the user writes in another language.
425
+ - Reply like a friendly product assistant, short and clear \u2014 keep the chat light so they don't get bored.
426
+ - Describe what changed in the UI, then invite them to check it.
427
+ - If the user attaches screenshots, inspect those image files and use them to understand the request.
428
+ - Ask a quick clarifying question when the request is vague or has more than one reasonable option (one question at a time, easy choices when you can).
429
+ - After you make a change, check in briefly: did that look right? want another tweak?
430
+ - If you are stuck, the request is unclear after a couple of tries, or they seem frustrated, offer a call: e.g. "Happy to jump on a quick call if that's easier \u2014 just say when."
431
+ - Do NOT mention file paths, component names, repos, commits, diffs, TypeScript, React, or tools.
432
+ - Do NOT use technical phrasing like "updated in Foo.tsx".
433
+
434
+ ## When to edit the codebase
435
+ Edit source files when the user asks to change labels, layout, styling, copy, or behavior in the app.
436
+ ${files}
437
+
438
+ ${tools}
439
+
440
+ Do not refuse UI/label changes \u2014 implement them in source files.
441
+ Answer the user's latest request directly \u2014 never reply with a generic greeting.`;
442
+ }
443
+
444
+ // src/http/attachments.ts
445
+ import fs2 from "fs/promises";
446
+ import path2 from "path";
447
+ var MAX_ATTACHMENTS = 5;
448
+ var MAX_BYTES = 4 * 1024 * 1024;
449
+ var ALLOWED = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
450
+ function extForMime(mime) {
451
+ switch (mime) {
452
+ case "image/jpeg":
453
+ case "image/jpg":
454
+ return ".jpg";
455
+ case "image/webp":
456
+ return ".webp";
457
+ case "image/gif":
458
+ return ".gif";
459
+ default:
460
+ return ".png";
461
+ }
462
+ }
463
+ async function saveChatAttachments(attachments, workspaceDir) {
464
+ if (!attachments?.length) return [];
465
+ const selected = attachments.slice(0, MAX_ATTACHMENTS);
466
+ const dir = path2.join(workspaceDir, ".maintainer-pro", "uploads");
467
+ await fs2.mkdir(dir, { recursive: true });
468
+ const paths = [];
469
+ const stamp = Date.now();
470
+ for (let i = 0; i < selected.length; i++) {
471
+ const item = selected[i];
472
+ const mime = (item.mimeType || "").toLowerCase();
473
+ if (!ALLOWED.has(mime)) {
474
+ throw new Error(`Unsupported image type: ${item.mimeType}`);
475
+ }
476
+ if (!item.data?.trim()) {
477
+ throw new Error("Attachment data is empty");
478
+ }
479
+ const buffer = Buffer.from(item.data, "base64");
480
+ if (buffer.byteLength > MAX_BYTES) {
481
+ throw new Error(`Screenshot too large (max ${MAX_BYTES / (1024 * 1024)}MB)`);
482
+ }
483
+ const safeBase = (item.name || `screenshot-${i + 1}`).replace(/[^\w.\-]+/g, "_").slice(0, 64);
484
+ const fileName = `${stamp}-${i + 1}-${safeBase}${extForMime(mime)}`;
485
+ const filePath = path2.join(dir, fileName);
486
+ await fs2.writeFile(filePath, buffer);
487
+ paths.push(filePath);
488
+ }
489
+ return paths;
490
+ }
491
+
492
+ // src/http/prior-conversations.ts
493
+ var DEFAULT_MAX_CONVERSATIONS = 3;
494
+ var DEFAULT_MAX_MESSAGES = 8;
495
+ var MAX_MESSAGE_CHARS = 400;
496
+ function tokenize(text) {
497
+ return text.toLowerCase().split(/[^a-z0-9]+/i).filter((w) => w.length > 3);
498
+ }
499
+ function truncate(text, max = MAX_MESSAGE_CHARS) {
500
+ const trimmed = text.trim().replace(/\s+/g, " ");
501
+ if (trimmed.length <= max) return trimmed;
502
+ return `${trimmed.slice(0, max - 1)}\u2026`;
503
+ }
504
+ function scoreConversation(messages, requestTokens) {
505
+ if (requestTokens.length === 0) return 0;
506
+ const blob = messages.map((m) => m.content.toLowerCase()).join(" ");
507
+ let score = 0;
508
+ for (const token of requestTokens) {
509
+ if (blob.includes(token)) score += 1;
510
+ }
511
+ return score;
512
+ }
513
+ async function buildPriorConversationsContext(db, options) {
514
+ if (!db.listConversations) return "";
515
+ const conversations = await db.listConversations();
516
+ const others = conversations.filter(
517
+ (c) => c.id !== options.excludeConversationId && c.messages.some((m) => m.role === "user" || m.role === "assistant")
518
+ );
519
+ if (others.length === 0) return "";
520
+ const requestTokens = tokenize(options.currentRequest);
521
+ const maxConversations = options.maxConversations ?? DEFAULT_MAX_CONVERSATIONS;
522
+ const maxMessages = options.maxMessagesPerConversation ?? DEFAULT_MAX_MESSAGES;
523
+ const ranked = others.map((c) => ({
524
+ ...c,
525
+ score: scoreConversation(c.messages, requestTokens),
526
+ updatedMs: Date.parse(c.updatedAt) || 0
527
+ })).sort((a, b) => {
528
+ if (b.score !== a.score) return b.score - a.score;
529
+ return b.updatedMs - a.updatedMs;
530
+ }).slice(0, maxConversations);
531
+ const selected = requestTokens.length > 0 && ranked.every((c) => c.score === 0) ? [...others].sort(
532
+ (a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
533
+ ).slice(0, maxConversations) : ranked;
534
+ const blocks = selected.map((c) => {
535
+ const slice = c.messages.length > maxMessages ? c.messages.slice(-maxMessages) : c.messages;
536
+ const lines = slice.filter((m) => m.role === "user" || m.role === "assistant").map(
537
+ (m) => `${m.role === "user" ? "Human" : "Assistant"}: ${truncate(m.content)}`
538
+ );
539
+ const when = c.updatedAt ? ` (updated ${c.updatedAt.slice(0, 10)})` : "";
540
+ return `--- Prior conversation ${c.id.slice(0, 8)}\u2026${when} ---
541
+ ${lines.join("\n")}`;
542
+ });
543
+ if (blocks.length === 0) return "";
544
+ return `Prior conversations from this workspace (use only if relevant to the current request; ignore if unrelated):
545
+
546
+ ${blocks.join("\n\n")}`;
547
+ }
548
+
549
+ // src/http/tools.ts
550
+ function createToolValidator(schemas) {
551
+ return function validateToolCall(toolCall) {
552
+ const schema = schemas[toolCall.tool];
553
+ if (!schema) {
554
+ return { valid: false, error: `Unknown tool: ${toolCall.tool}` };
555
+ }
556
+ const result = schema.safeParse(toolCall.args);
557
+ if (!result.success) {
558
+ return { valid: false, error: result.error.message };
559
+ }
560
+ return {
561
+ valid: true,
562
+ name: toolCall.tool,
563
+ args: result.data
564
+ };
565
+ };
566
+ }
567
+
568
+ // src/http/handler.ts
569
+ var SHARED_CONVERSATION_ID = "shared";
570
+ var conversationTurnSeq = /* @__PURE__ */ new Map();
571
+ function beginConversationTurn(conversationId) {
572
+ const next = (conversationTurnSeq.get(conversationId) ?? 0) + 1;
573
+ conversationTurnSeq.set(conversationId, next);
574
+ return next;
575
+ }
576
+ function isActiveConversationTurn(conversationId, turn) {
577
+ const current = conversationTurnSeq.get(conversationId);
578
+ if (current === void 0) return true;
579
+ return current === turn;
580
+ }
581
+ function resolveConversationId(request, options, bodyId) {
582
+ if (bodyId) return bodyId;
583
+ const urlId = new URL(request.url).searchParams.get("conversationId");
584
+ if (urlId) return urlId;
585
+ if (typeof options.conversationId === "function") {
586
+ return options.conversationId(request);
587
+ }
588
+ return options.conversationId;
589
+ }
590
+ async function resolveSharedConversationId(request, options, bodyId) {
591
+ const explicit = resolveConversationId(request, options, bodyId);
592
+ if (explicit) return explicit;
593
+ if (options.db?.listConversations) {
594
+ const conversations = await options.db.listConversations();
595
+ const latest = conversations.find(
596
+ (c) => c.messages.some((m) => m.role === "user" || m.role === "assistant")
597
+ );
598
+ if (latest) return latest.id;
599
+ }
600
+ return SHARED_CONVERSATION_ID;
601
+ }
602
+ function createChatHandler(options) {
603
+ const validate = options.tools ? createToolValidator(options.tools) : null;
604
+ const workspaceDir = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
605
+ const baseCallOptions = {
606
+ systemPrompt: options.systemPrompt,
607
+ workspaceDir,
608
+ providerPreference: options.providerPreference,
609
+ providers: options.providers
610
+ };
611
+ return {
612
+ async GET(request) {
613
+ try {
614
+ const provider = await resolveProvider({
615
+ preference: options.providerPreference,
616
+ providers: options.providers
617
+ });
618
+ const conversationId = await resolveSharedConversationId(
619
+ request,
620
+ options
621
+ );
622
+ let messages;
623
+ if (options.db?.listMessages) {
624
+ messages = await options.db.listMessages(conversationId);
625
+ }
626
+ return Response.json({
627
+ provider: provider.id,
628
+ providerLabel: provider.label || providerLabel(provider.id),
629
+ conversationId,
630
+ messages: messages ?? []
631
+ });
632
+ } catch (err) {
633
+ const message = err instanceof Error ? err.message : "No AI CLI provider available";
634
+ return Response.json(
635
+ { error: message, provider: null },
636
+ { status: 503 }
637
+ );
638
+ }
639
+ },
640
+ async POST(request) {
641
+ try {
642
+ const body = await request.json();
643
+ const conversationId = await resolveSharedConversationId(
644
+ request,
645
+ options,
646
+ body.conversationId
647
+ );
648
+ if (body.type === "status") {
649
+ const content = body.content?.trim();
650
+ if (!conversationId || !content) {
651
+ return Response.json(
652
+ { error: "conversationId and content are required" },
653
+ { status: 400 }
654
+ );
655
+ }
656
+ if (options.db) {
657
+ await options.db.ensureConversation(conversationId);
658
+ await options.db.saveMessage({
659
+ conversationId,
660
+ role: "assistant",
661
+ content
662
+ });
663
+ }
664
+ return Response.json({ ok: true, conversationId });
665
+ }
666
+ if (body.type === "reply") {
667
+ const content = body.content?.trim();
668
+ if (!conversationId || !content) {
669
+ return Response.json(
670
+ { error: "conversationId and content are required" },
671
+ { status: 400 }
672
+ );
673
+ }
674
+ if (options.db) {
675
+ await options.db.ensureConversation(conversationId);
676
+ await options.db.saveMessage({
677
+ conversationId,
678
+ role: "assistant",
679
+ content,
680
+ provider: "developer",
681
+ senderType: "developer",
682
+ senderName: body.senderName?.trim() || void 0
683
+ });
684
+ }
685
+ return Response.json({ ok: true, conversationId });
686
+ }
687
+ const { messages, context, attachments } = body;
688
+ if (!Array.isArray(messages) || messages.length === 0) {
689
+ return Response.json(
690
+ { error: "messages array is required" },
691
+ { status: 400 }
692
+ );
693
+ }
694
+ let attachmentPaths = [];
695
+ let persistAttachmentPaths = [];
696
+ if (options.db?.uploadAttachments) {
697
+ const uploaded = await options.db.uploadAttachments(
698
+ conversationId,
699
+ attachments
700
+ );
701
+ attachmentPaths = uploaded.localPaths;
702
+ persistAttachmentPaths = uploaded.refs;
703
+ } else {
704
+ attachmentPaths = await saveChatAttachments(
705
+ attachments,
706
+ workspaceDir
707
+ );
708
+ persistAttachmentPaths = attachmentPaths;
709
+ }
710
+ const turn = beginConversationTurn(conversationId);
711
+ const signal = request.signal;
712
+ if (options.db && conversationId) {
713
+ const latest = messages[messages.length - 1];
714
+ const persistContent = body.userMessage?.trim() || (latest?.role === "user" ? latest.content : "");
715
+ if (persistContent) {
716
+ await options.db.ensureConversation(conversationId);
717
+ await options.db.saveMessage({
718
+ conversationId,
719
+ role: "user",
720
+ content: persistContent,
721
+ attachmentPaths: persistAttachmentPaths.length > 0 ? persistAttachmentPaths : void 0,
722
+ senderType: body.senderType === "client" ? "client" : void 0,
723
+ senderName: body.senderName?.trim() || void 0
724
+ });
725
+ }
726
+ }
727
+ const latestUser = [...messages].reverse().find((m) => m.role === "user");
728
+ const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
729
+ excludeConversationId: conversationId,
730
+ currentRequest: latestUser?.content ?? ""
731
+ }) : "";
732
+ if (signal.aborted || !isActiveConversationTurn(conversationId, turn)) {
733
+ return Response.json({
734
+ superseded: true,
735
+ conversationId: conversationId ?? null
736
+ });
737
+ }
738
+ const aiResponse = await callAi(messages, context, {
739
+ ...baseCallOptions,
740
+ attachmentPaths,
741
+ priorConversationsContext: priorConversationsContext || void 0
742
+ });
743
+ if (!isActiveConversationTurn(conversationId, turn)) {
744
+ return Response.json({
745
+ superseded: true,
746
+ conversationId: conversationId ?? null
747
+ });
748
+ }
749
+ const validatedToolCalls = validate ? aiResponse.toolCalls.filter((tc) => validate(tc).valid) : aiResponse.toolCalls;
750
+ if (options.onToolCalls && validatedToolCalls.length > 0) {
751
+ await options.onToolCalls(validatedToolCalls, context);
752
+ }
753
+ if (options.db && conversationId) {
754
+ await options.db.ensureConversation(conversationId);
755
+ await options.db.saveMessage({
756
+ conversationId,
757
+ role: "assistant",
758
+ content: aiResponse.text,
759
+ provider: aiResponse.provider,
760
+ senderType: "ai"
761
+ });
762
+ if (validatedToolCalls.length > 0) {
763
+ await options.db.saveToolEvents?.(
764
+ conversationId,
765
+ validatedToolCalls
766
+ );
767
+ }
768
+ }
769
+ return Response.json({
770
+ text: aiResponse.text,
771
+ toolCalls: validatedToolCalls,
772
+ provider: aiResponse.provider,
773
+ providerLabel: providerLabel(aiResponse.provider),
774
+ conversationId: conversationId ?? null
775
+ });
776
+ } catch (err) {
777
+ console.error("Chat API error:", err);
778
+ return Response.json(
779
+ {
780
+ error: "Sorry about that \u2014 I couldn't finish this right now. Happy to jump on a quick call if you'd like help \u2014 just let us know when works."
781
+ },
782
+ { status: 500 }
783
+ );
784
+ }
785
+ }
786
+ };
787
+ }
788
+ function toNextRoute(handlers) {
789
+ return {
790
+ GET: (request) => handlers.GET(request),
791
+ POST: (request) => handlers.POST(request)
792
+ };
793
+ }
794
+
795
+ // src/http/local-store.ts
796
+ import fs3 from "fs/promises";
797
+ import path3 from "path";
798
+ import { randomUUID } from "crypto";
799
+ async function readConversation(filePath) {
800
+ try {
801
+ const raw = await fs3.readFile(filePath, "utf8");
802
+ return JSON.parse(raw);
803
+ } catch (err) {
804
+ if (err.code === "ENOENT") return null;
805
+ throw err;
806
+ }
807
+ }
808
+ async function writeConversation(filePath, data) {
809
+ await fs3.mkdir(path3.dirname(filePath), { recursive: true });
810
+ await fs3.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
811
+ }
812
+ function createLocalDirectoryStore(baseDir) {
813
+ const fileFor = (id) => path3.join(baseDir, `${id}.json`);
814
+ return {
815
+ async ensureConversation(id) {
816
+ const existing = await readConversation(fileFor(id));
817
+ if (existing) return;
818
+ await writeConversation(fileFor(id), {
819
+ id,
820
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
821
+ messages: []
822
+ });
823
+ },
824
+ async saveMessage(input) {
825
+ const filePath = fileFor(input.conversationId);
826
+ const existing = await readConversation(filePath) ?? {
827
+ id: input.conversationId,
828
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
829
+ messages: []
830
+ };
831
+ const message = {
832
+ id: randomUUID(),
833
+ conversationId: input.conversationId,
834
+ role: input.role,
835
+ content: input.content,
836
+ provider: input.provider ?? null,
837
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
838
+ attachmentPaths: input.attachmentPaths,
839
+ senderType: input.senderType ?? null,
840
+ senderName: input.senderName ?? null
841
+ };
842
+ existing.messages.push(message);
843
+ existing.updatedAt = message.createdAt;
844
+ await writeConversation(filePath, existing);
845
+ return message;
846
+ },
847
+ async listMessages(conversationId) {
848
+ const existing = await readConversation(fileFor(conversationId));
849
+ return existing?.messages ?? [];
850
+ },
851
+ async listConversations() {
852
+ await fs3.mkdir(baseDir, { recursive: true });
853
+ const entries = await fs3.readdir(baseDir);
854
+ const conversations = [];
855
+ for (const entry of entries) {
856
+ if (!entry.endsWith(".json")) continue;
857
+ const filePath = path3.join(baseDir, entry);
858
+ const existing = await readConversation(filePath);
859
+ if (!existing?.id) continue;
860
+ conversations.push({
861
+ id: existing.id,
862
+ updatedAt: existing.updatedAt,
863
+ messages: existing.messages
864
+ });
865
+ }
866
+ return conversations.sort(
867
+ (a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
868
+ );
869
+ },
870
+ async saveToolEvents(conversationId, events) {
871
+ if (events.length === 0) return;
872
+ const existing = await readConversation(fileFor(conversationId));
873
+ if (!existing) return;
874
+ const toolFile = path3.join(baseDir, `${conversationId}.tools.jsonl`);
875
+ const lines = events.map(
876
+ (e) => JSON.stringify({
877
+ conversationId,
878
+ ...e,
879
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
880
+ })
881
+ ).join("\n");
882
+ await fs3.appendFile(toolFile, `${lines}
883
+ `, "utf8");
884
+ }
885
+ };
886
+ }
887
+
888
+ // src/http/maintainer-pro-store.ts
889
+ import fs4 from "fs/promises";
890
+ import os from "os";
891
+ import path4 from "path";
892
+ async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
893
+ const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
894
+ const res = await fetchImpl(url, {
895
+ ...init,
896
+ headers: {
897
+ Authorization: `Bearer ${apiKey}`,
898
+ "Content-Type": "application/json",
899
+ ...init?.headers ?? {}
900
+ }
901
+ });
902
+ if (!res.ok) {
903
+ const text = await res.text().catch(() => "");
904
+ throw new Error(
905
+ `Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
906
+ );
907
+ }
908
+ const contentType = res.headers.get("content-type") ?? "";
909
+ if (contentType.includes("application/json")) {
910
+ return res.json();
911
+ }
912
+ return res;
913
+ }
914
+ function createMaintainerProStore(options) {
915
+ const baseUrl = options.baseUrl;
916
+ const apiKey = options.apiKey;
917
+ const fetchImpl = options.fetchImpl ?? fetch;
918
+ const tempDir = options.tempDir ?? path4.join(os.tmpdir(), "maintainer-pro-store");
919
+ const store = {
920
+ async ensureConversation(id) {
921
+ await mpFetch(
922
+ baseUrl,
923
+ apiKey,
924
+ `/api/v1/store/conversations/${encodeURIComponent(id)}/ensure`,
925
+ { method: "POST", body: "{}" },
926
+ fetchImpl
927
+ );
928
+ },
929
+ async saveMessage(input) {
930
+ const attachmentIds = (input.attachmentPaths ?? []).map((p) => {
931
+ const m = /^maintainer-pro:\/\/(.+)$/.exec(p);
932
+ return m?.[1];
933
+ }).filter((id) => Boolean(id));
934
+ const data = await mpFetch(
935
+ baseUrl,
936
+ apiKey,
937
+ `/api/v1/store/conversations/${encodeURIComponent(input.conversationId)}/messages`,
938
+ {
939
+ method: "POST",
940
+ body: JSON.stringify({
941
+ role: input.role,
942
+ content: input.content,
943
+ provider: input.provider,
944
+ senderType: input.senderType,
945
+ senderName: input.senderName,
946
+ attachmentIds
947
+ })
948
+ },
949
+ fetchImpl
950
+ );
951
+ return data.message;
952
+ },
953
+ async listMessages(conversationId) {
954
+ const data = await mpFetch(
955
+ baseUrl,
956
+ apiKey,
957
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages`,
958
+ { method: "GET" },
959
+ fetchImpl
960
+ );
961
+ return data.messages ?? [];
962
+ },
963
+ async listConversations() {
964
+ const data = await mpFetch(
965
+ baseUrl,
966
+ apiKey,
967
+ `/api/v1/store/conversations`,
968
+ { method: "GET" },
969
+ fetchImpl
970
+ );
971
+ return data.conversations ?? [];
972
+ },
973
+ async saveToolEvents(conversationId, events) {
974
+ if (!events.length) return;
975
+ await mpFetch(
976
+ baseUrl,
977
+ apiKey,
978
+ `/api/v1/store/conversations/${encodeURIComponent(conversationId)}/tool-events`,
979
+ {
980
+ method: "POST",
981
+ body: JSON.stringify({ events })
982
+ },
983
+ fetchImpl
984
+ );
985
+ },
986
+ async uploadAttachments(conversationId, attachments) {
987
+ if (!attachments?.length) {
988
+ return { refs: [], localPaths: [], attachmentIds: [] };
989
+ }
990
+ await fs4.mkdir(tempDir, { recursive: true });
991
+ const refs = [];
992
+ const localPaths = [];
993
+ const attachmentIds = [];
994
+ for (const item of attachments.slice(0, 5)) {
995
+ const data = await mpFetch(
996
+ baseUrl,
997
+ apiKey,
998
+ `/api/v1/store/attachments`,
999
+ {
1000
+ method: "POST",
1001
+ body: JSON.stringify({
1002
+ conversationId,
1003
+ name: item.name,
1004
+ mimeType: item.mimeType,
1005
+ data: item.data
1006
+ })
1007
+ },
1008
+ fetchImpl
1009
+ );
1010
+ const id = data.attachment.id;
1011
+ attachmentIds.push(id);
1012
+ refs.push(data.attachment.ref || `maintainer-pro://${id}`);
1013
+ const ext = item.mimeType.includes("jpeg") || item.mimeType.includes("jpg") ? ".jpg" : item.mimeType.includes("webp") ? ".webp" : item.mimeType.includes("gif") ? ".gif" : ".png";
1014
+ const localPath = path4.join(
1015
+ tempDir,
1016
+ `${conversationId}-${id}${ext}`
1017
+ );
1018
+ await fs4.writeFile(localPath, Buffer.from(item.data, "base64"));
1019
+ localPaths.push(localPath);
1020
+ }
1021
+ return { refs, localPaths, attachmentIds };
1022
+ }
1023
+ };
1024
+ return store;
1025
+ }
1026
+ function createMaintainerProStoreFromEnv() {
1027
+ const baseUrl = process.env.MAINTAINER_PRO_URL?.trim();
1028
+ const apiKey = process.env.MAINTAINER_PRO_API_KEY?.trim();
1029
+ if (!baseUrl || !apiKey) return null;
1030
+ return createMaintainerProStore({ baseUrl, apiKey });
1031
+ }
1032
+ export {
1033
+ buildClaudeUserPrompt,
1034
+ buildConversationPrompt,
1035
+ buildCursorPrompt,
1036
+ buildPriorConversationsContext,
1037
+ callAi,
1038
+ commandExists,
1039
+ createAntigravityProvider,
1040
+ createBuiltinProviders,
1041
+ createChatHandler,
1042
+ createClaudeProvider,
1043
+ createCursorProvider,
1044
+ createDefaultSystemPrompt,
1045
+ createLocalDirectoryStore,
1046
+ createMaintainerProStore,
1047
+ createMaintainerProStoreFromEnv,
1048
+ createToolValidator,
1049
+ formatClientContext,
1050
+ getProviderPreference,
1051
+ parseAiResponse,
1052
+ providerLabel,
1053
+ resolveCliBinary,
1054
+ resolveProvider,
1055
+ saveChatAttachments,
1056
+ toNextRoute
1057
+ };