@antigenic-oss/paint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +178 -0
  2. package/NOTICE +4 -0
  3. package/README.md +180 -0
  4. package/bin/paint.js +266 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +19 -0
  7. package/package.json +81 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/dev-editor-inspector.js +1872 -0
  10. package/src/app/api/claude/analyze/route.ts +319 -0
  11. package/src/app/api/claude/apply/route.ts +185 -0
  12. package/src/app/api/claude/pick-folder/route.ts +64 -0
  13. package/src/app/api/claude/scan/route.ts +221 -0
  14. package/src/app/api/claude/status/route.ts +55 -0
  15. package/src/app/api/project/scan/route.ts +634 -0
  16. package/src/app/api/project-scan/css-variables/route.ts +238 -0
  17. package/src/app/api/project-scan/route.ts +40 -0
  18. package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
  19. package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
  20. package/src/app/docs/DocsClient.tsx +322 -0
  21. package/src/app/docs/layout.tsx +7 -0
  22. package/src/app/docs/page.tsx +855 -0
  23. package/src/app/globals.css +176 -0
  24. package/src/app/layout.tsx +19 -0
  25. package/src/app/page.tsx +46 -0
  26. package/src/bridge/api-handlers.ts +885 -0
  27. package/src/bridge/proxy-handler.ts +329 -0
  28. package/src/bridge/server.ts +113 -0
  29. package/src/components/BreakpointTabs.tsx +72 -0
  30. package/src/components/ChangeSummaryModal.tsx +267 -0
  31. package/src/components/ConnectModal.tsx +994 -0
  32. package/src/components/Editor.tsx +90 -0
  33. package/src/components/PageSelector.tsx +208 -0
  34. package/src/components/PreviewFrame.tsx +299 -0
  35. package/src/components/ProjectFolderBanner.tsx +91 -0
  36. package/src/components/ResponsiveToolbar.tsx +222 -0
  37. package/src/components/TargetSelector.tsx +243 -0
  38. package/src/components/TopBar.tsx +315 -0
  39. package/src/components/common/CollapsibleSection.tsx +36 -0
  40. package/src/components/common/ColorPicker.tsx +920 -0
  41. package/src/components/common/EditablePre.tsx +136 -0
  42. package/src/components/common/ErrorBoundary.tsx +65 -0
  43. package/src/components/common/ResizablePanel.tsx +83 -0
  44. package/src/components/common/ScanAnimation.tsx +76 -0
  45. package/src/components/common/ToastContainer.tsx +97 -0
  46. package/src/components/common/UnitInput.tsx +77 -0
  47. package/src/components/common/VariableColorPicker.tsx +622 -0
  48. package/src/components/left-panel/AddElementPanel.tsx +237 -0
  49. package/src/components/left-panel/ComponentsPanel.tsx +609 -0
  50. package/src/components/left-panel/IconSidebar.tsx +99 -0
  51. package/src/components/left-panel/LayerNode.tsx +874 -0
  52. package/src/components/left-panel/LayerSearch.tsx +23 -0
  53. package/src/components/left-panel/LayersPanel.tsx +52 -0
  54. package/src/components/left-panel/LeftPanel.tsx +122 -0
  55. package/src/components/left-panel/PagesPanel.tsx +114 -0
  56. package/src/components/left-panel/icons.tsx +162 -0
  57. package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
  58. package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
  59. package/src/components/right-panel/ElementLogBox.tsx +248 -0
  60. package/src/components/right-panel/PanelTabs.tsx +83 -0
  61. package/src/components/right-panel/RightPanel.tsx +41 -0
  62. package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
  63. package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
  64. package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
  65. package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
  66. package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
  67. package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
  68. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
  69. package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
  70. package/src/components/right-panel/claude/DiffCard.tsx +130 -0
  71. package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
  72. package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
  73. package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
  74. package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
  75. package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
  76. package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
  77. package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
  78. package/src/components/right-panel/design/BorderSection.tsx +161 -0
  79. package/src/components/right-panel/design/CSSRawView.tsx +412 -0
  80. package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
  81. package/src/components/right-panel/design/DesignPanel.tsx +275 -0
  82. package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
  83. package/src/components/right-panel/design/GradientEditor.tsx +726 -0
  84. package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
  85. package/src/components/right-panel/design/PositionSection.tsx +865 -0
  86. package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
  87. package/src/components/right-panel/design/SVGSection.tsx +361 -0
  88. package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
  89. package/src/components/right-panel/design/SizeSection.tsx +183 -0
  90. package/src/components/right-panel/design/TextSection.tsx +719 -0
  91. package/src/components/right-panel/design/icons.tsx +948 -0
  92. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
  93. package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
  94. package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
  95. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
  96. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
  97. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
  98. package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
  99. package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
  100. package/src/hooks/useBridge.ts +95 -0
  101. package/src/hooks/useChangeTracker.ts +563 -0
  102. package/src/hooks/useClaudeAPI.ts +118 -0
  103. package/src/hooks/useDOMTree.ts +25 -0
  104. package/src/hooks/useKeyboardShortcuts.ts +76 -0
  105. package/src/hooks/usePostMessage.ts +589 -0
  106. package/src/hooks/useProjectScan.ts +204 -0
  107. package/src/hooks/useResizable.ts +20 -0
  108. package/src/hooks/useSelectedElement.ts +51 -0
  109. package/src/hooks/useTargetUrl.ts +81 -0
  110. package/src/inspector/DOMTraverser.ts +71 -0
  111. package/src/inspector/ElementSelector.ts +23 -0
  112. package/src/inspector/HoverHighlighter.ts +54 -0
  113. package/src/inspector/SelectionHighlighter.ts +27 -0
  114. package/src/inspector/StyleExtractor.ts +19 -0
  115. package/src/inspector/inspector.ts +17 -0
  116. package/src/inspector/messaging.ts +30 -0
  117. package/src/lib/apiBase.ts +15 -0
  118. package/src/lib/classifyElement.ts +430 -0
  119. package/src/lib/claude-bin.ts +197 -0
  120. package/src/lib/claude-stream.ts +158 -0
  121. package/src/lib/clientProjectScanner.ts +344 -0
  122. package/src/lib/componentMatcher.ts +156 -0
  123. package/src/lib/constants.ts +573 -0
  124. package/src/lib/cssVariableUtils.ts +409 -0
  125. package/src/lib/diffParser.ts +206 -0
  126. package/src/lib/folderPicker.ts +84 -0
  127. package/src/lib/gradientParser.ts +160 -0
  128. package/src/lib/projectScanner.ts +355 -0
  129. package/src/lib/promptBuilder.ts +402 -0
  130. package/src/lib/shadowParser.ts +124 -0
  131. package/src/lib/tailwindClassParser.ts +248 -0
  132. package/src/lib/textShadowUtils.ts +106 -0
  133. package/src/lib/utils.ts +299 -0
  134. package/src/lib/validatePath.ts +40 -0
  135. package/src/proxy.ts +92 -0
  136. package/src/server/terminal-server.ts +104 -0
  137. package/src/store/changeSlice.ts +288 -0
  138. package/src/store/claudeSlice.ts +222 -0
  139. package/src/store/componentSlice.ts +90 -0
  140. package/src/store/consoleSlice.ts +51 -0
  141. package/src/store/cssVariableSlice.ts +94 -0
  142. package/src/store/elementSlice.ts +78 -0
  143. package/src/store/index.ts +35 -0
  144. package/src/store/terminalSlice.ts +30 -0
  145. package/src/store/treeSlice.ts +69 -0
  146. package/src/store/uiSlice.ts +327 -0
  147. package/src/types/changelog.ts +49 -0
  148. package/src/types/claude.ts +131 -0
  149. package/src/types/component.ts +49 -0
  150. package/src/types/cssVariables.ts +18 -0
  151. package/src/types/element.ts +21 -0
  152. package/src/types/file-system-access.d.ts +27 -0
  153. package/src/types/gradient.ts +12 -0
  154. package/src/types/messages.ts +392 -0
  155. package/src/types/shadow.ts +8 -0
  156. package/src/types/tree.ts +9 -0
  157. package/tsconfig.json +42 -0
  158. package/tsconfig.server.json +12 -0
