@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,430 @@
1
+ /**
2
+ * Infer a likely source file path for a DOM element based on its
3
+ * tag name, class names, id, the current page path, and selector depth.
4
+ *
5
+ * When a FileMap is provided (from project scan), attempts filesystem-backed
6
+ * resolution first. Falls back to heuristics when no match is found.
7
+ */
8
+
9
+ import type {
10
+ FileMap,
11
+ ComponentEntry,
12
+ RouteEntry,
13
+ SourceInfo,
14
+ } from '@/types/claude'
15
+
16
+ const LAYOUT_TAGS = new Set([
17
+ 'html',
18
+ 'body',
19
+ 'header',
20
+ 'footer',
21
+ 'nav',
22
+ 'aside',
23
+ 'main',
24
+ ])
25
+
26
+ const LAYOUT_HINTS = [
27
+ 'layout',
28
+ 'wrapper',
29
+ 'container',
30
+ 'sidebar',
31
+ 'navbar',
32
+ 'topbar',
33
+ 'app-shell',
34
+ 'shell',
35
+ 'scaffold',
36
+ 'frame',
37
+ 'toolbar',
38
+ 'drawer',
39
+ 'app-bar',
40
+ 'navigation',
41
+ 'menu-bar',
42
+ ]
43
+
44
+ const PAGE_HINTS = [
45
+ 'page',
46
+ 'view',
47
+ 'screen',
48
+ 'content',
49
+ 'hero',
50
+ 'banner',
51
+ 'landing',
52
+ 'home',
53
+ 'dashboard',
54
+ 'profile',
55
+ 'settings',
56
+ 'about',
57
+ 'checkout',
58
+ 'feed',
59
+ 'detail',
60
+ 'overview',
61
+ ]
62
+
63
+ const COMPONENT_MAP: Record<string, string> = {
64
+ btn: 'Button',
65
+ button: 'Button',
66
+ card: 'Card',
67
+ modal: 'Modal',
68
+ dialog: 'Dialog',
69
+ dropdown: 'Dropdown',
70
+ popover: 'Popover',
71
+ tooltip: 'Tooltip',
72
+ badge: 'Badge',
73
+ chip: 'Chip',
74
+ avatar: 'Avatar',
75
+ icon: 'Icon',
76
+ alert: 'Alert',
77
+ toast: 'Toast',
78
+ accordion: 'Accordion',
79
+ tab: 'Tabs',
80
+ carousel: 'Carousel',
81
+ slider: 'Slider',
82
+ pagination: 'Pagination',
83
+ stepper: 'Stepper',
84
+ progress: 'Progress',
85
+ breadcrumb: 'Breadcrumb',
86
+ spinner: 'Spinner',
87
+ skeleton: 'Skeleton',
88
+ form: 'Form',
89
+ table: 'Table',
90
+ navbar: 'Navbar',
91
+ header: 'Header',
92
+ footer: 'Footer',
93
+ sidebar: 'Sidebar',
94
+ }
95
+
96
+ function matchesAny(text: string, hints: string[]): boolean {
97
+ const lower = text.toLowerCase()
98
+ return hints.some((h) => lower.includes(h))
99
+ }
100
+
101
+ function findComponentName(text: string): string | null {
102
+ const lower = text.toLowerCase()
103
+ for (const [hint, name] of Object.entries(COMPONENT_MAP)) {
104
+ if (lower.includes(hint)) return name
105
+ }
106
+ return null
107
+ }
108
+
109
+ function toPascalCase(str: string): string {
110
+ return str
111
+ .split(/[-_\s]+/)
112
+ .filter(Boolean)
113
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
114
+ .join('')
115
+ }
116
+
117
+ /** Check if a URL path matches a route pattern with dynamic segments. */
118
+ function matchesUrlPattern(urlPath: string, pattern: string): boolean {
119
+ const urlSegs = urlPath
120
+ .replace(/^\/|\/$/g, '')
121
+ .split('/')
122
+ .filter(Boolean)
123
+ const patSegs = pattern
124
+ .replace(/^\/|\/$/g, '')
125
+ .split('/')
126
+ .filter(Boolean)
127
+
128
+ // Handle catch-all: [...slug] or [[...slug]]
129
+ if (patSegs.length > 0) {
130
+ const last = patSegs[patSegs.length - 1]
131
+ if (/^\[{1,2}\.\.\./.test(last)) {
132
+ // Catch-all matches if all preceding segments match
133
+ for (let i = 0; i < patSegs.length - 1; i++) {
134
+ if (i >= urlSegs.length) return false
135
+ if (!patSegs[i].startsWith('[') && patSegs[i] !== urlSegs[i])
136
+ return false
137
+ }
138
+ return urlSegs.length >= patSegs.length - 1
139
+ }
140
+ }
141
+
142
+ if (urlSegs.length !== patSegs.length) return false
143
+ for (let i = 0; i < patSegs.length; i++) {
144
+ // Dynamic segment matches anything
145
+ if (patSegs[i].startsWith('[') && patSegs[i].endsWith(']')) continue
146
+ if (patSegs[i] !== urlSegs[i]) return false
147
+ }
148
+ return true
149
+ }
150
+
151
+ /** Find the best page route match for a given pagePath. */
152
+ function findPageRoute(
153
+ routes: RouteEntry[],
154
+ pagePath: string,
155
+ ): RouteEntry | null {
156
+ const pageRoutes = routes.filter((r) => r.type === 'page')
157
+ // Exact match first
158
+ const exact = pageRoutes.find((r) => r.urlPattern === pagePath)
159
+ if (exact) return exact
160
+ // Dynamic segment match
161
+ const dynamic = pageRoutes.find((r) =>
162
+ matchesUrlPattern(pagePath, r.urlPattern),
163
+ )
164
+ if (dynamic) return dynamic
165
+ return null
166
+ }
167
+
168
+ /** Attempt to resolve a source path from the FileMap. Returns null on no match. */
169
+ function resolveFromFileMap(opts: {
170
+ tagName: string
171
+ className: string | null
172
+ id: string | null
173
+ selectorPath: string | null
174
+ pagePath: string
175
+ fileMap: FileMap
176
+ }): string | null {
177
+ const { fileMap } = opts
178
+ const tag = opts.tagName.toLowerCase()
179
+ const cls = (opts.className || '').toLowerCase()
180
+ const id = (opts.id || '').toLowerCase()
181
+ const combined = `${cls} ${id} ${tag}`
182
+ const pagePath = opts.pagePath || '/'
183
+
184
+ // 1. Component match — check className/id/tagName against ComponentEntry.nameLower
185
+ for (const comp of fileMap.components) {
186
+ if (
187
+ comp.category === 'page' ||
188
+ comp.category === 'screen' ||
189
+ comp.category === 'layout'
190
+ )
191
+ continue
192
+ // Exact substring match: component name appears in the combined text
193
+ if (comp.nameLower.length >= 3 && combined.includes(comp.nameLower)) {
194
+ return comp.filePath
195
+ }
196
+ }
197
+
198
+ // Also try COMPONENT_MAP mapping: e.g. "btn" → look for "Button"
199
+ for (const [hint, name] of Object.entries(COMPONENT_MAP)) {
200
+ if (combined.includes(hint)) {
201
+ const nameLower = name.toLowerCase()
202
+ const match = fileMap.components.find((c) => c.nameLower === nameLower)
203
+ if (match) return match.filePath
204
+ }
205
+ }
206
+
207
+ // 2. Layout match — if element looks like a layout, find the most specific layout route
208
+ const isLayoutHint =
209
+ LAYOUT_TAGS.has(tag) || matchesAny(combined, LAYOUT_HINTS)
210
+ if (isLayoutHint && fileMap.routes.length > 0) {
211
+ const layouts = fileMap.routes
212
+ .filter((r) => r.type === 'layout')
213
+ .filter(
214
+ (r) =>
215
+ matchesUrlPattern(pagePath, r.urlPattern) || r.urlPattern === '/',
216
+ )
217
+ if (layouts.length > 0) {
218
+ // Most specific layout = longest urlPattern
219
+ layouts.sort((a, b) => b.urlPattern.length - a.urlPattern.length)
220
+ return layouts[0].filePath
221
+ }
222
+ }
223
+
224
+ // 3. Page/route match (strict) — for shallow elements or page-hint elements
225
+ if (fileMap.routes.length > 0) {
226
+ const pageRoute = findPageRoute(fileMap.routes, pagePath)
227
+ if (pageRoute) {
228
+ const depth = opts.selectorPath
229
+ ? opts.selectorPath.split(' > ').length
230
+ : 0
231
+ if (depth <= 4 || matchesAny(combined, PAGE_HINTS))
232
+ return pageRoute.filePath
233
+ }
234
+ }
235
+
236
+ // 4. Screen/view match — look for screen/page entries with name matching URL segment
237
+ const urlSegments = pagePath
238
+ .replace(/^\/|\/$/g, '')
239
+ .split('/')
240
+ .filter(Boolean)
241
+ if (urlSegments.length > 0) {
242
+ const screenEntries = fileMap.components.filter(
243
+ (c) =>
244
+ c.category === 'screen' ||
245
+ c.category === 'page' ||
246
+ c.category === 'view',
247
+ )
248
+ for (const seg of urlSegments) {
249
+ const segLower = seg.toLowerCase()
250
+ const match = screenEntries.find((c) => c.nameLower.includes(segLower))
251
+ if (match) return match.filePath
252
+ }
253
+ }
254
+
255
+ // 5. Root page fallback — for "/" when no route matched, look for index/home screen entries
256
+ if (pagePath === '/' || pagePath === '') {
257
+ const screenEntries = fileMap.components.filter(
258
+ (c) =>
259
+ c.category === 'screen' ||
260
+ c.category === 'page' ||
261
+ c.category === 'view',
262
+ )
263
+ const homeNames = [
264
+ 'index',
265
+ 'home',
266
+ 'main',
267
+ 'app',
268
+ 'homescreen',
269
+ 'homeview',
270
+ 'homepage',
271
+ 'mainscreen',
272
+ ]
273
+ const homeMatch = screenEntries.find((c) => homeNames.includes(c.nameLower))
274
+ if (homeMatch) return homeMatch.filePath
275
+ }
276
+
277
+ // 6. Lenient page route fallback — return the page route file for the current path
278
+ // regardless of element depth (better than a hardcoded heuristic path)
279
+ if (fileMap.routes.length > 0) {
280
+ const pageRoute = findPageRoute(fileMap.routes, pagePath)
281
+ if (pageRoute) return pageRoute.filePath
282
+ }
283
+
284
+ // 7. Last resort — if we have any routes at all, return the root page
285
+ // This is always better than the hardcoded heuristic fallback
286
+ if (fileMap.routes.length > 0) {
287
+ const rootPage = fileMap.routes.find(
288
+ (r) => r.type === 'page' && r.urlPattern === '/',
289
+ )
290
+ if (rootPage) return rootPage.filePath
291
+ }
292
+
293
+ return null
294
+ }
295
+
296
+ export function inferComponentWidgetName(opts: {
297
+ tagName: string
298
+ className: string | null
299
+ elementId: string | null
300
+ sourceInfo?: SourceInfo | null
301
+ }): string {
302
+ if (opts.sourceInfo?.componentName) {
303
+ return opts.sourceInfo.componentName
304
+ }
305
+ if (opts.sourceInfo?.componentChain?.length) {
306
+ return opts.sourceInfo.componentChain[
307
+ opts.sourceInfo.componentChain.length - 1
308
+ ]
309
+ }
310
+ const combined = `${opts.className || ''} ${opts.elementId || ''} ${opts.tagName}`
311
+ const mapped = findComponentName(combined)
312
+ if (mapped) return mapped
313
+ return opts.tagName
314
+ }
315
+
316
+ export function inferSourcePath(opts: {
317
+ tagName: string
318
+ className: string | null
319
+ id: string | null
320
+ selectorPath: string | null
321
+ pagePath: string
322
+ fileMap?: FileMap | null
323
+ sourceInfo?: SourceInfo | null
324
+ projectRoot?: string | null
325
+ }): string {
326
+ // Priority 0: React fiber _debugSource (exact file + line)
327
+ if (opts.sourceInfo?.fileName) {
328
+ const abs = opts.sourceInfo.fileName
329
+ if (opts.projectRoot) {
330
+ const root = opts.projectRoot.replace(/\/$/, '')
331
+ if (abs.startsWith(root + '/')) {
332
+ return abs.substring(root.length + 1)
333
+ }
334
+ }
335
+ // Return absolute path if can't make relative (still accurate)
336
+ return abs
337
+ }
338
+
339
+ // Try filesystem-backed resolution first
340
+ if (opts.fileMap) {
341
+ const resolved = resolveFromFileMap({
342
+ tagName: opts.tagName,
343
+ className: opts.className,
344
+ id: opts.id,
345
+ selectorPath: opts.selectorPath,
346
+ pagePath: opts.pagePath,
347
+ fileMap: opts.fileMap,
348
+ })
349
+ if (resolved) return resolved
350
+ }
351
+
352
+ const tag = opts.tagName.toLowerCase()
353
+ const cls = opts.className || ''
354
+ const id = opts.id || ''
355
+ const combined = `${cls} ${id} ${tag}`
356
+ const pagePath = opts.pagePath || '/'
357
+
358
+ // 1. Layout — structural wrappers
359
+ if (LAYOUT_TAGS.has(tag) || matchesAny(combined, LAYOUT_HINTS)) {
360
+ // Try to guess specific component name from classes/id
361
+ const name = findComponentName(combined)
362
+ if (name && tag !== 'html' && tag !== 'body' && tag !== 'main') {
363
+ return `src/components/${name}.tsx`
364
+ }
365
+ // Root layout
366
+ if (pagePath === '/') return 'src/app/layout.tsx'
367
+ const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
368
+ return `src/app/${segments.join('/')}/layout.tsx`
369
+ }
370
+
371
+ // 2. Page-level content
372
+ if (matchesAny(combined, PAGE_HINTS)) {
373
+ if (pagePath === '/') return 'src/app/page.tsx'
374
+ const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
375
+ return `src/app/${segments.join('/')}/page.tsx`
376
+ }
377
+
378
+ // 3. Component — match known component names
379
+ const componentName = findComponentName(combined)
380
+ if (componentName) {
381
+ return `src/components/${componentName}.tsx`
382
+ }
383
+
384
+ // 4. Interactive HTML elements → components
385
+ if (
386
+ tag === 'button' ||
387
+ tag === 'input' ||
388
+ tag === 'select' ||
389
+ tag === 'textarea' ||
390
+ tag === 'a' ||
391
+ tag === 'img' ||
392
+ tag === 'table' ||
393
+ tag === 'form' ||
394
+ tag === 'label'
395
+ ) {
396
+ // Try to derive name from id or first meaningful class
397
+ if (id) return `src/components/${toPascalCase(id)}.tsx`
398
+ const firstClass = cls
399
+ .split(/\s+/)
400
+ .find(
401
+ (c) =>
402
+ c.length > 2 &&
403
+ !c.startsWith('text-') &&
404
+ !c.startsWith('bg-') &&
405
+ !c.startsWith('flex') &&
406
+ !c.startsWith('p-') &&
407
+ !c.startsWith('m-'),
408
+ )
409
+ if (firstClass) return `src/components/${toPascalCase(firstClass)}.tsx`
410
+ return 'src/components/'
411
+ }
412
+
413
+ // 5. Depth heuristic — shallow = layout/page, deep = component
414
+ if (opts.selectorPath) {
415
+ const depth = opts.selectorPath.split(' > ').length
416
+ if (depth <= 2) {
417
+ if (pagePath === '/') return 'src/app/layout.tsx'
418
+ return `src/app/${pagePath.replace(/^\/|\/$/g, '')}/layout.tsx`
419
+ }
420
+ if (depth <= 4) {
421
+ if (pagePath === '/') return 'src/app/page.tsx'
422
+ return `src/app/${pagePath.replace(/^\/|\/$/g, '')}/page.tsx`
423
+ }
424
+ }
425
+
426
+ // Default — page file
427
+ if (pagePath === '/') return 'src/app/page.tsx'
428
+ const segments = pagePath.replace(/^\/|\/$/g, '').split('/')
429
+ return `src/app/${segments.join('/')}/page.tsx`
430
+ }
@@ -0,0 +1,197 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { spawn, execFileSync } from 'node:child_process'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ let cached: string | null = null
7
+
8
+ /**
9
+ * Resolve the absolute path to the `claude` CLI binary.
10
+ * Next.js server processes often lack HOME and have a minimal PATH,
11
+ * so we use os.homedir() and scan multiple known locations.
12
+ */
13
+ export function getClaudeBin(): string {
14
+ if (cached) return cached
15
+
16
+ // os.homedir() works even when $HOME is unset (reads /etc/passwd)
17
+ const home = homedir()
18
+ const candidates = [
19
+ `${home}/.local/bin/claude`,
20
+ `${home}/.claude/local/claude`,
21
+ '/usr/local/bin/claude',
22
+ `${home}/.npm-global/bin/claude`,
23
+ `${home}/.bun/bin/claude`,
24
+ `${home}/.volta/bin/claude`,
25
+ ]
26
+
27
+ // Also check nvm versioned dirs (the "current" symlink may not exist)
28
+ try {
29
+ const nvmDir = `${home}/.nvm/versions/node`
30
+ if (existsSync(nvmDir)) {
31
+ for (const ver of readdirSync(nvmDir)) {
32
+ candidates.push(join(nvmDir, ver, 'bin', 'claude'))
33
+ }
34
+ }
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+
39
+ for (const p of candidates) {
40
+ if (existsSync(p)) {
41
+ cached = p
42
+ return cached
43
+ }
44
+ }
45
+
46
+ // Scan PATH directories as a last resort
47
+ const pathDirs = (process.env.PATH || '').split(':')
48
+ for (const dir of pathDirs) {
49
+ if (!dir) continue
50
+ const p = join(dir, 'claude')
51
+ if (existsSync(p)) {
52
+ cached = p
53
+ return cached
54
+ }
55
+ }
56
+
57
+ // Fall back to bare name — will only work if PATH happens to include it
58
+ return 'claude'
59
+ }
60
+
61
+ export interface SpawnResult {
62
+ exitCode: number
63
+ stdout: string
64
+ stderr: string
65
+ }
66
+
67
+ export interface StreamingSpawnResult {
68
+ exitCode: number
69
+ stdout: string
70
+ }
71
+
72
+ export interface StreamingSpawnOptions {
73
+ cwd?: string
74
+ timeout?: number
75
+ onStderr?: (line: string) => void
76
+ }
77
+
78
+ const AUTH_PATTERNS = [
79
+ /not authenticated/i,
80
+ /not logged in/i,
81
+ /authentication required/i,
82
+ /api key/i,
83
+ /unauthorized/i,
84
+ /login required/i,
85
+ /please log in/i,
86
+ /claude login/i,
87
+ /ANTHROPIC_API_KEY/,
88
+ ]
89
+
90
+ /** Check if stderr indicates an authentication / login issue. */
91
+ export function isAuthError(stderr: string): boolean {
92
+ return AUTH_PATTERNS.some((re) => re.test(stderr))
93
+ }
94
+
95
+ /**
96
+ * Spawn the claude CLI with given args using Node's child_process.
97
+ * Works in both Bun and Node runtimes (Next.js Turbopack uses Node).
98
+ */
99
+ export function spawnClaude(
100
+ args: string[],
101
+ options: { cwd?: string; timeout?: number } = {},
102
+ ): Promise<SpawnResult> {
103
+ const claudeBin = getClaudeBin()
104
+ const { cwd, timeout = 120_000 } = options
105
+
106
+ return new Promise((resolve, reject) => {
107
+ const proc = spawn(claudeBin, args, {
108
+ cwd,
109
+ env: { ...process.env, CLAUDECODE: undefined },
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ })
112
+
113
+ const stdoutChunks: Buffer[] = []
114
+ const stderrChunks: Buffer[] = []
115
+
116
+ proc.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
117
+ proc.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
118
+
119
+ const timer = setTimeout(() => {
120
+ proc.kill('SIGKILL')
121
+ reject(new Error('TIMEOUT'))
122
+ }, timeout)
123
+
124
+ proc.on('close', (code) => {
125
+ clearTimeout(timer)
126
+ resolve({
127
+ exitCode: code ?? 1,
128
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
129
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
130
+ })
131
+ })
132
+
133
+ proc.on('error', (err) => {
134
+ clearTimeout(timer)
135
+ reject(err)
136
+ })
137
+ })
138
+ }
139
+
140
+ /**
141
+ * Spawn the claude CLI with streaming stderr.
142
+ * stderr is line-buffered and delivered via onStderr callback.
143
+ * stdout is fully buffered (contains diffs/JSON that need complete parsing).
144
+ */
145
+ export function spawnClaudeStreaming(
146
+ args: string[],
147
+ options: StreamingSpawnOptions = {},
148
+ ): Promise<StreamingSpawnResult> {
149
+ const claudeBin = getClaudeBin()
150
+ const { cwd, timeout = 120_000, onStderr } = options
151
+
152
+ return new Promise((resolve, reject) => {
153
+ const proc = spawn(claudeBin, args, {
154
+ cwd,
155
+ env: { ...process.env, CLAUDECODE: undefined },
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ })
158
+
159
+ const stdoutChunks: Buffer[] = []
160
+ let stderrPartial = ''
161
+
162
+ proc.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk))
163
+
164
+ proc.stderr.on('data', (chunk: Buffer) => {
165
+ if (!onStderr) return
166
+ stderrPartial += chunk.toString('utf-8')
167
+ const lines = stderrPartial.split('\n')
168
+ // Hold the last element — it may be a partial line
169
+ stderrPartial = lines.pop() || ''
170
+ for (const line of lines) {
171
+ if (line) onStderr(line)
172
+ }
173
+ })
174
+
175
+ const timer = setTimeout(() => {
176
+ proc.kill('SIGKILL')
177
+ reject(new Error('TIMEOUT'))
178
+ }, timeout)
179
+
180
+ proc.on('close', (code) => {
181
+ clearTimeout(timer)
182
+ // Flush remaining partial line
183
+ if (onStderr && stderrPartial) {
184
+ onStderr(stderrPartial)
185
+ }
186
+ resolve({
187
+ exitCode: code ?? 1,
188
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
189
+ })
190
+ })
191
+
192
+ proc.on('error', (err) => {
193
+ clearTimeout(timer)
194
+ reject(err)
195
+ })
196
+ })
197
+ }