prjct-cli 0.12.2 → 0.13.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 (39) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CLAUDE.md +18 -6
  3. package/core/data/index.ts +19 -5
  4. package/core/data/md-base-manager.ts +203 -0
  5. package/core/data/md-queue-manager.ts +179 -0
  6. package/core/data/md-state-manager.ts +133 -0
  7. package/core/serializers/index.ts +20 -0
  8. package/core/serializers/queue-serializer.ts +210 -0
  9. package/core/serializers/state-serializer.ts +136 -0
  10. package/core/utils/file-helper.ts +12 -0
  11. package/package.json +1 -1
  12. package/packages/web/app/api/projects/[id]/stats/route.ts +6 -29
  13. package/packages/web/app/page.tsx +1 -6
  14. package/packages/web/app/project/[id]/page.tsx +34 -1
  15. package/packages/web/app/project/[id]/stats/page.tsx +11 -5
  16. package/packages/web/app/settings/page.tsx +2 -221
  17. package/packages/web/components/BlockersCard/BlockersCard.tsx +67 -0
  18. package/packages/web/components/BlockersCard/BlockersCard.types.ts +11 -0
  19. package/packages/web/components/BlockersCard/index.ts +2 -0
  20. package/packages/web/components/CommandButton/CommandButton.tsx +10 -3
  21. package/packages/web/lib/projects.ts +28 -27
  22. package/packages/web/lib/services/projects.server.ts +25 -21
  23. package/packages/web/lib/services/stats.server.ts +355 -57
  24. package/packages/web/package.json +0 -2
  25. package/templates/commands/decision.md +226 -0
  26. package/templates/commands/done.md +100 -68
  27. package/templates/commands/feature.md +102 -103
  28. package/templates/commands/idea.md +41 -38
  29. package/templates/commands/now.md +94 -33
  30. package/templates/commands/pause.md +90 -30
  31. package/templates/commands/ship.md +179 -74
  32. package/templates/commands/sync.md +324 -200
  33. package/packages/web/app/api/migrate/route.ts +0 -46
  34. package/packages/web/app/api/settings/route.ts +0 -97
  35. package/packages/web/app/api/v2/projects/[id]/unified/route.ts +0 -57
  36. package/packages/web/components/MigrationGate/MigrationGate.tsx +0 -304
  37. package/packages/web/components/MigrationGate/index.ts +0 -1
  38. package/packages/web/lib/json-loader.ts +0 -630
  39. package/packages/web/lib/services/migration.server.ts +0 -580
@@ -1,17 +1,12 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
4
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { useQuery } from '@tanstack/react-query'
5
4
  import { useTheme } from 'next-themes'
6
- import { Settings as SettingsIcon, CheckCircle, XCircle, Terminal, Sun, Moon, Monitor, Key, Eye, EyeOff, Loader2, Trash2, RefreshCw, Database } from 'lucide-react'
5
+ import { Settings as SettingsIcon, CheckCircle, XCircle, Terminal, Sun, Moon, Monitor } from 'lucide-react'
7
6
  import { Button } from '@/components/ui/button'
8
- import { Input } from '@/components/ui/input'
9
7
 
