@harbinger-ai/harbinger 0.1.1 → 0.1.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.
@@ -1,77 +1,142 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
- import { PackageIcon, PlusIcon, SearchIcon, SpinnerIcon, CheckIcon, TrashIcon, ChevronDownIcon, DownloadIcon, GlobeIcon } from './icons.js';
5
- import { getCatalog, getInstalledTools, installTool, installCustomTool, uninstallTool, toggleTool, installFromGithub, getContainers, spawnContainer, stopContainer } from '../../registry/actions.js';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import { PackageIcon, SearchIcon, SpinnerIcon, CheckIcon, ChevronDownIcon, DownloadIcon, GlobeIcon } from './icons.js';
6
+ import { getCatalog, getInstalledTools, installTool, uninstallTool, toggleTool, installFromGithub, getContainers, spawnContainer, stopContainer } from '../../registry/actions.js';
6
7
 
7
8
  const CATEGORY_COLORS = {
8
- recon: 'bg-blue-500/10 text-blue-500',
9
- scanning: 'bg-cyan-500/10 text-cyan-500',
10
- web: 'bg-orange-500/10 text-orange-500',
11
- osint: 'bg-green-500/10 text-green-500',
12
- cloud: 'bg-purple-500/10 text-purple-500',
13
- credential: 'bg-red-500/10 text-red-500',
14
- exploitation: 'bg-red-600/10 text-red-600',
15
- binary: 'bg-gray-500/10 text-gray-500',
16
- forensics: 'bg-yellow-500/10 text-yellow-500',
17
- automation: 'bg-indigo-500/10 text-indigo-500',
18
- custom: 'bg-muted text-muted-foreground',
9
+ recon: { bg: 'bg-blue-500/10', text: 'text-blue-500', border: 'border-blue-500/20' },
10
+ scanning: { bg: 'bg-cyan-500/10', text: 'text-cyan-500', border: 'border-cyan-500/20' },
11
+ web: { bg: 'bg-orange-500/10', text: 'text-orange-500', border: 'border-orange-500/20' },
12
+ osint: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/20' },
13
+ cloud: { bg: 'bg-purple-500/10', text: 'text-purple-500', border: 'border-purple-500/20' },
14
+ credential: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/20' },
15
+ exploitation: { bg: 'bg-red-600/10', text: 'text-red-600', border: 'border-red-600/20' },
16
+ binary: { bg: 'bg-gray-500/10', text: 'text-gray-500', border: 'border-gray-500/20' },
17
+ forensics: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/20' },
18
+ automation: { bg: 'bg-indigo-500/10', text: 'text-indigo-500', border: 'border-indigo-500/20' },
19
+ custom: { bg: 'bg-white/5', text: 'text-muted-foreground', border: 'border-white/10' },
19
20
  };
20
21
 
