create-claudeportal 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/bin/cli.js +37 -0
- package/dist/assets/index-BBU5K5iA.js +132 -0
- package/dist/assets/index-fNmv07eE.css +1 -0
- package/dist/index.html +13 -0
- package/index.html +12 -0
- package/mockups/01-chat-conversation-v2.html +803 -0
- package/mockups/01-chat-conversation.html +592 -0
- package/mockups/02-activity-feed.html +648 -0
- package/mockups/03-focused-workspace.html +680 -0
- package/mockups/04-documents-mode.html +1556 -0
- package/package.json +54 -0
- package/server/index.js +140 -0
- package/server/lib/detect-tools.js +93 -0
- package/server/lib/file-scanner.js +46 -0
- package/server/lib/file-watcher.js +45 -0
- package/server/lib/fix-npm-prefix.js +61 -0
- package/server/lib/folder-scanner.js +43 -0
- package/server/lib/install-tools.js +122 -0
- package/server/lib/platform.js +18 -0
- package/server/lib/sse-manager.js +36 -0
- package/server/lib/terminal.js +95 -0
- package/server/lib/validate-folder-path.js +17 -0
- package/server/lib/validate-path.js +13 -0
- package/server/routes/detect.js +64 -0
- package/server/routes/doc-events.js +94 -0
- package/server/routes/events.js +37 -0
- package/server/routes/folder.js +195 -0
- package/server/routes/github.js +21 -0
- package/server/routes/health.js +16 -0
- package/server/routes/install.js +102 -0
- package/server/routes/project.js +18 -0
- package/server/routes/scaffold.js +45 -0
- package/skills-lock.json +15 -0
- package/tsconfig.app.json +17 -0
- package/tsconfig.node.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/app.js +747 -0
- package/ui/index.html +272 -0
- package/ui/styles.css +788 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// GREENHOUSE SETUP WIZARD — Client
|
|
3
|
+
// ============================================
|
|
4
|
+
|
|
5
|
+
let appState = {
|
|
6
|
+
currentScreen: 'setup',
|
|
7
|
+
tools: [],
|
|
8
|
+
platform: null,
|
|
9
|
+
ptyAvailable: false,
|
|
10
|
+
projectDir: null,
|
|
11
|
+
projectName: null,
|
|
12
|
+
terminal: null,
|
|
13
|
+
ws: null,
|
|
14
|
+
intentDescription: null,
|
|
15
|
+
claudeReady: false,
|
|
16
|
+
pendingPrompt: null,
|
|
17
|
+
firstBuildDone: false,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================
|
|
21
|
+
// INIT
|
|
22
|
+
// ============================================
|
|
23
|
+
|
|
24
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
25
|
+
// Get app info
|
|
26
|
+
const info = await fetchJson('/api/info')
|
|
27
|
+
if (info) {
|
|
28
|
+
appState.ptyAvailable = info.ptyAvailable
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Restore saved state
|
|
32
|
+
const saved = localStorage.getItem('greenhouse-setup')
|
|
33
|
+
let savedState = null
|
|
34
|
+
if (saved) {
|
|
35
|
+
try {
|
|
36
|
+
savedState = JSON.parse(saved)
|
|
37
|
+
appState.projectDir = savedState.projectDir || null
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Set up event listeners
|
|
42
|
+
setupListeners()
|
|
43
|
+
|
|
44
|
+
// If setup was already completed, skip straight to build screen
|
|
45
|
+
if (savedState?.setupComplete) {
|
|
46
|
+
showScreen('build')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Otherwise run tool detection
|
|
51
|
+
await detectTools()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ============================================
|
|
55
|
+
// SCREEN NAVIGATION
|
|
56
|
+
// ============================================
|
|
57
|
+
|
|
58
|
+
function showScreen(name) {
|
|
59
|
+
document.querySelectorAll('.screen').forEach((s) => s.classList.remove('active'))
|
|
60
|
+
document.getElementById(`screen-${name}`).classList.add('active')
|
|
61
|
+
appState.currentScreen = name
|
|
62
|
+
|
|
63
|
+
if (name === 'build') {
|
|
64
|
+
saveState(true)
|
|
65
|
+
// Show sidebar immediately for existing projects
|
|
66
|
+
if (appState.firstBuildDone) {
|
|
67
|
+
document.getElementById('build-sidebar').classList.remove('hidden')
|
|
68
|
+
document.getElementById('sidebar-reopen').classList.add('hidden')
|
|
69
|
+
}
|
|
70
|
+
initTerminal()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveState(setupComplete = false) {
|
|
75
|
+
localStorage.setItem('greenhouse-setup', JSON.stringify({
|
|
76
|
+
setupComplete,
|
|
77
|
+
projectDir: appState.projectDir,
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================
|
|
82
|
+
// SCREEN 1: TOOL DETECTION
|
|
83
|
+
// ============================================
|
|
84
|
+
|
|
85
|
+
async function detectTools() {
|
|
86
|
+
const list = document.getElementById('tools-list')
|
|
87
|
+
list.innerHTML = '<div class="tool-item loading"><span class="tool-status">⏳</span><span class="tool-name">Scanning your machine...</span></div>'
|
|
88
|
+
|
|
89
|
+
const data = await fetchJson('/api/detect')
|
|
90
|
+
if (!data) {
|
|
91
|
+
list.innerHTML = '<div class="tool-item error"><span class="tool-status">❌</span><span class="tool-name">Failed to connect to the setup server. Please refresh.</span></div>'
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
appState.tools = data.tools
|
|
96
|
+
appState.platform = data.platform
|
|
97
|
+
appState.drives = data.drives || []
|
|
98
|
+
|
|
99
|
+
renderToolList()
|
|
100
|
+
renderDriveShortcuts()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderToolList() {
|
|
104
|
+
const container = document.getElementById('tools-list')
|
|
105
|
+
const claudeInstalled = appState.tools.find((t) => t.id === 'claude')?.installed
|
|
106
|
+
|
|
107
|
+
// Compact inline display
|
|
108
|
+
const items = appState.tools.map((tool) => {
|
|
109
|
+
const icon = tool.installed ? '✅' : (tool.required ? '❌' : '⚠️')
|
|
110
|
+
const label = tool.installed ? `${tool.name} ${tool.version}` : `${tool.name} — missing`
|
|
111
|
+
return `<span class="tool-check">${icon} ${label}</span>`
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
container.innerHTML = items.join('')
|
|
115
|
+
|
|
116
|
+
// Show install button if Claude Code is missing
|
|
117
|
+
if (!claudeInstalled) {
|
|
118
|
+
container.innerHTML += `<span class="tool-install">
|
|
119
|
+
<button class="btn btn-small btn-secondary" onclick="installSingle('claude')">Install Claude Code</button>
|
|
120
|
+
</span>`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Update build button state
|
|
124
|
+
updateBuildButton()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function updateBuildButton() {
|
|
128
|
+
const buildBtn = document.getElementById('btn-start-building')
|
|
129
|
+
const descInput = document.getElementById('intent-description')
|
|
130
|
+
const hasDescription = descInput && descInput.value.trim().length > 0
|
|
131
|
+
const claudeOk = appState.tools.find((t) => t.id === 'claude')?.installed
|
|
132
|
+
const hasName = appState.projectName && appState.projectName.length > 0
|
|
133
|
+
buildBtn.disabled = !(hasDescription && claudeOk && hasName)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function installSingle(toolId) {
|
|
137
|
+
const container = document.getElementById('tools-list')
|
|
138
|
+
const tool = appState.tools.find((t) => t.id === toolId)
|
|
139
|
+
const toolName = tool ? tool.name : toolId
|
|
140
|
+
|
|
141
|
+
// Show installing state inline
|
|
142
|
+
container.innerHTML = `<span class="tool-check"><span class="spinner-small"></span> Installing ${toolName}...</span>`
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch(`/api/install/${toolId}`, { method: 'POST' })
|
|
146
|
+
if (!response.ok || !response.body) {
|
|
147
|
+
throw new Error(`Install request failed (HTTP ${response.status})`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const reader = response.body.getReader()
|
|
151
|
+
const decoder = new TextDecoder()
|
|
152
|
+
|
|
153
|
+
let buffer = ''
|
|
154
|
+
let failed = false
|
|
155
|
+
while (true) {
|
|
156
|
+
const { done, value } = await reader.read()
|
|
157
|
+
if (done) break
|
|
158
|
+
|
|
159
|
+
buffer += decoder.decode(value, { stream: true })
|
|
160
|
+
const lines = buffer.split('\n\n')
|
|
161
|
+
buffer = lines.pop() || ''
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
if (line.startsWith('data: ')) {
|
|
165
|
+
try {
|
|
166
|
+
const data = JSON.parse(line.slice(6))
|
|
167
|
+
if (data.status === 'error') {
|
|
168
|
+
failed = true
|
|
169
|
+
showError(toolId, data.error)
|
|
170
|
+
} else if (data.status === 'progress' && data.message) {
|
|
171
|
+
container.innerHTML = `<span class="tool-check"><span class="spinner-small"></span> ${data.message}</span>`
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error('Failed to parse install event:', line, e)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(`Install failed for ${toolId}:`, err)
|
|
181
|
+
showError(toolId, err.message)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-detect to update versions and rebuild tool display
|
|
186
|
+
await detectTools()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function showError(toolId, error) {
|
|
190
|
+
const recovery = document.getElementById('error-recovery')
|
|
191
|
+
const message = document.getElementById('error-message')
|
|
192
|
+
const technical = document.getElementById('error-technical')
|
|
193
|
+
|
|
194
|
+
const tool = appState.tools.find((t) => t.id === toolId)
|
|
195
|
+
message.textContent = `${tool ? tool.name : toolId} failed to install.`
|
|
196
|
+
const osInfo = appState.platform ? `${appState.platform.os} ${appState.platform.arch}` : 'unknown'
|
|
197
|
+
technical.textContent = `Tool: ${toolId}\nError: ${error}\nOS: ${osInfo}\nTime: ${new Date().toISOString()}`
|
|
198
|
+
|
|
199
|
+
recovery.style.display = 'block'
|
|
200
|
+
|
|
201
|
+
document.getElementById('btn-retry').onclick = () => {
|
|
202
|
+
recovery.style.display = 'none'
|
|
203
|
+
installSingle(toolId)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
document.getElementById('btn-skip').onclick = () => {
|
|
207
|
+
recovery.style.display = 'none'
|
|
208
|
+
// Mark tool as skipped so the Next button can appear
|
|
209
|
+
const tool = appState.tools.find((t) => t.id === toolId)
|
|
210
|
+
if (tool) tool.installed = true
|
|
211
|
+
renderToolList()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================
|
|
216
|
+
// START BUILDING
|
|
217
|
+
// ============================================
|
|
218
|
+
|
|
219
|
+
async function startBuilding() {
|
|
220
|
+
const projectName = appState.projectName
|
|
221
|
+
const description = document.getElementById('intent-description').value.trim()
|
|
222
|
+
if (!projectName || !description) return
|
|
223
|
+
|
|
224
|
+
const claudeInstalled = appState.tools.find((t) => t.id === 'claude')?.installed
|
|
225
|
+
if (!claudeInstalled) {
|
|
226
|
+
alert('Claude Code must be installed before you can build. Click "Install Claude Code" above.')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Disable button while scaffolding
|
|
231
|
+
const buildBtn = document.getElementById('btn-start-building')
|
|
232
|
+
buildBtn.disabled = true
|
|
233
|
+
buildBtn.textContent = 'Setting up...'
|
|
234
|
+
|
|
235
|
+
// Scaffold the starter template into ~/Claude/<name>/
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch('/api/scaffold', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({ projectName }),
|
|
241
|
+
})
|
|
242
|
+
const data = await res.json()
|
|
243
|
+
if (!res.ok) throw new Error(data.error || 'Scaffold failed')
|
|
244
|
+
appState.projectDir = data.projectDir
|
|
245
|
+
} catch (err) {
|
|
246
|
+
alert('Failed to create project: ' + err.message)
|
|
247
|
+
buildBtn.disabled = false
|
|
248
|
+
buildBtn.textContent = "Let's Build"
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
appState.intentDescription = [
|
|
253
|
+
`I want to build: ${description}.`,
|
|
254
|
+
'There is already a starter template in this directory with CLAUDE.md, src/ folder with React components, hooks, and utilities.',
|
|
255
|
+
'Read the CLAUDE.md first to understand the architecture rules.',
|
|
256
|
+
'Set up the project with Vite + React, install dependencies, and build the app using the provided component structure.',
|
|
257
|
+
'Make it look polished and professional.',
|
|
258
|
+
'When done, tell me to run npm run dev to preview it.',
|
|
259
|
+
].join(' ')
|
|
260
|
+
|
|
261
|
+
// Update sidebar project path
|
|
262
|
+
document.getElementById('current-project-path').textContent = `~/Claude/${projectName}`
|
|
263
|
+
|
|
264
|
+
showScreen('build')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderDriveShortcuts() {
|
|
268
|
+
const container = document.getElementById('drive-shortcuts')
|
|
269
|
+
const list = document.getElementById('drive-list')
|
|
270
|
+
if (!appState.drives || !appState.drives.length) return
|
|
271
|
+
|
|
272
|
+
const icons = {
|
|
273
|
+
icloud: '☁️',
|
|
274
|
+
gdrive: '📁',
|
|
275
|
+
onedrive: '☁️',
|
|
276
|
+
dropbox: '📦',
|
|
277
|
+
documents: '📄',
|
|
278
|
+
desktop: '🖥️',
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
list.innerHTML = appState.drives.map((drive) => {
|
|
282
|
+
const icon = icons[drive.id] || '📂'
|
|
283
|
+
return `<button class="drive-btn" data-path="${drive.path}">
|
|
284
|
+
<span class="drive-icon">${icon}</span>${drive.name}
|
|
285
|
+
</button>`
|
|
286
|
+
}).join('')
|
|
287
|
+
|
|
288
|
+
// Click to fill the path input
|
|
289
|
+
list.querySelectorAll('.drive-btn').forEach((btn) => {
|
|
290
|
+
btn.addEventListener('click', () => {
|
|
291
|
+
const pathInput = document.getElementById('existing-path')
|
|
292
|
+
pathInput.value = btn.dataset.path
|
|
293
|
+
pathInput.dispatchEvent(new Event('input'))
|
|
294
|
+
pathInput.focus()
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
container.style.display = 'block'
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function openExisting() {
|
|
302
|
+
const folderPath = document.getElementById('existing-path').value.trim()
|
|
303
|
+
const pain = document.getElementById('existing-pain').value.trim()
|
|
304
|
+
const outcome = document.getElementById('existing-outcome').value.trim()
|
|
305
|
+
if (!folderPath || !pain || !outcome) return
|
|
306
|
+
|
|
307
|
+
appState.projectDir = folderPath
|
|
308
|
+
appState.projectName = null
|
|
309
|
+
|
|
310
|
+
// Build a prompt from pain + outcome
|
|
311
|
+
appState.intentDescription = [
|
|
312
|
+
'First, explore this project — read the key files and understand the codebase structure.',
|
|
313
|
+
`The problem I need help with: ${pain}.`,
|
|
314
|
+
`The ideal outcome: ${outcome}.`,
|
|
315
|
+
'Start by summarizing what you found in the project, then suggest a plan to achieve the outcome.',
|
|
316
|
+
'Ask me before making any changes.',
|
|
317
|
+
].join(' ')
|
|
318
|
+
|
|
319
|
+
// Update sidebar project path
|
|
320
|
+
document.getElementById('current-project-path').textContent = folderPath
|
|
321
|
+
|
|
322
|
+
// Show sidebar immediately for existing projects
|
|
323
|
+
appState.firstBuildDone = true
|
|
324
|
+
|
|
325
|
+
showScreen('build')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================
|
|
329
|
+
// SCREEN 3: TERMINAL
|
|
330
|
+
// ============================================
|
|
331
|
+
|
|
332
|
+
function initTerminal() {
|
|
333
|
+
// Clean up existing terminal if any
|
|
334
|
+
if (appState.terminal) {
|
|
335
|
+
appState.terminal.dispose()
|
|
336
|
+
appState.terminal = null
|
|
337
|
+
}
|
|
338
|
+
if (appState.ws) {
|
|
339
|
+
appState.ws.close()
|
|
340
|
+
appState.ws = null
|
|
341
|
+
}
|
|
342
|
+
if (appState.fallbackTimer) {
|
|
343
|
+
clearTimeout(appState.fallbackTimer)
|
|
344
|
+
appState.fallbackTimer = null
|
|
345
|
+
}
|
|
346
|
+
if (appState.resizeObserver) {
|
|
347
|
+
appState.resizeObserver.disconnect()
|
|
348
|
+
appState.resizeObserver = null
|
|
349
|
+
}
|
|
350
|
+
appState.firstBuildDone = false
|
|
351
|
+
appState.claudeReady = false
|
|
352
|
+
appState.claudeLaunchedAt = null
|
|
353
|
+
// Clear the container
|
|
354
|
+
const container = document.getElementById('terminal-container')
|
|
355
|
+
container.innerHTML = ''
|
|
356
|
+
|
|
357
|
+
if (!appState.ptyAvailable) {
|
|
358
|
+
container.style.display = 'none'
|
|
359
|
+
document.getElementById('terminal-fallback').style.display = 'flex'
|
|
360
|
+
|
|
361
|
+
document.getElementById('btn-open-native').onclick = openNativeTerminal
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check if xterm CDN scripts loaded
|
|
366
|
+
if (!window.Terminal || !window.FitAddon || !window.WebLinksAddon) {
|
|
367
|
+
container.style.display = 'none'
|
|
368
|
+
document.getElementById('terminal-fallback').style.display = 'flex'
|
|
369
|
+
document.getElementById('terminal-fallback').querySelector('p').textContent =
|
|
370
|
+
'Terminal libraries failed to load (check your internet connection).'
|
|
371
|
+
document.getElementById('btn-open-native').onclick = openNativeTerminal
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const Terminal = window.Terminal
|
|
376
|
+
const FitAddon = window.FitAddon.FitAddon
|
|
377
|
+
const WebLinksAddon = window.WebLinksAddon.WebLinksAddon
|
|
378
|
+
|
|
379
|
+
const term = new Terminal({
|
|
380
|
+
theme: {
|
|
381
|
+
background: '#1a1a2e',
|
|
382
|
+
foreground: '#e0e0e0',
|
|
383
|
+
cursor: '#4ade80',
|
|
384
|
+
selectionBackground: 'rgba(74, 222, 128, 0.25)',
|
|
385
|
+
black: '#1a1a2e',
|
|
386
|
+
red: '#ef4444',
|
|
387
|
+
green: '#22c55e',
|
|
388
|
+
yellow: '#f59e0b',
|
|
389
|
+
blue: '#3b82f6',
|
|
390
|
+
magenta: '#a855f7',
|
|
391
|
+
cyan: '#06b6d4',
|
|
392
|
+
white: '#e0e0e0',
|
|
393
|
+
},
|
|
394
|
+
fontFamily: '"JetBrains Mono", "Fira Code", "SF Mono", monospace',
|
|
395
|
+
fontSize: 14,
|
|
396
|
+
cursorBlink: true,
|
|
397
|
+
scrollback: 5000,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const fitAddon = new FitAddon()
|
|
401
|
+
term.loadAddon(fitAddon)
|
|
402
|
+
term.loadAddon(new WebLinksAddon())
|
|
403
|
+
|
|
404
|
+
term.open(container)
|
|
405
|
+
fitAddon.fit()
|
|
406
|
+
|
|
407
|
+
appState.terminal = term
|
|
408
|
+
|
|
409
|
+
// Connect WebSocket
|
|
410
|
+
const cwd = encodeURIComponent(appState.projectDir || '')
|
|
411
|
+
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
412
|
+
const ws = new WebSocket(`${wsProtocol}//${location.host}/terminal?cwd=${cwd}`)
|
|
413
|
+
appState.ws = ws
|
|
414
|
+
|
|
415
|
+
ws.onopen = () => {
|
|
416
|
+
appState.claudeReady = false
|
|
417
|
+
appState.claudeLaunchedAt = null
|
|
418
|
+
appState.pendingPrompt = appState.intentDescription || null
|
|
419
|
+
appState.intentDescription = null
|
|
420
|
+
|
|
421
|
+
// Launch Claude Code — project folder already created by /api/scaffold
|
|
422
|
+
// and the WebSocket cwd param sets the working directory
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
ws.send(JSON.stringify({ type: 'input', data: 'claude --dangerously-skip-permissions\r' }))
|
|
425
|
+
appState.claudeLaunchedAt = Date.now()
|
|
426
|
+
}, 500)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
ws.onmessage = (e) => {
|
|
430
|
+
let msg
|
|
431
|
+
try {
|
|
432
|
+
msg = JSON.parse(e.data)
|
|
433
|
+
} catch {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
switch (msg.type) {
|
|
437
|
+
case 'output':
|
|
438
|
+
term.write(msg.data)
|
|
439
|
+
|
|
440
|
+
// Detect Claude Code readiness — look for the interactive prompt.
|
|
441
|
+
// Ignore output in the first 2s after launch to avoid false positives
|
|
442
|
+
// from shell echo, npm warnings, etc. that may contain ">".
|
|
443
|
+
if (!appState.claudeReady && appState.pendingPrompt && appState.claudeLaunchedAt) {
|
|
444
|
+
const elapsed = Date.now() - appState.claudeLaunchedAt
|
|
445
|
+
if (elapsed > 2000) {
|
|
446
|
+
const clean = msg.data.replace(/\x1b(?:\[[0-9;]*[a-zA-Z]|\][^\x07]*\x07|.)/g, '')
|
|
447
|
+
if (/>\s*$/.test(clean) || clean.includes('What can I help')) {
|
|
448
|
+
appState.claudeReady = true
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
if (appState.pendingPrompt && ws.readyState === WebSocket.OPEN) {
|
|
451
|
+
ws.send(JSON.stringify({ type: 'input', data: appState.pendingPrompt + '\r' }))
|
|
452
|
+
appState.pendingPrompt = null
|
|
453
|
+
}
|
|
454
|
+
}, 300)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// After the first prompt is sent, watch for Claude finishing its response.
|
|
460
|
+
// Require at least 10s after prompt delivery to avoid mid-response triggers.
|
|
461
|
+
if (appState.claudeReady && !appState.firstBuildDone && !appState.pendingPrompt) {
|
|
462
|
+
const elapsed = Date.now() - appState.claudeLaunchedAt
|
|
463
|
+
if (elapsed > 10000) {
|
|
464
|
+
const clean = msg.data.replace(/\x1b(?:\[[0-9;]*[a-zA-Z]|\][^\x07]*\x07|.)/g, '')
|
|
465
|
+
if (/>\s*$/.test(clean)) {
|
|
466
|
+
appState.firstBuildDone = true
|
|
467
|
+
const sidebar = document.getElementById('build-sidebar')
|
|
468
|
+
const reopenTab = document.getElementById('sidebar-reopen')
|
|
469
|
+
sidebar.classList.remove('hidden')
|
|
470
|
+
reopenTab.classList.add('hidden')
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
break
|
|
475
|
+
case 'exit':
|
|
476
|
+
term.writeln('\r\n[Process exited. Press any key to restart]')
|
|
477
|
+
break
|
|
478
|
+
case 'error':
|
|
479
|
+
document.getElementById('terminal-container').style.display = 'none'
|
|
480
|
+
document.getElementById('terminal-fallback').style.display = 'flex'
|
|
481
|
+
break
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
ws.onclose = () => {
|
|
486
|
+
term.writeln('\r\n[Connection lost. Refresh to reconnect]')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Send keystrokes to server
|
|
490
|
+
term.onData((data) => {
|
|
491
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
492
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// Handle resize
|
|
497
|
+
appState.resizeObserver = new ResizeObserver(() => {
|
|
498
|
+
fitAddon.fit()
|
|
499
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
500
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
appState.resizeObserver.observe(container)
|
|
504
|
+
|
|
505
|
+
// Fallback: if readiness detection doesn't trigger in 15s, send anyway
|
|
506
|
+
appState.fallbackTimer = setTimeout(() => {
|
|
507
|
+
if (appState.pendingPrompt && ws.readyState === WebSocket.OPEN) {
|
|
508
|
+
ws.send(JSON.stringify({ type: 'input', data: appState.pendingPrompt + '\r' }))
|
|
509
|
+
appState.pendingPrompt = null
|
|
510
|
+
appState.claudeReady = true
|
|
511
|
+
}
|
|
512
|
+
appState.fallbackTimer = null
|
|
513
|
+
}, 15000)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function openNativeTerminal() {
|
|
517
|
+
const dir = appState.projectDir || '~'
|
|
518
|
+
alert(`Open your terminal app and run:\n\ncd ${dir}\nclaude --dangerously-skip-permissions`)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ============================================
|
|
522
|
+
// PANEL TOGGLES
|
|
523
|
+
// ============================================
|
|
524
|
+
|
|
525
|
+
function toggleSidebar() {
|
|
526
|
+
const sidebar = document.getElementById('build-sidebar')
|
|
527
|
+
const reopenTab = document.getElementById('sidebar-reopen')
|
|
528
|
+
const isHidden = sidebar.classList.toggle('hidden')
|
|
529
|
+
reopenTab.classList.toggle('hidden', !isHidden)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function togglePreview() {
|
|
533
|
+
const preview = document.getElementById('build-preview')
|
|
534
|
+
const iframe = document.getElementById('preview-iframe')
|
|
535
|
+
const placeholder = document.getElementById('preview-placeholder')
|
|
536
|
+
const isHidden = preview.classList.toggle('hidden')
|
|
537
|
+
|
|
538
|
+
if (!isHidden) {
|
|
539
|
+
iframe.src = 'http://localhost:5173'
|
|
540
|
+
iframe.onload = () => {
|
|
541
|
+
placeholder.style.display = 'none'
|
|
542
|
+
iframe.style.display = 'block'
|
|
543
|
+
}
|
|
544
|
+
iframe.onerror = () => {
|
|
545
|
+
placeholder.style.display = 'flex'
|
|
546
|
+
iframe.style.display = 'none'
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
iframe.src = ''
|
|
550
|
+
placeholder.style.display = 'flex'
|
|
551
|
+
iframe.style.display = 'none'
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ============================================
|
|
556
|
+
// GITHUB CLONE
|
|
557
|
+
// ============================================
|
|
558
|
+
|
|
559
|
+
async function showRepoList() {
|
|
560
|
+
const list = document.getElementById('clone-repo-list')
|
|
561
|
+
const items = document.getElementById('repo-items')
|
|
562
|
+
list.style.display = 'block'
|
|
563
|
+
items.innerHTML = '<div class="loading">Loading repos...</div>'
|
|
564
|
+
|
|
565
|
+
const data = await fetchJson('/api/github/repos')
|
|
566
|
+
if (!data || !data.repos || !data.repos.length) {
|
|
567
|
+
items.innerHTML = '<div class="text-muted">No repos found</div>'
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
items.innerHTML = ''
|
|
572
|
+
data.repos.forEach((repo) => {
|
|
573
|
+
const btn = document.createElement('button')
|
|
574
|
+
btn.className = 'repo-item'
|
|
575
|
+
btn.textContent = repo.name
|
|
576
|
+
btn.onclick = () => cloneRepo(repo.name, repo.url)
|
|
577
|
+
items.appendChild(btn)
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function shellEscape(str) {
|
|
582
|
+
return "'" + str.replace(/'/g, "'\\''") + "'"
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function cloneRepo(name, url) {
|
|
586
|
+
if (!appState.ws || appState.ws.readyState !== WebSocket.OPEN) return
|
|
587
|
+
document.getElementById('clone-repo-list').style.display = 'none'
|
|
588
|
+
appState.ws.send(JSON.stringify({ type: 'input', data: `gh repo clone ${shellEscape(url)} && cd ${shellEscape(name)}\r` }))
|
|
589
|
+
document.getElementById('current-project-path').textContent = `~/Claude/${name}`
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ============================================
|
|
593
|
+
// EVENT LISTENERS
|
|
594
|
+
// ============================================
|
|
595
|
+
|
|
596
|
+
function setupListeners() {
|
|
597
|
+
// Path selection
|
|
598
|
+
document.getElementById('path-new').addEventListener('click', () => showScreen('new'))
|
|
599
|
+
document.getElementById('path-existing').addEventListener('click', () => showScreen('existing'))
|
|
600
|
+
|
|
601
|
+
// Back buttons
|
|
602
|
+
document.getElementById('btn-back-new').addEventListener('click', () => showScreen('setup'))
|
|
603
|
+
document.getElementById('btn-back-existing').addEventListener('click', () => showScreen('setup'))
|
|
604
|
+
|
|
605
|
+
// New project: project name input
|
|
606
|
+
const nameInput = document.getElementById('project-name')
|
|
607
|
+
const descStep = document.getElementById('description-step')
|
|
608
|
+
const descInput = document.getElementById('intent-description')
|
|
609
|
+
const buildBtn = document.getElementById('btn-start-building')
|
|
610
|
+
|
|
611
|
+
nameInput.addEventListener('input', () => {
|
|
612
|
+
const raw = nameInput.value
|
|
613
|
+
const clean = raw.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9._-]/g, '')
|
|
614
|
+
if (raw !== clean) nameInput.value = clean
|
|
615
|
+
appState.projectName = clean
|
|
616
|
+
|
|
617
|
+
if (clean.length > 0) {
|
|
618
|
+
descStep.style.display = 'block'
|
|
619
|
+
} else {
|
|
620
|
+
descStep.style.display = 'none'
|
|
621
|
+
}
|
|
622
|
+
updateBuildButton()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
descInput.addEventListener('input', updateBuildButton)
|
|
626
|
+
|
|
627
|
+
descInput.addEventListener('keydown', (e) => {
|
|
628
|
+
if (e.key === 'Enter' && !buildBtn.disabled) {
|
|
629
|
+
e.preventDefault()
|
|
630
|
+
startBuilding()
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
buildBtn.addEventListener('click', startBuilding)
|
|
635
|
+
|
|
636
|
+
// Example buttons fill the project name input
|
|
637
|
+
document.querySelectorAll('.example-btn').forEach((btn) => {
|
|
638
|
+
btn.addEventListener('click', () => {
|
|
639
|
+
nameInput.value = btn.dataset.example
|
|
640
|
+
nameInput.dispatchEvent(new Event('input'))
|
|
641
|
+
descInput.focus()
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Existing project: progressive reveal (path → pain → outcome → button)
|
|
646
|
+
const pathInput = document.getElementById('existing-path')
|
|
647
|
+
const contextStep = document.getElementById('existing-context')
|
|
648
|
+
const painInput = document.getElementById('existing-pain')
|
|
649
|
+
const outcomeStep = document.getElementById('existing-outcome-step')
|
|
650
|
+
const outcomeInput = document.getElementById('existing-outcome')
|
|
651
|
+
const openBtn = document.getElementById('btn-open-existing')
|
|
652
|
+
|
|
653
|
+
pathInput.addEventListener('input', () => {
|
|
654
|
+
if (pathInput.value.trim()) {
|
|
655
|
+
contextStep.style.display = 'block'
|
|
656
|
+
} else {
|
|
657
|
+
contextStep.style.display = 'none'
|
|
658
|
+
outcomeStep.style.display = 'none'
|
|
659
|
+
}
|
|
660
|
+
updateOpenButton()
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
painInput.addEventListener('input', () => {
|
|
664
|
+
if (painInput.value.trim()) {
|
|
665
|
+
outcomeStep.style.display = 'block'
|
|
666
|
+
} else {
|
|
667
|
+
outcomeStep.style.display = 'none'
|
|
668
|
+
}
|
|
669
|
+
updateOpenButton()
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
outcomeInput.addEventListener('input', updateOpenButton)
|
|
673
|
+
|
|
674
|
+
outcomeInput.addEventListener('keydown', (e) => {
|
|
675
|
+
if (e.key === 'Enter' && !openBtn.disabled) {
|
|
676
|
+
e.preventDefault()
|
|
677
|
+
openExisting()
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
openBtn.addEventListener('click', openExisting)
|
|
682
|
+
|
|
683
|
+
function updateOpenButton() {
|
|
684
|
+
const hasPath = pathInput.value.trim().length > 0
|
|
685
|
+
const hasPain = painInput.value.trim().length > 0
|
|
686
|
+
const hasOutcome = outcomeInput.value.trim().length > 0
|
|
687
|
+
openBtn.disabled = !(hasPath && hasPain && hasOutcome)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Screen 3: Project management
|
|
691
|
+
document.getElementById('btn-clone-github').addEventListener('click', showRepoList)
|
|
692
|
+
document.getElementById('btn-close-repos').addEventListener('click', () => {
|
|
693
|
+
document.getElementById('clone-repo-list').style.display = 'none'
|
|
694
|
+
})
|
|
695
|
+
document.getElementById('btn-new-project').addEventListener('click', () => {
|
|
696
|
+
const name = prompt('Project folder name:')
|
|
697
|
+
if (!name) return
|
|
698
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
699
|
+
alert('Folder name can only contain letters, numbers, hyphens, dots, and underscores.')
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
if (appState.ws && appState.ws.readyState === WebSocket.OPEN) {
|
|
703
|
+
appState.ws.send(JSON.stringify({ type: 'input', data: `mkdir -p ${shellEscape(name)} && cd ${shellEscape(name)}\r` }))
|
|
704
|
+
}
|
|
705
|
+
document.getElementById('current-project-path').textContent = `~/Claude/${name}`
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
// Screen 3: Panel toggles
|
|
709
|
+
document.getElementById('btn-toggle-sidebar').addEventListener('click', toggleSidebar)
|
|
710
|
+
document.getElementById('sidebar-reopen').addEventListener('click', toggleSidebar)
|
|
711
|
+
document.getElementById('btn-toggle-preview').addEventListener('click', togglePreview)
|
|
712
|
+
document.getElementById('btn-close-preview').addEventListener('click', togglePreview)
|
|
713
|
+
document.getElementById('btn-refresh-preview').addEventListener('click', () => {
|
|
714
|
+
const iframe = document.getElementById('preview-iframe')
|
|
715
|
+
iframe.src = iframe.src
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
// Prompt buttons — click to type into terminal
|
|
719
|
+
document.addEventListener('click', (e) => {
|
|
720
|
+
const promptBtn = e.target.closest('.prompt-btn')
|
|
721
|
+
if (!promptBtn) return
|
|
722
|
+
|
|
723
|
+
const text = promptBtn.dataset.prompt
|
|
724
|
+
if (!text || !appState.ws || appState.ws.readyState !== WebSocket.OPEN) return
|
|
725
|
+
|
|
726
|
+
const autoExec = promptBtn.dataset.auto === 'true'
|
|
727
|
+
appState.ws.send(JSON.stringify({ type: 'input', data: autoExec ? text + '\r' : text }))
|
|
728
|
+
|
|
729
|
+
promptBtn.classList.add('prompt-sent')
|
|
730
|
+
setTimeout(() => promptBtn.classList.remove('prompt-sent'), 600)
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ============================================
|
|
735
|
+
// HELPERS
|
|
736
|
+
// ============================================
|
|
737
|
+
|
|
738
|
+
async function fetchJson(url, options = {}) {
|
|
739
|
+
try {
|
|
740
|
+
const res = await fetch(url, options)
|
|
741
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
742
|
+
return res.json()
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error(`Failed to fetch ${url}:`, err)
|
|
745
|
+
return null
|
|
746
|
+
}
|
|
747
|
+
}
|