upfynai-code 2.5.1 → 2.6.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.
@@ -1,1266 +1,1226 @@
1
- import express from 'express';
2
- import { spawn } from 'child_process';
3
- import path from 'path';
4
- import os from 'os';
5
- import { promises as fs } from 'fs';
6
- import crypto from 'crypto';
7
- import { userDb, apiKeysDb, githubTokensDb, credentialsDb } from '../database/db.js';
8
- import { addProjectManually } from '../projects.js';
9
- import { queryClaudeSDK } from '../claude-sdk.js';
10
- import { spawnCursor } from '../cursor-cli.js';
11
- import { queryCodex } from '../openai-codex.js';
12
- import { Octokit } from '@octokit/rest';
13
- import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
14
- import { queryOpenRouter, OPENROUTER_MODELS } from '../openrouter.js';
15
- import { IS_PLATFORM } from '../constants/config.js';
16
-
17
- // BYOK helper: get user's stored API key for a provider
18
- async function getUserProviderKey(userId, providerType) {
19
- if (!userId) return null;
20
- try {
21
- const creds = await credentialsDb.getCredentials(userId, providerType);
22
- const active = creds.find(c => c.is_active);
23
- return active?.credential_value || null;
24
- } catch { return null; }
25
- }
26
-
27
- async function withUserApiKey(envKey, userKey, fn) {
28
- if (!userKey) return fn();
29
- const prev = process.env[envKey];
30
- process.env[envKey] = userKey;
31
- try { return await fn(); }
32
- finally { if (prev !== undefined) process.env[envKey] = prev; else delete process.env[envKey]; }
33
- }
34
-
35
- const router = express.Router();
36
-
37
- /**
38
- * Middleware to authenticate agent API requests.
39
- *
40
- * Supports two authentication modes:
41
- * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
42
- * authentication is handled by an external proxy. Requests are trusted and
43
- * the default user context is used.
44
- *
45
- * 2. API key mode (default): For self-hosted deployments where users authenticate
46
- * via API keys created in the UI. Keys are validated against the local database.
47
- */
48
- const validateExternalApiKey = async (req, res, next) => {
49
- // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
50
- // Trust the request and use the default user context.
51
- if (IS_PLATFORM) {
52
- try {
53
- const user = await userDb.getFirstUser();
54
- if (!user) {
55
- return res.status(500).json({ error: 'Platform mode: No user found in database' });
56
- }
57
- req.user = user;
58
- return next();
59
- } catch (error) {
60
- console.error('Platform mode error:', error);
61
- return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
62
- }
63
- }
64
-
65
- // Self-hosted mode: Validate API key from header or query parameter
66
- const apiKey = req.headers['x-api-key'] || req.query.apiKey;
67
-
68
- if (!apiKey) {
69
- return res.status(401).json({ error: 'API key required' });
70
- }
71
-
72
- const user = await apiKeysDb.validateApiKey(apiKey);
73
-
74
- if (!user) {
75
- return res.status(401).json({ error: 'Invalid or inactive API key' });
76
- }
77
-
78
- req.user = user;
79
- next();
80
- };
81
-
82
- /**
83
- * Get the remote URL of a git repository
84
- * @param {string} repoPath - Path to the git repository
85
- * @returns {Promise<string>} - Remote URL of the repository
86
- */
87
- async function getGitRemoteUrl(repoPath) {
88
- return new Promise((resolve, reject) => {
89
- const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
90
- cwd: repoPath,
91
- stdio: ['pipe', 'pipe', 'pipe']
92
- });
93
-
94
- let stdout = '';
95
- let stderr = '';
96
-
97
- gitProcess.stdout.on('data', (data) => {
98
- stdout += data.toString();
99
- });
100
-
101
- gitProcess.stderr.on('data', (data) => {
102
- stderr += data.toString();
103
- });
104
-
105
- gitProcess.on('close', (code) => {
106
- if (code === 0) {
107
- resolve(stdout.trim());
108
- } else {
109
- reject(new Error(`Failed to get git remote: ${stderr}`));
110
- }
111
- });
112
-
113
- gitProcess.on('error', (error) => {
114
- reject(new Error(`Failed to execute git: ${error.message}`));
115
- });
116
- });
117
- }
118
-
119
- /**
120
- * Normalize GitHub URLs for comparison
121
- * @param {string} url - GitHub URL
122
- * @returns {string} - Normalized URL
123
- */
124
- function normalizeGitHubUrl(url) {
125
- // Remove .git suffix
126
- let normalized = url.replace(/\.git$/, '');
127
- // Convert SSH to HTTPS format for comparison
128
- normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
129
- // Remove trailing slash
130
- normalized = normalized.replace(/\/$/, '');
131
- return normalized.toLowerCase();
132
- }
133
-
134
- /**
135
- * Parse GitHub URL to extract owner and repo
136
- * @param {string} url - GitHub URL (HTTPS or SSH)
137
- * @returns {{owner: string, repo: string}} - Parsed owner and repo
138
- */
139
- function parseGitHubUrl(url) {
140
- // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
141
- // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
142
- const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
143
- if (!match) {
144
- throw new Error('Invalid GitHub URL format');
145
- }
146
- return {
147
- owner: match[1],
148
- repo: match[2].replace(/\.git$/, '')
149
- };
150
- }
151
-
152
- /**
153
- * Auto-generate a branch name from a message
154
- * @param {string} message - The agent message
155
- * @returns {string} - Generated branch name
156
- */
157
- function autogenerateBranchName(message) {
158
- // Convert to lowercase, replace spaces/special chars with hyphens
159
- let branchName = message
160
- .toLowerCase()
161
- .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
162
- .replace(/\s+/g, '-') // Replace spaces with hyphens
163
- .replace(/-+/g, '-') // Replace multiple hyphens with single
164
- .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
165
-
166
- // Ensure non-empty fallback
167
- if (!branchName) {
168
- branchName = 'task';
169
- }
170
-
171
- // Generate timestamp suffix (last 6 chars of base36 timestamp)
172
- const timestamp = Date.now().toString(36).slice(-6);
173
- const suffix = `-${timestamp}`;
174
-
175
- // Limit length to ensure total length including suffix fits within 50 characters
176
- const maxBaseLength = 50 - suffix.length;
177
- if (branchName.length > maxBaseLength) {
178
- branchName = branchName.substring(0, maxBaseLength);
179
- }
180
-
181
- // Remove any trailing hyphen after truncation and ensure no leading hyphen
182
- branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
183
-
184
- // If still empty or starts with hyphen after cleanup, use fallback
185
- if (!branchName || branchName.startsWith('-')) {
186
- branchName = 'task';
187
- }
188
-
189
- // Combine base name with timestamp suffix
190
- branchName = `${branchName}${suffix}`;
191
-
192
- // Final validation: ensure it matches safe pattern
193
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
194
- // Fallback to deterministic safe name
195
- return `branch-${timestamp}`;
196
- }
197
-
198
- return branchName;
199
- }
200
-
201
- /**
202
- * Validate a Git branch name
203
- * @param {string} branchName - Branch name to validate
204
- * @returns {{valid: boolean, error?: string}} - Validation result
205
- */
206
- function validateBranchName(branchName) {
207
- if (!branchName || branchName.trim() === '') {
208
- return { valid: false, error: 'Branch name cannot be empty' };
209
- }
210
-
211
- // Git branch name rules
212
- const invalidPatterns = [
213
- { pattern: /^\./, message: 'Branch name cannot start with a dot' },
214
- { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
215
- { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
216
- { pattern: /\s/, message: 'Branch name cannot contain spaces' },
217
- { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
218
- { pattern: /@{/, message: 'Branch name cannot contain @{' },
219
- { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
220
- { pattern: /^\//, message: 'Branch name cannot start with a slash' },
221
- { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
222
- { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
223
- ];
224
-
225
- for (const { pattern, message } of invalidPatterns) {
226
- if (pattern.test(branchName)) {
227
- return { valid: false, error: message };
228
- }
229
- }
230
-
231
- // Check for ASCII control characters
232
- if (/[\x00-\x1F\x7F]/.test(branchName)) {
233
- return { valid: false, error: 'Branch name cannot contain control characters' };
234
- }
235
-
236
- return { valid: true };
237
- }
238
-
239
- /**
240
- * Get recent commit messages from a repository
241
- * @param {string} projectPath - Path to the git repository
242
- * @param {number} limit - Number of commits to retrieve (default: 5)
243
- * @returns {Promise<string[]>} - Array of commit messages
244
- */
245
- async function getCommitMessages(projectPath, limit = 5) {
246
- return new Promise((resolve, reject) => {
247
- const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
248
- cwd: projectPath,
249
- stdio: ['pipe', 'pipe', 'pipe']
250
- });
251
-
252
- let stdout = '';
253
- let stderr = '';
254
-
255
- gitProcess.stdout.on('data', (data) => {
256
- stdout += data.toString();
257
- });
258
-
259
- gitProcess.stderr.on('data', (data) => {
260
- stderr += data.toString();
261
- });
262
-
263
- gitProcess.on('close', (code) => {
264
- if (code === 0) {
265
- const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
266
- resolve(messages);
267
- } else {
268
- reject(new Error(`Failed to get commit messages: ${stderr}`));
269
- }
270
- });
271
-
272
- gitProcess.on('error', (error) => {
273
- reject(new Error(`Failed to execute git: ${error.message}`));
274
- });
275
- });
276
- }
277
-
278
- /**
279
- * Create a new branch on GitHub using the API
280
- * @param {Octokit} octokit - Octokit instance
281
- * @param {string} owner - Repository owner
282
- * @param {string} repo - Repository name
283
- * @param {string} branchName - Name of the new branch
284
- * @param {string} baseBranch - Base branch to branch from (default: 'main')
285
- * @returns {Promise<void>}
286
- */
287
- async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
288
- try {
289
- // Get the SHA of the base branch
290
- const { data: ref } = await octokit.git.getRef({
291
- owner,
292
- repo,
293
- ref: `heads/${baseBranch}`
294
- });
295
-
296
- const baseSha = ref.object.sha;
297
-
298
- // Create the new branch
299
- await octokit.git.createRef({
300
- owner,
301
- repo,
302
- ref: `refs/heads/${branchName}`,
303
- sha: baseSha
304
- });
305
-
306
- console.log(`✅ Created branch '${branchName}' on GitHub`);
307
- } catch (error) {
308
- if (error.status === 422 && error.message.includes('Reference already exists')) {
309
- console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`);
310
- } else {
311
- throw error;
312
- }
313
- }
314
- }
315
-
316
- /**
317
- * Create a pull request on GitHub
318
- * @param {Octokit} octokit - Octokit instance
319
- * @param {string} owner - Repository owner
320
- * @param {string} repo - Repository name
321
- * @param {string} branchName - Head branch name
322
- * @param {string} title - PR title
323
- * @param {string} body - PR body/description
324
- * @param {string} baseBranch - Base branch (default: 'main')
325
- * @returns {Promise<{number: number, url: string}>} - PR number and URL
326
- */
327
- async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
328
- const { data: pr } = await octokit.pulls.create({
329
- owner,
330
- repo,
331
- title,
332
- head: branchName,
333
- base: baseBranch,
334
- body
335
- });
336
-
337
- console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
338
-
339
- return {
340
- number: pr.number,
341
- url: pr.html_url
342
- };
343
- }
344
-
345
- /**
346
- * Clone a GitHub repository to a directory
347
- * @param {string} githubUrl - GitHub repository URL
348
- * @param {string} githubToken - Optional GitHub token for private repos
349
- * @param {string} projectPath - Path for cloning the repository
350
- * @returns {Promise<string>} - Path to the cloned repository
351
- */
352
- async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
353
- return new Promise(async (resolve, reject) => {
354
- try {
355
- // Validate GitHub URL
356
- if (!githubUrl || !githubUrl.includes('github.com')) {
357
- throw new Error('Invalid GitHub URL');
358
- }
359
-
360
- const cloneDir = path.resolve(projectPath);
361
-
362
- // Check if directory already exists
363
- try {
364
- await fs.access(cloneDir);
365
- // Directory exists - check if it's a git repo with the same URL
366
- try {
367
- const existingUrl = await getGitRemoteUrl(cloneDir);
368
- const normalizedExisting = normalizeGitHubUrl(existingUrl);
369
- const normalizedRequested = normalizeGitHubUrl(githubUrl);
370
-
371
- if (normalizedExisting === normalizedRequested) {
372
- console.log('✅ Repository already exists at path with correct URL');
373
- return resolve(cloneDir);
374
- } else {
375
- throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
376
- }
377
- } catch (gitError) {
378
- throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
379
- }
380
- } catch (accessError) {
381
- // Directory doesn't exist - proceed with clone
382
- }
383
-
384
- // Ensure parent directory exists
385
- await fs.mkdir(path.dirname(cloneDir), { recursive: true });
386
-
387
- // Prepare the git clone URL with authentication if token is provided
388
- let cloneUrl = githubUrl;
389
- if (githubToken) {
390
- // Convert HTTPS URL to authenticated URL
391
- // Example: https://github.com/user/repo -> https://token@github.com/user/repo
392
- cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
393
- }
394
-
395
- console.log('🔄 Cloning repository:', githubUrl);
396
- console.log('📁 Destination:', cloneDir);
397
-
398
- // Execute git clone
399
- const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
400
- stdio: ['pipe', 'pipe', 'pipe']
401
- });
402
-
403
- let stdout = '';
404
- let stderr = '';
405
-
406
- gitProcess.stdout.on('data', (data) => {
407
- stdout += data.toString();
408
- });
409
-
410
- gitProcess.stderr.on('data', (data) => {
411
- stderr += data.toString();
412
- console.log('Git stderr:', data.toString());
413
- });
414
-
415
- gitProcess.on('close', (code) => {
416
- if (code === 0) {
417
- console.log('✅ Repository cloned successfully');
418
- resolve(cloneDir);
419
- } else {
420
- console.error('❌ Git clone failed:', stderr);
421
- reject(new Error(`Git clone failed: ${stderr}`));
422
- }
423
- });
424
-
425
- gitProcess.on('error', (error) => {
426
- reject(new Error(`Failed to execute git: ${error.message}`));
427
- });
428
- } catch (error) {
429
- reject(error);
430
- }
431
- });
432
- }
433
-
434
- /**
435
- * Clean up a temporary project directory and its Claude session
436
- * @param {string} projectPath - Path to the project directory
437
- * @param {string} sessionId - Session ID to clean up
438
- */
439
- async function cleanupProject(projectPath, sessionId = null) {
440
- try {
441
- // Only clean up projects in the external-projects directory
442
- if (!projectPath.includes('.claude/external-projects')) {
443
- console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
444
- return;
445
- }
446
-
447
- console.log('🧹 Cleaning up project:', projectPath);
448
- await fs.rm(projectPath, { recursive: true, force: true });
449
- console.log('✅ Project cleaned up');
450
-
451
- // Also clean up the Claude session directory if sessionId provided
452
- if (sessionId) {
453
- try {
454
- const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
455
- console.log('🧹 Cleaning up session directory:', sessionPath);
456
- await fs.rm(sessionPath, { recursive: true, force: true });
457
- console.log('✅ Session directory cleaned up');
458
- } catch (error) {
459
- // session cleanup error
460
- }
461
- }
462
- } catch (error) {
463
- console.error('❌ Failed to clean up project:', error);
464
- }
465
- }
466
-
467
- /**
468
- * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
469
- */
470
- class SSEStreamWriter {
471
- constructor(res) {
472
- this.res = res;
473
- this.sessionId = null;
474
- this.isSSEStreamWriter = true; // Marker for transport detection
475
- }
476
-
477
- send(data) {
478
- if (this.res.writableEnded) {
479
- return;
480
- }
481
-
482
- // Format as SSE - providers send raw objects, we stringify
483
- this.res.write(`data: ${JSON.stringify(data)}\n\n`);
484
- }
485
-
486
- end() {
487
- if (!this.res.writableEnded) {
488
- this.res.write('data: {"type":"done"}\n\n');
489
- this.res.end();
490
- }
491
- }
492
-
493
- setSessionId(sessionId) {
494
- this.sessionId = sessionId;
495
- }
496
-
497
- getSessionId() {
498
- return this.sessionId;
499
- }
500
- }
501
-
502
- /**
503
- * Non-streaming response collector
504
- */
505
- class ResponseCollector {
506
- constructor() {
507
- this.messages = [];
508
- this.sessionId = null;
509
- }
510
-
511
- send(data) {
512
- // Store ALL messages for now - we'll filter when returning
513
- this.messages.push(data);
514
-
515
- // Extract sessionId if present
516
- if (typeof data === 'string') {
517
- try {
518
- const parsed = JSON.parse(data);
519
- if (parsed.sessionId) {
520
- this.sessionId = parsed.sessionId;
521
- }
522
- } catch (e) {
523
- // Not JSON, ignore
524
- }
525
- } else if (data && data.sessionId) {
526
- this.sessionId = data.sessionId;
527
- }
528
- }
529
-
530
- end() {
531
- // Do nothing - we'll collect all messages
532
- }
533
-
534
- setSessionId(sessionId) {
535
- this.sessionId = sessionId;
536
- }
537
-
538
- getSessionId() {
539
- return this.sessionId;
540
- }
541
-
542
- getMessages() {
543
- return this.messages;
544
- }
545
-
546
- /**
547
- * Get filtered assistant messages only
548
- */
549
- getAssistantMessages() {
550
- const assistantMessages = [];
551
-
552
- for (const msg of this.messages) {
553
- // Skip initial status message
554
- if (msg && msg.type === 'status') {
555
- continue;
556
- }
557
-
558
- // Handle JSON strings
559
- if (typeof msg === 'string') {
560
- try {
561
- const parsed = JSON.parse(msg);
562
- // Only include claude-response messages with assistant type
563
- if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
564
- assistantMessages.push(parsed.data);
565
- }
566
- } catch (e) {
567
- // Not JSON, skip
568
- }
569
- }
570
- }
571
-
572
- return assistantMessages;
573
- }
574
-
575
- /**
576
- * Calculate total tokens from all messages
577
- */
578
- getTotalTokens() {
579
- let totalInput = 0;
580
- let totalOutput = 0;
581
- let totalCacheRead = 0;
582
- let totalCacheCreation = 0;
583
-
584
- for (const msg of this.messages) {
585
- let data = msg;
586
-
587
- // Parse if string
588
- if (typeof msg === 'string') {
589
- try {
590
- data = JSON.parse(msg);
591
- } catch (e) {
592
- continue;
593
- }
594
- }
595
-
596
- // Extract usage from claude-response messages
597
- if (data && data.type === 'claude-response' && data.data) {
598
- const msgData = data.data;
599
- if (msgData.message && msgData.message.usage) {
600
- const usage = msgData.message.usage;
601
- totalInput += usage.input_tokens || 0;
602
- totalOutput += usage.output_tokens || 0;
603
- totalCacheRead += usage.cache_read_input_tokens || 0;
604
- totalCacheCreation += usage.cache_creation_input_tokens || 0;
605
- }
606
- }
607
- }
608
-
609
- return {
610
- inputTokens: totalInput,
611
- outputTokens: totalOutput,
612
- cacheReadTokens: totalCacheRead,
613
- cacheCreationTokens: totalCacheCreation,
614
- totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
615
- };
616
- }
617
- }
618
-
619
- // ===============================
620
- // External API Endpoint
621
- // ===============================
622
-
623
- /**
624
- * POST /api/agent
625
- *
626
- * Trigger an AI agent (Claude or Cursor) to work on a project.
627
- * Supports automatic GitHub branch and pull request creation after successful completion.
628
- *
629
- * ================================================================================================
630
- * REQUEST BODY PARAMETERS
631
- * ================================================================================================
632
- *
633
- * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
634
- * Supported formats:
635
- * - HTTPS: https://github.com/owner/repo
636
- * - HTTPS with .git: https://github.com/owner/repo.git
637
- * - SSH: git@github.com:owner/repo
638
- * - SSH with .git: git@github.com:owner/repo.git
639
- *
640
- * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
641
- * Behavior depends on usage:
642
- * - If used alone: Must point to existing project directory
643
- * - If used with githubUrl: Target location for cloning
644
- * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
645
- *
646
- * @param {string} message - (Required) Task description for the AI agent. Used as:
647
- * - Instructions for the agent
648
- * - Source for auto-generated branch names (if createBranch=true and no branchName)
649
- * - Fallback for PR title if no commits are made
650
- *
651
- * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
652
- * Default: 'claude'
653
- *
654
- * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
655
- * Default: true
656
- * - true: Returns text/event-stream with incremental updates
657
- * - false: Returns complete JSON response after completion
658
- *
659
- * @param {string} model - (Optional) Model identifier for providers.
660
- *
661
- * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
662
- * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
663
- * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
664
- * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
665
- * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
666
- * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
667
- *
668
- * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
669
- * Default: true
670
- * Behavior:
671
- * - Only applies when cloning via githubUrl (not for existing projectPath)
672
- * - Deletes cloned repository after 5 seconds
673
- * - Also deletes associated Claude session directory
674
- * - Remote branch and PR remain on GitHub if created
675
- *
676
- * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
677
- * Overrides stored token from user settings.
678
- * Required for:
679
- * - Private repositories
680
- * - Branch/PR creation features
681
- * Token must have 'repo' scope for full functionality.
682
- *
683
- * @param {string} branchName - (Optional) Custom name for the Git branch.
684
- * If provided, createBranch is automatically set to true.
685
- * Validation rules (errors returned if violated):
686
- * - Cannot be empty or whitespace only
687
- * - Cannot start or end with dot (.)
688
- * - Cannot contain consecutive dots (..)
689
- * - Cannot contain spaces
690
- * - Cannot contain special characters: ~ ^ : ? * [ \
691
- * - Cannot contain @{
692
- * - Cannot start or end with forward slash (/)
693
- * - Cannot contain consecutive slashes (//)
694
- * - Cannot end with .lock
695
- * - Cannot contain ASCII control characters
696
- * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
697
- *
698
- * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
699
- * Default: false (or true if branchName is provided)
700
- * Behavior:
701
- * - Creates branch locally and pushes to remote
702
- * - If branch exists locally: Checks out existing branch (no error)
703
- * - If branch exists on remote: Uses existing branch (no error)
704
- * - Branch name: Custom (if branchName provided) or auto-generated from message
705
- * - Requires either githubUrl OR projectPath with GitHub remote
706
- *
707
- * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
708
- * Default: false
709
- * Behavior:
710
- * - PR title: First commit message (or fallback to message parameter)
711
- * - PR description: Auto-generated from all commit messages
712
- * - Base branch: Always 'main' (currently hardcoded)
713
- * - If PR already exists: GitHub returns error with details
714
- * - Requires either githubUrl OR projectPath with GitHub remote
715
- *
716
- * ================================================================================================
717
- * PATH HANDLING BEHAVIOR
718
- * ================================================================================================
719
- *
720
- * Scenario 1: Only githubUrl provided
721
- * Input: { githubUrl: "https://github.com/owner/repo" }
722
- * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
723
- * Cleanup: Yes (if cleanup=true)
724
- *
725
- * Scenario 2: Only projectPath provided
726
- * Input: { projectPath: "/home/user/my-project" }
727
- * Action: Uses existing project at specified path
728
- * Validation: Path must exist and be accessible
729
- * Cleanup: No (never cleanup existing projects)
730
- *
731
- * Scenario 3: Both githubUrl and projectPath provided
732
- * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
733
- * Action: Clones githubUrl to projectPath location
734
- * Validation:
735
- * - If projectPath exists with git repo:
736
- * - Compares remote URL with githubUrl
737
- * - If URLs match: Reuses existing repo
738
- * - If URLs differ: Returns error
739
- * Cleanup: Yes (if cleanup=true)
740
- *
741
- * ================================================================================================
742
- * GITHUB BRANCH/PR CREATION REQUIREMENTS
743
- * ================================================================================================
744
- *
745
- * For createBranch or createPR to work, one of the following must be true:
746
- *
747
- * Option A: githubUrl provided
748
- * - Repository URL directly specified
749
- * - Works with both cloning and existing paths
750
- *
751
- * Option B: projectPath with GitHub remote
752
- * - Project must be a Git repository
753
- * - Must have 'origin' remote configured
754
- * - Remote URL must point to github.com
755
- * - System auto-detects GitHub URL via: git remote get-url origin
756
- *
757
- * Additional Requirements:
758
- * - Valid GitHub token (from settings or githubToken parameter)
759
- * - Token must have 'repo' scope for private repos
760
- * - Project must have commits (for PR creation)
761
- *
762
- * ================================================================================================
763
- * VALIDATION & ERROR HANDLING
764
- * ================================================================================================
765
- *
766
- * Input Validations (400 Bad Request):
767
- * - Either githubUrl OR projectPath must be provided (not neither)
768
- * - message must be non-empty string
769
- * - provider must be 'claude' or 'cursor'
770
- * - createBranch/createPR requires githubUrl OR projectPath (not neither)
771
- * - branchName must pass Git naming rules (if provided)
772
- *
773
- * Runtime Validations (500 Internal Server Error or specific error in response):
774
- * - projectPath must exist (if used alone)
775
- * - GitHub URL format must be valid
776
- * - Git remote URL must include github.com (for projectPath + branch/PR)
777
- * - GitHub token must be available (for private repos and branch/PR)
778
- * - Directory conflicts handled (existing path with different repo)
779
- *
780
- * Branch Name Validation Errors (returned in response, not HTTP error):
781
- * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
782
- * Examples:
783
- * - "my branch" "Branch name cannot contain spaces"
784
- * - ".feature" → "Branch name cannot start with a dot"
785
- * - "feature.lock" → "Branch name cannot end with .lock"
786
- *
787
- * ================================================================================================
788
- * RESPONSE FORMATS
789
- * ================================================================================================
790
- *
791
- * Streaming Response (stream=true):
792
- * Content-Type: text/event-stream
793
- * Events:
794
- * - { type: "status", message: "...", projectPath: "..." }
795
- * - { type: "claude-response", data: {...} }
796
- * - { type: "github-branch", branch: { name: "...", url: "..." } }
797
- * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
798
- * - { type: "github-error", error: "..." }
799
- * - { type: "done" }
800
- *
801
- * Non-Streaming Response (stream=false):
802
- * Content-Type: application/json
803
- * {
804
- * success: true,
805
- * sessionId: "session-123",
806
- * messages: [...], // Assistant messages only (filtered)
807
- * tokens: {
808
- * inputTokens: 150,
809
- * outputTokens: 50,
810
- * cacheReadTokens: 0,
811
- * cacheCreationTokens: 0,
812
- * totalTokens: 200
813
- * },
814
- * projectPath: "/path/to/project",
815
- * branch: { // Only if createBranch=true
816
- * name: "feature/xyz",
817
- * url: "https://github.com/owner/repo/tree/feature/xyz"
818
- * } | { error: "..." },
819
- * pullRequest: { // Only if createPR=true
820
- * number: 42,
821
- * url: "https://github.com/owner/repo/pull/42"
822
- * } | { error: "..." }
823
- * }
824
- *
825
- * Error Response:
826
- * HTTP Status: 400, 401, 500
827
- * Content-Type: application/json
828
- * { success: false, error: "Error description" }
829
- *
830
- * ================================================================================================
831
- * EXAMPLES
832
- * ================================================================================================
833
- *
834
- * Example 1: Clone and process with auto-cleanup
835
- * POST /api/agent
836
- * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
837
- *
838
- * Example 2: Use existing project with custom branch and PR
839
- * POST /api/agent
840
- * {
841
- * "projectPath": "/home/user/project",
842
- * "message": "Add feature",
843
- * "branchName": "feature/new-feature",
844
- * "createPR": true
845
- * }
846
- *
847
- * Example 3: Clone to specific path with auto-generated branch
848
- * POST /api/agent
849
- * {
850
- * "githubUrl": "https://github.com/user/repo",
851
- * "projectPath": "/tmp/work",
852
- * "message": "Refactor code",
853
- * "createBranch": true,
854
- * "cleanup": false
855
- * }
856
- */
857
- router.post('/', validateExternalApiKey, async (req, res) => {
858
- const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body;
859
-
860
- // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
861
- const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
862
- const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
863
-
864
- // If branchName is provided, automatically enable createBranch
865
- const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
866
- const createPR = req.body.createPR === true || req.body.createPR === 'true';
867
-
868
- // Validate inputs
869
- if (!githubUrl && !projectPath) {
870
- return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
871
- }
872
-
873
- if (!message || !message.trim()) {
874
- return res.status(400).json({ error: 'message is required' });
875
- }
876
-
877
- if (!['claude', 'cursor', 'codex'].includes(provider)) {
878
- return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
879
- }
880
-
881
- // Validate GitHub branch/PR creation requirements
882
- // Allow branch/PR creation with projectPath as long as it has a GitHub remote
883
- if ((createBranch || createPR) && !githubUrl && !projectPath) {
884
- return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
885
- }
886
-
887
- let finalProjectPath = null;
888
- let writer = null;
889
-
890
- try {
891
- // Determine the final project path
892
- if (githubUrl) {
893
- // Clone repository (to projectPath if provided, otherwise generate path)
894
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
895
-
896
- let targetPath;
897
- if (projectPath) {
898
- targetPath = projectPath;
899
- } else {
900
- // Generate a unique path for cloning
901
- const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
902
- targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
903
- }
904
-
905
- finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
906
- } else {
907
- // Use existing project path
908
- finalProjectPath = path.resolve(projectPath);
909
-
910
- // Verify the path exists
911
- try {
912
- await fs.access(finalProjectPath);
913
- } catch (error) {
914
- throw new Error(`Project path does not exist: ${finalProjectPath}`);
915
- }
916
- }
917
-
918
- // Register the project (or use existing registration)
919
- let project;
920
- try {
921
- project = await addProjectManually(finalProjectPath);
922
- console.log('📦 Project registered:', project);
923
- } catch (error) {
924
- // If project already exists, that's fine - continue with the existing registration
925
- if (error.message && error.message.includes('Project already configured')) {
926
- console.log('📦 Using existing project registration for:', finalProjectPath);
927
- project = { path: finalProjectPath };
928
- } else {
929
- throw error;
930
- }
931
- }
932
-
933
- // Set up writer based on streaming mode
934
- if (stream) {
935
- // Set up SSE headers for streaming
936
- res.setHeader('Content-Type', 'text/event-stream');
937
- res.setHeader('Cache-Control', 'no-cache');
938
- res.setHeader('Connection', 'keep-alive');
939
- res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
940
-
941
- writer = new SSEStreamWriter(res);
942
-
943
- // Send initial status
944
- writer.send({
945
- type: 'status',
946
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
947
- projectPath: finalProjectPath
948
- });
949
- } else {
950
- // Non-streaming mode: collect messages
951
- writer = new ResponseCollector();
952
-
953
- // Collect initial status message
954
- writer.send({
955
- type: 'status',
956
- message: githubUrl ? 'Repository cloned and session started' : 'Session started',
957
- projectPath: finalProjectPath
958
- });
959
- }
960
-
961
- // Start the appropriate session (with BYOK key injection where applicable)
962
- const userId = req.user?.id;
963
-
964
- if (provider === 'claude') {
965
- console.log('🤖 Starting Claude SDK session');
966
- const userKey = await getUserProviderKey(userId, 'anthropic_key');
967
-
968
- await withUserApiKey('ANTHROPIC_API_KEY', userKey, () =>
969
- queryClaudeSDK(message.trim(), {
970
- projectPath: finalProjectPath,
971
- cwd: finalProjectPath,
972
- sessionId: null,
973
- model: model,
974
- permissionMode: 'bypassPermissions'
975
- }, writer)
976
- );
977
-
978
- } else if (provider === 'cursor') {
979
- console.log('🖱️ Starting Cursor CLI session');
980
-
981
- await spawnCursor(message.trim(), {
982
- projectPath: finalProjectPath,
983
- cwd: finalProjectPath,
984
- sessionId: null,
985
- model: model || undefined,
986
- skipPermissions: true
987
- }, writer);
988
- } else if (provider === 'codex') {
989
- console.log('🤖 Starting Codex SDK session');
990
- const userKey = await getUserProviderKey(userId, 'openai_key');
991
-
992
- await withUserApiKey('OPENAI_API_KEY', userKey, () =>
993
- queryCodex(message.trim(), {
994
- projectPath: finalProjectPath,
995
- cwd: finalProjectPath,
996
- sessionId: null,
997
- model: model || CODEX_MODELS.DEFAULT,
998
- permissionMode: 'bypassPermissions'
999
- }, writer)
1000
- );
1001
- } else if (provider === 'openrouter') {
1002
- console.log('🌐 Starting OpenRouter session');
1003
- const userKey = await getUserProviderKey(userId, 'openrouter_key');
1004
-
1005
- await queryOpenRouter(message.trim(), {
1006
- model: model || OPENROUTER_MODELS.DEFAULT,
1007
- apiKey: userKey,
1008
- }, writer);
1009
- }
1010
-
1011
- // Handle GitHub branch and PR creation after successful agent completion
1012
- let branchInfo = null;
1013
- let prInfo = null;
1014
-
1015
- if (createBranch || createPR) {
1016
- try {
1017
- console.log('🔄 Starting GitHub branch/PR creation workflow...');
1018
-
1019
- // Get GitHub token
1020
- const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
1021
-
1022
- if (!tokenToUse) {
1023
- throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1024
- }
1025
-
1026
- // Initialize Octokit
1027
- const octokit = new Octokit({ auth: tokenToUse });
1028
-
1029
- // Get GitHub URL - either from parameter or from git remote
1030
- let repoUrl = githubUrl;
1031
- if (!repoUrl) {
1032
- console.log('🔍 Getting GitHub URL from git remote...');
1033
- try {
1034
- repoUrl = await getGitRemoteUrl(finalProjectPath);
1035
- if (!repoUrl.includes('github.com')) {
1036
- throw new Error('Project does not have a GitHub remote configured');
1037
- }
1038
- console.log(`✅ Found GitHub remote: ${repoUrl}`);
1039
- } catch (error) {
1040
- throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1041
- }
1042
- }
1043
-
1044
- // Parse GitHub URL to get owner and repo
1045
- const { owner, repo } = parseGitHubUrl(repoUrl);
1046
- console.log(`📦 Repository: ${owner}/${repo}`);
1047
-
1048
- // Use provided branch name or auto-generate from message
1049
- const finalBranchName = branchName || autogenerateBranchName(message);
1050
- if (branchName) {
1051
- console.log(`🌿 Using provided branch name: ${finalBranchName}`);
1052
-
1053
- // Validate custom branch name
1054
- const validation = validateBranchName(finalBranchName);
1055
- if (!validation.valid) {
1056
- throw new Error(`Invalid branch name: ${validation.error}`);
1057
- }
1058
- } else {
1059
- console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
1060
- }
1061
-
1062
- if (createBranch) {
1063
- // Create and checkout the new branch locally
1064
- console.log('🔄 Creating local branch...');
1065
- const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1066
- cwd: finalProjectPath,
1067
- stdio: 'pipe'
1068
- });
1069
-
1070
- await new Promise((resolve, reject) => {
1071
- let stderr = '';
1072
- checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1073
- checkoutProcess.on('close', (code) => {
1074
- if (code === 0) {
1075
- console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
1076
- resolve();
1077
- } else {
1078
- // Branch might already exist locally, try to checkout
1079
- if (stderr.includes('already exists')) {
1080
- console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);
1081
- const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1082
- cwd: finalProjectPath,
1083
- stdio: 'pipe'
1084
- });
1085
- checkoutExisting.on('close', (checkoutCode) => {
1086
- if (checkoutCode === 0) {
1087
- console.log(`✅ Checked out existing branch '${finalBranchName}'`);
1088
- resolve();
1089
- } else {
1090
- reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1091
- }
1092
- });
1093
- } else {
1094
- reject(new Error(`Failed to create branch: ${stderr}`));
1095
- }
1096
- }
1097
- });
1098
- });
1099
-
1100
- // Push the branch to remote
1101
- console.log('🔄 Pushing branch to remote...');
1102
- const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1103
- cwd: finalProjectPath,
1104
- stdio: 'pipe'
1105
- });
1106
-
1107
- await new Promise((resolve, reject) => {
1108
- let stderr = '';
1109
- let stdout = '';
1110
- pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1111
- pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1112
- pushProcess.on('close', (code) => {
1113
- if (code === 0) {
1114
- console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
1115
- resolve();
1116
- } else {
1117
- // Check if branch exists on remote but has different commits
1118
- if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1119
- console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);
1120
- resolve();
1121
- } else {
1122
- reject(new Error(`Failed to push branch: ${stderr}`));
1123
- }
1124
- }
1125
- });
1126
- });
1127
-
1128
- branchInfo = {
1129
- name: finalBranchName,
1130
- url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1131
- };
1132
- }
1133
-
1134
- if (createPR) {
1135
- // Get commit messages to generate PR description
1136
- console.log('🔄 Generating PR title and description...');
1137
- const commitMessages = await getCommitMessages(finalProjectPath, 5);
1138
-
1139
- // Use the first commit message as the PR title, or fallback to the agent message
1140
- const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1141
-
1142
- // Generate PR body from commit messages
1143
- let prBody = '## Changes\n\n';
1144
- if (commitMessages.length > 0) {
1145
- prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1146
- } else {
1147
- prBody += `Agent task: ${message}`;
1148
- }
1149
- prBody += '\n\n---\n*This pull request was automatically created by Upfyn-Code Agent.*';
1150
-
1151
- console.log(`📝 PR Title: ${prTitle}`);
1152
-
1153
- // Create the pull request
1154
- console.log('🔄 Creating pull request...');
1155
- prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1156
- }
1157
-
1158
- // Send branch/PR info in response
1159
- if (stream) {
1160
- if (branchInfo) {
1161
- writer.send({
1162
- type: 'github-branch',
1163
- branch: branchInfo
1164
- });
1165
- }
1166
- if (prInfo) {
1167
- writer.send({
1168
- type: 'github-pr',
1169
- pullRequest: prInfo
1170
- });
1171
- }
1172
- }
1173
-
1174
- } catch (error) {
1175
- console.error('❌ GitHub branch/PR creation error:', error);
1176
-
1177
- // Send error but don't fail the entire request
1178
- if (stream) {
1179
- writer.send({
1180
- type: 'github-error',
1181
- error: error.message
1182
- });
1183
- }
1184
- // Store error info for non-streaming response
1185
- if (!stream) {
1186
- branchInfo = { error: error.message };
1187
- prInfo = { error: error.message };
1188
- }
1189
- }
1190
- }
1191
-
1192
- // Handle response based on streaming mode
1193
- if (stream) {
1194
- // Streaming mode: end the SSE stream
1195
- writer.end();
1196
- } else {
1197
- // Non-streaming mode: send filtered messages and token summary as JSON
1198
- const assistantMessages = writer.getAssistantMessages();
1199
- const tokenSummary = writer.getTotalTokens();
1200
-
1201
- const response = {
1202
- success: true,
1203
- sessionId: writer.getSessionId(),
1204
- messages: assistantMessages,
1205
- tokens: tokenSummary,
1206
- projectPath: finalProjectPath
1207
- };
1208
-
1209
- // Add branch/PR info if created
1210
- if (branchInfo) {
1211
- response.branch = branchInfo;
1212
- }
1213
- if (prInfo) {
1214
- response.pullRequest = prInfo;
1215
- }
1216
-
1217
- res.json(response);
1218
- }
1219
-
1220
- // Clean up if requested
1221
- if (cleanup && githubUrl) {
1222
- // Only cleanup if we cloned a repo (not for existing project paths)
1223
- const sessionIdForCleanup = writer.getSessionId();
1224
- setTimeout(() => {
1225
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1226
- }, 5000);
1227
- }
1228
-
1229
- } catch (error) {
1230
- console.error('❌ External session error:', error);
1231
-
1232
- // Clean up on error
1233
- if (finalProjectPath && cleanup && githubUrl) {
1234
- const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1235
- cleanupProject(finalProjectPath, sessionIdForCleanup);
1236
- }
1237
-
1238
- if (stream) {
1239
- // For streaming, send error event and stop
1240
- if (!writer) {
1241
- // Set up SSE headers if not already done
1242
- res.setHeader('Content-Type', 'text/event-stream');
1243
- res.setHeader('Cache-Control', 'no-cache');
1244
- res.setHeader('Connection', 'keep-alive');
1245
- res.setHeader('X-Accel-Buffering', 'no');
1246
- writer = new SSEStreamWriter(res);
1247
- }
1248
-
1249
- if (!res.writableEnded) {
1250
- writer.send({
1251
- type: 'error',
1252
- error: error.message,
1253
- message: `Failed: ${error.message}`
1254
- });
1255
- writer.end();
1256
- }
1257
- } else if (!res.headersSent) {
1258
- res.status(500).json({
1259
- success: false,
1260
- error: error.message
1261
- });
1262
- }
1263
- }
1264
- });
1265
-
1266
- export default router;
1
+ import express from 'express';
2
+ import { spawn } from 'child_process';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { promises as fs } from 'fs';
6
+ import crypto from 'crypto';
7
+ import { userDb, apiKeysDb, githubTokensDb, credentialsDb } from '../database/db.js';
8
+ import { addProjectManually } from '../projects.js';
9
+ import { queryClaudeSDK } from '../claude-sdk.js';
10
+ import { spawnCursor } from '../cursor-cli.js';
11
+ import { queryCodex } from '../openai-codex.js';
12
+ import { Octokit } from '@octokit/rest';
13
+ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
14
+ import { queryOpenRouter, OPENROUTER_MODELS } from '../openrouter.js';
15
+ import { IS_PLATFORM } from '../constants/config.js';
16
+
17
+ // BYOK helper: get user's stored API key for a provider
18
+ async function getUserProviderKey(userId, providerType) {
19
+ if (!userId) return null;
20
+ try {
21
+ const creds = await credentialsDb.getCredentials(userId, providerType);
22
+ const active = creds.find(c => c.is_active);
23
+ return active?.credential_value || null;
24
+ } catch { return null; }
25
+ }
26
+
27
+ async function withUserApiKey(envKey, userKey, fn) {
28
+ if (!userKey) return fn();
29
+ const prev = process.env[envKey];
30
+ process.env[envKey] = userKey;
31
+ try { return await fn(); }
32
+ finally { if (prev !== undefined) process.env[envKey] = prev; else delete process.env[envKey]; }
33
+ }
34
+
35
+ const router = express.Router();
36
+
37
+ /**
38
+ * Middleware to authenticate agent API requests.
39
+ *
40
+ * Supports two authentication modes:
41
+ * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
42
+ * authentication is handled by an external proxy. Requests are trusted and
43
+ * the default user context is used.
44
+ *
45
+ * 2. API key mode (default): For self-hosted deployments where users authenticate
46
+ * via API keys created in the UI. Keys are validated against the local database.
47
+ */
48
+ const validateExternalApiKey = async (req, res, next) => {
49
+ // Platform mode: Authentication is handled externally (e.g., by a proxy layer).
50
+ // Trust the request and use the default user context.
51
+ if (IS_PLATFORM) {
52
+ try {
53
+ const user = await userDb.getFirstUser();
54
+ if (!user) {
55
+ return res.status(500).json({ error: 'Platform mode: No user found in database' });
56
+ }
57
+ req.user = user;
58
+ return next();
59
+ } catch (error) {
60
+ return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
61
+ }
62
+ }
63
+
64
+ // Self-hosted mode: Validate API key from header or query parameter
65
+ const apiKey = req.headers['x-api-key'] || req.query.apiKey;
66
+
67
+ if (!apiKey) {
68
+ return res.status(401).json({ error: 'API key required' });
69
+ }
70
+
71
+ const user = await apiKeysDb.validateApiKey(apiKey);
72
+
73
+ if (!user) {
74
+ return res.status(401).json({ error: 'Invalid or inactive API key' });
75
+ }
76
+
77
+ req.user = user;
78
+ next();
79
+ };
80
+
81
+ /**
82
+ * Get the remote URL of a git repository
83
+ * @param {string} repoPath - Path to the git repository
84
+ * @returns {Promise<string>} - Remote URL of the repository
85
+ */
86
+ async function getGitRemoteUrl(repoPath) {
87
+ return new Promise((resolve, reject) => {
88
+ const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
89
+ cwd: repoPath,
90
+ stdio: ['pipe', 'pipe', 'pipe']
91
+ });
92
+
93
+ let stdout = '';
94
+ let stderr = '';
95
+
96
+ gitProcess.stdout.on('data', (data) => {
97
+ stdout += data.toString();
98
+ });
99
+
100
+ gitProcess.stderr.on('data', (data) => {
101
+ stderr += data.toString();
102
+ });
103
+
104
+ gitProcess.on('close', (code) => {
105
+ if (code === 0) {
106
+ resolve(stdout.trim());
107
+ } else {
108
+ reject(new Error(`Failed to get git remote: ${stderr}`));
109
+ }
110
+ });
111
+
112
+ gitProcess.on('error', (error) => {
113
+ reject(new Error(`Failed to execute git: ${error.message}`));
114
+ });
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Normalize GitHub URLs for comparison
120
+ * @param {string} url - GitHub URL
121
+ * @returns {string} - Normalized URL
122
+ */
123
+ function normalizeGitHubUrl(url) {
124
+ // Remove .git suffix
125
+ let normalized = url.replace(/\.git$/, '');
126
+ // Convert SSH to HTTPS format for comparison
127
+ normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
128
+ // Remove trailing slash
129
+ normalized = normalized.replace(/\/$/, '');
130
+ return normalized.toLowerCase();
131
+ }
132
+
133
+ /**
134
+ * Parse GitHub URL to extract owner and repo
135
+ * @param {string} url - GitHub URL (HTTPS or SSH)
136
+ * @returns {{owner: string, repo: string}} - Parsed owner and repo
137
+ */
138
+ function parseGitHubUrl(url) {
139
+ // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
140
+ // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
141
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
142
+ if (!match) {
143
+ throw new Error('Invalid GitHub URL format');
144
+ }
145
+ return {
146
+ owner: match[1],
147
+ repo: match[2].replace(/\.git$/, '')
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Auto-generate a branch name from a message
153
+ * @param {string} message - The agent message
154
+ * @returns {string} - Generated branch name
155
+ */
156
+ function autogenerateBranchName(message) {
157
+ // Convert to lowercase, replace spaces/special chars with hyphens
158
+ let branchName = message
159
+ .toLowerCase()
160
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
161
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
162
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
163
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
164
+
165
+ // Ensure non-empty fallback
166
+ if (!branchName) {
167
+ branchName = 'task';
168
+ }
169
+
170
+ // Generate timestamp suffix (last 6 chars of base36 timestamp)
171
+ const timestamp = Date.now().toString(36).slice(-6);
172
+ const suffix = `-${timestamp}`;
173
+
174
+ // Limit length to ensure total length including suffix fits within 50 characters
175
+ const maxBaseLength = 50 - suffix.length;
176
+ if (branchName.length > maxBaseLength) {
177
+ branchName = branchName.substring(0, maxBaseLength);
178
+ }
179
+
180
+ // Remove any trailing hyphen after truncation and ensure no leading hyphen
181
+ branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
182
+
183
+ // If still empty or starts with hyphen after cleanup, use fallback
184
+ if (!branchName || branchName.startsWith('-')) {
185
+ branchName = 'task';
186
+ }
187
+
188
+ // Combine base name with timestamp suffix
189
+ branchName = `${branchName}${suffix}`;
190
+
191
+ // Final validation: ensure it matches safe pattern
192
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
193
+ // Fallback to deterministic safe name
194
+ return `branch-${timestamp}`;
195
+ }
196
+
197
+ return branchName;
198
+ }
199
+
200
+ /**
201
+ * Validate a Git branch name
202
+ * @param {string} branchName - Branch name to validate
203
+ * @returns {{valid: boolean, error?: string}} - Validation result
204
+ */
205
+ function validateBranchName(branchName) {
206
+ if (!branchName || branchName.trim() === '') {
207
+ return { valid: false, error: 'Branch name cannot be empty' };
208
+ }
209
+
210
+ // Git branch name rules
211
+ const invalidPatterns = [
212
+ { pattern: /^\./, message: 'Branch name cannot start with a dot' },
213
+ { pattern: /\.$/, message: 'Branch name cannot end with a dot' },
214
+ { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
215
+ { pattern: /\s/, message: 'Branch name cannot contain spaces' },
216
+ { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
217
+ { pattern: /@{/, message: 'Branch name cannot contain @{' },
218
+ { pattern: /\/$/, message: 'Branch name cannot end with a slash' },
219
+ { pattern: /^\//, message: 'Branch name cannot start with a slash' },
220
+ { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
221
+ { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
222
+ ];
223
+
224
+ for (const { pattern, message } of invalidPatterns) {
225
+ if (pattern.test(branchName)) {
226
+ return { valid: false, error: message };
227
+ }
228
+ }
229
+
230
+ // Check for ASCII control characters
231
+ if (/[\x00-\x1F\x7F]/.test(branchName)) {
232
+ return { valid: false, error: 'Branch name cannot contain control characters' };
233
+ }
234
+
235
+ return { valid: true };
236
+ }
237
+
238
+ /**
239
+ * Get recent commit messages from a repository
240
+ * @param {string} projectPath - Path to the git repository
241
+ * @param {number} limit - Number of commits to retrieve (default: 5)
242
+ * @returns {Promise<string[]>} - Array of commit messages
243
+ */
244
+ async function getCommitMessages(projectPath, limit = 5) {
245
+ return new Promise((resolve, reject) => {
246
+ const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
247
+ cwd: projectPath,
248
+ stdio: ['pipe', 'pipe', 'pipe']
249
+ });
250
+
251
+ let stdout = '';
252
+ let stderr = '';
253
+
254
+ gitProcess.stdout.on('data', (data) => {
255
+ stdout += data.toString();
256
+ });
257
+
258
+ gitProcess.stderr.on('data', (data) => {
259
+ stderr += data.toString();
260
+ });
261
+
262
+ gitProcess.on('close', (code) => {
263
+ if (code === 0) {
264
+ const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
265
+ resolve(messages);
266
+ } else {
267
+ reject(new Error(`Failed to get commit messages: ${stderr}`));
268
+ }
269
+ });
270
+
271
+ gitProcess.on('error', (error) => {
272
+ reject(new Error(`Failed to execute git: ${error.message}`));
273
+ });
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Create a new branch on GitHub using the API
279
+ * @param {Octokit} octokit - Octokit instance
280
+ * @param {string} owner - Repository owner
281
+ * @param {string} repo - Repository name
282
+ * @param {string} branchName - Name of the new branch
283
+ * @param {string} baseBranch - Base branch to branch from (default: 'main')
284
+ * @returns {Promise<void>}
285
+ */
286
+ async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
287
+ try {
288
+ // Get the SHA of the base branch
289
+ const { data: ref } = await octokit.git.getRef({
290
+ owner,
291
+ repo,
292
+ ref: `heads/${baseBranch}`
293
+ });
294
+
295
+ const baseSha = ref.object.sha;
296
+
297
+ // Create the new branch
298
+ await octokit.git.createRef({
299
+ owner,
300
+ repo,
301
+ ref: `refs/heads/${branchName}`,
302
+ sha: baseSha
303
+ });
304
+
305
+ } catch (error) {
306
+ if (error.status === 422 && error.message.includes('Reference already exists')) {
307
+ } else {
308
+ throw error;
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Create a pull request on GitHub
315
+ * @param {Octokit} octokit - Octokit instance
316
+ * @param {string} owner - Repository owner
317
+ * @param {string} repo - Repository name
318
+ * @param {string} branchName - Head branch name
319
+ * @param {string} title - PR title
320
+ * @param {string} body - PR body/description
321
+ * @param {string} baseBranch - Base branch (default: 'main')
322
+ * @returns {Promise<{number: number, url: string}>} - PR number and URL
323
+ */
324
+ async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
325
+ const { data: pr } = await octokit.pulls.create({
326
+ owner,
327
+ repo,
328
+ title,
329
+ head: branchName,
330
+ base: baseBranch,
331
+ body
332
+ });
333
+
334
+
335
+ return {
336
+ number: pr.number,
337
+ url: pr.html_url
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Clone a GitHub repository to a directory
343
+ * @param {string} githubUrl - GitHub repository URL
344
+ * @param {string} githubToken - Optional GitHub token for private repos
345
+ * @param {string} projectPath - Path for cloning the repository
346
+ * @returns {Promise<string>} - Path to the cloned repository
347
+ */
348
+ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
349
+ return new Promise(async (resolve, reject) => {
350
+ try {
351
+ // Validate GitHub URL
352
+ if (!githubUrl || !githubUrl.includes('github.com')) {
353
+ throw new Error('Invalid GitHub URL');
354
+ }
355
+
356
+ const cloneDir = path.resolve(projectPath);
357
+
358
+ // Check if directory already exists
359
+ try {
360
+ await fs.access(cloneDir);
361
+ // Directory exists - check if it's a git repo with the same URL
362
+ try {
363
+ const existingUrl = await getGitRemoteUrl(cloneDir);
364
+ const normalizedExisting = normalizeGitHubUrl(existingUrl);
365
+ const normalizedRequested = normalizeGitHubUrl(githubUrl);
366
+
367
+ if (normalizedExisting === normalizedRequested) {
368
+ return resolve(cloneDir);
369
+ } else {
370
+ throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
371
+ }
372
+ } catch (gitError) {
373
+ throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
374
+ }
375
+ } catch (accessError) {
376
+ // Directory doesn't exist - proceed with clone
377
+ }
378
+
379
+ // Ensure parent directory exists
380
+ await fs.mkdir(path.dirname(cloneDir), { recursive: true });
381
+
382
+ // Prepare the git clone URL with authentication if token is provided
383
+ let cloneUrl = githubUrl;
384
+ if (githubToken) {
385
+ // Convert HTTPS URL to authenticated URL
386
+ // Example: https://github.com/user/repo -> https://token@github.com/user/repo
387
+ cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
388
+ }
389
+
390
+
391
+ // Execute git clone
392
+ const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
393
+ stdio: ['pipe', 'pipe', 'pipe']
394
+ });
395
+
396
+ let stdout = '';
397
+ let stderr = '';
398
+
399
+ gitProcess.stdout.on('data', (data) => {
400
+ stdout += data.toString();
401
+ });
402
+
403
+ gitProcess.stderr.on('data', (data) => {
404
+ stderr += data.toString();
405
+ });
406
+
407
+ gitProcess.on('close', (code) => {
408
+ if (code === 0) {
409
+ resolve(cloneDir);
410
+ } else {
411
+ reject(new Error(`Git clone failed: ${stderr}`));
412
+ }
413
+ });
414
+
415
+ gitProcess.on('error', (error) => {
416
+ reject(new Error(`Failed to execute git: ${error.message}`));
417
+ });
418
+ } catch (error) {
419
+ reject(error);
420
+ }
421
+ });
422
+ }
423
+
424
+ /**
425
+ * Clean up a temporary project directory and its Claude session
426
+ * @param {string} projectPath - Path to the project directory
427
+ * @param {string} sessionId - Session ID to clean up
428
+ */
429
+ async function cleanupProject(projectPath, sessionId = null) {
430
+ try {
431
+ // Only clean up projects in the external-projects directory
432
+ if (!projectPath.includes('.claude/external-projects')) {
433
+ return;
434
+ }
435
+
436
+ await fs.rm(projectPath, { recursive: true, force: true });
437
+
438
+ // Also clean up the Claude session directory if sessionId provided
439
+ if (sessionId) {
440
+ try {
441
+ const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
442
+ await fs.rm(sessionPath, { recursive: true, force: true });
443
+ } catch (error) {
444
+ // session cleanup error
445
+ }
446
+ }
447
+ } catch (error) {
448
+ }
449
+ }
450
+
451
+ /**
452
+ * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
453
+ */
454
+ class SSEStreamWriter {
455
+ constructor(res) {
456
+ this.res = res;
457
+ this.sessionId = null;
458
+ this.isSSEStreamWriter = true; // Marker for transport detection
459
+ }
460
+
461
+ send(data) {
462
+ if (this.res.writableEnded) {
463
+ return;
464
+ }
465
+
466
+ // Format as SSE - providers send raw objects, we stringify
467
+ this.res.write(`data: ${JSON.stringify(data)}\n\n`);
468
+ }
469
+
470
+ end() {
471
+ if (!this.res.writableEnded) {
472
+ this.res.write('data: {"type":"done"}\n\n');
473
+ this.res.end();
474
+ }
475
+ }
476
+
477
+ setSessionId(sessionId) {
478
+ this.sessionId = sessionId;
479
+ }
480
+
481
+ getSessionId() {
482
+ return this.sessionId;
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Non-streaming response collector
488
+ */
489
+ class ResponseCollector {
490
+ constructor() {
491
+ this.messages = [];
492
+ this.sessionId = null;
493
+ }
494
+
495
+ send(data) {
496
+ // Store ALL messages for now - we'll filter when returning
497
+ this.messages.push(data);
498
+
499
+ // Extract sessionId if present
500
+ if (typeof data === 'string') {
501
+ try {
502
+ const parsed = JSON.parse(data);
503
+ if (parsed.sessionId) {
504
+ this.sessionId = parsed.sessionId;
505
+ }
506
+ } catch (e) {
507
+ // Not JSON, ignore
508
+ }
509
+ } else if (data && data.sessionId) {
510
+ this.sessionId = data.sessionId;
511
+ }
512
+ }
513
+
514
+ end() {
515
+ // Do nothing - we'll collect all messages
516
+ }
517
+
518
+ setSessionId(sessionId) {
519
+ this.sessionId = sessionId;
520
+ }
521
+
522
+ getSessionId() {
523
+ return this.sessionId;
524
+ }
525
+
526
+ getMessages() {
527
+ return this.messages;
528
+ }
529
+
530
+ /**
531
+ * Get filtered assistant messages only
532
+ */
533
+ getAssistantMessages() {
534
+ const assistantMessages = [];
535
+
536
+ for (const msg of this.messages) {
537
+ // Skip initial status message
538
+ if (msg && msg.type === 'status') {
539
+ continue;
540
+ }
541
+
542
+ // Handle JSON strings
543
+ if (typeof msg === 'string') {
544
+ try {
545
+ const parsed = JSON.parse(msg);
546
+ // Only include claude-response messages with assistant type
547
+ if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
548
+ assistantMessages.push(parsed.data);
549
+ }
550
+ } catch (e) {
551
+ // Not JSON, skip
552
+ }
553
+ }
554
+ }
555
+
556
+ return assistantMessages;
557
+ }
558
+
559
+ /**
560
+ * Calculate total tokens from all messages
561
+ */
562
+ getTotalTokens() {
563
+ let totalInput = 0;
564
+ let totalOutput = 0;
565
+ let totalCacheRead = 0;
566
+ let totalCacheCreation = 0;
567
+
568
+ for (const msg of this.messages) {
569
+ let data = msg;
570
+
571
+ // Parse if string
572
+ if (typeof msg === 'string') {
573
+ try {
574
+ data = JSON.parse(msg);
575
+ } catch (e) {
576
+ continue;
577
+ }
578
+ }
579
+
580
+ // Extract usage from claude-response messages
581
+ if (data && data.type === 'claude-response' && data.data) {
582
+ const msgData = data.data;
583
+ if (msgData.message && msgData.message.usage) {
584
+ const usage = msgData.message.usage;
585
+ totalInput += usage.input_tokens || 0;
586
+ totalOutput += usage.output_tokens || 0;
587
+ totalCacheRead += usage.cache_read_input_tokens || 0;
588
+ totalCacheCreation += usage.cache_creation_input_tokens || 0;
589
+ }
590
+ }
591
+ }
592
+
593
+ return {
594
+ inputTokens: totalInput,
595
+ outputTokens: totalOutput,
596
+ cacheReadTokens: totalCacheRead,
597
+ cacheCreationTokens: totalCacheCreation,
598
+ totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
599
+ };
600
+ }
601
+ }
602
+
603
+ // ===============================
604
+ // External API Endpoint
605
+ // ===============================
606
+
607
+ /**
608
+ * POST /api/agent
609
+ *
610
+ * Trigger an AI agent (Claude or Cursor) to work on a project.
611
+ * Supports automatic GitHub branch and pull request creation after successful completion.
612
+ *
613
+ * ================================================================================================
614
+ * REQUEST BODY PARAMETERS
615
+ * ================================================================================================
616
+ *
617
+ * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
618
+ * Supported formats:
619
+ * - HTTPS: https://github.com/owner/repo
620
+ * - HTTPS with .git: https://github.com/owner/repo.git
621
+ * - SSH: git@github.com:owner/repo
622
+ * - SSH with .git: git@github.com:owner/repo.git
623
+ *
624
+ * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
625
+ * Behavior depends on usage:
626
+ * - If used alone: Must point to existing project directory
627
+ * - If used with githubUrl: Target location for cloning
628
+ * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
629
+ *
630
+ * @param {string} message - (Required) Task description for the AI agent. Used as:
631
+ * - Instructions for the agent
632
+ * - Source for auto-generated branch names (if createBranch=true and no branchName)
633
+ * - Fallback for PR title if no commits are made
634
+ *
635
+ * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
636
+ * Default: 'claude'
637
+ *
638
+ * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
639
+ * Default: true
640
+ * - true: Returns text/event-stream with incremental updates
641
+ * - false: Returns complete JSON response after completion
642
+ *
643
+ * @param {string} model - (Optional) Model identifier for providers.
644
+ *
645
+ * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
646
+ * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
647
+ * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
648
+ * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
649
+ * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants
650
+ * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'
651
+ *
652
+ * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
653
+ * Default: true
654
+ * Behavior:
655
+ * - Only applies when cloning via githubUrl (not for existing projectPath)
656
+ * - Deletes cloned repository after 5 seconds
657
+ * - Also deletes associated Claude session directory
658
+ * - Remote branch and PR remain on GitHub if created
659
+ *
660
+ * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
661
+ * Overrides stored token from user settings.
662
+ * Required for:
663
+ * - Private repositories
664
+ * - Branch/PR creation features
665
+ * Token must have 'repo' scope for full functionality.
666
+ *
667
+ * @param {string} branchName - (Optional) Custom name for the Git branch.
668
+ * If provided, createBranch is automatically set to true.
669
+ * Validation rules (errors returned if violated):
670
+ * - Cannot be empty or whitespace only
671
+ * - Cannot start or end with dot (.)
672
+ * - Cannot contain consecutive dots (..)
673
+ * - Cannot contain spaces
674
+ * - Cannot contain special characters: ~ ^ : ? * [ \
675
+ * - Cannot contain @{
676
+ * - Cannot start or end with forward slash (/)
677
+ * - Cannot contain consecutive slashes (//)
678
+ * - Cannot end with .lock
679
+ * - Cannot contain ASCII control characters
680
+ * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
681
+ *
682
+ * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
683
+ * Default: false (or true if branchName is provided)
684
+ * Behavior:
685
+ * - Creates branch locally and pushes to remote
686
+ * - If branch exists locally: Checks out existing branch (no error)
687
+ * - If branch exists on remote: Uses existing branch (no error)
688
+ * - Branch name: Custom (if branchName provided) or auto-generated from message
689
+ * - Requires either githubUrl OR projectPath with GitHub remote
690
+ *
691
+ * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
692
+ * Default: false
693
+ * Behavior:
694
+ * - PR title: First commit message (or fallback to message parameter)
695
+ * - PR description: Auto-generated from all commit messages
696
+ * - Base branch: Always 'main' (currently hardcoded)
697
+ * - If PR already exists: GitHub returns error with details
698
+ * - Requires either githubUrl OR projectPath with GitHub remote
699
+ *
700
+ * ================================================================================================
701
+ * PATH HANDLING BEHAVIOR
702
+ * ================================================================================================
703
+ *
704
+ * Scenario 1: Only githubUrl provided
705
+ * Input: { githubUrl: "https://github.com/owner/repo" }
706
+ * Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
707
+ * Cleanup: Yes (if cleanup=true)
708
+ *
709
+ * Scenario 2: Only projectPath provided
710
+ * Input: { projectPath: "/home/user/my-project" }
711
+ * Action: Uses existing project at specified path
712
+ * Validation: Path must exist and be accessible
713
+ * Cleanup: No (never cleanup existing projects)
714
+ *
715
+ * Scenario 3: Both githubUrl and projectPath provided
716
+ * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
717
+ * Action: Clones githubUrl to projectPath location
718
+ * Validation:
719
+ * - If projectPath exists with git repo:
720
+ * - Compares remote URL with githubUrl
721
+ * - If URLs match: Reuses existing repo
722
+ * - If URLs differ: Returns error
723
+ * Cleanup: Yes (if cleanup=true)
724
+ *
725
+ * ================================================================================================
726
+ * GITHUB BRANCH/PR CREATION REQUIREMENTS
727
+ * ================================================================================================
728
+ *
729
+ * For createBranch or createPR to work, one of the following must be true:
730
+ *
731
+ * Option A: githubUrl provided
732
+ * - Repository URL directly specified
733
+ * - Works with both cloning and existing paths
734
+ *
735
+ * Option B: projectPath with GitHub remote
736
+ * - Project must be a Git repository
737
+ * - Must have 'origin' remote configured
738
+ * - Remote URL must point to github.com
739
+ * - System auto-detects GitHub URL via: git remote get-url origin
740
+ *
741
+ * Additional Requirements:
742
+ * - Valid GitHub token (from settings or githubToken parameter)
743
+ * - Token must have 'repo' scope for private repos
744
+ * - Project must have commits (for PR creation)
745
+ *
746
+ * ================================================================================================
747
+ * VALIDATION & ERROR HANDLING
748
+ * ================================================================================================
749
+ *
750
+ * Input Validations (400 Bad Request):
751
+ * - Either githubUrl OR projectPath must be provided (not neither)
752
+ * - message must be non-empty string
753
+ * - provider must be 'claude' or 'cursor'
754
+ * - createBranch/createPR requires githubUrl OR projectPath (not neither)
755
+ * - branchName must pass Git naming rules (if provided)
756
+ *
757
+ * Runtime Validations (500 Internal Server Error or specific error in response):
758
+ * - projectPath must exist (if used alone)
759
+ * - GitHub URL format must be valid
760
+ * - Git remote URL must include github.com (for projectPath + branch/PR)
761
+ * - GitHub token must be available (for private repos and branch/PR)
762
+ * - Directory conflicts handled (existing path with different repo)
763
+ *
764
+ * Branch Name Validation Errors (returned in response, not HTTP error):
765
+ * Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
766
+ * Examples:
767
+ * - "my branch" "Branch name cannot contain spaces"
768
+ * - ".feature" "Branch name cannot start with a dot"
769
+ * - "feature.lock" "Branch name cannot end with .lock"
770
+ *
771
+ * ================================================================================================
772
+ * RESPONSE FORMATS
773
+ * ================================================================================================
774
+ *
775
+ * Streaming Response (stream=true):
776
+ * Content-Type: text/event-stream
777
+ * Events:
778
+ * - { type: "status", message: "...", projectPath: "..." }
779
+ * - { type: "claude-response", data: {...} }
780
+ * - { type: "github-branch", branch: { name: "...", url: "..." } }
781
+ * - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
782
+ * - { type: "github-error", error: "..." }
783
+ * - { type: "done" }
784
+ *
785
+ * Non-Streaming Response (stream=false):
786
+ * Content-Type: application/json
787
+ * {
788
+ * success: true,
789
+ * sessionId: "session-123",
790
+ * messages: [...], // Assistant messages only (filtered)
791
+ * tokens: {
792
+ * inputTokens: 150,
793
+ * outputTokens: 50,
794
+ * cacheReadTokens: 0,
795
+ * cacheCreationTokens: 0,
796
+ * totalTokens: 200
797
+ * },
798
+ * projectPath: "/path/to/project",
799
+ * branch: { // Only if createBranch=true
800
+ * name: "feature/xyz",
801
+ * url: "https://github.com/owner/repo/tree/feature/xyz"
802
+ * } | { error: "..." },
803
+ * pullRequest: { // Only if createPR=true
804
+ * number: 42,
805
+ * url: "https://github.com/owner/repo/pull/42"
806
+ * } | { error: "..." }
807
+ * }
808
+ *
809
+ * Error Response:
810
+ * HTTP Status: 400, 401, 500
811
+ * Content-Type: application/json
812
+ * { success: false, error: "Error description" }
813
+ *
814
+ * ================================================================================================
815
+ * EXAMPLES
816
+ * ================================================================================================
817
+ *
818
+ * Example 1: Clone and process with auto-cleanup
819
+ * POST /api/agent
820
+ * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
821
+ *
822
+ * Example 2: Use existing project with custom branch and PR
823
+ * POST /api/agent
824
+ * {
825
+ * "projectPath": "/home/user/project",
826
+ * "message": "Add feature",
827
+ * "branchName": "feature/new-feature",
828
+ * "createPR": true
829
+ * }
830
+ *
831
+ * Example 3: Clone to specific path with auto-generated branch
832
+ * POST /api/agent
833
+ * {
834
+ * "githubUrl": "https://github.com/user/repo",
835
+ * "projectPath": "/tmp/work",
836
+ * "message": "Refactor code",
837
+ * "createBranch": true,
838
+ * "cleanup": false
839
+ * }
840
+ */
841
+ router.post('/', validateExternalApiKey, async (req, res) => {
842
+ const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body;
843
+
844
+ // Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
845
+ const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
846
+ const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
847
+
848
+ // If branchName is provided, automatically enable createBranch
849
+ const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
850
+ const createPR = req.body.createPR === true || req.body.createPR === 'true';
851
+
852
+ // Validate inputs
853
+ if (!githubUrl && !projectPath) {
854
+ return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
855
+ }
856
+
857
+ if (!message || !message.trim()) {
858
+ return res.status(400).json({ error: 'message is required' });
859
+ }
860
+
861
+ if (!['claude', 'cursor', 'codex'].includes(provider)) {
862
+ return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
863
+ }
864
+
865
+ // Validate GitHub branch/PR creation requirements
866
+ // Allow branch/PR creation with projectPath as long as it has a GitHub remote
867
+ if ((createBranch || createPR) && !githubUrl && !projectPath) {
868
+ return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
869
+ }
870
+
871
+ let finalProjectPath = null;
872
+ let writer = null;
873
+
874
+ try {
875
+ // Determine the final project path
876
+ if (githubUrl) {
877
+ // Clone repository (to projectPath if provided, otherwise generate path)
878
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
879
+
880
+ let targetPath;
881
+ if (projectPath) {
882
+ targetPath = projectPath;
883
+ } else {
884
+ // Generate a unique path for cloning
885
+ const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
886
+ targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
887
+ }
888
+
889
+ finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
890
+ } else {
891
+ // Use existing project path
892
+ finalProjectPath = path.resolve(projectPath);
893
+
894
+ // Verify the path exists
895
+ try {
896
+ await fs.access(finalProjectPath);
897
+ } catch (error) {
898
+ throw new Error(`Project path does not exist: ${finalProjectPath}`);
899
+ }
900
+ }
901
+
902
+ // Register the project (or use existing registration)
903
+ let project;
904
+ try {
905
+ project = await addProjectManually(finalProjectPath);
906
+ } catch (error) {
907
+ // If project already exists, that's fine - continue with the existing registration
908
+ if (error.message && error.message.includes('Project already configured')) {
909
+ project = { path: finalProjectPath };
910
+ } else {
911
+ throw error;
912
+ }
913
+ }
914
+
915
+ // Set up writer based on streaming mode
916
+ if (stream) {
917
+ // Set up SSE headers for streaming
918
+ res.setHeader('Content-Type', 'text/event-stream');
919
+ res.setHeader('Cache-Control', 'no-cache');
920
+ res.setHeader('Connection', 'keep-alive');
921
+ res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
922
+
923
+ writer = new SSEStreamWriter(res);
924
+
925
+ // Send initial status
926
+ writer.send({
927
+ type: 'status',
928
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
929
+ projectPath: finalProjectPath
930
+ });
931
+ } else {
932
+ // Non-streaming mode: collect messages
933
+ writer = new ResponseCollector();
934
+
935
+ // Collect initial status message
936
+ writer.send({
937
+ type: 'status',
938
+ message: githubUrl ? 'Repository cloned and session started' : 'Session started',
939
+ projectPath: finalProjectPath
940
+ });
941
+ }
942
+
943
+ // Start the appropriate session (with BYOK key injection where applicable)
944
+ const userId = req.user?.id;
945
+
946
+ if (provider === 'claude') {
947
+ const userKey = await getUserProviderKey(userId, 'anthropic_key');
948
+
949
+ await withUserApiKey('ANTHROPIC_API_KEY', userKey, () =>
950
+ queryClaudeSDK(message.trim(), {
951
+ projectPath: finalProjectPath,
952
+ cwd: finalProjectPath,
953
+ sessionId: null,
954
+ model: model,
955
+ permissionMode: 'bypassPermissions'
956
+ }, writer)
957
+ );
958
+
959
+ } else if (provider === 'cursor') {
960
+
961
+ await spawnCursor(message.trim(), {
962
+ projectPath: finalProjectPath,
963
+ cwd: finalProjectPath,
964
+ sessionId: null,
965
+ model: model || undefined,
966
+ skipPermissions: true
967
+ }, writer);
968
+ } else if (provider === 'codex') {
969
+ const userKey = await getUserProviderKey(userId, 'openai_key');
970
+
971
+ await withUserApiKey('OPENAI_API_KEY', userKey, () =>
972
+ queryCodex(message.trim(), {
973
+ projectPath: finalProjectPath,
974
+ cwd: finalProjectPath,
975
+ sessionId: null,
976
+ model: model || CODEX_MODELS.DEFAULT,
977
+ permissionMode: 'bypassPermissions'
978
+ }, writer)
979
+ );
980
+ } else if (provider === 'openrouter') {
981
+ const userKey = await getUserProviderKey(userId, 'openrouter_key');
982
+
983
+ await queryOpenRouter(message.trim(), {
984
+ model: model || OPENROUTER_MODELS.DEFAULT,
985
+ apiKey: userKey,
986
+ }, writer);
987
+ }
988
+
989
+ // Handle GitHub branch and PR creation after successful agent completion
990
+ let branchInfo = null;
991
+ let prInfo = null;
992
+
993
+ if (createBranch || createPR) {
994
+ try {
995
+
996
+ // Get GitHub token
997
+ const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
998
+
999
+ if (!tokenToUse) {
1000
+ throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
1001
+ }
1002
+
1003
+ // Initialize Octokit
1004
+ const octokit = new Octokit({ auth: tokenToUse });
1005
+
1006
+ // Get GitHub URL - either from parameter or from git remote
1007
+ let repoUrl = githubUrl;
1008
+ if (!repoUrl) {
1009
+ try {
1010
+ repoUrl = await getGitRemoteUrl(finalProjectPath);
1011
+ if (!repoUrl.includes('github.com')) {
1012
+ throw new Error('Project does not have a GitHub remote configured');
1013
+ }
1014
+ } catch (error) {
1015
+ throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
1016
+ }
1017
+ }
1018
+
1019
+ // Parse GitHub URL to get owner and repo
1020
+ const { owner, repo } = parseGitHubUrl(repoUrl);
1021
+
1022
+ // Use provided branch name or auto-generate from message
1023
+ const finalBranchName = branchName || autogenerateBranchName(message);
1024
+ if (branchName) {
1025
+
1026
+ // Validate custom branch name
1027
+ const validation = validateBranchName(finalBranchName);
1028
+ if (!validation.valid) {
1029
+ throw new Error(`Invalid branch name: ${validation.error}`);
1030
+ }
1031
+ } else {
1032
+ }
1033
+
1034
+ if (createBranch) {
1035
+ // Create and checkout the new branch locally
1036
+ const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
1037
+ cwd: finalProjectPath,
1038
+ stdio: 'pipe'
1039
+ });
1040
+
1041
+ await new Promise((resolve, reject) => {
1042
+ let stderr = '';
1043
+ checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1044
+ checkoutProcess.on('close', (code) => {
1045
+ if (code === 0) {
1046
+ resolve();
1047
+ } else {
1048
+ // Branch might already exist locally, try to checkout
1049
+ if (stderr.includes('already exists')) {
1050
+ const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
1051
+ cwd: finalProjectPath,
1052
+ stdio: 'pipe'
1053
+ });
1054
+ checkoutExisting.on('close', (checkoutCode) => {
1055
+ if (checkoutCode === 0) {
1056
+ resolve();
1057
+ } else {
1058
+ reject(new Error(`Failed to checkout existing branch: ${stderr}`));
1059
+ }
1060
+ });
1061
+ } else {
1062
+ reject(new Error(`Failed to create branch: ${stderr}`));
1063
+ }
1064
+ }
1065
+ });
1066
+ });
1067
+
1068
+ // Push the branch to remote
1069
+ const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
1070
+ cwd: finalProjectPath,
1071
+ stdio: 'pipe'
1072
+ });
1073
+
1074
+ await new Promise((resolve, reject) => {
1075
+ let stderr = '';
1076
+ let stdout = '';
1077
+ pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
1078
+ pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
1079
+ pushProcess.on('close', (code) => {
1080
+ if (code === 0) {
1081
+ resolve();
1082
+ } else {
1083
+ // Check if branch exists on remote but has different commits
1084
+ if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
1085
+ resolve();
1086
+ } else {
1087
+ reject(new Error(`Failed to push branch: ${stderr}`));
1088
+ }
1089
+ }
1090
+ });
1091
+ });
1092
+
1093
+ branchInfo = {
1094
+ name: finalBranchName,
1095
+ url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
1096
+ };
1097
+ }
1098
+
1099
+ if (createPR) {
1100
+ // Get commit messages to generate PR description
1101
+ const commitMessages = await getCommitMessages(finalProjectPath, 5);
1102
+
1103
+ // Use the first commit message as the PR title, or fallback to the agent message
1104
+ const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
1105
+
1106
+ // Generate PR body from commit messages
1107
+ let prBody = '## Changes\n\n';
1108
+ if (commitMessages.length > 0) {
1109
+ prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
1110
+ } else {
1111
+ prBody += `Agent task: ${message}`;
1112
+ }
1113
+ prBody += '\n\n---\n*This pull request was automatically created by Upfyn-Code Agent.*';
1114
+
1115
+
1116
+ // Create the pull request
1117
+ prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
1118
+ }
1119
+
1120
+ // Send branch/PR info in response
1121
+ if (stream) {
1122
+ if (branchInfo) {
1123
+ writer.send({
1124
+ type: 'github-branch',
1125
+ branch: branchInfo
1126
+ });
1127
+ }
1128
+ if (prInfo) {
1129
+ writer.send({
1130
+ type: 'github-pr',
1131
+ pullRequest: prInfo
1132
+ });
1133
+ }
1134
+ }
1135
+
1136
+ } catch (error) {
1137
+
1138
+ // Send error but don't fail the entire request
1139
+ if (stream) {
1140
+ writer.send({
1141
+ type: 'github-error',
1142
+ error: error.message
1143
+ });
1144
+ }
1145
+ // Store error info for non-streaming response
1146
+ if (!stream) {
1147
+ branchInfo = { error: error.message };
1148
+ prInfo = { error: error.message };
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Handle response based on streaming mode
1154
+ if (stream) {
1155
+ // Streaming mode: end the SSE stream
1156
+ writer.end();
1157
+ } else {
1158
+ // Non-streaming mode: send filtered messages and token summary as JSON
1159
+ const assistantMessages = writer.getAssistantMessages();
1160
+ const tokenSummary = writer.getTotalTokens();
1161
+
1162
+ const response = {
1163
+ success: true,
1164
+ sessionId: writer.getSessionId(),
1165
+ messages: assistantMessages,
1166
+ tokens: tokenSummary,
1167
+ projectPath: finalProjectPath
1168
+ };
1169
+
1170
+ // Add branch/PR info if created
1171
+ if (branchInfo) {
1172
+ response.branch = branchInfo;
1173
+ }
1174
+ if (prInfo) {
1175
+ response.pullRequest = prInfo;
1176
+ }
1177
+
1178
+ res.json(response);
1179
+ }
1180
+
1181
+ // Clean up if requested
1182
+ if (cleanup && githubUrl) {
1183
+ // Only cleanup if we cloned a repo (not for existing project paths)
1184
+ const sessionIdForCleanup = writer.getSessionId();
1185
+ setTimeout(() => {
1186
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1187
+ }, 5000);
1188
+ }
1189
+
1190
+ } catch (error) {
1191
+
1192
+ // Clean up on error
1193
+ if (finalProjectPath && cleanup && githubUrl) {
1194
+ const sessionIdForCleanup = writer ? writer.getSessionId() : null;
1195
+ cleanupProject(finalProjectPath, sessionIdForCleanup);
1196
+ }
1197
+
1198
+ if (stream) {
1199
+ // For streaming, send error event and stop
1200
+ if (!writer) {
1201
+ // Set up SSE headers if not already done
1202
+ res.setHeader('Content-Type', 'text/event-stream');
1203
+ res.setHeader('Cache-Control', 'no-cache');
1204
+ res.setHeader('Connection', 'keep-alive');
1205
+ res.setHeader('X-Accel-Buffering', 'no');
1206
+ writer = new SSEStreamWriter(res);
1207
+ }
1208
+
1209
+ if (!res.writableEnded) {
1210
+ writer.send({
1211
+ type: 'error',
1212
+ error: error.message,
1213
+ message: `Failed: ${error.message}`
1214
+ });
1215
+ writer.end();
1216
+ }
1217
+ } else if (!res.headersSent) {
1218
+ res.status(500).json({
1219
+ success: false,
1220
+ error: error.message
1221
+ });
1222
+ }
1223
+ }
1224
+ });
1225
+
1226
+ export default router;