agentfit 0.1.0 → 0.1.1
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/README.md +30 -34
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/(dashboard)/settings/page.tsx +180 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +25 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +258 -8
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +404 -83
- package/components/dashboard-shell.tsx +9 -24
- package/components/data-provider.tsx +66 -2
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +96 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +187 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +530 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +1 -1
- package/lib/parse-codex.ts +5 -0
- package/lib/parse-logs.ts +65 -0
- package/lib/queries-codex.ts +22 -0
- package/lib/queries.ts +42 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +77 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +16 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/schema.prisma +17 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma/migrations/migration_lock.toml +0 -3
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
3
|
+
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { usePathname } from 'next/navigation'
|
|
6
6
|
import {
|
|
7
|
+
AlertTriangle,
|
|
7
8
|
BarChart3,
|
|
8
9
|
Brain,
|
|
9
10
|
Camera,
|
|
11
|
+
Check,
|
|
10
12
|
ChevronRight,
|
|
13
|
+
Cloud,
|
|
14
|
+
CloudOff,
|
|
11
15
|
Coins,
|
|
16
|
+
FileText,
|
|
12
17
|
FolderOpen,
|
|
18
|
+
GitBranch,
|
|
13
19
|
HeartPulse,
|
|
14
20
|
LayoutDashboard,
|
|
15
21
|
ListTree,
|
|
22
|
+
Loader2,
|
|
16
23
|
Puzzle,
|
|
24
|
+
RefreshCw,
|
|
25
|
+
RotateCcw,
|
|
26
|
+
Settings,
|
|
17
27
|
Terminal,
|
|
28
|
+
Upload,
|
|
18
29
|
Wrench,
|
|
19
30
|
} from 'lucide-react'
|
|
20
31
|
import type { ComponentType } from 'react'
|
|
@@ -24,6 +35,7 @@ import {
|
|
|
24
35
|
SidebarGroup,
|
|
25
36
|
SidebarGroupContent,
|
|
26
37
|
SidebarGroupLabel,
|
|
38
|
+
SidebarFooter,
|
|
27
39
|
SidebarHeader,
|
|
28
40
|
SidebarMenu,
|
|
29
41
|
SidebarMenuButton,
|
|
@@ -33,11 +45,23 @@ import {
|
|
|
33
45
|
SidebarMenuSubItem,
|
|
34
46
|
SidebarRail,
|
|
35
47
|
} from '@/components/ui/sidebar'
|
|
48
|
+
import {
|
|
49
|
+
Dialog,
|
|
50
|
+
DialogContent,
|
|
51
|
+
DialogDescription,
|
|
52
|
+
DialogFooter,
|
|
53
|
+
DialogHeader,
|
|
54
|
+
DialogTitle,
|
|
55
|
+
} from '@/components/ui/dialog'
|
|
56
|
+
import { Button } from '@/components/ui/button'
|
|
57
|
+
import { Badge } from '@/components/ui/badge'
|
|
58
|
+
import { useData } from './data-provider'
|
|
36
59
|
import { getPlugins } from '@/lib/plugins'
|
|
60
|
+
import { toast } from 'sonner'
|
|
37
61
|
import '@/plugins'
|
|
38
62
|
|
|
39
63
|
interface NavItem {
|
|
40
|
-
title:
|
|
64
|
+
title: ReactNode
|
|
41
65
|
icon: ComponentType<{ className?: string }>
|
|
42
66
|
href: string
|
|
43
67
|
}
|
|
@@ -50,11 +74,19 @@ interface NavGroup {
|
|
|
50
74
|
|
|
51
75
|
const topItems: NavItem[] = [
|
|
52
76
|
{ title: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
|
53
|
-
{ title: '
|
|
54
|
-
{ title: '
|
|
77
|
+
{ title: 'CRAFT Coach', icon: HeartPulse, href: '/coach' },
|
|
78
|
+
{ title: 'Reports', icon: FileText, href: '/reports' },
|
|
55
79
|
]
|
|
56
80
|
|
|
57
81
|
const navGroups: NavGroup[] = [
|
|
82
|
+
{
|
|
83
|
+
title: 'Projects',
|
|
84
|
+
icon: FolderOpen,
|
|
85
|
+
items: [
|
|
86
|
+
{ title: 'Overview', icon: FolderOpen, href: '/projects' },
|
|
87
|
+
{ title: 'Sessions', icon: ListTree, href: '/sessions' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
58
90
|
{
|
|
59
91
|
title: 'Usage',
|
|
60
92
|
icon: BarChart3,
|
|
@@ -70,6 +102,7 @@ const navGroups: NavGroup[] = [
|
|
|
70
102
|
items: [
|
|
71
103
|
{ title: 'Personality Fit', icon: Brain, href: '/personality' },
|
|
72
104
|
{ title: 'Command Usage', icon: Terminal, href: '/commands' },
|
|
105
|
+
{ title: 'Session Flow', icon: GitBranch, href: '/flow' },
|
|
73
106
|
{ title: 'Images', icon: Camera, href: '/images' },
|
|
74
107
|
],
|
|
75
108
|
},
|
|
@@ -106,6 +139,194 @@ function CollapsibleGroup({ group, pathname }: { group: NavGroup; pathname: stri
|
|
|
106
139
|
)
|
|
107
140
|
}
|
|
108
141
|
|
|
142
|
+
interface BackupFolderStatus {
|
|
143
|
+
exists: boolean
|
|
144
|
+
hasGit: boolean
|
|
145
|
+
hasRemote: boolean
|
|
146
|
+
remoteUrl: string | null
|
|
147
|
+
isDirty: boolean
|
|
148
|
+
lastCommit: string | null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface BackupStatus {
|
|
152
|
+
ghInstalled: boolean
|
|
153
|
+
ghAuthenticated: boolean
|
|
154
|
+
ghUser: string | null
|
|
155
|
+
claude: BackupFolderStatus
|
|
156
|
+
codex: BackupFolderStatus
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function BackupSection() {
|
|
160
|
+
const [status, setStatus] = useState<BackupStatus | null>(null)
|
|
161
|
+
const [loading, setLoading] = useState(false)
|
|
162
|
+
const [syncing, setSyncing] = useState<Record<string, boolean>>({})
|
|
163
|
+
|
|
164
|
+
const fetchStatus = useCallback(async () => {
|
|
165
|
+
setLoading(true)
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch('/api/backup')
|
|
168
|
+
setStatus(await res.json())
|
|
169
|
+
} catch {
|
|
170
|
+
// ignore
|
|
171
|
+
} finally {
|
|
172
|
+
setLoading(false)
|
|
173
|
+
}
|
|
174
|
+
}, [])
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
fetchStatus()
|
|
178
|
+
}, [fetchStatus])
|
|
179
|
+
|
|
180
|
+
const handleInit = async (folder: 'claude' | 'codex') => {
|
|
181
|
+
setSyncing((s) => ({ ...s, [folder]: true }))
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch('/api/backup', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ action: 'init', folder }),
|
|
187
|
+
})
|
|
188
|
+
const data = await res.json()
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
toast.error(`Backup failed`, { description: data.error })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
toast.success(`~/.${folder} backed up`, {
|
|
194
|
+
description: (
|
|
195
|
+
<a href={data.repoUrl} target="_blank" rel="noopener noreferrer" className="underline">
|
|
196
|
+
{data.repoUrl}
|
|
197
|
+
</a>
|
|
198
|
+
),
|
|
199
|
+
})
|
|
200
|
+
await fetchStatus()
|
|
201
|
+
} catch (e) {
|
|
202
|
+
toast.error('Backup failed', { description: (e as Error).message })
|
|
203
|
+
} finally {
|
|
204
|
+
setSyncing((s) => ({ ...s, [folder]: false }))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handleSync = async (folder: 'claude' | 'codex') => {
|
|
209
|
+
setSyncing((s) => ({ ...s, [folder]: true }))
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch('/api/backup', {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({ action: 'sync', folder }),
|
|
215
|
+
})
|
|
216
|
+
const data = await res.json()
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
toast.error(`Sync failed`, { description: data.error })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
toast.success(`~/.${folder} synced`, { description: data.message })
|
|
222
|
+
await fetchStatus()
|
|
223
|
+
} catch (e) {
|
|
224
|
+
toast.error('Sync failed', { description: (e as Error).message })
|
|
225
|
+
} finally {
|
|
226
|
+
setSyncing((s) => ({ ...s, [folder]: false }))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (loading || !status) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground">
|
|
233
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
234
|
+
Checking GitHub...
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!status.ghInstalled) {
|
|
240
|
+
return (
|
|
241
|
+
<div className="space-y-2 px-3 py-1.5">
|
|
242
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
243
|
+
<CloudOff className="h-3.5 w-3.5" />
|
|
244
|
+
GitHub CLI not found
|
|
245
|
+
</div>
|
|
246
|
+
<a
|
|
247
|
+
href="https://cli.github.com"
|
|
248
|
+
target="_blank"
|
|
249
|
+
rel="noopener noreferrer"
|
|
250
|
+
className="block text-xs text-primary underline"
|
|
251
|
+
>
|
|
252
|
+
Install GitHub CLI
|
|
253
|
+
</a>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!status.ghAuthenticated) {
|
|
259
|
+
return (
|
|
260
|
+
<div className="space-y-2 px-3 py-1.5">
|
|
261
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
262
|
+
<CloudOff className="h-3.5 w-3.5" />
|
|
263
|
+
Not logged in to GitHub
|
|
264
|
+
</div>
|
|
265
|
+
<div className="rounded-md bg-muted px-2 py-1.5 text-xs font-mono">
|
|
266
|
+
gh auth login
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const folders = [
|
|
273
|
+
{ key: 'claude' as const, label: '.claude', status: status.claude },
|
|
274
|
+
{ key: 'codex' as const, label: '.codex', status: status.codex },
|
|
275
|
+
].filter((f) => f.status.exists)
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className="space-y-1.5 px-1">
|
|
279
|
+
<div className="flex items-center gap-1.5 px-2 text-xs text-muted-foreground">
|
|
280
|
+
<Cloud className="h-3 w-3" />
|
|
281
|
+
{status.ghUser}
|
|
282
|
+
</div>
|
|
283
|
+
{folders.map(({ key, label, status: fs }) => {
|
|
284
|
+
const isSetUp = fs.hasGit && fs.hasRemote
|
|
285
|
+
const isSyncing = syncing[key]
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div key={key} className="flex items-center justify-between px-2">
|
|
289
|
+
<div className="flex items-center gap-1.5">
|
|
290
|
+
<span className="text-xs font-medium">~/{label}</span>
|
|
291
|
+
{isSetUp && !fs.isDirty && (
|
|
292
|
+
<Check className="h-3 w-3 text-green-500" />
|
|
293
|
+
)}
|
|
294
|
+
{isSetUp && fs.isDirty && (
|
|
295
|
+
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
|
|
296
|
+
new
|
|
297
|
+
</Badge>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
{!isSetUp ? (
|
|
301
|
+
<Button
|
|
302
|
+
variant="outline"
|
|
303
|
+
size="xs"
|
|
304
|
+
onClick={() => handleInit(key)}
|
|
305
|
+
disabled={isSyncing}
|
|
306
|
+
className="gap-1"
|
|
307
|
+
>
|
|
308
|
+
{isSyncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
|
|
309
|
+
Backup
|
|
310
|
+
</Button>
|
|
311
|
+
) : (
|
|
312
|
+
<Button
|
|
313
|
+
variant="ghost"
|
|
314
|
+
size="xs"
|
|
315
|
+
onClick={() => handleSync(key)}
|
|
316
|
+
disabled={isSyncing}
|
|
317
|
+
className="gap-1"
|
|
318
|
+
>
|
|
319
|
+
{isSyncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Cloud className="h-3 w-3" />}
|
|
320
|
+
Sync
|
|
321
|
+
</Button>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
})}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
109
330
|
export function AppSidebar() {
|
|
110
331
|
const pathname = usePathname()
|
|
111
332
|
const plugins = getPlugins()
|
|
@@ -115,10 +336,7 @@ export function AppSidebar() {
|
|
|
115
336
|
<SidebarHeader>
|
|
116
337
|
<div className="flex items-center gap-2 px-2 py-2">
|
|
117
338
|
<img src="/logo.svg" alt="AgentFit" className="h-8 w-8" />
|
|
118
|
-
<div>
|
|
119
|
-
<div className="text-sm font-semibold">AgentFit</div>
|
|
120
|
-
<div className="text-xs text-muted-foreground">Coding Agent Fitness Tracker</div>
|
|
121
|
-
</div>
|
|
339
|
+
<div className="text-sm font-semibold">AgentFit</div>
|
|
122
340
|
</div>
|
|
123
341
|
</SidebarHeader>
|
|
124
342
|
<SidebarContent>
|
|
@@ -154,7 +372,39 @@ export function AppSidebar() {
|
|
|
154
372
|
</SidebarMenu>
|
|
155
373
|
</SidebarGroupContent>
|
|
156
374
|
</SidebarGroup>
|
|
375
|
+
|
|
376
|
+
{/* Data Management Section */}
|
|
377
|
+
<SidebarGroup>
|
|
378
|
+
<SidebarGroupLabel className="sr-only">Settings</SidebarGroupLabel>
|
|
379
|
+
<SidebarGroupContent>
|
|
380
|
+
<SidebarMenu>
|
|
381
|
+
<SidebarMenuItem>
|
|
382
|
+
<SidebarMenuButton
|
|
383
|
+
render={<Link href="/settings" />}
|
|
384
|
+
isActive={pathname === '/settings'}
|
|
385
|
+
>
|
|
386
|
+
<Settings className="h-4 w-4" />
|
|
387
|
+
<span>Settings</span>
|
|
388
|
+
</SidebarMenuButton>
|
|
389
|
+
</SidebarMenuItem>
|
|
390
|
+
</SidebarMenu>
|
|
391
|
+
</SidebarGroupContent>
|
|
392
|
+
</SidebarGroup>
|
|
157
393
|
</SidebarContent>
|
|
394
|
+
|
|
395
|
+
<SidebarFooter>
|
|
396
|
+
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground">
|
|
397
|
+
<span>v0.1.0</span>
|
|
398
|
+
<a
|
|
399
|
+
href="https://github.com/harrywang/agentfit"
|
|
400
|
+
target="_blank"
|
|
401
|
+
rel="noopener noreferrer"
|
|
402
|
+
className="hover:text-foreground transition-colors"
|
|
403
|
+
>
|
|
404
|
+
GitHub
|
|
405
|
+
</a>
|
|
406
|
+
</div>
|
|
407
|
+
</SidebarFooter>
|
|
158
408
|
<SidebarRail />
|
|
159
409
|
</Sidebar>
|
|
160
410
|
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import {
|
|
9
|
+
Cloud,
|
|
10
|
+
CloudOff,
|
|
11
|
+
Check,
|
|
12
|
+
Loader2,
|
|
13
|
+
Upload,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
|
|
16
|
+
interface FolderStatus {
|
|
17
|
+
exists: boolean
|
|
18
|
+
hasGit: boolean
|
|
19
|
+
hasRemote: boolean
|
|
20
|
+
isDirty: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface BackupStatus {
|
|
24
|
+
ghInstalled: boolean
|
|
25
|
+
ghAuthenticated: boolean
|
|
26
|
+
ghUser: string
|
|
27
|
+
claude: FolderStatus
|
|
28
|
+
codex: FolderStatus
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function BackupCard() {
|
|
32
|
+
const [status, setStatus] = useState<BackupStatus | null>(null)
|
|
33
|
+
const [loading, setLoading] = useState(false)
|
|
34
|
+
const [syncing, setSyncing] = useState<Record<string, boolean>>({})
|
|
35
|
+
|
|
36
|
+
const fetchStatus = useCallback(async () => {
|
|
37
|
+
setLoading(true)
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch('/api/backup')
|
|
40
|
+
setStatus(await res.json())
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
} finally {
|
|
44
|
+
setLoading(false)
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
fetchStatus()
|
|
50
|
+
}, [fetchStatus])
|
|
51
|
+
|
|
52
|
+
const handleInit = async (folder: 'claude' | 'codex') => {
|
|
53
|
+
setSyncing((s) => ({ ...s, [folder]: true }))
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch('/api/backup', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ action: 'init', folder }),
|
|
59
|
+
})
|
|
60
|
+
const data = await res.json()
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
toast.error('Backup failed', { description: data.error })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
toast.success(`~/.${folder} backed up`, {
|
|
66
|
+
description: (
|
|
67
|
+
<a href={data.repoUrl} target="_blank" rel="noopener noreferrer" className="underline">
|
|
68
|
+
{data.repoUrl}
|
|
69
|
+
</a>
|
|
70
|
+
),
|
|
71
|
+
})
|
|
72
|
+
await fetchStatus()
|
|
73
|
+
} catch (e) {
|
|
74
|
+
toast.error('Backup failed', { description: (e as Error).message })
|
|
75
|
+
} finally {
|
|
76
|
+
setSyncing((s) => ({ ...s, [folder]: false }))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const handleSync = async (folder: 'claude' | 'codex') => {
|
|
81
|
+
setSyncing((s) => ({ ...s, [folder]: true }))
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch('/api/backup', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({ action: 'sync', folder }),
|
|
87
|
+
})
|
|
88
|
+
const data = await res.json()
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
toast.error('Sync failed', { description: data.error })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
toast.success(`~/.${folder} synced`, { description: data.message })
|
|
94
|
+
await fetchStatus()
|
|
95
|
+
} catch (e) {
|
|
96
|
+
toast.error('Sync failed', { description: (e as Error).message })
|
|
97
|
+
} finally {
|
|
98
|
+
setSyncing((s) => ({ ...s, [folder]: false }))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (loading) {
|
|
103
|
+
return (
|
|
104
|
+
<Card>
|
|
105
|
+
<CardHeader>
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<Cloud className="h-5 w-5 text-muted-foreground" />
|
|
108
|
+
<div>
|
|
109
|
+
<CardTitle>GitHub Backup</CardTitle>
|
|
110
|
+
<CardDescription>Checking GitHub status...</CardDescription>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</CardHeader>
|
|
114
|
+
<CardContent>
|
|
115
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
116
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
117
|
+
Loading...
|
|
118
|
+
</div>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!status || !status.ghInstalled) {
|
|
125
|
+
return (
|
|
126
|
+
<Card>
|
|
127
|
+
<CardHeader>
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
<CloudOff className="h-5 w-5 text-muted-foreground" />
|
|
130
|
+
<div>
|
|
131
|
+
<CardTitle>GitHub Backup</CardTitle>
|
|
132
|
+
<CardDescription>Back up your agent logs to a private GitHub repo</CardDescription>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</CardHeader>
|
|
136
|
+
<CardContent>
|
|
137
|
+
<div className="space-y-2 text-sm text-muted-foreground">
|
|
138
|
+
<p>GitHub CLI is not installed.</p>
|
|
139
|
+
<a href="https://cli.github.com" target="_blank" rel="noopener noreferrer" className="text-primary underline">
|
|
140
|
+
Install GitHub CLI
|
|
141
|
+
</a>
|
|
142
|
+
</div>
|
|
143
|
+
</CardContent>
|
|
144
|
+
</Card>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!status.ghAuthenticated) {
|
|
149
|
+
return (
|
|
150
|
+
<Card>
|
|
151
|
+
<CardHeader>
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
153
|
+
<CloudOff className="h-5 w-5 text-muted-foreground" />
|
|
154
|
+
<div>
|
|
155
|
+
<CardTitle>GitHub Backup</CardTitle>
|
|
156
|
+
<CardDescription>Back up your agent logs to a private GitHub repo</CardDescription>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</CardHeader>
|
|
160
|
+
<CardContent>
|
|
161
|
+
<div className="space-y-2 text-sm text-muted-foreground">
|
|
162
|
+
<p>Not logged in to GitHub.</p>
|
|
163
|
+
<code className="block rounded bg-muted px-2 py-1.5 text-xs font-mono">gh auth login</code>
|
|
164
|
+
</div>
|
|
165
|
+
</CardContent>
|
|
166
|
+
</Card>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const folders = [
|
|
171
|
+
{ key: 'claude' as const, label: '~/.claude', status: status.claude },
|
|
172
|
+
{ key: 'codex' as const, label: '~/.codex', status: status.codex },
|
|
173
|
+
].filter((f) => f.status.exists)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Card>
|
|
177
|
+
<CardHeader>
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
<Cloud className="h-5 w-5 text-muted-foreground" />
|
|
180
|
+
<div>
|
|
181
|
+
<CardTitle>GitHub Backup</CardTitle>
|
|
182
|
+
<CardDescription>
|
|
183
|
+
Back up agent logs to private GitHub repos — logged in as <strong>{status.ghUser}</strong>
|
|
184
|
+
</CardDescription>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</CardHeader>
|
|
188
|
+
<CardContent className="space-y-3">
|
|
189
|
+
{folders.map(({ key, label, status: fs }) => {
|
|
190
|
+
const isSetUp = fs.hasGit && fs.hasRemote
|
|
191
|
+
const isSyncing = syncing[key]
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div key={key} className="flex items-center justify-between rounded-lg border p-4">
|
|
195
|
+
<div className="flex items-center gap-3">
|
|
196
|
+
<code className="text-sm font-medium">{label}</code>
|
|
197
|
+
{isSetUp && !fs.isDirty && (
|
|
198
|
+
<Check className="h-4 w-4 text-green-500" />
|
|
199
|
+
)}
|
|
200
|
+
{isSetUp && fs.isDirty && (
|
|
201
|
+
<Badge variant="secondary" className="text-xs">new changes</Badge>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
{!isSetUp ? (
|
|
205
|
+
<Button
|
|
206
|
+
variant="outline"
|
|
207
|
+
size="sm"
|
|
208
|
+
onClick={() => handleInit(key)}
|
|
209
|
+
disabled={isSyncing}
|
|
210
|
+
className="gap-2"
|
|
211
|
+
>
|
|
212
|
+
{isSyncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
|
213
|
+
Backup
|
|
214
|
+
</Button>
|
|
215
|
+
) : (
|
|
216
|
+
<Button
|
|
217
|
+
variant="outline"
|
|
218
|
+
size="sm"
|
|
219
|
+
onClick={() => handleSync(key)}
|
|
220
|
+
disabled={isSyncing}
|
|
221
|
+
className="gap-2"
|
|
222
|
+
>
|
|
223
|
+
{isSyncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
|
|
224
|
+
Sync
|
|
225
|
+
</Button>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
})}
|
|
230
|
+
{folders.length === 0 && (
|
|
231
|
+
<p className="text-sm text-muted-foreground">No agent log directories found.</p>
|
|
232
|
+
)}
|
|
233
|
+
</CardContent>
|
|
234
|
+
</Card>
|
|
235
|
+
)
|
|
236
|
+
}
|