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