@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.
- package/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- 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
|
+
}
|