@@ -0,0 +1,319 @@
1
+ import { NextResponse } from 'next/server'
2
+ import path from 'node:path'
3
+ import { stripControlChars } from '@/lib/utils'
4
+ import {
5
+ spawnClaude,
6
+ spawnClaudeStreaming,
7
+ isAuthError,
8
+ } from '@/lib/claude-bin'
9
+ import { validateProjectRoot } from '@/lib/validatePath'
10
+ import { buildSmartAnalysisPrompt } from '@/lib/promptBuilder'
11
+ import type {
12
+ ClaudeAnalyzeRequest,
13
+ ClaudeAnalyzeResponse,
14
+ ParsedDiff,
15
+ DiffHunk,
16
+ DiffLine,
17
+ } from '@/types/claude'
18
+
19
+ const MAX_CHANGELOG_BYTES = 50 * 1024 // 50KB
20
+ const TIMEOUT_MS = 120_000 // 120 seconds
21
+
22
+ /**
23
+ * Parse unified diff output into structured ParsedDiff objects.
24
+ */
25
+ function parseDiffs(output: string): ParsedDiff[] {
26
+ const diffs: ParsedDiff[] = []
27
+
28
+ // Split on diff headers: "diff --git a/... b/..." or "--- a/..." patterns
29
+ // We look for file markers: "--- a/<path>" followed by "+++ b/<path>"
30
+ const lines = output.split('\n')
31
+ let currentDiff: ParsedDiff | null = null
32
+ let currentHunk: DiffHunk | null = null
33
+
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i]
36
+
37
+ // Detect file path from +++ line (the "new" file in unified diff)
38
+ if (line.startsWith('+++ b/') || line.startsWith('+++ ')) {
39
+ // Start a new diff for this file
40
+ const filePath = line.startsWith('+++ b/')
41
+ ? line.slice(6).trim()
42
+ : line.slice(4).trim()
43
+
44
+ if (filePath && filePath !== '/dev/null') {
45
+ currentDiff = {
46
+ filePath,
47
+ hunks: [],
48
+ linesAdded: 0,
49
+ linesRemoved: 0,
50
+ }
51
+ diffs.push(currentDiff)
52
+ currentHunk = null
53
+ }
54
+ continue
55
+ }
56
+
57
+ // Detect --- line for removed file (skip, we use +++ for file path)
58
+ if (line.startsWith('--- a/') || line.startsWith('--- ')) {
59
+ continue
60
+ }
61
+
62
+ // Detect hunk header: @@ -oldStart,oldCount +newStart,newCount @@
63
+ const hunkMatch = line.match(/^@@\s+[^@]+\s+@@(.*)?$/)
64
+ if (hunkMatch && currentDiff) {
65
+ currentHunk = {
66
+ header: line,
67
+ lines: [],
68
+ }
69
+ currentDiff.hunks.push(currentHunk)
70
+ continue
71
+ }
72
+
73
+ // Inside a hunk, classify lines
74
+ if (currentHunk && currentDiff) {
75
+ if (line.startsWith('+')) {
76
+ const diffLine: DiffLine = {
77
+ type: 'addition',
78
+ content: line.slice(1),
79
+ }
80
+ currentHunk.lines.push(diffLine)
81
+ currentDiff.linesAdded++
82
+ } else if (line.startsWith('-')) {
83
+ const diffLine: DiffLine = {
84
+ type: 'removal',
85
+ content: line.slice(1),
86
+ }
87
+ currentHunk.lines.push(diffLine)
88
+ currentDiff.linesRemoved++
89
+ } else if (line.startsWith(' ')) {
90
+ const diffLine: DiffLine = {
91
+ type: 'context',
92
+ content: line.slice(1),
93
+ }
94
+ currentHunk.lines.push(diffLine)
95
+ }
96
+ // Lines that don't start with +, -, or space end the hunk
97
+ // (e.g. "" or blank separator)
98
+ }
99
+ }
100
+
101
+ return diffs
102
+ }
103
+
104
+ /**
105
+ * Build a summary string from parsed diffs.
106
+ */
107
+ function buildSummary(diffs: ParsedDiff[]): string {
108
+ if (diffs.length === 0) {
109
+ return 'No file changes detected in the analysis output.'
110
+ }
111
+
112
+ const totalAdded = diffs.reduce((sum, d) => sum + d.linesAdded, 0)
113
+ const totalRemoved = diffs.reduce((sum, d) => sum + d.linesRemoved, 0)
114
+ const fileList = diffs.map((d) => d.filePath).join(', ')
115
+
116
+ return (
117
+ `${diffs.length} file${diffs.length !== 1 ? 's' : ''} to modify: ${fileList}. ` +
118
+ `+${totalAdded} / -${totalRemoved} lines.`
119
+ )
120
+ }
121
+
122
+ /**
123
+ * Validate the request body and build the prompt. Returns an error NextResponse or the resolved data.
124
+ */
125
+ function parseAndValidate(
126
+ body: ClaudeAnalyzeRequest,
127
+ ): { error: NextResponse } | { prompt: string; resolvedRoot: string } {
128
+ const { changelog, projectRoot, smartPrompt } = body
129
+ const hasSmartPrompt =
130
+ typeof smartPrompt === 'string' && smartPrompt.length > 0
131
+
132
+ if (!hasSmartPrompt) {
133
+ if (!changelog || typeof changelog !== 'string' || changelog.length === 0) {
134
+ return {
135
+ error: NextResponse.json(
136
+ { error: 'changelog is required and must be a non-empty string' },
137
+ { status: 400 },
138
+ ),
139
+ }
140
+ }
141
+ }
142
+
143
+ if (!projectRoot || typeof projectRoot !== 'string') {
144
+ return {
145
+ error: NextResponse.json(
146
+ { error: 'projectRoot is required and must be a string' },
147
+ { status: 400 },
148
+ ),
149
+ }
150
+ }
151
+
152
+ const rootError = validateProjectRoot(projectRoot)
153
+ if (rootError) {
154
+ return { error: NextResponse.json({ error: rootError }, { status: 400 }) }
155
+ }
156
+
157
+ const resolvedRoot = path.resolve(projectRoot)
158
+
159
+ let prompt: string
160
+ if (hasSmartPrompt) {
161
+ const sanitized = stripControlChars(smartPrompt).slice(
162
+ 0,
163
+ MAX_CHANGELOG_BYTES,
164
+ )
165
+ prompt = buildSmartAnalysisPrompt(sanitized, resolvedRoot)
166
+ } else {
167
+ const sanitizedChangelog = stripControlChars(changelog).slice(
168
+ 0,
169
+ MAX_CHANGELOG_BYTES,
170
+ )
171
+ prompt = [
172
+ 'You are a code assistant. A user has made visual changes in a design editor.',
173
+ 'Below is the changelog of changes they made. Analyze the project source code',
174
+ 'and generate unified diffs that would apply these visual changes to the source files.',
175
+ '',
176
+ 'IMPORTANT:',
177
+ '- Output ONLY unified diff format (diff --git a/... b/...)',
178
+ '- Use paths relative to the project root',
179
+ '- Do not include any explanatory text outside of the diff',
180
+ '- Make minimal, targeted changes',
181
+ '',
182
+ '--- CHANGELOG START ---',
183
+ sanitizedChangelog,
184
+ '--- CHANGELOG END ---',
185
+ ].join('\n')
186
+ }
187
+
188
+ return { prompt, resolvedRoot }
189
+ }
190
+
191
+ /** Format an SSE event. */
192
+ function sseEvent(event: string, data: Record<string, unknown>): string {
193
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
194
+ }
195
+
196
+ export async function POST(request: Request): Promise<Response> {
197
+ let body: ClaudeAnalyzeRequest
198
+ try {
199
+ body = await request.json()
200
+ } catch {
201
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
202
+ }
203
+
204
+ const validated = parseAndValidate(body)
205
+ if ('error' in validated) return validated.error
206
+ const { prompt, resolvedRoot } = validated
207
+
208
+ const wantsStream = request.headers
209
+ .get('accept')
210
+ ?.includes('text/event-stream')
211
+
212
+ // ── SSE streaming path ──
213
+ if (wantsStream) {
214
+ const encoder = new TextEncoder()
215
+ const stream = new ReadableStream({
216
+ start(controller) {
217
+ const enqueue = (event: string, data: Record<string, unknown>) => {
218
+ try {
219
+ controller.enqueue(encoder.encode(sseEvent(event, data)))
220
+ } catch {
221
+ /* closed */
222
+ }
223
+ }
224
+
225
+ spawnClaudeStreaming(
226
+ ['--print', '--allowedTools', 'Read', '-p', prompt],
227
+ {
228
+ cwd: resolvedRoot,
229
+ timeout: TIMEOUT_MS,
230
+ onStderr: (line) => enqueue('stderr', { line }),
231
+ },
232
+ )
233
+ .then((result) => {
234
+ if (result.exitCode !== 0) {
235
+ enqueue('error', {
236
+ code: 'CLI_ERROR',
237
+ message: 'Claude CLI exited with an error',
238
+ })
239
+ } else {
240
+ const diffs = parseDiffs(result.stdout)
241
+ const summary = buildSummary(diffs)
242
+ const sessionId = crypto.randomUUID()
243
+ const response: ClaudeAnalyzeResponse = {
244
+ sessionId,
245
+ diffs,
246
+ summary,
247
+ }
248
+ enqueue('result', response as unknown as Record<string, unknown>)
249
+ }
250
+ })
251
+ .catch((err) => {
252
+ const message = err instanceof Error ? err.message : 'Unknown error'
253
+ const code = message === 'TIMEOUT' ? 'TIMEOUT' : 'SPAWN_ERROR'
254
+ enqueue('error', { code, message })
255
+ })
256
+ .finally(() => {
257
+ enqueue('done', {})
258
+ controller.close()
259
+ })
260
+ },
261
+ })
262
+
263
+ return new Response(stream, {
264
+ headers: {
265
+ 'Content-Type': 'text/event-stream',
266
+ 'Cache-Control': 'no-cache',
267
+ Connection: 'keep-alive',
268
+ },
269
+ })
270
+ }
271
+
272
+ // ── JSON fallback path (backward compatible) ──
273
+ try {
274
+ const result = await spawnClaude(
275
+ ['--print', '--allowedTools', 'Read', '-p', prompt],
276
+ { cwd: resolvedRoot, timeout: TIMEOUT_MS },
277
+ )
278
+
279
+ if (result.exitCode !== 0) {
280
+ const stderr = result.stderr.trim()
281
+ if (isAuthError(stderr)) {
282
+ return NextResponse.json(
283
+ {
284
+ error:
285
+ 'Claude CLI is not authenticated. Run `claude login` in your terminal.',
286
+ authRequired: true,
287
+ },
288
+ { status: 401 },
289
+ )
290
+ }
291
+ return NextResponse.json(
292
+ {
293
+ error: 'Claude CLI exited with an error',
294
+ details: stderr || 'Unknown CLI error',
295
+ },
296
+ { status: 500 },
297
+ )
298
+ }
299
+
300
+ const sessionIdMatch = result.stderr.match(/session[:\s]+([a-f0-9-]+)/i)
301
+ const sessionId = sessionIdMatch ? sessionIdMatch[1] : crypto.randomUUID()
302
+ const diffs = parseDiffs(result.stdout)
303
+ const summary = buildSummary(diffs)
304
+ const response: ClaudeAnalyzeResponse = { sessionId, diffs, summary }
305
+ return NextResponse.json(response)
306
+ } catch (error) {
307
+ const message = error instanceof Error ? error.message : 'Unknown error'
308
+ if (message === 'TIMEOUT') {
309
+ return NextResponse.json(
310
+ { error: 'Claude CLI timed out after 120 seconds' },
311
+ { status: 504 },
312
+ )
313
+ }
314
+ return NextResponse.json(
315
+ { error: 'Failed to run Claude CLI', details: message },
316
+ { status: 500 },
317
+ )
318
+ }
319
+ }
@@ -0,0 +1,185 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { spawnClaude } from '@/lib/claude-bin'
6
+ import type { ClaudeApplyRequest, ClaudeApplyResponse } from '@/types/claude'
7
+
8
+ const TIMEOUT_MS = 120_000 // 120 seconds
9
+
10
+ /**
11
+ * Validate that projectRoot is an absolute path, exists as a directory,
12
+ * and resides under the user's HOME directory.
13
+ */
14
+ function validateProjectRoot(projectRoot: string): string | null {
15
+ if (!path.isAbsolute(projectRoot)) {
16
+ return 'projectRoot must be an absolute path'
17
+ }
18
+
19
+ const resolvedHome = path.resolve(homedir())
20
+ const resolved = path.resolve(projectRoot)
21
+
22
+ if (
23
+ !resolved.startsWith(resolvedHome + path.sep) &&
24
+ resolved !== resolvedHome
25
+ ) {
26
+ return 'projectRoot must be under the user home directory'
27
+ }
28
+
29
+ if (!existsSync(resolved)) {
30
+ return 'projectRoot does not exist'
31
+ }
32
+
33
+ try {
34
+ const stat = statSync(resolved)
35
+ if (!stat.isDirectory()) {
36
+ return 'projectRoot is not a directory'
37
+ }
38
+ } catch {
39
+ return 'Unable to stat projectRoot'
40
+ }
41
+
42
+ return null
43
+ }
44
+
45
+ /**
46
+ * Extract modified file paths from Claude CLI output.
47
+ * Looks for patterns like "Edited <path>" or "Modified <path>" or diff file headers.
48
+ */
49
+ function extractModifiedFiles(output: string): string[] {
50
+ const files = new Set<string>()
51
+
52
+ const lines = output.split('\n')
53
+ for (const line of lines) {
54
+ // Match "Edit" / "Edited" / "Modified" patterns from Claude CLI output
55
+ const editMatch = line.match(
56
+ /(?:Edit(?:ed)?|Modified|Updated|Changed)\s+([^\s]+\.\w+)/i,
57
+ )
58
+ if (editMatch) {
59
+ files.add(editMatch[1])
60
+ }
61
+
62
+ // Match diff-style file headers: "+++ b/<path>"
63
+ if (line.startsWith('+++ b/')) {
64
+ const filePath = line.slice(6).trim()
65
+ if (filePath && filePath !== '/dev/null') {
66
+ files.add(filePath)
67
+ }
68
+ }
69
+ }
70
+
71
+ return Array.from(files)
72
+ }
73
+
74
+ /**
75
+ * Build a summary from the apply output.
76
+ */
77
+ function buildSummary(output: string, filesModified: string[]): string {
78
+ if (filesModified.length === 0) {
79
+ // If no files were detected, try to extract a meaningful summary
80
+ const trimmed = output.trim()
81
+ if (trimmed.length === 0) {
82
+ return 'Changes applied. No specific file modifications detected in output.'
83
+ }
84
+ // Return the first 200 chars of the output as summary
85
+ return trimmed.length > 200 ? trimmed.slice(0, 200) + '...' : trimmed
86
+ }
87
+
88
+ return (
89
+ `Successfully modified ${filesModified.length} file${filesModified.length !== 1 ? 's' : ''}: ` +
90
+ filesModified.join(', ')
91
+ )
92
+ }
93
+
94
+ export async function POST(request: Request): Promise<NextResponse> {
95
+ // Parse and validate request body
96
+ let body: ClaudeApplyRequest
97
+ try {
98
+ body = await request.json()
99
+ } catch {
100
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
101
+ }
102
+
103
+ const { sessionId, projectRoot } = body
104
+
105
+ // Validate sessionId
106
+ if (!sessionId || typeof sessionId !== 'string') {
107
+ return NextResponse.json(
108
+ { error: 'sessionId is required and must be a non-empty string' },
109
+ { status: 400 },
110
+ )
111
+ }
112
+
113
+ // Basic sessionId format validation (UUID or hex string)
114
+ if (!/^[a-f0-9-]+$/i.test(sessionId)) {
115
+ return NextResponse.json(
116
+ { error: 'sessionId contains invalid characters' },
117
+ { status: 400 },
118
+ )
119
+ }
120
+
121
+ // Validate projectRoot
122
+ if (!projectRoot || typeof projectRoot !== 'string') {
123
+ return NextResponse.json(
124
+ { error: 'projectRoot is required and must be a string' },
125
+ { status: 400 },
126
+ )
127
+ }
128
+
129
+ const rootError = validateProjectRoot(projectRoot)
130
+ if (rootError) {
131
+ return NextResponse.json({ error: rootError }, { status: 400 })
132
+ }
133
+
134
+ const resolvedRoot = path.resolve(projectRoot)
135
+
136
+ try {
137
+ const result = await spawnClaude(
138
+ [
139
+ '--resume',
140
+ sessionId,
141
+ '--allowedTools',
142
+ 'Read,Edit',
143
+ '--print',
144
+ '-p',
145
+ 'Apply the changes discussed in the previous analysis. Edit the source files to implement all the visual changes from the changelog.',
146
+ ],
147
+ { cwd: resolvedRoot, timeout: TIMEOUT_MS },
148
+ )
149
+
150
+ if (result.exitCode !== 0) {
151
+ return NextResponse.json(
152
+ {
153
+ error: 'Claude CLI exited with an error',
154
+ details: result.stderr.trim() || 'Unknown CLI error',
155
+ },
156
+ { status: 500 },
157
+ )
158
+ }
159
+
160
+ // Combine stdout and stderr to look for file modification signals
161
+ const combinedOutput = result.stdout + '\n' + result.stderr
162
+ const filesModified = extractModifiedFiles(combinedOutput)
163
+ const summary = buildSummary(result.stdout, filesModified)
164
+
165
+ const response: ClaudeApplyResponse = {
166
+ success: true,
167
+ filesModified,
168
+ summary,
169
+ }
170
+
171
+ return NextResponse.json(response)
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : 'Unknown error'
174
+ if (message === 'TIMEOUT') {
175
+ return NextResponse.json(
176
+ { error: 'Claude CLI timed out after 120 seconds' },
177
+ { status: 504 },
178
+ )
179
+ }
180
+ return NextResponse.json(
181
+ { error: 'Failed to run Claude CLI', details: message },
182
+ { status: 500 },
183
+ )
184
+ }
185
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ const execFileAsync = promisify(execFile)
6
+
7
+ /**
8
+ * Opens a native OS folder picker dialog and returns the selected path.
9
+ * macOS: uses osascript (AppleScript)
10
+ * Linux: uses zenity
11
+ */
12
+ export async function GET(): Promise<NextResponse> {
13
+ const platform = process.platform
14
+
15
+ try {
16
+ let folderPath: string | null = null
17
+
18
+ if (platform === 'darwin') {
19
+ const { stdout } = await execFileAsync(
20
+ 'osascript',
21
+ [
22
+ '-e',
23
+ 'set theFolder to POSIX path of (choose folder with prompt "Select your project root folder")',
24
+ '-e',
25
+ 'return theFolder',
26
+ ],
27
+ { timeout: 60_000 },
28
+ )
29
+ folderPath = stdout.trim().replace(/\/$/, '') // strip trailing slash
30
+ } else if (platform === 'linux') {
31
+ const { stdout } = await execFileAsync(
32
+ 'zenity',
33
+ [
34
+ '--file-selection',
35
+ '--directory',
36
+ '--title=Select your project root folder',
37
+ ],
38
+ { timeout: 60_000 },
39
+ )
40
+ folderPath = stdout.trim()
41
+ } else {
42
+ return NextResponse.json(
43
+ { error: 'Folder picker not supported on this platform' },
44
+ { status: 501 },
45
+ )
46
+ }
47
+
48
+ if (!folderPath) {
49
+ return NextResponse.json({ cancelled: true })
50
+ }
51
+
52
+ return NextResponse.json({ path: folderPath })
53
+ } catch (err) {
54
+ // User cancelled the dialog (osascript exits with code 1, zenity with 1)
55
+ const code = (err as { code?: number }).code
56
+ if (code === 1) {
57
+ return NextResponse.json({ cancelled: true })
58
+ }
59
+ return NextResponse.json(
60
+ { error: 'Failed to open folder picker' },
61
+ { status: 500 },
62
+ )
63
+ }
64
+ }