upfynai-code 3.0.4 → 3.1.0

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