21
- function CatalogToolCard({ tool, installed, onInstall, installing }) {
22
+ function getCatStyle(cat) {
23
+ return CATEGORY_COLORS[cat] || CATEGORY_COLORS.custom;
24
+ }
25
+
26
+ function CatalogToolCard({ tool, installed, onInstall, installing, index }) {
27
+ const cat = getCatStyle(tool.category);
22
28
  return (
23
- <div className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/30 transition-colors">
24
- <div className="shrink-0 rounded-md bg-muted p-2"><PackageIcon size={14} /></div>
25
- <div className="flex-1 min-w-0">
26
- <p className="text-sm font-medium truncate">{tool.name}</p>
27
- <p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-1">{tool.description}</p>
29
+ <motion.div
30
+ initial={{ opacity: 0, y: 8 }}
31
+ animate={{ opacity: 1, y: 0 }}
32
+ transition={{ duration: 0.25, delay: index * 0.02 }}
33
+ className="group rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors overflow-hidden"
34
+ >
35
+ <div className="flex items-center gap-3 p-3">
36
+ <div className="flex items-center gap-1 shrink-0">
37
+ <div className="w-2 h-2 rounded-full bg-[#ff5f57]" />
38
+ <div className="w-2 h-2 rounded-full bg-[#febc2e]" />
39
+ <div className="w-2 h-2 rounded-full bg-[#28c840]" />
40
+ </div>
41
+ <div className="flex-1 min-w-0">
42
+ <p className="text-sm font-mono font-medium truncate">{tool.name}</p>
43
+ <p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-1 font-mono">{tool.description}</p>
44
+ </div>
45
+ <span className={`shrink-0 inline-flex rounded-full px-2 py-0.5 text-[10px] font-mono font-medium border ${cat.bg} ${cat.text} ${cat.border}`}>
46
+ {tool.category}
47
+ </span>
48
+ {installed ? (
49
+ <span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-500 border border-green-500/20 px-2 py-0.5 text-[10px] font-mono font-medium">
50
+ <CheckIcon size={10} /> installed
51
+ </span>
52
+ ) : (
53
+ <button
54
+ onClick={() => onInstall(tool.id)}
55
+ disabled={installing === tool.id}
56
+ className="shrink-0 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50"
57
+ >
58
+ {installing === tool.id ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
59
+ Install
60
+ </button>
61
+ )}
28
62
  </div>
29
- <span className={`shrink-0 inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[tool.category] || CATEGORY_COLORS.custom}`}>{tool.category}</span>
30
- {installed ? (
31
- <span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-500 px-2 py-0.5 text-[10px] font-medium"><CheckIcon size={10} /> installed</span>
32
- ) : (
33
- <button
34
- onClick={() => onInstall(tool.id)}
35
- disabled={installing === tool.id}
36
- className="shrink-0 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium border hover:bg-accent/50 transition-colors disabled:opacity-50"
37
- >
38
- {installing === tool.id ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
39
- Install
40
- </button>
41
- )}
42
- </div>
63
+ </motion.div>
43
64
  );
44
65
  }
45
66
 
46
- function InstalledToolCard({ tool, onUninstall, onToggle, onSpawn }) {
67
+ function InstalledToolCard({ tool, onUninstall, onToggle, onSpawn, index }) {
47
68
  const [expanded, setExpanded] = useState(false);
48
69
  const disabled = !tool.enabled;
70
+ const cat = getCatStyle(tool.category);
49
71
 
50
72
  return (
51
- <div className={`rounded-lg border bg-card transition-opacity ${disabled ? 'opacity-60' : ''}`}>
52
- <button onClick={() => setExpanded(!expanded)} className="flex items-center gap-3 w-full text-left p-3 hover:bg-accent/30 rounded-lg">
53
- <div className="shrink-0 rounded-md bg-muted p-2"><PackageIcon size={14} /></div>
73
+ <motion.div
74
+ initial={{ opacity: 0, y: 8 }}
75
+ animate={{ opacity: 1, y: 0 }}
76
+ transition={{ duration: 0.25, delay: index * 0.03 }}
77
+ className={`rounded-lg border border-white/[0.06] bg-[--card] transition-all ${disabled ? 'opacity-50' : 'hover:border-[--cyan]/20'}`}
78
+ >
79
+ <button onClick={() => setExpanded(!expanded)} className="flex items-center gap-3 w-full text-left p-3 hover:bg-white/[0.02] rounded-lg transition-colors">
80
+ <div className="flex items-center gap-1 shrink-0">
81
+ <div className="w-2 h-2 rounded-full bg-[#ff5f57]" />
82
+ <div className="w-2 h-2 rounded-full bg-[#febc2e]" />
83
+ <div className="w-2 h-2 rounded-full bg-[#28c840]" />
84
+ </div>
54
85
  <div className="flex-1 min-w-0">
55
- <p className="text-sm font-medium truncate">{tool.name}</p>
56
- <p className="text-[11px] text-muted-foreground mt-0.5 truncate">{tool.description}</p>
86
+ <p className="text-sm font-mono font-medium truncate">{tool.name}</p>
87
+ <p className="text-[11px] text-muted-foreground mt-0.5 truncate font-mono">{tool.description}</p>
57
88
  </div>
58
- <span className={`shrink-0 inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[tool.category] || CATEGORY_COLORS.custom}`}>{tool.category}</span>
89
+ <span className={`shrink-0 inline-flex rounded-full px-2 py-0.5 text-[10px] font-mono font-medium border ${cat.bg} ${cat.text} ${cat.border}`}>{tool.category}</span>
59
90
  <span className={`shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}><ChevronDownIcon size={14} /></span>
60
91
  </button>
61
- {expanded && (
62
- <div className="border-t px-4 py-3 flex flex-col gap-2">
63
- {tool.dockerImage && <div className="flex gap-2 text-xs"><span className="text-muted-foreground">Docker:</span><span className="font-mono">{tool.dockerImage}</span></div>}
64
- {tool.installCmd && <div className="flex gap-2 text-xs"><span className="text-muted-foreground">Install:</span><code className="font-mono bg-muted px-1.5 py-0.5 rounded text-[11px] break-all">{tool.installCmd}</code></div>}
65
- {tool.sourceUrl && <div className="flex gap-2 text-xs"><span className="text-muted-foreground">Source:</span><a href={tool.sourceUrl} target="_blank" rel="noopener" className="text-blue-500 hover:underline truncate">{tool.sourceUrl}</a></div>}
66
- <div className="flex items-center gap-2 mt-1">
67
- {tool.dockerImage && <button onClick={() => onSpawn(tool.id)} className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium border hover:bg-accent/50">Spawn Container</button>}
68
- <button onClick={() => onToggle(tool.id)} className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium border hover:bg-accent/50">{tool.enabled ? 'Disable' : 'Enable'}</button>
69
- <div className="flex-1" />
70
- <button onClick={() => onUninstall(tool.id)} className="text-xs text-muted-foreground hover:text-destructive">Uninstall</button>
71
- </div>
72
- </div>
73
- )}
74
- </div>
92
+
93
+ <AnimatePresence>
94
+ {expanded && (
95
+ <motion.div
96
+ initial={{ height: 0, opacity: 0 }}
97
+ animate={{ height: 'auto', opacity: 1 }}
98
+ exit={{ height: 0, opacity: 0 }}
99
+ transition={{ duration: 0.2 }}
100
+ className="overflow-hidden"
101
+ >
102
+ <div className="border-t border-white/[0.06] px-4 py-3 flex flex-col gap-2">
103
+ {tool.dockerImage && (
104
+ <div className="flex gap-2 text-xs items-baseline">
105
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-14 shrink-0">Docker</span>
106
+ <span className="font-mono text-foreground/80">{tool.dockerImage}</span>
107
+ </div>
108
+ )}
109
+ {tool.installCmd && (
110
+ <div className="flex gap-2 text-xs items-baseline">
111
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-14 shrink-0">Install</span>
112
+ <code className="font-mono bg-black/30 px-2 py-0.5 rounded text-[11px] text-foreground/80 border border-white/[0.04] break-all">{tool.installCmd}</code>
113
+ </div>
114
+ )}
115
+ {tool.sourceUrl && (
116
+ <div className="flex gap-2 text-xs items-baseline">
117
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-14 shrink-0">Source</span>
118
+ <a href={tool.sourceUrl} target="_blank" rel="noopener" className="text-[--cyan] hover:underline truncate font-mono">{tool.sourceUrl}</a>
119
+ </div>
120
+ )}
121
+ <div className="flex items-center gap-2 mt-2">
122
+ {tool.dockerImage && (
123
+ <button onClick={() => onSpawn(tool.id)} className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors">
124
+ Spawn
125
+ </button>
126
+ )}
127
+ <button onClick={() => onToggle(tool.id)} className="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors">
128
+ {tool.enabled ? 'Disable' : 'Enable'}
129
+ </button>
130
+ <div className="flex-1" />
131
+ <button onClick={() => onUninstall(tool.id)} className="text-xs font-mono text-muted-foreground hover:text-[--destructive] transition-colors">
132
+ Uninstall
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </motion.div>
137
+ )}
138
+ </AnimatePresence>
139
+ </motion.div>
75
140
  );
76
141
  }
77
142
 
@@ -91,17 +156,29 @@ function GithubInstaller({ onInstall }) {
91
156
  }
92
157
 
93
158
  return (
94
- <div className="rounded-lg border bg-card p-4 mb-4">
95
- <p className="text-sm font-medium mb-2">Install from GitHub</p>
96
- <p className="text-xs text-muted-foreground mb-3">Paste a GitHub repository URL to add any tool to your registry.</p>
159
+ <div className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-4">
160
+ <div className="flex items-center gap-2 mb-2">
161
+ <GlobeIcon size={14} />
162
+ <p className="text-sm font-mono font-medium text-[--cyan]">Install from GitHub</p>
163
+ </div>
164
+ <p className="text-[11px] text-muted-foreground mb-3 font-mono">Paste a GitHub repository URL to add any tool to your registry.</p>
97
165
  <div className="flex gap-2">
98
- <input placeholder="https://github.com/owner/repo" value={url} onChange={e => setUrl(e.target.value)} className="flex-1 text-sm border rounded-md px-3 py-2 bg-background font-mono" />
99
- <button onClick={handleInstall} disabled={loading || !url} className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-medium bg-foreground text-background hover:opacity-90 disabled:opacity-50">
166
+ <input
167
+ placeholder="https://github.com/owner/repo"
168
+ value={url}
169
+ onChange={e => setUrl(e.target.value)}
170
+ className="flex-1 text-sm border border-white/[0.06] rounded-md px-3 py-2 bg-black/20 font-mono text-foreground/80 placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors"
171
+ />
172
+ <button
173
+ onClick={handleInstall}
174
+ disabled={loading || !url}
175
+ className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-[--cyan] text-[--primary-foreground] hover:opacity-90 disabled:opacity-50 transition-opacity"
176
+ >
100
177
  {loading ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />} Install
101
178
  </button>
102
179
  </div>
103
180
  {result && (
104
- <p className={`text-xs mt-2 ${result.error ? 'text-destructive' : 'text-green-500'}`}>
181
+ <p className={`text-xs font-mono mt-2 ${result.error ? 'text-[--destructive]' : 'text-green-500'}`}>
105
182
  {result.error || `Installed ${result.name}`}
106
183
  </p>
107
184
  )}
@@ -117,7 +194,7 @@ export function RegistryPage() {
117
194
  const [search, setSearch] = useState('');
118
195
  const [activeCategory, setActiveCategory] = useState('all');
119
196
  const [installing, setInstalling] = useState(null);
120
- const [tab, setTab] = useState('catalog'); // catalog, installed, containers
197
+ const [tab, setTab] = useState('catalog');
121
198
 
122
199
  async function load() {
123
200
  const [c, i, cont] = await Promise.all([getCatalog(), getInstalledTools(), getContainers()]);
@@ -130,6 +207,7 @@ export function RegistryPage() {
130
207
  useEffect(() => { load(); }, []);
131
208
 
132
209
  const installedSlugs = new Set(installed.map(t => t.slug));
210
+ const runningContainers = containers.filter(c => c.status === 'running').length;
133
211
 
134
212
  async function handleInstall(catalogId) {
135
213
  setInstalling(catalogId);
@@ -155,101 +233,184 @@ export function RegistryPage() {
155
233
  return true;
156
234
  });
157
235
 
158
- if (loading) return <div className="flex flex-col gap-3">{[...Array(5)].map((_, i) => <div key={i} className="h-14 animate-pulse rounded-lg bg-border/50" />)}</div>;
236
+ if (loading) {
237
+ return (
238
+ <div className="flex flex-col gap-3">
239
+ {[...Array(5)].map((_, i) => (
240
+ <div key={i} className="h-14 animate-pulse rounded-lg bg-white/[0.04] border border-white/[0.06]" />
241
+ ))}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ const tabs = [
247
+ { id: 'catalog', label: 'CATALOG', count: catalog.tools.length },
248
+ { id: 'installed', label: 'INSTALLED', count: installed.length },
249
+ { id: 'containers', label: 'CONTAINERS', count: runningContainers },
250
+ ];
159
251
 
160
252
  return (
161
253
  <>
162
- <div className="flex items-center justify-between mb-4">
254
+ {/* Header */}
255
+ <div className="flex items-center justify-between mb-6">
163
256
  <div>
164
- <h1 className="text-2xl font-semibold">Toolbox</h1>
165
- <p className="text-sm text-muted-foreground mt-1">{installed.length} installed, {catalog.tools.length} available in catalog</p>
257
+ <h1 className="text-2xl font-mono font-semibold text-[--cyan] text-glow-cyan">Toolbox</h1>
258
+ <p className="text-[11px] text-muted-foreground mt-1 font-mono">Security tool registry and container management</p>
259
+ </div>
260
+ <div className="flex items-center gap-2">
261
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20">
262
+ {catalog.tools.length} tools
263
+ </span>
264
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-green-500/10 text-green-500 border border-green-500/20">
265
+ {installed.length} installed
266
+ </span>
267
+ {runningContainers > 0 && (
268
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-orange-500/10 text-orange-500 border border-orange-500/20">
269
+ <span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
270
+ {runningContainers} running
271
+ </span>
272
+ )}
166
273
  </div>
167
274
  </div>
168
275
 
169
276
  {/* Tabs */}
170
- <div className="flex gap-1 border-b border-border mb-4">
171
- {[
172
- { id: 'catalog', label: `Catalog (${catalog.tools.length})` },
173
- { id: 'installed', label: `Installed (${installed.length})` },
174
- { id: 'containers', label: `Containers (${containers.filter(c => c.status === 'running').length})` },
175
- ].map(t => (
176
- <button key={t.id} onClick={() => setTab(t.id)} className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${tab === t.id ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>{t.label}</button>
277
+ <div className="flex gap-1 border-b border-white/[0.06] mb-4">
278
+ {tabs.map(t => (
279
+ <button
280
+ key={t.id}
281
+ onClick={() => setTab(t.id)}
282
+ className={`px-4 py-2.5 text-[11px] font-mono font-medium uppercase tracking-wider border-b-2 transition-colors ${
283
+ tab === t.id
284
+ ? 'border-[--cyan] text-[--cyan]'
285
+ : 'border-transparent text-muted-foreground hover:text-foreground'
286
+ }`}
287
+ >
288
+ {t.label} <span className="text-muted-foreground">({t.count})</span>
289
+ </button>
177
290
  ))}
178
291
  </div>
179
292
 
180
- {tab === 'catalog' && (
181
- <>
182
- {/* Search + category filter */}
183
- <div className="flex flex-col sm:flex-row gap-3 mb-4">
184
- <div className="relative flex-1">
185
- <SearchIcon size={14} />
186
- <input placeholder="Search tools..." value={search} onChange={e => setSearch(e.target.value)} className="w-full text-sm border rounded-md pl-3 pr-3 py-2 bg-background" />
293
+ <AnimatePresence mode="wait">
294
+ {tab === 'catalog' && (
295
+ <motion.div key="catalog" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
296
+ {/* Search */}
297
+ <div className="flex flex-col sm:flex-row gap-3 mb-4">
298
+ <div className="relative flex-1">
299
+ <div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={14} /></div>
300
+ <input
301
+ placeholder="Search tools..."
302
+ value={search}
303
+ onChange={e => setSearch(e.target.value)}
304
+ className="w-full text-sm border border-white/[0.06] rounded-md pl-9 pr-3 py-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors"
305
+ />
306
+ </div>
187
307
  </div>
188
- </div>
189
- <div className="flex gap-1 mb-4 overflow-x-auto pb-1">
190
- <button onClick={() => setActiveCategory('all')} className={`shrink-0 px-3 py-1 rounded-full text-xs font-medium transition-colors ${activeCategory === 'all' ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground'}`}>All ({catalog.tools.length})</button>
191
- {catalog.categories.map(c => (
192
- <button key={c.id} onClick={() => setActiveCategory(c.id)} className={`shrink-0 px-3 py-1 rounded-full text-xs font-medium transition-colors ${activeCategory === c.id ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground'}`}>
193
- {c.name} ({catalog.tools.filter(t => t.category === c.id).length})
194
- </button>
195
- ))}
196
- </div>
197
-
198
- <GithubInstaller onInstall={handleGithubInstall} />
199
308
 
200
- <div className="flex flex-col gap-2">
201
- {filteredCatalog.map(t => (
202
- <CatalogToolCard key={t.id} tool={t} installed={installedSlugs.has(t.id)} onInstall={handleInstall} installing={installing} />
203
- ))}
204
- {filteredCatalog.length === 0 && (
205
- <p className="text-center text-sm text-muted-foreground py-8">No tools match your search.</p>
206
- )}
207
- </div>
208
- </>
209
- )}
210
-
211
- {tab === 'installed' && (
212
- <>
213
- {installed.length === 0 ? (
214
- <div className="flex flex-col items-center justify-center py-16 text-center">
215
- <div className="rounded-full bg-muted p-4 mb-4"><PackageIcon size={24} /></div>
216
- <p className="text-sm font-medium mb-1">No tools installed</p>
217
- <p className="text-xs text-muted-foreground">Browse the catalog to install security tools for your agents.</p>
218
- </div>
219
- ) : (
220
- <div className="flex flex-col gap-2">
221
- {installed.map(t => <InstalledToolCard key={t.id} tool={t} onUninstall={handleUninstall} onToggle={handleToggle} onSpawn={handleSpawn} />)}
309
+ {/* Category filter */}
310
+ <div className="flex gap-1.5 mb-4 overflow-x-auto pb-1 scrollbar-thin">
311
+ <button
312
+ onClick={() => setActiveCategory('all')}
313
+ className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
314
+ activeCategory === 'all'
315
+ ? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20'
316
+ : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
317
+ }`}
318
+ >
319
+ All ({catalog.tools.length})
320
+ </button>
321
+ {catalog.categories.map(c => {
322
+ const cs = getCatStyle(c.id);
323
+ const isActive = activeCategory === c.id;
324
+ return (
325
+ <button
326
+ key={c.id}
327
+ onClick={() => setActiveCategory(c.id)}
328
+ className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
329
+ isActive
330
+ ? `${cs.bg} ${cs.text} ${cs.border}`
331
+ : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
332
+ }`}
333
+ >
334
+ {c.name} ({catalog.tools.filter(t => t.category === c.id).length})
335
+ </button>
336
+ );
337
+ })}
222
338
  </div>
223
- )}
224
- </>
225
- )}
226
339
 