10
8
  export default function Settings() {
11
9
  const { theme, setTheme } = useTheme()
12
- const queryClient = useQueryClient()
13
- const [apiKey, setApiKey] = useState('')
14
- const [showKey, setShowKey] = useState(false)
15
10
 
16
11
  const { data: claudeStatus } = useQuery({
17
12
  queryKey: ['claude-status'],
@@ -22,79 +17,6 @@ export default function Settings() {
22
17
  }
23
18
  })
24
19
 
25
- const { data: settings } = useQuery({
26
- queryKey: ['settings'],
27
- queryFn: async () => {
28
- const res = await fetch('/api/settings')
29
- const json = await res.json()
30
- return json.data
31
- }
32
- })
33
-
34
- const saveKeyMutation = useMutation({
35
- mutationFn: async (key: string) => {
36
- const res = await fetch('/api/settings', {
37
- method: 'POST',
38
- headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify({ openRouterApiKey: key })
40
- })
41
- if (!res.ok) throw new Error('Failed to save')
42
- return res.json()
43
- },
44
- onSuccess: () => {
45
- queryClient.invalidateQueries({ queryKey: ['settings'] })
46
- setApiKey('')
47
- }
48
- })
49
-
50
- const deleteKeyMutation = useMutation({
51
- mutationFn: async () => {
52
- const res = await fetch('/api/settings', { method: 'DELETE' })
53
- if (!res.ok) throw new Error('Failed to delete')
54
- return res.json()
55
- },
56
- onSuccess: () => {
57
- queryClient.invalidateQueries({ queryKey: ['settings'] })
58
- }
59
- })
60
-
61
- const { data: projects } = useQuery({
62
- queryKey: ['migrate-projects'],
63
- queryFn: async () => {
64
- const res = await fetch('/api/migrate')
65
- const json = await res.json()
66
- return json.data?.projects || [] as { id: string; name: string }[]
67
- }
68
- })
69
-
70
- const [selectedProject, setSelectedProject] = useState<string>('')
71
- const [migrationResults, setMigrationResults] = useState<{
72
- success: boolean
73
- results: Array<{ file: string; success: boolean; error?: string }>
74
- } | null>(null)
75
-
76
- const migrateMutation = useMutation({
77
- mutationFn: async (projectId: string) => {
78
- const res = await fetch('/api/migrate', {
79
- method: 'POST',
80
- headers: { 'Content-Type': 'application/json' },
81
- body: JSON.stringify({ projectId })
82
- })
83
- const json = await res.json()
84
- if (!res.ok) throw new Error(json.error || 'Migration failed')
85
- return json.data
86
- },
87
- onSuccess: (data) => {
88
- setMigrationResults(data)
89
- },
90
- onError: (error) => {
91
- setMigrationResults({
92
- success: false,
93
- results: [{ file: 'migration', success: false, error: error.message }]
94
- })
95
- }
96
- })
97
-
98
20
  return (
99
21
  <div className="p-6 h-full overflow-auto">
100
22
  <header className="mb-6">
@@ -150,147 +72,6 @@ export default function Settings() {
150
72
  </div>
151
73
  </section>
152
74
 
153
- {/* OpenRouter API Key */}
154
- <section className="bg-card border border-border rounded-lg p-6">
155
- <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
156
- <Key className="w-5 h-5" />
157
- OpenRouter API Key
158
- </h2>
159
-
160
- <p className="text-sm text-muted-foreground mb-4">
161
- Required for AI-powered features like MD→JSON migration. Get your key at{' '}
162
- <a
163
- href="https://openrouter.ai/keys"
164
- target="_blank"
165
- rel="noopener noreferrer"
166
- className="text-primary underline"
167
- >
168
- openrouter.ai/keys
169
- </a>
170
- </p>
171
-
172
- {settings?.hasApiKey ? (
173
- <div className="space-y-3">
174
- <div className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
175
- <CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
176
- <span className="text-sm font-mono">{settings.maskedKey}</span>
177
- <Button
178
- variant="ghost"
179
- size="icon-sm"
180
- onClick={() => deleteKeyMutation.mutate()}
181
- disabled={deleteKeyMutation.isPending}
182
- className="ml-auto text-muted-foreground hover:text-destructive"
183
- >
184
- {deleteKeyMutation.isPending ? (
185
- <Loader2 className="w-4 h-4 animate-spin" />
186
- ) : (
187
- <Trash2 className="w-4 h-4" />
188
- )}
189
- </Button>
190
- </div>
191
- <p className="text-xs text-muted-foreground mb-4">
192
- API key is stored locally in ~/.prjct-cli/settings.json
193
- </p>
194
-
195
- {/* Data Migration Section - only shown when API key exists */}
196
- <div className="pt-4 border-t border-border">
197
- <h3 className="text-sm font-medium mb-3 flex items-center gap-2">
198
- <Database className="w-4 h-4" />
199
- Data Migration (MD → JSON)
200
- </h3>
201
- <p className="text-xs text-muted-foreground mb-3">
202
- Convert markdown files to structured JSON using AI. This enables better parsing and querying.
203
- </p>
204
-
205
- <div className="flex gap-2 mb-3">
206
- <select
207
- value={selectedProject}
208
- onChange={(e) => {
209
- setSelectedProject(e.target.value)
210
- setMigrationResults(null)
211
- }}
212
- className="flex-1 h-9 rounded-md border border-input bg-transparent px-3 text-sm"
213
- >
214
- <option value="">Select a project...</option>
215
- {(projects as { id: string; name: string }[] || []).map((p) => (
216
- <option key={p.id} value={p.id}>{p.name}</option>
217
- ))}
218
- </select>
219
- <Button
220
- onClick={() => selectedProject && migrateMutation.mutate(selectedProject)}
221
- disabled={!selectedProject || migrateMutation.isPending}
222
- size="sm"
223
- >
224
- {migrateMutation.isPending ? (
225
- <Loader2 className="w-4 h-4 animate-spin" />
226
- ) : (
227
- <>
228
- <RefreshCw className="w-4 h-4" />
229
- Sync
230
- </>
231
- )}
232
- </Button>
233
- </div>
234
-
235
- {migrationResults && (
236
- <div className={`p-3 rounded-md text-sm ${migrationResults.success ? 'bg-green-500/10 border border-green-500/20' : 'bg-red-500/10 border border-red-500/20'}`}>
237
- <p className={`font-medium mb-2 ${migrationResults.success ? 'text-green-500' : 'text-red-500'}`}>
238
- {migrationResults.success ? 'Migration complete!' : 'Migration had errors'}
239
- </p>
240
- <ul className="space-y-1 text-xs">
241
- {migrationResults.results.map((r, i) => (
242
- <li key={i} className="flex items-center gap-2">
243
- {r.success ? (
244
- <CheckCircle className="w-3 h-3 text-green-500" />
245
- ) : (
246
- <XCircle className="w-3 h-3 text-red-500" />
247
- )}
248
- <span>{r.file}</span>
249
- {r.error && <span className="text-muted-foreground">- {r.error}</span>}
250
- </li>
251
- ))}
252
- </ul>
253
- </div>
254
- )}
255
- </div>
256
- </div>
257
- ) : (
258
- <div className="space-y-3">
259
- <div className="flex gap-2">
260
- <div className="relative flex-1">
261
- <Input
262
- type={showKey ? 'text' : 'password'}
263
- placeholder="sk-or-v1-..."
264
- value={apiKey}
265
- onChange={(e) => setApiKey(e.target.value)}
266
- className="pr-10 font-mono"
267
- />
268
- <button
269
- type="button"
270
- onClick={() => setShowKey(!showKey)}
271
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
272
- >
273
- {showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
274
- </button>
275
- </div>
276
- <Button
277
- onClick={() => saveKeyMutation.mutate(apiKey)}
278
- disabled={!apiKey || saveKeyMutation.isPending}
279
- >
280
- {saveKeyMutation.isPending ? (
281
- <Loader2 className="w-4 h-4 animate-spin" />
282
- ) : (
283
- 'Save'
284
- )}
285
- </Button>
286
- </div>
287
- {saveKeyMutation.isError && (
288
- <p className="text-sm text-destructive">Failed to save API key</p>
289
- )}
290
- </div>
291
- )}
292
- </section>
293
-
294
75
  {/* Claude Code Status */}
295
76
  <section className="bg-card border border-border rounded-lg p-6">
296
77
  <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
@@ -0,0 +1,67 @@
1
+ import { BentoCard } from '@/components/BentoCard'
2
+ import { AlertTriangle, Clock } from 'lucide-react'
3
+ import { cn } from '@/lib/utils'
4
+ import type { BlockersCardProps } from './BlockersCard.types'
5
+
6
+ export function BlockersCard({ blockers, className }: BlockersCardProps) {
7
+ const hasBlockers = blockers.length > 0
8
+
9
+ return (
10
+ <BentoCard
11
+ size="1x1"
12
+ title="Blockers"
13
+ icon={AlertTriangle}
14
+ className={cn(
15
+ hasBlockers && 'border-amber-500/50 dark:border-amber-500/30',
16
+ className
17
+ )}
18
+ >
19
+ <div className="flex flex-col h-full">
20
+ {!hasBlockers ? (
21
+ <div className="flex flex-col items-center justify-center h-full text-center">
22
+ <div className="text-2xl mb-1">&#x2705;</div>
23
+ <p className="text-sm text-muted-foreground">No blockers</p>
24
+ <p className="text-xs text-muted-foreground mt-1">Keep the momentum!</p>
25
+ </div>
26
+ ) : (
27
+ <>
28
+ <div className="flex items-center gap-2 mb-2">
29
+ <span className="text-2xl font-bold text-amber-600 dark:text-amber-400">
30
+ {blockers.length}
31
+ </span>
32
+ <span className="text-xs text-muted-foreground">
33
+ {blockers.length === 1 ? 'task blocked' : 'tasks blocked'}
34
+ </span>
35
+ </div>
36
+
37
+ <div className="flex-1 overflow-auto space-y-2">
38
+ {blockers.slice(0, 3).map((blocker, i) => (
39
+ <div
40
+ key={i}
41
+ className="p-2 rounded-md bg-amber-500/10 dark:bg-amber-500/5 border border-amber-500/20"
42
+ >
43
+ <p className="text-xs font-medium truncate">{blocker.task}</p>
44
+ <p className="text-xs text-muted-foreground truncate mt-0.5">
45
+ {blocker.reason}
46
+ </p>
47
+ <div className="flex items-center gap-1 mt-1">
48
+ <Clock className="h-3 w-3 text-muted-foreground" />
49
+ <span className="text-xs text-muted-foreground">
50
+ {blocker.daysBlocked}d
51
+ </span>
52
+ </div>
53
+ </div>
54
+ ))}
55
+ </div>
56
+
57
+ {blockers.length > 3 && (
58
+ <p className="text-xs text-muted-foreground mt-2">
59
+ +{blockers.length - 3} more
60
+ </p>
61
+ )}
62
+ </>
63
+ )}
64
+ </div>
65
+ </BentoCard>
66
+ )
67
+ }
@@ -0,0 +1,11 @@
1
+ export interface Blocker {
2
+ task: string
3
+ reason: string
4
+ since: string
5
+ daysBlocked: number
6
+ }
7
+
8
+ export interface BlockersCardProps {
9
+ blockers: Blocker[]
10
+ className?: string
11
+ }
@@ -0,0 +1,2 @@
1
+ export { BlockersCard } from './BlockersCard'
2
+ export type { BlockersCardProps, Blocker } from './BlockersCard.types'
@@ -7,6 +7,7 @@ import {
7
7
  TooltipContent,
8
8
  TooltipTrigger,
9
9
  } from '@/components/ui/tooltip'
10
+ import { cn } from '@/lib/utils'
10
11
 
11
12
  interface CommandButtonProps {
12
13
  cmd: string
@@ -14,18 +15,24 @@ interface CommandButtonProps {
14
15
  tip: string
15
16
  disabled?: boolean
16
17
  onClick: () => void
18
+ variant?: 'default' | 'primary'
17
19
  }
18
20
 
19
- export function CommandButton({ cmd, icon: Icon, tip, disabled, onClick }: CommandButtonProps) {
21
+ export function CommandButton({ cmd, icon: Icon, tip, disabled, onClick, variant = 'default' }: CommandButtonProps) {
22
+ const isPrimary = variant === 'primary'
23
+
20
24
  return (
21
25
  <Tooltip>
22
26
  <TooltipTrigger asChild>
23
27
  <Button
24
- variant="ghost"
28
+ variant={isPrimary ? 'default' : 'ghost'}
25
29
  size="icon"
26
30
  onClick={onClick}
27
31
  disabled={disabled}
28
- className="h-11 w-11"
32
+ className={cn(
33
+ "h-11 w-11",
34
+ isPrimary && "bg-primary text-primary-foreground hover:bg-primary/90"
35
+ )}
29
36
  >
30
37
  <Icon className="w-5 h-5" />
31
38
  </Button>
@@ -264,15 +264,25 @@ export async function getProjects() {
264
264
  export async function getProject(projectId: string) {
265
265
  const storagePath = join(GLOBAL_STORAGE, projectId)
266
266
 
267
- // 1. Try to read from project.json (source of truth)
267
+ // 1. Try to read from project.json (source of truth for dashboard)
268
268
  let repoPath: string | null = null
269
269
  let name: string = projectId
270
+ let version: string | null = null
271
+ let stack: string | null = null
272
+ let filesCount: string | null = null
273
+ let commitsCount: string | null = null
274
+ let techStack: string[] = []
270
275
 
271
276
  try {
272
277
  const projectJsonPath = join(storagePath, 'project.json')
273
278
  const projectJson = JSON.parse(await fs.readFile(projectJsonPath, 'utf-8'))
274
279
  repoPath = projectJson.repoPath || null
275
280
  name = projectJson.name || projectId
281
+ version = projectJson.version || null
282
+ stack = projectJson.stack || null
283
+ filesCount = projectJson.fileCount ? String(projectJson.fileCount) : null
284
+ commitsCount = projectJson.commitCount ? String(projectJson.commitCount) : null
285
+ techStack = projectJson.techStack || []
276
286
  } catch {
277
287
  // project.json doesn't exist - fallback to scan
278
288
  if (projectPathCache.size === 0) {
@@ -308,35 +318,26 @@ export async function getProject(projectId: string) {
308
318
  currentTask = await fs.readFile(nowPath, 'utf-8')
309
319
  } catch {}
310
320
 
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()
321
+ // Fallback: Extract stats from claudeMd Quick Reference table if not from project.json
322
+ if (!version) {
323
+ const versionMatch = claudeMd.match(/\*\*Version\*\*\s*\|\s*([^\n|]+)/)
324
+ if (versionMatch) version = versionMatch[1].trim()
325
+ }
325
326
 
326
- // Parse files from | **Files** | 130+ |
327
- const filesMatch = claudeMd.match(/\*\*Files\*\*\s*\|\s*([^\n|]+)/)
328
- if (filesMatch) filesCount = filesMatch[1].trim()
327
+ if (!stack) {
328
+ const stackMatch = claudeMd.match(/\*\*Stack\*\*\s*\|\s*([^\n|]+)/)
329
+ if (stackMatch) stack = stackMatch[1].trim()
330
+ }
329
331
 
330
- // Parse commits from | **Commits** | 175 |
331
- const commitsMatch = claudeMd.match(/\*\*Commits\*\*\s*\|\s*([^\n|]+)/)
332
- if (commitsMatch) commitsCount = commitsMatch[1].trim()
332
+ if (!filesCount) {
333
+ const filesMatch = claudeMd.match(/\*\*Files\*\*\s*\|\s*([^\n|]+)/)
334
+ if (filesMatch) filesCount = filesMatch[1].trim()
335
+ }
333
336
 
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 {}
337
+ if (!commitsCount) {
338
+ const commitsMatch = claudeMd.match(/\*\*Commits\*\*\s*\|\s*([^\n|]+)/)
339
+ if (commitsMatch) commitsCount = commitsMatch[1].trim()
340
+ }
340
341
 
341
342
  // Find favicon/icon in project repo
342
343
  let iconPath: string | null = null
@@ -1,44 +1,48 @@
1
1
  /**
2
2
  * Projects Service (Server-only)
3
3
  *
4
- * Direct data access for Server Components.
5
- * No API calls needed - reads directly from filesystem.
4
+ * MD-First Architecture: Reads directly from MD files.
5
+ * No JSON fallback - MD is the source of truth.
6
6
  */
7
7
 
8
8
  import 'server-only'
9
9
  import { cache } from 'react'
10
- import { loadProject, type ProjectJson } from '@/lib/json-loader'
11
- import { getProjects as getProjectsList, getProject as getProjectLegacy } from '@/lib/projects'
10
+ import { getProjects as getProjectsList, getProject as getMdProject } from '@/lib/projects'
12
11
 
13
- export type { ProjectJson }
12
+ // Types for project data
13
+ export interface ProjectJson {
14
+ projectId: string
15
+ name: string
16
+ repoPath?: string | null
17
+ techStack: string[]
18
+ fileCount: number
19
+ commitCount: number
20
+ createdAt: string
21
+ lastSync: string
22
+ }
14
23
 
15
24
  /**
16
25
  * Get single project by ID - cached per request
26
+ *
27
+ * MD-First: Uses MD files as source of truth
17
28
  */
18
29
  export const getProject = cache(async (projectId: string): Promise<ProjectJson | null> => {
19
- // Try JSON first
20
- const jsonProject = await loadProject(projectId)
21
- if (jsonProject) {
22
- return jsonProject
23
- }
24
-
25
- // Fallback to legacy
26
30
  try {
27
- const legacyProject = await getProjectLegacy(projectId)
28
- if (legacyProject) {
31
+ const project = await getMdProject(projectId)
32
+ if (project) {
29
33
  return {
30
- projectId: legacyProject.id,
31
- name: legacyProject.name,
32
- repoPath: legacyProject.path,
33
- techStack: legacyProject.techStack || [],
34
- fileCount: legacyProject.filesCount ? parseInt(legacyProject.filesCount) : 0,
35
- commitCount: legacyProject.commitsCount ? parseInt(legacyProject.commitsCount) : 0,
34
+ projectId: project.id,
35
+ name: project.name,
36
+ repoPath: project.repoPath,
37
+ techStack: project.techStack || [],
38
+ fileCount: project.filesCount ? parseInt(project.filesCount) : 0,
39
+ commitCount: project.commitsCount ? parseInt(project.commitsCount) : 0,
36
40
  createdAt: new Date().toISOString(),
37
41
  lastSync: new Date().toISOString()
38
42
  }
39
43
  }
40
44
  } catch {
41
- // Ignore errors
45
+ // Project not found
42
46
  }
43
47
 
44
48
  return null