prjct-cli 0.11.0 → 0.11.2
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/serve.js +90 -26
- package/package.json +11 -1
- package/packages/shared/dist/index.d.ts +615 -0
- package/packages/shared/dist/index.js +204 -0
- package/packages/shared/package.json +29 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/schemas.ts +124 -0
- package/packages/shared/src/types.ts +187 -0
- package/packages/shared/src/utils.ts +148 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/web/README.md +36 -0
- package/packages/web/app/api/claude/sessions/route.ts +44 -0
- package/packages/web/app/api/claude/status/route.ts +34 -0
- package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
- package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
- package/packages/web/app/api/projects/[id]/route.ts +29 -0
- package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
- package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
- package/packages/web/app/api/projects/route.ts +16 -0
- package/packages/web/app/api/sessions/history/route.ts +122 -0
- package/packages/web/app/api/stats/route.ts +38 -0
- package/packages/web/app/error.tsx +34 -0
- package/packages/web/app/favicon.ico +0 -0
- package/packages/web/app/globals.css +155 -0
- package/packages/web/app/layout.tsx +43 -0
- package/packages/web/app/loading.tsx +7 -0
- package/packages/web/app/not-found.tsx +25 -0
- package/packages/web/app/page.tsx +227 -0
- package/packages/web/app/project/[id]/error.tsx +41 -0
- package/packages/web/app/project/[id]/loading.tsx +9 -0
- package/packages/web/app/project/[id]/not-found.tsx +27 -0
- package/packages/web/app/project/[id]/page.tsx +253 -0
- package/packages/web/app/project/[id]/stats/page.tsx +447 -0
- package/packages/web/app/sessions/page.tsx +165 -0
- package/packages/web/app/settings/page.tsx +150 -0
- package/packages/web/components/AppSidebar.tsx +113 -0
- package/packages/web/components/CommandButton.tsx +39 -0
- package/packages/web/components/ConnectionStatus.tsx +29 -0
- package/packages/web/components/Logo.tsx +65 -0
- package/packages/web/components/MarkdownContent.tsx +123 -0
- package/packages/web/components/ProjectAvatar.tsx +54 -0
- package/packages/web/components/TechStackBadges.tsx +20 -0
- package/packages/web/components/TerminalTab.tsx +84 -0
- package/packages/web/components/TerminalTabs.tsx +210 -0
- package/packages/web/components/charts/SessionsChart.tsx +172 -0
- package/packages/web/components/providers.tsx +45 -0
- package/packages/web/components/ui/alert-dialog.tsx +157 -0
- package/packages/web/components/ui/badge.tsx +46 -0
- package/packages/web/components/ui/button.tsx +60 -0
- package/packages/web/components/ui/card.tsx +92 -0
- package/packages/web/components/ui/chart.tsx +385 -0
- package/packages/web/components/ui/dropdown-menu.tsx +257 -0
- package/packages/web/components/ui/scroll-area.tsx +58 -0
- package/packages/web/components/ui/sheet.tsx +139 -0
- package/packages/web/components/ui/tabs.tsx +66 -0
- package/packages/web/components/ui/tooltip.tsx +61 -0
- package/packages/web/components.json +22 -0
- package/packages/web/context/TerminalContext.tsx +45 -0
- package/packages/web/context/TerminalTabsContext.tsx +136 -0
- package/packages/web/eslint.config.mjs +18 -0
- package/packages/web/hooks/useClaudeTerminal.ts +375 -0
- package/packages/web/hooks/useProjectStats.ts +38 -0
- package/packages/web/hooks/useProjects.ts +73 -0
- package/packages/web/hooks/useStats.ts +28 -0
- package/packages/web/lib/format.ts +23 -0
- package/packages/web/lib/parse-prjct-files.ts +1122 -0
- package/packages/web/lib/projects.ts +452 -0
- package/packages/web/lib/pty.ts +101 -0
- package/packages/web/lib/query-config.ts +44 -0
- package/packages/web/lib/utils.ts +6 -0
- package/packages/web/next-env.d.ts +6 -0
- package/packages/web/next.config.ts +7 -0
- package/packages/web/package.json +53 -0
- package/packages/web/postcss.config.mjs +7 -0
- package/packages/web/public/file.svg +1 -0
- package/packages/web/public/globe.svg +1 -0
- package/packages/web/public/next.svg +1 -0
- package/packages/web/public/vercel.svg +1 -0
- package/packages/web/public/window.svg +1 -0
- package/packages/web/server.ts +262 -0
- package/packages/web/tsconfig.json +34 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project utilities for prjct
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'fs'
|
|
6
|
+
import { join, dirname } from 'path'
|
|
7
|
+
import { homedir } from 'os'
|
|
8
|
+
import { exec } from 'child_process'
|
|
9
|
+
import { promisify } from 'util'
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec)
|
|
12
|
+
|
|
13
|
+
export const GLOBAL_STORAGE = join(homedir(), '.prjct-cli', 'projects')
|
|
14
|
+
export const TRASH_PATH = join(homedir(), '.prjct-cli', '.trash')
|
|
15
|
+
|
|
16
|
+
// Cache for project paths (projectId -> real path)
|
|
17
|
+
const projectPathCache = new Map<string, string>()
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scan common directories for .prjct/prjct.config.json files
|
|
21
|
+
*/
|
|
22
|
+
export async function scanForProjects(): Promise<Map<string, string>> {
|
|
23
|
+
const searchPaths = [
|
|
24
|
+
join(homedir(), 'Apps'),
|
|
25
|
+
join(homedir(), 'Projects'),
|
|
26
|
+
join(homedir(), 'Documents'),
|
|
27
|
+
join(homedir(), 'Development'),
|
|
28
|
+
join(homedir(), 'Code'),
|
|
29
|
+
join(homedir(), 'dev'),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (const searchPath of searchPaths) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execAsync(
|
|
35
|
+
`find "${searchPath}" -maxdepth 4 -type f -name "prjct.config.json" -path "*/.prjct/*" 2>/dev/null`,
|
|
36
|
+
{ timeout: 5000 }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const configFiles = stdout.trim().split('\n').filter(Boolean)
|
|
40
|
+
|
|
41
|
+
for (const configFile of configFiles) {
|
|
42
|
+
try {
|
|
43
|
+
const content = await fs.readFile(configFile, 'utf-8')
|
|
44
|
+
const config = JSON.parse(content)
|
|
45
|
+
if (config.projectId) {
|
|
46
|
+
const projectPath = dirname(dirname(configFile))
|
|
47
|
+
projectPathCache.set(config.projectId, projectPath)
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Skip invalid config files
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip directories that don't exist or are not accessible
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return projectPathCache
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract project path from CLAUDE.md
|
|
63
|
+
*/
|
|
64
|
+
export function extractProjectPath(claudeMd: string): string | null {
|
|
65
|
+
const pathMatch = claudeMd.match(/(?:Path|Location|Directory):\s*`?([^\n`]+)`?/i)
|
|
66
|
+
if (pathMatch) return pathMatch[1].trim()
|
|
67
|
+
|
|
68
|
+
const infoMatch = claudeMd.match(/\*\*Path\*\*:\s*`?([^\n`]+)`?/i)
|
|
69
|
+
if (infoMatch) return infoMatch[1].trim()
|
|
70
|
+
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get all projects with rich metadata
|
|
76
|
+
*/
|
|
77
|
+
export async function getProjects() {
|
|
78
|
+
if (projectPathCache.size === 0) {
|
|
79
|
+
await scanForProjects()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const projects = []
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const dirs = await fs.readdir(GLOBAL_STORAGE)
|
|
86
|
+
|
|
87
|
+
for (const projectId of dirs) {
|
|
88
|
+
// Skip hidden directories like .trash
|
|
89
|
+
if (projectId.startsWith('.')) continue
|
|
90
|
+
|
|
91
|
+
const storagePath = join(GLOBAL_STORAGE, projectId)
|
|
92
|
+
|
|
93
|
+
// Try project.json first (source of truth)
|
|
94
|
+
let name: string = projectId
|
|
95
|
+
let repoPath: string | null = null
|
|
96
|
+
let techStack: string[] = []
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const projectJsonPath = join(storagePath, 'project.json')
|
|
100
|
+
const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
|
|
101
|
+
name = projectJson.name || projectId
|
|
102
|
+
repoPath = projectJson.repoPath || null
|
|
103
|
+
techStack = projectJson.techStack || []
|
|
104
|
+
} catch {
|
|
105
|
+
// Fallback to CLAUDE.md for name/repoPath only
|
|
106
|
+
// techStack comes from project.json (populated by /p:sync)
|
|
107
|
+
try {
|
|
108
|
+
const claudeMd = await fs.readFile(join(storagePath, 'CLAUDE.md'), 'utf-8')
|
|
109
|
+
const nameMatch = claudeMd.match(/# (.+) - Project Context/)
|
|
110
|
+
if (nameMatch) name = nameMatch[1]
|
|
111
|
+
|
|
112
|
+
const cachedPath = projectPathCache.get(projectId)
|
|
113
|
+
repoPath = cachedPath || extractProjectPath(claudeMd)
|
|
114
|
+
} catch {
|
|
115
|
+
// Skip projects without valid config
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get current task
|
|
121
|
+
let currentTask: string | null = null
|
|
122
|
+
try {
|
|
123
|
+
const nowContent = await fs.readFile(join(storagePath, 'core', 'now.md'), 'utf-8')
|
|
124
|
+
// Skip headers like "# NOW", "# Current Task" and find the actual task content
|
|
125
|
+
// Look for **bold text** (task description) or first non-header, non-metadata line
|
|
126
|
+
const boldMatch = nowContent.match(/\*\*([^*]+)\*\*/)
|
|
127
|
+
if (boldMatch && boldMatch[1].trim() && !boldMatch[1].includes(':')) {
|
|
128
|
+
currentTask = boldMatch[1].trim()
|
|
129
|
+
} else {
|
|
130
|
+
// Find first content line that's not a header, metadata, or empty
|
|
131
|
+
const lines = nowContent.split('\n')
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const trimmed = line.trim()
|
|
134
|
+
// Skip headers, empty lines, metadata lines (key: value), and "No active/current task" messages
|
|
135
|
+
if (trimmed &&
|
|
136
|
+
!trimmed.startsWith('#') &&
|
|
137
|
+
!trimmed.toLowerCase().includes('no active task') &&
|
|
138
|
+
!trimmed.toLowerCase().includes('no current task') &&
|
|
139
|
+
!trimmed.match(/^(Feature|Started|Status|Agent):/i) &&
|
|
140
|
+
!trimmed.startsWith('**') &&
|
|
141
|
+
!trimmed.startsWith('-')) {
|
|
142
|
+
currentTask = trimmed
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Truncate if too long
|
|
148
|
+
if (currentTask && currentTask.length > 60) {
|
|
149
|
+
currentTask = currentTask.substring(0, 57) + '...'
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
|
|
153
|
+
// Get session status and last activity
|
|
154
|
+
let hasActiveSession = false
|
|
155
|
+
let lastActivity: string | null = null
|
|
156
|
+
|
|
157
|
+
// Try current session first
|
|
158
|
+
try {
|
|
159
|
+
const sessionPath = join(storagePath, 'sessions', 'current.json')
|
|
160
|
+
const sessionData = JSON.parse(await fs.readFile(sessionPath, 'utf-8'))
|
|
161
|
+
hasActiveSession = sessionData.status === 'active'
|
|
162
|
+
lastActivity = sessionData.startedAt || sessionData.updatedAt
|
|
163
|
+
} catch {}
|
|
164
|
+
|
|
165
|
+
// If no session, get last modified time from key files
|
|
166
|
+
if (!lastActivity) {
|
|
167
|
+
const filesToCheck = [
|
|
168
|
+
join(storagePath, 'core', 'now.md'),
|
|
169
|
+
join(storagePath, 'core', 'next.md'),
|
|
170
|
+
join(storagePath, 'planning', 'ideas.md'),
|
|
171
|
+
join(storagePath, 'progress', 'shipped.md'),
|
|
172
|
+
join(storagePath, 'memory', 'context.jsonl'),
|
|
173
|
+
join(storagePath, 'CLAUDE.md')
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
let latestMtime = 0
|
|
177
|
+
for (const filePath of filesToCheck) {
|
|
178
|
+
try {
|
|
179
|
+
const stat = await fs.stat(filePath)
|
|
180
|
+
if (stat.mtimeMs > latestMtime) {
|
|
181
|
+
latestMtime = stat.mtimeMs
|
|
182
|
+
}
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (latestMtime > 0) {
|
|
187
|
+
lastActivity = new Date(latestMtime).toISOString()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Count ideas and next tasks
|
|
192
|
+
let ideasCount = 0
|
|
193
|
+
let nextTasksCount = 0
|
|
194
|
+
try {
|
|
195
|
+
const ideasContent = await fs.readFile(join(storagePath, 'planning', 'ideas.md'), 'utf-8')
|
|
196
|
+
ideasCount = (ideasContent.match(/^- /gm) || []).length
|
|
197
|
+
} catch {}
|
|
198
|
+
try {
|
|
199
|
+
const nextContent = await fs.readFile(join(storagePath, 'core', 'next.md'), 'utf-8')
|
|
200
|
+
nextTasksCount = (nextContent.match(/^- /gm) || []).length
|
|
201
|
+
} catch {}
|
|
202
|
+
|
|
203
|
+
// Find favicon/icon in project repo
|
|
204
|
+
let iconPath: string | null = null
|
|
205
|
+
if (repoPath) {
|
|
206
|
+
const iconPatterns = [
|
|
207
|
+
'public/favicon.ico',
|
|
208
|
+
'public/favicon.svg',
|
|
209
|
+
'public/icon.svg',
|
|
210
|
+
'public/icon.png',
|
|
211
|
+
'public/logo.svg',
|
|
212
|
+
'public/logo.png',
|
|
213
|
+
'app/favicon.ico',
|
|
214
|
+
'app/icon.svg',
|
|
215
|
+
'app/icon.png',
|
|
216
|
+
'favicon.ico',
|
|
217
|
+
'favicon.svg'
|
|
218
|
+
]
|
|
219
|
+
for (const pattern of iconPatterns) {
|
|
220
|
+
try {
|
|
221
|
+
const fullPath = join(repoPath, pattern)
|
|
222
|
+
await fs.access(fullPath)
|
|
223
|
+
iconPath = fullPath
|
|
224
|
+
break
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
projects.push({
|
|
230
|
+
id: projectId,
|
|
231
|
+
name,
|
|
232
|
+
path: repoPath || storagePath,
|
|
233
|
+
repoPath,
|
|
234
|
+
storagePath,
|
|
235
|
+
currentTask,
|
|
236
|
+
hasActiveSession,
|
|
237
|
+
lastActivity,
|
|
238
|
+
ideasCount,
|
|
239
|
+
nextTasksCount,
|
|
240
|
+
techStack,
|
|
241
|
+
iconPath
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// Storage directory doesn't exist
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Sort by lastActivity (most recent first), then by name
|
|
249
|
+
projects.sort((a, b) => {
|
|
250
|
+
if (a.lastActivity && b.lastActivity) {
|
|
251
|
+
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
|
252
|
+
}
|
|
253
|
+
if (a.lastActivity) return -1
|
|
254
|
+
if (b.lastActivity) return 1
|
|
255
|
+
return a.name.localeCompare(b.name)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return projects
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get project by ID
|
|
263
|
+
*/
|
|
264
|
+
export async function getProject(projectId: string) {
|
|
265
|
+
const storagePath = join(GLOBAL_STORAGE, projectId)
|
|
266
|
+
|
|
267
|
+
// 1. Try to read from project.json (source of truth)
|
|
268
|
+
let repoPath: string | null = null
|
|
269
|
+
let name: string = projectId
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const projectJsonPath = join(storagePath, 'project.json')
|
|
273
|
+
const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
|
|
274
|
+
repoPath = projectJson.repoPath || null
|
|
275
|
+
name = projectJson.name || projectId
|
|
276
|
+
} catch {
|
|
277
|
+
// project.json doesn't exist - fallback to scan
|
|
278
|
+
if (projectPathCache.size === 0) {
|
|
279
|
+
await scanForProjects()
|
|
280
|
+
}
|
|
281
|
+
repoPath = projectPathCache.get(projectId) || null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const claudeMd = await fs.readFile(join(storagePath, 'CLAUDE.md'), 'utf-8')
|
|
286
|
+
|
|
287
|
+
// If still no repoPath, try extracting from CLAUDE.md
|
|
288
|
+
if (!repoPath) {
|
|
289
|
+
repoPath = extractProjectPath(claudeMd)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// If name is still projectId, try CLAUDE.md
|
|
293
|
+
if (name === projectId) {
|
|
294
|
+
const nameMatch = claudeMd.match(/# (.+) - Project Context/)
|
|
295
|
+
if (nameMatch) name = nameMatch[1]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let currentSession = null
|
|
299
|
+
try {
|
|
300
|
+
const sessionPath = join(storagePath, 'sessions', 'current.json')
|
|
301
|
+
const sessionData = await fs.readFile(sessionPath, 'utf-8')
|
|
302
|
+
currentSession = JSON.parse(sessionData)
|
|
303
|
+
} catch {}
|
|
304
|
+
|
|
305
|
+
let currentTask = null
|
|
306
|
+
try {
|
|
307
|
+
const nowPath = join(storagePath, 'core', 'now.md')
|
|
308
|
+
currentTask = await fs.readFile(nowPath, 'utf-8')
|
|
309
|
+
} catch {}
|
|
310
|
+
|
|
311
|
+
// Extract stats from claudeMd Quick Reference table
|
|
312
|
+
let version: string | null = null
|
|
313
|
+
let stack: string | null = null
|
|
314
|
+
let filesCount: string | null = null
|
|
315
|
+
let commitsCount: string | null = null
|
|
316
|
+
let techStack: string[] = []
|
|
317
|
+
|
|
318
|
+
// Parse version from | **Version** | 0.10.13 |
|
|
319
|
+
const versionMatch = claudeMd.match(/\*\*Version\*\*\s*\|\s*([^\n|]+)/)
|
|
320
|
+
if (versionMatch) version = versionMatch[1].trim()
|
|
321
|
+
|
|
322
|
+
// Parse stack from | **Stack** | Node.js CLI (CommonJS) |
|
|
323
|
+
const stackMatch = claudeMd.match(/\*\*Stack\*\*\s*\|\s*([^\n|]+)/)
|
|
324
|
+
if (stackMatch) stack = stackMatch[1].trim()
|
|
325
|
+
|
|
326
|
+
// Parse files from | **Files** | 130+ |
|
|
327
|
+
const filesMatch = claudeMd.match(/\*\*Files\*\*\s*\|\s*([^\n|]+)/)
|
|
328
|
+
if (filesMatch) filesCount = filesMatch[1].trim()
|
|
329
|
+
|
|
330
|
+
// Parse commits from | **Commits** | 175 |
|
|
331
|
+
const commitsMatch = claudeMd.match(/\*\*Commits\*\*\s*\|\s*([^\n|]+)/)
|
|
332
|
+
if (commitsMatch) commitsCount = commitsMatch[1].trim()
|
|
333
|
+
|
|
334
|
+
// Get techStack from project.json
|
|
335
|
+
try {
|
|
336
|
+
const projectJsonPath = join(storagePath, 'project.json')
|
|
337
|
+
const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
|
|
338
|
+
techStack = projectJson.techStack || []
|
|
339
|
+
} catch {}
|
|
340
|
+
|
|
341
|
+
// Find favicon/icon in project repo
|
|
342
|
+
let iconPath: string | null = null
|
|
343
|
+
if (repoPath) {
|
|
344
|
+
const iconPatterns = [
|
|
345
|
+
'public/favicon.ico',
|
|
346
|
+
'public/favicon.svg',
|
|
347
|
+
'public/icon.svg',
|
|
348
|
+
'public/icon.png',
|
|
349
|
+
'public/logo.svg',
|
|
350
|
+
'public/logo.png',
|
|
351
|
+
'app/favicon.ico',
|
|
352
|
+
'app/icon.svg',
|
|
353
|
+
'app/icon.png',
|
|
354
|
+
'favicon.ico',
|
|
355
|
+
'favicon.svg'
|
|
356
|
+
]
|
|
357
|
+
for (const pattern of iconPatterns) {
|
|
358
|
+
try {
|
|
359
|
+
const fullPath = join(repoPath, pattern)
|
|
360
|
+
await fs.access(fullPath)
|
|
361
|
+
iconPath = fullPath
|
|
362
|
+
break
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
id: projectId,
|
|
369
|
+
name,
|
|
370
|
+
path: storagePath, // Storage path (for prjct data)
|
|
371
|
+
repoPath, // Real repository path (for terminal/Claude)
|
|
372
|
+
storagePath,
|
|
373
|
+
claudeMd,
|
|
374
|
+
currentSession,
|
|
375
|
+
currentTask,
|
|
376
|
+
// Parsed stats
|
|
377
|
+
version,
|
|
378
|
+
stack,
|
|
379
|
+
filesCount,
|
|
380
|
+
commitsCount,
|
|
381
|
+
techStack,
|
|
382
|
+
iconPath
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
return null
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Move project to trash (soft delete)
|
|
391
|
+
*/
|
|
392
|
+
export async function moveToTrash(projectId: string) {
|
|
393
|
+
const sourcePath = join(GLOBAL_STORAGE, projectId)
|
|
394
|
+
const trashPath = join(TRASH_PATH, projectId)
|
|
395
|
+
|
|
396
|
+
// Verify source exists
|
|
397
|
+
try {
|
|
398
|
+
await fs.access(sourcePath)
|
|
399
|
+
} catch {
|
|
400
|
+
throw new Error(`Project ${projectId} not found`)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Create trash directory if it doesn't exist
|
|
404
|
+
await fs.mkdir(TRASH_PATH, { recursive: true })
|
|
405
|
+
|
|
406
|
+
// Move to trash
|
|
407
|
+
await fs.rename(sourcePath, trashPath)
|
|
408
|
+
|
|
409
|
+
// Write deletion metadata
|
|
410
|
+
const deletedAt = new Date().toISOString()
|
|
411
|
+
await fs.writeFile(
|
|
412
|
+
join(trashPath, '.deleted'),
|
|
413
|
+
JSON.stringify({ deletedAt, projectId })
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return { trashedAt: deletedAt }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get project status
|
|
421
|
+
*/
|
|
422
|
+
export async function getProjectStatus(projectId: string) {
|
|
423
|
+
const projectPath = join(GLOBAL_STORAGE, projectId)
|
|
424
|
+
|
|
425
|
+
let session = null
|
|
426
|
+
try {
|
|
427
|
+
const sessionPath = join(projectPath, 'sessions', 'current.json')
|
|
428
|
+
session = JSON.parse(await fs.readFile(sessionPath, 'utf-8'))
|
|
429
|
+
} catch {}
|
|
430
|
+
|
|
431
|
+
let ideas: string[] = []
|
|
432
|
+
try {
|
|
433
|
+
const ideasPath = join(projectPath, 'planning', 'ideas.md')
|
|
434
|
+
const content = await fs.readFile(ideasPath, 'utf-8')
|
|
435
|
+
ideas = content.split('\n').filter(l => l.startsWith('- ')).slice(0, 5)
|
|
436
|
+
} catch {}
|
|
437
|
+
|
|
438
|
+
let nextTasks: string[] = []
|
|
439
|
+
try {
|
|
440
|
+
const nextPath = join(projectPath, 'core', 'next.md')
|
|
441
|
+
const content = await fs.readFile(nextPath, 'utf-8')
|
|
442
|
+
nextTasks = content.split('\n').filter(l => l.startsWith('- ')).slice(0, 5)
|
|
443
|
+
} catch {}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
projectId,
|
|
447
|
+
session,
|
|
448
|
+
hasActiveSession: session?.status === 'active',
|
|
449
|
+
ideas,
|
|
450
|
+
nextTasks
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY Manager - Handle Claude Code CLI sessions via pseudo-terminal
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as pty from 'node-pty'
|
|
6
|
+
import type { IPty } from 'node-pty'
|
|
7
|
+
|
|
8
|
+
interface Session {
|
|
9
|
+
pty: IPty
|
|
10
|
+
projectDir: string
|
|
11
|
+
createdAt: Date
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sessions = new Map<string, Session>()
|
|
15
|
+
|
|
16
|
+
export function createClaudeSession(sessionId: string, projectDir: string): IPty {
|
|
17
|
+
// Kill existing session if any
|
|
18
|
+
const existing = sessions.get(sessionId)
|
|
19
|
+
if (existing) {
|
|
20
|
+
try {
|
|
21
|
+
existing.pty.kill()
|
|
22
|
+
} catch {
|
|
23
|
+
// Ignore
|
|
24
|
+
}
|
|
25
|
+
sessions.delete(sessionId)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Spawn claude CLI
|
|
29
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : 'bash'
|
|
30
|
+
const args = process.platform === 'win32' ? [] : ['-l']
|
|
31
|
+
|
|
32
|
+
const ptyProcess = pty.spawn(shell, args, {
|
|
33
|
+
name: 'xterm-256color',
|
|
34
|
+
cols: 120,
|
|
35
|
+
rows: 30,
|
|
36
|
+
cwd: projectDir,
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
TERM: 'xterm-256color',
|
|
40
|
+
COLORTERM: 'truecolor'
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Store session
|
|
45
|
+
sessions.set(sessionId, {
|
|
46
|
+
pty: ptyProcess,
|
|
47
|
+
projectDir,
|
|
48
|
+
createdAt: new Date()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Auto-start Claude Code CLI
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
ptyProcess.write('claude\r')
|
|
54
|
+
}, 500)
|
|
55
|
+
|
|
56
|
+
return ptyProcess
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getSession(sessionId: string): IPty | null {
|
|
60
|
+
const session = sessions.get(sessionId)
|
|
61
|
+
return session?.pty || null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function killSession(sessionId: string): boolean {
|
|
65
|
+
const session = sessions.get(sessionId)
|
|
66
|
+
if (session) {
|
|
67
|
+
try {
|
|
68
|
+
session.pty.kill()
|
|
69
|
+
} catch {
|
|
70
|
+
// Ignore
|
|
71
|
+
}
|
|
72
|
+
sessions.delete(sessionId)
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resizeSession(sessionId: string, cols: number, rows: number): boolean {
|
|
79
|
+
const session = sessions.get(sessionId)
|
|
80
|
+
if (session) {
|
|
81
|
+
try {
|
|
82
|
+
session.pty.resize(cols, rows)
|
|
83
|
+
return true
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function listSessions(): { sessionId: string; projectDir: string; createdAt: Date }[] {
|
|
92
|
+
const result: { sessionId: string; projectDir: string; createdAt: Date }[] = []
|
|
93
|
+
sessions.forEach((session, sessionId) => {
|
|
94
|
+
result.push({
|
|
95
|
+
sessionId,
|
|
96
|
+
projectDir: session.projectDir,
|
|
97
|
+
createdAt: session.createdAt
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UseQueryOptions } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
// Refresh intervals in milliseconds
|
|
4
|
+
export const REFRESH_INTERVALS = {
|
|
5
|
+
realtime: 2000, // 2s - for active sessions, connection status
|
|
6
|
+
fast: 5000, // 5s - for project status, current task
|
|
7
|
+
normal: 10000, // 10s - for project list, stats
|
|
8
|
+
slow: 30000, // 30s - for historical data
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
// Default query options for different data freshness needs
|
|
12
|
+
export const queryPresets = {
|
|
13
|
+
// For data that needs to feel "live" (sessions, connection status)
|
|
14
|
+
realtime: {
|
|
15
|
+
staleTime: 0,
|
|
16
|
+
refetchInterval: REFRESH_INTERVALS.realtime,
|
|
17
|
+
refetchOnWindowFocus: true,
|
|
18
|
+
refetchOnReconnect: true,
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// For frequently changing data (project status, tasks)
|
|
22
|
+
fast: {
|
|
23
|
+
staleTime: REFRESH_INTERVALS.fast / 2,
|
|
24
|
+
refetchInterval: REFRESH_INTERVALS.fast,
|
|
25
|
+
refetchOnWindowFocus: true,
|
|
26
|
+
refetchOnReconnect: true,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// For moderately changing data (project list, stats)
|
|
30
|
+
normal: {
|
|
31
|
+
staleTime: REFRESH_INTERVALS.normal / 2,
|
|
32
|
+
refetchInterval: REFRESH_INTERVALS.normal,
|
|
33
|
+
refetchOnWindowFocus: true,
|
|
34
|
+
refetchOnReconnect: true,
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// For rarely changing data (settings, historical)
|
|
38
|
+
slow: {
|
|
39
|
+
staleTime: REFRESH_INTERVALS.slow / 2,
|
|
40
|
+
refetchInterval: REFRESH_INTERVALS.slow,
|
|
41
|
+
refetchOnWindowFocus: true,
|
|
42
|
+
refetchOnReconnect: false,
|
|
43
|
+
},
|
|
44
|
+
} as const satisfies Record<string, Partial<UseQueryOptions>>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsx server.ts",
|
|
7
|
+
"dev:next": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"lint": "eslint"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
14
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
15
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
16
|
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
17
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
18
|
+
"@radix-ui/react-tabs": "^1.1.13",
|
|
19
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
20
|
+
"@tanstack/react-query": "^5.90.12",
|
|
21
|
+
"@types/mime-types": "^3.0.1",
|
|
22
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
23
|
+
"@xterm/addon-web-links": "^0.11.0",
|
|
24
|
+
"@xterm/xterm": "^5.5.0",
|
|
25
|
+
"class-variance-authority": "^0.7.1",
|
|
26
|
+
"clsx": "^2.1.1",
|
|
27
|
+
"lucide-react": "^0.556.0",
|
|
28
|
+
"mime-types": "^3.0.2",
|
|
29
|
+
"next": "16.0.7",
|
|
30
|
+
"next-themes": "^0.4.6",
|
|
31
|
+
"node-pty": "^1.0.0",
|
|
32
|
+
"react": "19.2.0",
|
|
33
|
+
"react-dom": "19.2.0",
|
|
34
|
+
"react-markdown": "^10.1.0",
|
|
35
|
+
"recharts": "^3.5.1",
|
|
36
|
+
"remark-gfm": "^4.0.1",
|
|
37
|
+
"tailwind-merge": "^3.4.0",
|
|
38
|
+
"ws": "^8.18.3"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@tailwindcss/postcss": "^4",
|
|
42
|
+
"@types/node": "^20",
|
|
43
|
+
"@types/react": "^19",
|
|
44
|
+
"@types/react-dom": "^19",
|
|
45
|
+
"@types/ws": "^8.18.0",
|
|
46
|
+
"eslint": "^9",
|
|
47
|
+
"eslint-config-next": "16.0.7",
|
|
48
|
+
"tailwindcss": "^4",
|
|
49
|
+
"tsx": "^4.19.2",
|
|
50
|
+
"tw-animate-css": "^1.4.0",
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|