agentcraft 0.0.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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +7 -0
  2. package/README.md +95 -0
  3. package/bin/agentcraft.js +12 -0
  4. package/commands/agentcraft.md +46 -0
  5. package/hooks/hooks.json +13 -0
  6. package/hooks/play-sound.sh +69 -0
  7. package/opencode.js +87 -0
  8. package/package.json +19 -0
  9. package/screenshot.jpg +0 -0
  10. package/social-share-og.jpg +0 -0
  11. package/social-share-tw.jpg +0 -0
  12. package/social-share.png +0 -0
  13. package/web/README.md +36 -0
  14. package/web/app/api/agents/[name]/route.ts +44 -0
  15. package/web/app/api/agents/route.ts +62 -0
  16. package/web/app/api/assignments/route.ts +53 -0
  17. package/web/app/api/audio/[...path]/route.ts +35 -0
  18. package/web/app/api/health/route.ts +5 -0
  19. package/web/app/api/preview/route.ts +22 -0
  20. package/web/app/api/skills/route.ts +83 -0
  21. package/web/app/api/sounds/route.ts +106 -0
  22. package/web/app/api/ui-sounds/route.ts +44 -0
  23. package/web/app/favicon.ico +0 -0
  24. package/web/app/globals.css +99 -0
  25. package/web/app/icon.svg +13 -0
  26. package/web/app/layout.tsx +19 -0
  27. package/web/app/opengraph-image.tsx +96 -0
  28. package/web/app/page.tsx +268 -0
  29. package/web/bun.lock +292 -0
  30. package/web/components/agent-form.tsx +190 -0
  31. package/web/components/agent-roster-panel.tsx +502 -0
  32. package/web/components/assignment-log-panel.tsx +109 -0
  33. package/web/components/hook-slot.tsx +149 -0
  34. package/web/components/hud-header.tsx +135 -0
  35. package/web/components/sound-browser-panel.tsx +206 -0
  36. package/web/components/sound-unit.tsx +203 -0
  37. package/web/components/ui-sounds-modal.tsx +308 -0
  38. package/web/lib/types.ts +87 -0
  39. package/web/lib/ui-audio.ts +126 -0
  40. package/web/lib/utils.ts +98 -0
  41. package/web/next.config.ts +8 -0
  42. package/web/package.json +37 -0
  43. package/web/postcss.config.mjs +1 -0
  44. package/web/public/file.svg +1 -0
  45. package/web/public/fonts/starcraft-normal.ttf +0 -0
  46. package/web/public/globe.svg +1 -0
  47. package/web/public/next.svg +1 -0
  48. package/web/public/vercel.svg +1 -0
  49. package/web/public/window.svg +1 -0
  50. package/web/tsconfig.json +34 -0
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { AgentFormData, AgentInfo } from '@/lib/types';
5
+
6
+ const MODELS = ['sonnet', 'opus', 'haiku'];
7
+ const COLORS = ['blue', 'green', 'red', 'purple', 'orange', 'yellow', 'pink'];
8
+ const COMMON_TOOLS = ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite'];
9
+
10
+ interface AgentFormProps {
11
+ initial?: AgentInfo;
12
+ onSave: (data: AgentFormData, originalName?: string) => Promise<void>;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ export function AgentForm({ initial, onSave, onCancel }: AgentFormProps) {
17
+ const [form, setForm] = useState<AgentFormData>({
18
+ name: initial?.name ?? '',
19
+ description: initial?.description ?? '',
20
+ model: initial?.model ?? 'sonnet',
21
+ tools: initial?.tools ?? 'Read, Write, Edit, Bash, Grep',
22
+ color: initial?.color ?? 'blue',
23
+ prompt: initial?.prompt ?? '',
24
+ });
25
+ const [saving, setSaving] = useState(false);
26
+
27
+ const set = (key: keyof AgentFormData, val: string) => setForm((f) => ({ ...f, [key]: val }));
28
+
29
+ const toggleTool = (tool: string) => {
30
+ const current = form.tools.split(',').map((t) => t.trim()).filter(Boolean);
31
+ const next = current.includes(tool)
32
+ ? current.filter((t) => t !== tool)
33
+ : [...current, tool];
34
+ set('tools', next.join(', '));
35
+ };
36
+
37
+ const hasTools = (tool: string) => form.tools.split(',').map((t) => t.trim()).includes(tool);
38
+
39
+ const handleSave = async () => {
40
+ if (!form.name.trim() || !form.description.trim()) return;
41
+ setSaving(true);
42
+ try {
43
+ await onSave(form, initial?.name);
44
+ } finally {
45
+ setSaving(false);
46
+ }
47
+ };
48
+
49
+ const inputStyle = {
50
+ backgroundColor: 'rgba(0,0,0,0.4)',
51
+ border: '1px solid var(--sf-border)',
52
+ color: 'rgba(255,255,255,0.85)',
53
+ outline: 'none',
54
+ fontFamily: 'inherit',
55
+ fontSize: '11px',
56
+ padding: '4px 8px',
57
+ width: '100%',
58
+ } as const;
59
+
60
+ const labelStyle = {
61
+ fontSize: '9px',
62
+ color: 'var(--sf-cyan)',
63
+ opacity: 0.6,
64
+ textTransform: 'uppercase' as const,
65
+ letterSpacing: '0.1em',
66
+ marginBottom: '3px',
67
+ display: 'block',
68
+ };
69
+
70
+ return (
71
+ <div
72
+ className="mt-1 mb-2 p-3 space-y-3"
73
+ style={{ border: '1px solid rgba(0,229,255,0.3)', backgroundColor: 'rgba(0,229,255,0.03)' }}
74
+ >
75
+ <div className="sf-heading text-[10px] tracking-widest uppercase opacity-60" style={{ color: 'var(--sf-cyan)' }}>
76
+ {initial ? 'EDIT UNIT' : 'DEPLOY NEW UNIT'}
77
+ </div>
78
+
79
+ {/* Name */}
80
+ <div>
81
+ <label style={labelStyle}>CALLSIGN (name)</label>
82
+ <input
83
+ style={inputStyle}
84
+ value={form.name}
85
+ onChange={(e) => set('name', e.target.value.toLowerCase().replace(/\s+/g, '-'))}
86
+ placeholder="my-specialist"
87
+ disabled={!!initial}
88
+ />
89
+ </div>
90
+
91
+ {/* Description */}
92
+ <div>
93
+ <label style={labelStyle}>BRIEFING (description)</label>
94
+ <textarea
95
+ style={{ ...inputStyle, resize: 'vertical', minHeight: '48px' }}
96
+ value={form.description}
97
+ onChange={(e) => set('description', e.target.value)}
98
+ placeholder="Expert in..."
99
+ rows={2}
100
+ />
101
+ </div>
102
+
103
+ {/* Model + Color row */}
104
+ <div className="flex gap-3">
105
+ <div className="flex-1">
106
+ <label style={labelStyle}>MODEL</label>
107
+ <select
108
+ style={{ ...inputStyle, cursor: 'pointer' }}
109
+ value={form.model}
110
+ onChange={(e) => set('model', e.target.value)}
111
+ >
112
+ {MODELS.map((m) => <option key={m} value={m}>{m}</option>)}
113
+ </select>
114
+ </div>
115
+ <div className="flex-1">
116
+ <label style={labelStyle}>COLOR</label>
117
+ <select
118
+ style={{ ...inputStyle, cursor: 'pointer' }}
119
+ value={form.color}
120
+ onChange={(e) => set('color', e.target.value)}
121
+ >
122
+ {COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
123
+ </select>
124
+ </div>
125
+ </div>
126
+
127
+ {/* Tools */}
128
+ <div>
129
+ <label style={labelStyle}>ARMAMENTS (tools)</label>
130
+ <div className="flex flex-wrap gap-1 mb-1">
131
+ {COMMON_TOOLS.map((tool) => (
132
+ <button
133
+ key={tool}
134
+ onClick={() => toggleTool(tool)}
135
+ className="text-[9px] px-1.5 py-0.5 transition-all"
136
+ style={{
137
+ border: `1px solid ${hasTools(tool) ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
138
+ color: hasTools(tool) ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.3)',
139
+ backgroundColor: hasTools(tool) ? 'rgba(0,229,255,0.08)' : 'transparent',
140
+ }}
141
+ >
142
+ {tool}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ <input
147
+ style={inputStyle}
148
+ value={form.tools}
149
+ onChange={(e) => set('tools', e.target.value)}
150
+ placeholder="Read, Write, Edit, Bash"
151
+ />
152
+ </div>
153
+
154
+ {/* System prompt */}
155
+ <div>
156
+ <label style={labelStyle}>ORDERS (system prompt)</label>
157
+ <textarea
158
+ style={{ ...inputStyle, resize: 'vertical', minHeight: '80px' }}
159
+ value={form.prompt}
160
+ onChange={(e) => set('prompt', e.target.value)}
161
+ placeholder="You are a specialist in..."
162
+ rows={4}
163
+ />
164
+ </div>
165
+
166
+ {/* Actions */}
167
+ <div className="flex gap-2 pt-1">
168
+ <button
169
+ onClick={handleSave}
170
+ disabled={saving || !form.name.trim() || !form.description.trim()}
171
+ className="flex-1 py-1.5 text-[10px] sf-heading font-bold uppercase tracking-wider transition-all"
172
+ style={{
173
+ border: '1px solid var(--sf-cyan)',
174
+ color: saving ? 'rgba(0,229,255,0.4)' : 'var(--sf-cyan)',
175
+ backgroundColor: 'rgba(0,229,255,0.08)',
176
+ }}
177
+ >
178
+ {saving ? 'DEPLOYING...' : (initial ? 'UPDATE UNIT' : 'DEPLOY UNIT')}
179
+ </button>
180
+ <button
181
+ onClick={onCancel}
182
+ className="px-4 py-1.5 text-[10px] sf-heading uppercase tracking-wider transition-all"
183
+ style={{ border: '1px solid var(--sf-border)', color: 'rgba(255,255,255,0.4)' }}
184
+ >
185
+ ABORT
186
+ </button>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,502 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo } from 'react';
4
+ import { HookSlot } from './hook-slot';
5
+ import { AgentForm } from './agent-form';
6
+ import { playUISound } from '@/lib/ui-audio';
7
+ import { getEventLabel } from '@/lib/utils';
8
+ import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
9
+
10
+ const HOOK_GROUPS: { label: string; events: HookEvent[] }[] = [
11
+ { label: 'LIFECYCLE', events: ['SessionStart', 'SessionEnd', 'Stop'] },
12
+ { label: 'TOOLING', events: ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] },
13
+ { label: 'SUBAGENTS', events: ['SubagentStop'] },
14
+ { label: 'SYSTEM', events: ['Notification', 'PreCompact'] },
15
+ ];
16
+
17
+ const ALL_EVENTS: HookEvent[] = HOOK_GROUPS.flatMap((g) => g.events);
18
+ const SKILL_EVENTS: SkillHookEvent[] = ['PreToolUse', 'PostToolUse'];
19
+ const SKILL_EVENT_LABELS: Record<SkillHookEvent, string> = {
20
+ PreToolUse: 'ON INVOKE',
21
+ PostToolUse: 'ON COMPLETE',
22
+ };
23
+
24
+ // ── Agent row ────────────────────────────────────────────────────
25
+
26
+ interface AgentRowProps {
27
+ scope: string;
28
+ label: string;
29
+ isGlobal?: boolean;
30
+ hooks: Partial<Record<HookEvent, string>>;
31
+ enabled?: boolean;
32
+ agentInfo?: AgentInfo;
33
+ onToggle?: () => void;
34
+ onClear: (event: HookEvent) => void;
35
+ onPreview: (path: string) => void;
36
+ onEdit?: () => void;
37
+ onDelete?: () => void;
38
+ selectMode: SelectMode | null;
39
+ onSlotSelect: (mode: SelectMode) => void;
40
+ }
41
+
42
+ function AgentRow({ scope, label, isGlobal, hooks, enabled, onToggle, onClear, onPreview, onEdit, onDelete, selectMode, onSlotSelect }: AgentRowProps) {
43
+ const [expanded, setExpanded] = useState(!!isGlobal);
44
+ const [isHovered, setIsHovered] = useState(false);
45
+ const filledCount = Object.values(hooks).filter(Boolean).length;
46
+
47
+ return (
48
+ <div className="mb-1">
49
+ <div
50
+ data-sf-hover
51
+ data-no-ui-sound
52
+ className="flex items-center justify-between px-3 py-2 cursor-pointer transition-all group"
53
+ style={{
54
+ border: `1px solid ${isGlobal ? 'var(--sf-border-gold)' : isHovered ? 'rgba(0,229,255,0.35)' : 'var(--sf-border)'}`,
55
+ backgroundColor: isGlobal
56
+ ? isHovered ? 'rgba(255,192,0,0.08)' : 'rgba(255,192,0,0.04)'
57
+ : isHovered ? 'rgba(0,229,255,0.04)' : 'transparent',
58
+ }}
59
+ onMouseEnter={() => setIsHovered(true)}
60
+ onMouseLeave={() => setIsHovered(false)}
61
+ onClick={() => { playUISound('toggle', 0.3); setExpanded(!expanded); }}
62
+ >
63
+ <div className="flex items-center gap-2 overflow-hidden">
64
+ <span className="text-[10px] opacity-60 shrink-0">{expanded ? '▾' : '▸'}</span>
65
+ <span
66
+ className="text-xs sf-heading font-semibold uppercase tracking-wider truncate"
67
+ style={{ color: isGlobal ? 'var(--sf-gold)' : 'rgba(255,255,255,0.8)' }}
68
+ >
69
+ {label}
70
+ </span>
71
+ </div>
72
+ <div className="flex items-center gap-1.5 shrink-0">
73
+ <span className="text-[10px] opacity-40">{filledCount}/{ALL_EVENTS.length}</span>
74
+ {!isGlobal && onToggle && (
75
+ <button
76
+ onClick={(e) => { e.stopPropagation(); onToggle(); }}
77
+ className="text-[10px] px-2 py-0.5 transition-all"
78
+ style={{
79
+ border: `1px solid ${enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.2)'}`,
80
+ color: enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.3)',
81
+ }}
82
+ >
83
+ {enabled ? 'ON' : 'OFF'}
84
+ </button>
85
+ )}
86
+ {onEdit && (
87
+ <button
88
+ onClick={(e) => { e.stopPropagation(); onEdit(); }}
89
+ className="text-[10px] px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-all"
90
+ style={{ border: '1px solid var(--sf-border)', color: 'rgba(255,255,255,0.4)' }}
91
+ title="Edit agent"
92
+ >
93
+
94
+ </button>
95
+ )}
96
+ {onDelete && (
97
+ <button
98
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
99
+ className="text-[10px] px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-all"
100
+ style={{ border: '1px solid rgba(255,51,102,0.3)', color: 'var(--sf-alert)' }}
101
+ title="Delete agent"
102
+ >
103
+
104
+ </button>
105
+ )}
106
+ </div>
107
+ </div>
108
+
109
+ {expanded && (
110
+ <div className="ml-3 mt-1 mb-2 space-y-1">
111
+ {HOOK_GROUPS.map((group) => (
112
+ <div key={group.label}>
113
+ <div className="text-[9px] uppercase tracking-widest opacity-30 mb-1 ml-1">{group.label}</div>
114
+ {group.events.map((event) => (
115
+ <HookSlot
116
+ key={event}
117
+ event={event}
118
+ scope={scope}
119
+ assignedSound={hooks[event]}
120
+ onClear={() => onClear(event)}
121
+ onPreview={onPreview}
122
+ selectMode={selectMode}
123
+ onSelect={() => onSlotSelect({ scope, event, label: getEventLabel(event) })}
124
+ />
125
+ ))}
126
+ </div>
127
+ ))}
128
+ </div>
129
+ )}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // ── Skill row ────────────────────────────────────────────────────
135
+
136
+ interface SkillRowProps {
137
+ skill: SkillInfo;
138
+ hooks: Partial<Record<SkillHookEvent, string>>;
139
+ enabled: boolean;
140
+ onToggle: () => void;
141
+ onClear: (event: SkillHookEvent) => void;
142
+ onPreview: (path: string) => void;
143
+ selectMode: SelectMode | null;
144
+ onSlotSelect: (mode: SelectMode) => void;
145
+ }
146
+
147
+ function SkillRow({ skill, hooks, enabled, onToggle, onClear, onPreview, selectMode, onSlotSelect }: SkillRowProps) {
148
+ const [expanded, setExpanded] = useState(false);
149
+ const [isHovered, setIsHovered] = useState(false);
150
+ const filledCount = Object.values(hooks).filter(Boolean).length;
151
+ const scope = `skill/${skill.qualifiedName}`;
152
+
153
+ return (
154
+ <div className="mb-1">
155
+ <div
156
+ data-sf-hover
157
+ data-no-ui-sound
158
+ className="flex items-center justify-between px-3 py-2 cursor-pointer transition-all"
159
+ style={{
160
+ border: `1px solid ${isHovered ? 'rgba(0,168,255,0.4)' : 'rgba(0,168,255,0.2)'}`,
161
+ backgroundColor: isHovered ? 'rgba(0,168,255,0.07)' : 'rgba(0,168,255,0.03)',
162
+ }}
163
+ onMouseEnter={() => setIsHovered(true)}
164
+ onMouseLeave={() => setIsHovered(false)}
165
+ onClick={() => { playUISound('toggle', 0.3); setExpanded(!expanded); }}
166
+ >
167
+ <div className="flex items-center gap-2 overflow-hidden">
168
+ <span className="text-[10px] opacity-60 shrink-0">{expanded ? '▾' : '▸'}</span>
169
+ <span className="text-xs sf-heading font-semibold uppercase tracking-wider truncate" style={{ color: 'var(--sf-blue)' }}>
170
+ {skill.name}
171
+ </span>
172
+ </div>
173
+ <div className="flex items-center gap-1.5 shrink-0">
174
+ <span className="text-[10px] opacity-40">{filledCount}/2</span>
175
+ <button
176
+ onClick={(e) => { e.stopPropagation(); onToggle(); }}
177
+ className="text-[10px] px-2 py-0.5 transition-all"
178
+ style={{
179
+ border: `1px solid ${enabled ? 'var(--sf-blue)' : 'rgba(255,255,255,0.2)'}`,
180
+ color: enabled ? 'var(--sf-blue)' : 'rgba(255,255,255,0.3)',
181
+ }}
182
+ >
183
+ {enabled ? 'ON' : 'OFF'}
184
+ </button>
185
+ </div>
186
+ </div>
187
+
188
+ {expanded && (
189
+ <div className="ml-3 mt-1 mb-2">
190
+ <div className="text-[9px] uppercase tracking-widest opacity-30 mb-1 ml-1">SIGNALS</div>
191
+ {SKILL_EVENTS.map((event) => (
192
+ <HookSlot
193
+ key={event}
194
+ event={event as HookEvent}
195
+ scope={scope}
196
+ assignedSound={hooks[event]}
197
+ onClear={() => onClear(event)}
198
+ onPreview={onPreview}
199
+ selectMode={selectMode}
200
+ onSelect={() => onSlotSelect({ scope, event, label: SKILL_EVENT_LABELS[event] })}
201
+ />
202
+ ))}
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ // ── Panel export ─────────────────────────────────────────────────
210
+
211
+ interface AgentRosterPanelProps {
212
+ assignments: SoundAssignments;
213
+ agents: AgentInfo[];
214
+ skills: SkillInfo[];
215
+ onAssignmentChange: (next: SoundAssignments) => void;
216
+ onPreview: (path: string) => void;
217
+ onAgentsChange: () => void;
218
+ selectMode: SelectMode | null;
219
+ onSlotSelect: (mode: SelectMode) => void;
220
+ }
221
+
222
+ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
223
+ const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
224
+ const [showForm, setShowForm] = useState(false);
225
+ const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
226
+ const [skillSearch, setSkillSearch] = useState('');
227
+ const [collapsedNs, setCollapsedNs] = useState<Set<string>>(new Set());
228
+
229
+ const clearGlobalHook = (event: HookEvent) => {
230
+ onAssignmentChange({ ...assignments, global: { ...assignments.global, [event]: undefined } });
231
+ };
232
+
233
+ const clearAgentHook = (agentName: string, event: HookEvent) => {
234
+ onAssignmentChange({
235
+ ...assignments,
236
+ agents: {
237
+ ...assignments.agents,
238
+ [agentName]: {
239
+ ...assignments.agents[agentName],
240
+ hooks: { ...assignments.agents[agentName]?.hooks, [event]: undefined },
241
+ },
242
+ },
243
+ });
244
+ };
245
+
246
+ const clearSkillHook = (skillName: string, event: SkillHookEvent) => {
247
+ onAssignmentChange({
248
+ ...assignments,
249
+ skills: {
250
+ ...assignments.skills,
251
+ [skillName]: {
252
+ ...assignments.skills[skillName],
253
+ hooks: { ...assignments.skills[skillName]?.hooks, [event]: undefined },
254
+ },
255
+ },
256
+ });
257
+ };
258
+
259
+ const toggleAgent = (agentName: string) => {
260
+ const current = assignments.agents[agentName] ?? { enabled: true, hooks: {} };
261
+ onAssignmentChange({
262
+ ...assignments,
263
+ agents: { ...assignments.agents, [agentName]: { ...current, enabled: !current.enabled } },
264
+ });
265
+ };
266
+
267
+ const toggleSkill = (skillName: string) => {
268
+ const current = assignments.skills[skillName] ?? { enabled: true, hooks: {} };
269
+ onAssignmentChange({
270
+ ...assignments,
271
+ skills: { ...assignments.skills, [skillName]: { ...current, enabled: !current.enabled } },
272
+ });
273
+ };
274
+
275
+ const handleSaveAgent = async (data: AgentFormData, originalName?: string) => {
276
+ if (originalName) {
277
+ await fetch(`/api/agents/${originalName}`, {
278
+ method: 'PUT',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify(data),
281
+ });
282
+ } else {
283
+ await fetch('/api/agents', {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify(data),
287
+ });
288
+ }
289
+ setShowForm(false);
290
+ setEditingAgent(undefined);
291
+ onAgentsChange();
292
+ };
293
+
294
+ const handleDeleteAgent = async (agentName: string) => {
295
+ await fetch(`/api/agents/${agentName}`, { method: 'DELETE' });
296
+ onAgentsChange();
297
+ };
298
+
299
+ const allAgentNames = new Set([
300
+ ...Object.keys(assignments.agents),
301
+ ...agents.map((a) => a.name),
302
+ ]);
303
+
304
+ // Build unified skill list: merge assigned skills + discovered skills by qualifiedName
305
+ const allSkills = useMemo(() => {
306
+ const map = new Map<string, SkillInfo>();
307
+ // Discovered skills
308
+ for (const s of skills) map.set(s.qualifiedName, s);
309
+ // Skills in assignments that weren't discovered (show them anyway)
310
+ for (const key of Object.keys(assignments.skills)) {
311
+ if (!map.has(key)) {
312
+ map.set(key, { name: key.includes(':') ? key.split(':').pop()! : key, qualifiedName: key, description: '' });
313
+ }
314
+ }
315
+ return [...map.values()];
316
+ }, [skills, assignments.skills]);
317
+
318
+ // Filter by search
319
+ const filteredSkills = useMemo(() => {
320
+ const q = skillSearch.toLowerCase();
321
+ return q ? allSkills.filter(s => s.qualifiedName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)) : allSkills;
322
+ }, [allSkills, skillSearch]);
323
+
324
+ // Group by namespace
325
+ const skillGroups = useMemo(() => {
326
+ const groups = new Map<string, SkillInfo[]>();
327
+ for (const s of filteredSkills) {
328
+ const ns = s.namespace ?? '(user)';
329
+ if (!groups.has(ns)) groups.set(ns, []);
330
+ groups.get(ns)!.push(s);
331
+ }
332
+ return [...groups.entries()].sort(([a], [b]) => {
333
+ if (a === '(user)') return -1;
334
+ if (b === '(user)') return 1;
335
+ return a.localeCompare(b);
336
+ });
337
+ }, [filteredSkills]);
338
+
339
+ return (
340
+ <div className="flex flex-col overflow-hidden">
341
+ {/* Tab bar header */}
342
+ <div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
343
+ <div className="flex items-stretch">
344
+ {(['agents', 'skills'] as const).map((view) => (
345
+ <button
346
+ key={view}
347
+ data-sf-hover
348
+ data-no-ui-sound
349
+ onClick={() => {
350
+ if (activeView !== view) {
351
+ playUISound('pageChange', 0.25);
352
+ setActiveView(view);
353
+ }
354
+ }}
355
+ className="flex-1 py-2.5 text-[10px] sf-heading font-semibold uppercase tracking-widest transition-all"
356
+ style={{
357
+ color: activeView === view ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
358
+ borderBottom: `2px solid ${activeView === view ? 'var(--sf-cyan)' : 'transparent'}`,
359
+ backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
360
+ }}
361
+ >
362
+ {view === 'agents' ? 'AGENTS' : 'SKILLS'}
363
+ </button>
364
+ ))}
365
+ {activeView === 'agents' && (
366
+ <button
367
+ onClick={() => { setShowForm(!showForm); setEditingAgent(undefined); }}
368
+ className="text-[10px] px-3 sf-heading uppercase tracking-wider transition-all"
369
+ style={{
370
+ borderLeft: '1px solid var(--sf-border)',
371
+ color: showForm ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
372
+ }}
373
+ >
374
+ {showForm ? '✕' : '+'}
375
+ </button>
376
+ )}
377
+ </div>
378
+ </div>
379
+
380
+ <div className="flex-1 overflow-y-auto p-3">
381
+ {activeView === 'agents' && (
382
+ <>
383
+ {/* Add/Edit form */}
384
+ {(showForm || editingAgent) && (
385
+ <AgentForm
386
+ initial={editingAgent}
387
+ onSave={handleSaveAgent}
388
+ onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
389
+ />
390
+ )}
391
+
392
+ {/* Global override */}
393
+ <AgentRow
394
+ scope="global"
395
+ label="GLOBAL OVERRIDE"
396
+ isGlobal
397
+ hooks={assignments.global}
398
+ onClear={clearGlobalHook}
399
+ onPreview={onPreview}
400
+ selectMode={selectMode}
401
+ onSlotSelect={onSlotSelect}
402
+ />
403
+
404
+ {/* Per-agent rows */}
405
+ {[...allAgentNames].map((name) => {
406
+ const config = assignments.agents[name] ?? { enabled: true, hooks: {} };
407
+ const agentInfo = agents.find((a) => a.name === name);
408
+ return (
409
+ <AgentRow
410
+ key={name}
411
+ scope={name}
412
+ label={name}
413
+ hooks={config.hooks}
414
+ enabled={config.enabled}
415
+ agentInfo={agentInfo}
416
+ onToggle={() => toggleAgent(name)}
417
+ onClear={(event) => clearAgentHook(name, event)}
418
+ onPreview={onPreview}
419
+ onEdit={() => { setEditingAgent(agentInfo); setShowForm(false); }}
420
+ onDelete={() => handleDeleteAgent(name)}
421
+ selectMode={selectMode}
422
+ onSlotSelect={onSlotSelect}
423
+ />
424
+ );
425
+ })}
426
+ </>
427
+ )}
428
+
429
+ {activeView === 'skills' && (
430
+ <>
431
+ {/* Search */}
432
+ <input
433
+ type="text"
434
+ placeholder="FILTER SKILLS..."
435
+ value={skillSearch}
436
+ onChange={e => setSkillSearch(e.target.value)}
437
+ className="w-full text-[10px] px-2 py-1 mb-3 bg-transparent outline-none sf-mono"
438
+ style={{
439
+ border: `1px solid ${skillSearch ? 'var(--sf-blue)' : 'var(--sf-border)'}`,
440
+ color: 'rgba(255,255,255,0.6)',
441
+ caretColor: 'var(--sf-cyan)',
442
+ }}
443
+ />
444
+
445
+ {/* Grouped by namespace */}
446
+ {skillGroups.map(([ns, nsSkills]) => {
447
+ const isCollapsed = collapsedNs.has(ns);
448
+ const assignedCount = nsSkills.filter(s => assignments.skills[s.qualifiedName]?.hooks.PreToolUse || assignments.skills[s.qualifiedName]?.hooks.PostToolUse).length;
449
+ return (
450
+ <div key={ns} className="mb-1">
451
+ {/* Namespace header */}
452
+ <div
453
+ data-sf-hover
454
+ data-no-ui-sound
455
+ className="flex items-center justify-between px-2 py-1 cursor-pointer transition-all"
456
+ style={{ backgroundColor: 'rgba(0,168,255,0.05)', borderBottom: '1px solid rgba(0,168,255,0.1)' }}
457
+ onClick={() => {
458
+ playUISound('toggle', 0.3);
459
+ setCollapsedNs(prev => {
460
+ const next = new Set(prev);
461
+ if (next.has(ns)) next.delete(ns); else next.add(ns);
462
+ return next;
463
+ });
464
+ }}
465
+ >
466
+ <div className="flex items-center gap-1.5">
467
+ <span className="text-[9px] opacity-40">{isCollapsed ? '▸' : '▾'}</span>
468
+ <span className="sf-heading text-[9px] uppercase tracking-wider" style={{ color: 'var(--sf-blue)' }}>
469
+ {ns}
470
+ </span>
471
+ </div>
472
+ <span className="text-[9px] opacity-40">
473
+ {assignedCount > 0 && <span style={{ color: 'var(--sf-blue)', marginRight: 4 }}>{assignedCount}✦</span>}
474
+ {nsSkills.length}
475
+ </span>
476
+ </div>
477
+
478
+ {!isCollapsed && nsSkills.sort((a, b) => a.name.localeCompare(b.name)).map((s) => {
479
+ const config = assignments.skills[s.qualifiedName] ?? { enabled: true, hooks: {} };
480
+ return (
481
+ <SkillRow
482
+ key={s.qualifiedName}
483
+ skill={s}
484
+ hooks={config.hooks}
485
+ enabled={config.enabled}
486
+ onToggle={() => toggleSkill(s.qualifiedName)}
487
+ onClear={(event) => clearSkillHook(s.qualifiedName, event)}
488
+ onPreview={onPreview}
489
+ selectMode={selectMode}
490
+ onSlotSelect={onSlotSelect}
491
+ />
492
+ );
493
+ })}
494
+ </div>
495
+ );
496
+ })}
497
+ </>
498
+ )}
499
+ </div>
500
+ </div>
501
+ );
502
+ }