227
- {tab === 'containers' && (
228
- <>
229
- {containers.length === 0 ? (
230
- <div className="flex flex-col items-center justify-center py-16 text-center">
231
- <div className="rounded-full bg-muted p-4 mb-4"><PackageIcon size={24} /></div>
232
- <p className="text-sm font-medium mb-1">No containers running</p>
233
- <p className="text-xs text-muted-foreground">Spawn containers from installed tools to give agents terminal access.</p>
234
- </div>
235
- ) : (
340
+ <GithubInstaller onInstall={handleGithubInstall} />
341
+
236
342
  <div className="flex flex-col gap-2">
237
- {containers.map(c => (
238
- <div key={c.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
239
- <div className={`shrink-0 w-2 h-2 rounded-full ${c.status === 'running' ? 'bg-green-500 animate-pulse' : c.status === 'stopped' ? 'bg-muted-foreground' : 'bg-red-500'}`} />
240
- <div className="flex-1 min-w-0">
241
- <p className="text-sm font-mono truncate">{c.imageName}</p>
242
- <p className="text-[10px] text-muted-foreground mt-0.5">{c.containerId?.slice(0, 12)} — {c.status}</p>
243
- </div>
244
- {c.status === 'running' && (
245
- <button onClick={() => handleStop(c.id)} className="text-xs text-muted-foreground hover:text-destructive">Stop</button>
246
- )}
247
- </div>
343
+ {filteredCatalog.map((t, i) => (
344
+ <CatalogToolCard key={t.id} tool={t} installed={installedSlugs.has(t.id)} onInstall={handleInstall} installing={installing} index={i} />
248
345
  ))}
346
+ {filteredCatalog.length === 0 && (
347
+ <div className="flex flex-col items-center py-12 text-center">
348
+ <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><SearchIcon size={24} /></div>
349
+ <p className="text-sm font-mono text-muted-foreground">No tools match your search.</p>
350
+ </div>
351
+ )}
249
352
  </div>
250
- )}
251
- </>
252
- )}
353
+ </motion.div>
354
+ )}
355
+
356
+ {tab === 'installed' && (
357
+ <motion.div key="installed" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
358
+ {installed.length === 0 ? (
359
+ <div className="flex flex-col items-center justify-center py-16 text-center">
360
+ <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><PackageIcon size={24} /></div>
361
+ <p className="text-sm font-mono font-medium mb-1">No tools installed</p>
362
+ <p className="text-[11px] text-muted-foreground font-mono">Browse the catalog to install security tools for your agents.</p>
363
+ </div>
364
+ ) : (
365
+ <div className="flex flex-col gap-2">
366
+ {installed.map((t, i) => <InstalledToolCard key={t.id} tool={t} onUninstall={handleUninstall} onToggle={handleToggle} onSpawn={handleSpawn} index={i} />)}
367
+ </div>
368
+ )}
369
+ </motion.div>
370
+ )}
371
+
372
+ {tab === 'containers' && (
373
+ <motion.div key="containers" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
374
+ {containers.length === 0 ? (
375
+ <div className="flex flex-col items-center justify-center py-16 text-center">
376
+ <div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><PackageIcon size={24} /></div>
377
+ <p className="text-sm font-mono font-medium mb-1">No containers running</p>
378
+ <p className="text-[11px] text-muted-foreground font-mono">Spawn containers from installed tools to give agents terminal access.</p>
379
+ </div>
380
+ ) : (
381
+ <div className="flex flex-col gap-2">
382
+ {containers.map((c, i) => (
383
+ <motion.div
384
+ key={c.id}
385
+ initial={{ opacity: 0, y: 8 }}
386
+ animate={{ opacity: 1, y: 0 }}
387
+ transition={{ duration: 0.25, delay: i * 0.03 }}
388
+ className="flex items-center gap-3 p-3 rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors"
389
+ >
390
+ <div className={`shrink-0 w-2.5 h-2.5 rounded-full ${
391
+ c.status === 'running' ? 'bg-green-500 animate-pulse' :
392
+ c.status === 'stopped' ? 'bg-muted-foreground' : 'bg-red-500'
393
+ }`} />
394
+ <div className="flex-1 min-w-0">
395
+ <p className="text-sm font-mono font-medium truncate">{c.imageName}</p>
396
+ <div className="flex items-center gap-2 mt-0.5">
397
+ <span className="text-[10px] font-mono text-muted-foreground">{c.containerId?.slice(0, 12)}</span>
398
+ <span className={`inline-flex rounded-full px-1.5 py-0.5 text-[9px] font-mono font-medium ${
399
+ c.status === 'running' ? 'bg-green-500/10 text-green-500 border border-green-500/20' :
400
+ 'bg-white/5 text-muted-foreground border border-white/10'
401
+ }`}>{c.status}</span>
402
+ </div>
403
+ </div>
404
+ {c.status === 'running' && (
405
+ <button onClick={() => handleStop(c.id)} className="text-xs font-mono text-muted-foreground hover:text-[--destructive] transition-colors">Stop</button>
406
+ )}
407
+ </motion.div>
408
+ ))}
409
+ </div>
410
+ )}
411
+ </motion.div>
412
+ )}
413
+ </AnimatePresence>
253
414
  </>
254
415
  );
255
416
  }