@aion0/forge 0.3.0 → 0.3.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.
@@ -2,16 +2,22 @@
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
 
5
+ type ItemType = 'skill' | 'command';
6
+
5
7
  interface Skill {
6
8
  name: string;
9
+ type: ItemType;
7
10
  displayName: string;
8
11
  description: string;
9
12
  author: string;
10
13
  version: string;
11
14
  tags: string[];
12
15
  score: number;
16
+ rating: number;
13
17
  sourceUrl: string;
14
18
  installedGlobal: boolean;
19
+ installedVersion: string;
20
+ hasUpdate: boolean;
15
21
  installedProjects: string[];
16
22
  }
17
23
 
@@ -26,6 +32,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
26
32
  const [syncing, setSyncing] = useState(false);
27
33
  const [loading, setLoading] = useState(true);
28
34
  const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
35
+ const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules'>('all');
36
+ const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
37
+ // Rules (CLAUDE.md templates)
38
+ const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
39
+ const [rulesProjects, setRulesProjects] = useState<{ name: string; path: string }[]>([]);
40
+ const [rulesSelectedTemplate, setRulesSelectedTemplate] = useState<string | null>(null);
41
+ const [rulesEditing, setRulesEditing] = useState(false);
42
+ const [rulesEditId, setRulesEditId] = useState('');
43
+ const [rulesEditName, setRulesEditName] = useState('');
44
+ const [rulesEditDesc, setRulesEditDesc] = useState('');
45
+ const [rulesEditContent, setRulesEditContent] = useState('');
46
+ const [rulesEditDefault, setRulesEditDefault] = useState(false);
47
+ const [rulesShowNew, setRulesShowNew] = useState(false);
48
+ const [rulesBatchProjects, setRulesBatchProjects] = useState<Set<string>>(new Set());
49
+ const [searchQuery, setSearchQuery] = useState('');
50
+ const [collapsedLocalSections, setCollapsedLocalSections] = useState<Set<string>>(new Set());
29
51
  const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
30
52
  const [skillFiles, setSkillFiles] = useState<{ name: string; path: string; type: string }[]>([]);
31
53
  const [activeFile, setActiveFile] = useState<string | null>(null);
@@ -33,16 +55,78 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
33
55
 
34
56
  const fetchSkills = useCallback(async () => {
35
57
  try {
36
- const res = await fetch('/api/skills');
37
- const data = await res.json();
58
+ const [registryRes, localRes] = await Promise.all([
59
+ fetch('/api/skills'),
60
+ fetch('/api/skills/local?action=scan&all=1'),
61
+ ]);
62
+ const data = await registryRes.json();
38
63
  setSkills(data.skills || []);
39
64
  setProjects(data.projects || []);
65
+ const localData = await localRes.json();
66
+ // Filter out items already in registry
67
+ const registryNames = new Set((data.skills || []).map((s: any) => s.name));
68
+ setLocalItems((localData.items || []).filter((i: any) => !registryNames.has(i.name)));
40
69
  } catch {}
41
70
  setLoading(false);
42
71
  }, []);
43
72
 
44
73
  useEffect(() => { fetchSkills(); }, [fetchSkills]);
45
74
 
75
+ const fetchRules = useCallback(async () => {
76
+ try {
77
+ const res = await fetch('/api/claude-templates?action=list');
78
+ const data = await res.json();
79
+ setRulesTemplates(data.templates || []);
80
+ setRulesProjects(data.projects || []);
81
+ } catch {}
82
+ }, []);
83
+
84
+ useEffect(() => { if (typeFilter === 'rules') fetchRules(); }, [typeFilter, fetchRules]);
85
+
86
+ const saveRule = async () => {
87
+ if (!rulesEditId || !rulesEditName || !rulesEditContent) return;
88
+ await fetch('/api/claude-templates', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ action: 'save', id: rulesEditId, name: rulesEditName, description: rulesEditDesc, tags: [], content: rulesEditContent, isDefault: rulesEditDefault }),
92
+ });
93
+ setRulesEditing(false);
94
+ setRulesShowNew(false);
95
+ fetchRules();
96
+ };
97
+
98
+ const deleteRule = async (id: string) => {
99
+ if (!confirm(`Delete template "${id}"?`)) return;
100
+ await fetch('/api/claude-templates', {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({ action: 'delete', id }),
104
+ });
105
+ if (rulesSelectedTemplate === id) setRulesSelectedTemplate(null);
106
+ fetchRules();
107
+ };
108
+
109
+ const toggleDefault = async (id: string, isDefault: boolean) => {
110
+ await fetch('/api/claude-templates', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ action: 'set-default', id, isDefault }),
114
+ });
115
+ fetchRules();
116
+ };
117
+
118
+ const batchInject = async (templateId: string) => {
119
+ const projects = [...rulesBatchProjects];
120
+ if (!projects.length) return;
121
+ await fetch('/api/claude-templates', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ action: 'inject', templateId, projects }),
125
+ });
126
+ setRulesBatchProjects(new Set());
127
+ fetchRules();
128
+ };
129
+
46
130
  const sync = async () => {
47
131
  setSyncing(true);
48
132
  await fetch('/api/skills', {
@@ -85,11 +169,17 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
85
169
  } catch { setSkillFiles([]); }
86
170
  };
87
171
 
88
- const loadFile = async (skillName: string, filePath: string) => {
172
+ const loadFile = async (skillName: string, filePath: string, isLocalItem?: boolean, localType?: string, localProject?: string) => {
89
173
  setActiveFile(filePath);
90
174
  setFileContent('Loading...');
91
175
  try {
92
- const res = await fetch(`/api/skills?action=file&name=${encodeURIComponent(skillName)}&path=${encodeURIComponent(filePath)}`);
176
+ let res;
177
+ if (isLocalItem) {
178
+ const projectParam = localProject ? `&project=${encodeURIComponent(localProject)}` : '';
179
+ res = await fetch(`/api/skills/local?action=read&name=${encodeURIComponent(skillName)}&type=${localType || 'command'}&path=${encodeURIComponent(filePath)}${projectParam}`);
180
+ } else {
181
+ res = await fetch(`/api/skills?action=file&name=${encodeURIComponent(skillName)}&path=${encodeURIComponent(filePath)}`);
182
+ }
93
183
  const data = await res.json();
94
184
  setFileContent(data.content || '(Empty)');
95
185
  } catch { setFileContent('(Failed to load)'); }
@@ -104,10 +194,37 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
104
194
  fetchSkills();
105
195
  };
106
196
 
107
- // Filter skills if viewing a specific project
108
- const filtered = projectFilter
109
- ? skills.filter(s => s.installedGlobal || s.installedProjects.includes(projectFilter))
110
- : skills;
197
+ // Filter by project, type, and search
198
+ const q = searchQuery.toLowerCase();
199
+ const filtered = typeFilter === 'local' ? [] : skills
200
+ .filter(s => projectFilter ? (s.installedGlobal || s.installedProjects.includes(projectFilter)) : true)
201
+ .filter(s => typeFilter === 'all' ? true : s.type === typeFilter)
202
+ .filter(s => !q || s.name.toLowerCase().includes(q) || s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
203
+
204
+ const filteredLocal = localItems
205
+ .filter(item => typeFilter === 'local' || typeFilter === 'all' || item.type === typeFilter)
206
+ .filter(item => !q || item.name.toLowerCase().includes(q));
207
+
208
+ // Group local items by scope
209
+ const localGroups = new Map<string, typeof localItems>();
210
+ for (const item of filteredLocal) {
211
+ const key = item.scope;
212
+ if (!localGroups.has(key)) localGroups.set(key, []);
213
+ localGroups.get(key)!.push(item);
214
+ }
215
+
216
+ const toggleLocalSection = (section: string) => {
217
+ setCollapsedLocalSections(prev => {
218
+ const next = new Set(prev);
219
+ if (next.has(section)) next.delete(section);
220
+ else next.add(section);
221
+ return next;
222
+ });
223
+ };
224
+
225
+ const skillCount = skills.filter(s => s.type === 'skill').length;
226
+ const commandCount = skills.filter(s => s.type === 'command').length;
227
+ const localCount = localItems.length;
111
228
 
112
229
  if (loading) {
113
230
  return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading skills...</div>;
@@ -118,8 +235,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
118
235
  {/* Header */}
119
236
  <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
120
237
  <div className="flex items-center gap-2">
121
- <span className="text-xs font-semibold text-[var(--text-primary)]">Skills</span>
122
- <span className="text-[9px] text-[var(--text-secondary)]">{filtered.length} available</span>
238
+ <span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
239
+ <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
240
+ {([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules']] as const).map(([value, label]) => (
241
+ <button
242
+ key={value}
243
+ onClick={() => setTypeFilter(value)}
244
+ className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
245
+ typeFilter === value
246
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
247
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
248
+ }`}
249
+ >
250
+ {label}
251
+ </button>
252
+ ))}
253
+ </div>
123
254
  </div>
124
255
  <button
125
256
  onClick={sync}
@@ -129,8 +260,18 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
129
260
  {syncing ? 'Syncing...' : 'Sync'}
130
261
  </button>
131
262
  </div>
263
+ {/* Search — hide on rules tab */}
264
+ {typeFilter !== 'rules' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
265
+ <input
266
+ type="text"
267
+ value={searchQuery}
268
+ onChange={e => setSearchQuery(e.target.value)}
269
+ placeholder="Search skills & commands..."
270
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)]"
271
+ />
272
+ </div>}
132
273
 
133
- {skills.length === 0 ? (
274
+ {typeFilter === 'rules' ? null : skills.length === 0 ? (
134
275
  <div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
135
276
  <p className="text-xs">No skills yet</p>
136
277
  <button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
@@ -141,6 +282,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
141
282
  <div className="flex-1 flex min-h-0">
142
283
  {/* Left: skill list */}
143
284
  <div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0">
285
+ {/* Registry items */}
144
286
  {filtered.map(skill => {
145
287
  const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
146
288
  const isActive = expandedSkill === skill.name;
@@ -155,40 +297,216 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
155
297
  <div className="flex items-center gap-2">
156
298
  <span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
157
299
  <span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
158
- {skill.score > 0 && (
159
- <span className="text-[8px] text-[var(--yellow)] shrink-0">{skill.score}pt</span>
300
+ {skill.rating > 0 && (
301
+ <span className="text-[8px] text-[var(--yellow)] shrink-0" title={`Rating: ${skill.rating}/5`}>
302
+ {'★'.repeat(Math.round(skill.rating))}{'☆'.repeat(5 - Math.round(skill.rating))}
303
+ </span>
304
+ )}
305
+ {skill.score > 0 && !skill.rating && (
306
+ <span className="text-[8px] text-[var(--text-secondary)] shrink-0">{skill.score}pt</span>
160
307
  )}
161
308
  </div>
162
309
  <p className="text-[9px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{skill.description}</p>
163
310
  <div className="flex items-center gap-1.5 mt-1">
311
+ <span className={`text-[7px] px-1 rounded font-medium ${
312
+ skill.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
313
+ }`}>{skill.type === 'skill' ? 'SKILL' : 'CMD'}</span>
164
314
  <span className="text-[8px] text-[var(--text-secondary)]">{skill.author}</span>
165
315
  {skill.tags.slice(0, 2).map(t => (
166
316
  <span key={t} className="text-[7px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
167
317
  ))}
168
- {isInstalled && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
318
+ {skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
319
+ {isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
169
320
  </div>
170
321
  </div>
171
322
  );
172
323
  })}
324
+ {/* Local items — collapsible by scope group */}
325
+ {(typeFilter === 'all' || typeFilter === 'local') && filteredLocal.length > 0 && (
326
+ <>
327
+ {/* Local section header — collapsible */}
328
+ {typeFilter !== 'local' && (
329
+ <button
330
+ onClick={() => toggleLocalSection('__local__')}
331
+ className="w-full px-3 py-1 text-[8px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)] border-b border-[var(--border)]/50 flex items-center gap-1 hover:text-[var(--text-primary)]"
332
+ >
333
+ <span>{collapsedLocalSections.has('__local__') ? '▸' : '▾'}</span>
334
+ Local ({filteredLocal.length})
335
+ </button>
336
+ )}
337
+ {(typeFilter === 'local' || !collapsedLocalSections.has('__local__')) && (
338
+ <>
339
+ {[...localGroups.entries()].sort(([a], [b]) => a === 'global' ? -1 : b === 'global' ? 1 : a.localeCompare(b)).map(([scope, items]) => (
340
+ <div key={scope}>
341
+ {/* Scope group header — collapsible */}
342
+ <button
343
+ onClick={() => toggleLocalSection(scope)}
344
+ className="w-full px-3 py-1 text-[8px] text-[var(--text-secondary)] border-b border-[var(--border)]/30 flex items-center gap-1.5 hover:bg-[var(--bg-tertiary)]"
345
+ >
346
+ <span className="text-[7px]">{collapsedLocalSections.has(scope) ? '▸' : '▾'}</span>
347
+ <span className={scope === 'global' ? 'text-green-400' : 'text-[var(--accent)]'}>{scope}</span>
348
+ <span className="text-[var(--text-secondary)]">({items.length})</span>
349
+ </button>
350
+ {!collapsedLocalSections.has(scope) && items.map(item => {
351
+ const key = `local:${item.name}:${item.scope}`;
352
+ const isActive = expandedSkill === key;
353
+ const projectParam = item.projectPath ? encodeURIComponent(item.projectPath) : '';
354
+ return (
355
+ <div
356
+ key={key}
357
+ className={`px-3 py-2 border-b border-[var(--border)]/50 cursor-pointer ${
358
+ isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
359
+ }`}
360
+ onClick={() => {
361
+ if (expandedSkill === key) { setExpandedSkill(null); return; }
362
+ setExpandedSkill(key);
363
+ setSkillFiles([]);
364
+ setActiveFile(null);
365
+ setFileContent('');
366
+ const fetchUrl = `/api/skills/local?action=files&name=${encodeURIComponent(item.name)}&type=${item.type}${projectParam ? `&project=${projectParam}` : ''}`;
367
+ fetch(fetchUrl)
368
+ .then(r => r.json())
369
+ .then(d => {
370
+ const files = (d.files || []).map((f: any) => ({ name: f.path.split('/').pop(), path: f.path, type: 'file' }));
371
+ setSkillFiles(files);
372
+ const first = files.find((f: any) => f.name?.endsWith('.md'));
373
+ if (first) {
374
+ setActiveFile(first.path);
375
+ fetch(`/api/skills/local?action=read&name=${encodeURIComponent(item.name)}&type=${item.type}&path=${encodeURIComponent(first.path)}${projectParam ? `&project=${projectParam}` : ''}`)
376
+ .then(r => r.json())
377
+ .then(rd => setFileContent(rd.content || ''))
378
+ .catch(() => {});
379
+ }
380
+ })
381
+ .catch(() => {});
382
+ }}
383
+ >
384
+ <div className="flex items-center gap-2">
385
+ <span className={`text-[7px] px-1 rounded font-medium ${
386
+ item.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
387
+ }`}>{item.type === 'skill' ? 'S' : 'C'}</span>
388
+ <span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{item.name}</span>
389
+ <span className="text-[8px] text-[var(--text-secondary)]">{item.fileCount}</span>
390
+ </div>
391
+ </div>
392
+ );
393
+ })}
394
+ </div>
395
+ ))}
396
+ </>
397
+ )}
398
+ </>
399
+ )}
173
400
  </div>
174
401
 
175
402
  {/* Right: detail panel */}
176
403
  <div className="flex-1 flex flex-col min-w-0">
177
404
  {expandedSkill ? (() => {
178
- const skill = skills.find(s => s.name === expandedSkill);
179
- if (!skill) return null;
180
- const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
405
+ const isLocal = expandedSkill.startsWith('local:');
406
+ // Key format: "local:<name>:<scope>" — extract name (could contain colons in scope)
407
+ const localParts = isLocal ? expandedSkill.slice(6).split(':') : [];
408
+ const itemName = isLocal ? localParts[0] : expandedSkill;
409
+ const localScope = isLocal ? localParts.slice(1).join(':') : '';
410
+ const skill = isLocal ? null : skills.find(s => s.name === expandedSkill);
411
+ const localItem = isLocal ? localItems.find(i => i.name === itemName && i.scope === localScope) : null;
412
+ if (!skill && !localItem) return null;
413
+ const isInstalled = skill ? (skill.installedGlobal || skill.installedProjects.length > 0) : true;
181
414
  return (
182
415
  <>
183
416
  {/* Skill header */}
184
417
  <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
185
418
  <div className="flex items-center gap-2">
186
- <span className="text-sm font-semibold text-[var(--text-primary)]">{skill.displayName}</span>
187
- <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>
188
- {skill.score > 0 && <span className="text-[9px] text-[var(--yellow)]">{skill.score}pt</span>}
419
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{skill?.displayName || localItem?.name || itemName}</span>
420
+ <span className={`text-[8px] px-1.5 py-0.5 rounded font-medium ${
421
+ (skill?.type || localItem?.type) === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
422
+ }`}>{(skill?.type || localItem?.type) === 'skill' ? 'Skill' : 'Command'}</span>
423
+ {isLocal && <span className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">local</span>}
424
+ {skill && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
425
+ {skill?.installedVersion && skill.installedVersion !== skill.version && (
426
+ <span className="text-[9px] text-[var(--yellow)] font-mono">installed: v{skill.installedVersion}</span>
427
+ )}
428
+ {skill && skill.rating > 0 && (
429
+ <span className="text-[9px] text-[var(--yellow)]" title={`Rating: ${skill.rating}/5`}>
430
+ {'★'.repeat(Math.round(skill.rating))}{'☆'.repeat(5 - Math.round(skill.rating))}
431
+ </span>
432
+ )}
433
+ {skill && skill.score > 0 && <span className="text-[9px] text-[var(--text-secondary)]">{skill.score}pt</span>}
434
+
435
+ {/* Update button */}
436
+ {skill?.hasUpdate && (
437
+ <button
438
+ onClick={async () => {
439
+ if (skill.installedGlobal) await install(skill.name, 'global');
440
+ for (const pp of skill.installedProjects) await install(skill.name, pp);
441
+ }}
442
+ className="text-[9px] px-2 py-1 bg-[var(--yellow)]/20 text-[var(--yellow)] border border-[var(--yellow)]/50 rounded hover:bg-[var(--yellow)]/30 transition-colors"
443
+ >
444
+ Update
445
+ </button>
446
+ )}
189
447
 
190
- {/* Install dropdown */}
191
- <div className="relative ml-auto">
448
+ {/* Local item actions: install to other projects, delete */}
449
+ {isLocal && localItem && (
450
+ <>
451
+ <div className="relative ml-auto">
452
+ <button
453
+ onClick={() => setInstallTarget(prev =>
454
+ prev.skill === itemName && prev.show ? { skill: '', show: false } : { skill: itemName, show: true }
455
+ )}
456
+ className="text-[9px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
457
+ >
458
+ Install to...
459
+ </button>
460
+ {installTarget.skill === itemName && installTarget.show && (
461
+ <>
462
+ <div className="fixed inset-0 z-40" onClick={() => setInstallTarget({ skill: '', show: false })} />
463
+ <div className="absolute right-0 top-7 w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
464
+ <button
465
+ onClick={async () => {
466
+ const res = await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
467
+ body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: 'global', force: true }) });
468
+ const data = await res.json();
469
+ if (!data.ok) alert(data.error);
470
+ setInstallTarget({ skill: '', show: false });
471
+ fetchSkills();
472
+ }}
473
+ className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
474
+ >Global (~/.claude)</button>
475
+ <div className="border-t border-[var(--border)] my-0.5" />
476
+ {projects.map(p => (
477
+ <button
478
+ key={p.path}
479
+ onClick={async () => {
480
+ const res = await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: p.path, force: true }) });
482
+ const data = await res.json();
483
+ if (!data.ok) alert(data.error);
484
+ setInstallTarget({ skill: '', show: false });
485
+ fetchSkills();
486
+ }}
487
+ className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] truncate"
488
+ title={p.path}
489
+ >{p.name}</button>
490
+ ))}
491
+ </div>
492
+ </>
493
+ )}
494
+ </div>
495
+ <button
496
+ onClick={async () => {
497
+ if (!confirm(`Delete "${itemName}" from ${localScope}?`)) return;
498
+ await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({ action: 'delete-local', name: itemName, type: localItem.type, project: localItem.projectPath }) });
500
+ setExpandedSkill(null);
501
+ fetchSkills();
502
+ }}
503
+ className="text-[9px] text-[var(--red)] hover:underline"
504
+ >Delete</button>
505
+ </>
506
+ )}
507
+
508
+ {/* Install dropdown — registry items only */}
509
+ {skill && <div className="relative ml-auto">
192
510
  <button
193
511
  onClick={() => setInstallTarget(prev =>
194
512
  prev.skill === skill.name && prev.show ? { skill: '', show: false } : { skill: skill.name, show: true }
@@ -228,22 +546,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
228
546
  </div>
229
547
  </>
230
548
  )}
231
- </div>
549
+ </div>}
232
550
  </div>
233
- <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill.description}</p>
551
+ <p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill?.description || ''}</p>
234
552
  {/* Installed indicators */}
235
- {isInstalled && (
553
+ {skill && isInstalled && (
236
554
  <div className="flex items-center gap-2 mt-1">
237
555
  {skill.installedGlobal && (
238
556
  <span className="flex items-center gap-1 text-[8px] text-[var(--green)]">
239
557
  Global
240
- <button onClick={() => uninstall(skill.name, 'global')} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
558
+ <button onClick={() => { if (confirm(`Uninstall "${skill.name}" from global?`)) uninstall(skill.name, 'global'); }} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
241
559
  </span>
242
560
  )}
243
561
  {skill.installedProjects.map(pp => (
244
562
  <span key={pp} className="flex items-center gap-1 text-[8px] text-[var(--accent)]">
245
563
  {pp.split('/').pop()}
246
- <button onClick={() => uninstall(skill.name, pp)} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
564
+ <button onClick={() => { if (confirm(`Uninstall "${skill.name}" from ${pp.split('/').pop()}?`)) uninstall(skill.name, pp); }} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
247
565
  </span>
248
566
  ))}
249
567
  </div>
@@ -261,7 +579,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
261
579
  f.type === 'file' ? (
262
580
  <button
263
581
  key={f.path}
264
- onClick={() => loadFile(skill.name, f.path)}
582
+ onClick={() => loadFile(itemName, f.path, isLocal, localItem?.type, localItem?.projectPath)}
265
583
  className={`w-full text-left px-2 py-1 text-[10px] truncate ${
266
584
  activeFile === f.path
267
585
  ? 'bg-[var(--accent)]/15 text-[var(--accent)]'
@@ -278,7 +596,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
278
596
  )
279
597
  ))
280
598
  )}
281
- {skill.sourceUrl && (
599
+ {skill?.sourceUrl && (
282
600
  <div className="border-t border-[var(--border)] p-2">
283
601
  <a
284
602
  href={skill.sourceUrl.replace(/\/blob\/main\/.*/, '')}
@@ -315,6 +633,174 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
315
633
  </div>
316
634
  </div>
317
635
  )}
636
+
637
+ {/* Rules (CLAUDE.md Templates) — full-page view */}
638
+ {typeFilter === 'rules' && (
639
+ <div className="flex-1 flex min-h-0">
640
+ {/* Left: template list */}
641
+ <div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
642
+ <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center justify-between">
643
+ <span className="text-[9px] text-[var(--text-secondary)] uppercase">Rule Templates</span>
644
+ <button
645
+ onClick={() => { setRulesShowNew(true); setRulesEditing(true); setRulesEditId(''); setRulesEditName(''); setRulesEditDesc(''); setRulesEditContent(''); setRulesEditDefault(false); setRulesSelectedTemplate(null); }}
646
+ className="text-[9px] text-[var(--accent)] hover:underline"
647
+ >+ New</button>
648
+ </div>
649
+ <div className="flex-1 overflow-y-auto">
650
+ {rulesTemplates.map(t => {
651
+ const isActive = rulesSelectedTemplate === t.id;
652
+ return (
653
+ <div
654
+ key={t.id}
655
+ className={`px-3 py-2 border-b border-[var(--border)]/50 cursor-pointer ${
656
+ isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
657
+ }`}
658
+ onClick={() => { setRulesSelectedTemplate(t.id); setRulesEditing(false); setRulesShowNew(false); }}
659
+ >
660
+ <div className="flex items-center gap-1.5">
661
+ <span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
662
+ {t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
663
+ <button
664
+ onClick={(e) => { e.stopPropagation(); toggleDefault(t.id, !t.isDefault); }}
665
+ className={`text-[7px] px-1 rounded ${t.isDefault ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
666
+ title={t.isDefault ? 'Default: auto-applied to new projects' : 'Click to set as default'}
667
+ >{t.isDefault ? 'default' : 'set default'}</button>
668
+ </div>
669
+ <p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
670
+ </div>
671
+ );
672
+ })}
673
+ </div>
674
+ </div>
675
+
676
+ {/* Right: template detail / editor / batch apply */}
677
+ <div className="flex-1 flex flex-col min-w-0">
678
+ {rulesShowNew || rulesEditing ? (
679
+ /* Edit / New form */
680
+ <div className="flex-1 flex flex-col overflow-hidden">
681
+ <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
682
+ <div className="text-[11px] font-semibold text-[var(--text-primary)]">{rulesShowNew ? 'New Rule Template' : 'Edit Template'}</div>
683
+ </div>
684
+ <div className="flex-1 overflow-auto p-4 space-y-3">
685
+ <div className="flex gap-2">
686
+ <input
687
+ type="text"
688
+ value={rulesEditId}
689
+ onChange={e => setRulesEditId(e.target.value.replace(/[^a-z0-9-]/g, ''))}
690
+ placeholder="template-id (kebab-case)"
691
+ disabled={!rulesShowNew}
692
+ className="flex-1 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] font-mono disabled:opacity-50"
693
+ />
694
+ <input
695
+ type="text"
696
+ value={rulesEditName}
697
+ onChange={e => setRulesEditName(e.target.value)}
698
+ placeholder="Display Name"
699
+ className="flex-1 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
700
+ />
701
+ </div>
702
+ <input
703
+ type="text"
704
+ value={rulesEditDesc}
705
+ onChange={e => setRulesEditDesc(e.target.value)}
706
+ placeholder="Description"
707
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
708
+ />
709
+ <textarea
710
+ value={rulesEditContent}
711
+ onChange={e => setRulesEditContent(e.target.value)}
712
+ placeholder="Template content (markdown)..."
713
+ className="w-full flex-1 min-h-[200px] p-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] font-mono text-[var(--text-primary)] resize-none"
714
+ spellCheck={false}
715
+ />
716
+ <div className="flex items-center gap-3">
717
+ <label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)] cursor-pointer">
718
+ <input type="checkbox" checked={rulesEditDefault} onChange={e => setRulesEditDefault(e.target.checked)} className="accent-[var(--accent)]" />
719
+ Auto-apply to new projects
720
+ </label>
721
+ <div className="flex gap-2 ml-auto">
722
+ <button onClick={saveRule} className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">Save</button>
723
+ <button onClick={() => { setRulesEditing(false); setRulesShowNew(false); }} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
724
+ </div>
725
+ </div>
726
+ </div>
727
+ </div>
728
+ ) : rulesSelectedTemplate ? (() => {
729
+ const tmpl = rulesTemplates.find(t => t.id === rulesSelectedTemplate);
730
+ if (!tmpl) return null;
731
+ return (
732
+ <div className="flex-1 flex flex-col overflow-hidden">
733
+ {/* Template header */}
734
+ <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
735
+ <div className="flex items-center gap-2">
736
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{tmpl.name}</span>
737
+ {tmpl.builtin && <span className="text-[8px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
738
+ <div className="ml-auto flex gap-1.5">
739
+ <button
740
+ onClick={() => { setRulesEditing(true); setRulesShowNew(false); setRulesEditId(tmpl.id); setRulesEditName(tmpl.name); setRulesEditDesc(tmpl.description); setRulesEditContent(tmpl.content); setRulesEditDefault(tmpl.isDefault); }}
741
+ className="text-[9px] text-[var(--accent)] hover:underline"
742
+ >Edit</button>
743
+ {!tmpl.builtin && (
744
+ <button onClick={() => deleteRule(tmpl.id)} className="text-[9px] text-[var(--red)] hover:underline">Delete</button>
745
+ )}
746
+ </div>
747
+ </div>
748
+ <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{tmpl.description}</p>
749
+ </div>
750
+
751
+ {/* Content + batch apply */}
752
+ <div className="flex-1 flex min-h-0 overflow-hidden">
753
+ {/* Template content */}
754
+ <div className="flex-1 min-w-0 overflow-auto">
755
+ <pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
756
+ {tmpl.content}
757
+ </pre>
758
+ </div>
759
+
760
+ {/* Batch apply panel */}
761
+ <div className="w-48 border-l border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
762
+ <div className="px-2 py-1.5 border-b border-[var(--border)] text-[9px] text-[var(--text-secondary)] uppercase">Apply to Projects</div>
763
+ <div className="flex-1 overflow-y-auto">
764
+ {rulesProjects.map(p => (
765
+ <label key={p.path} className="flex items-center gap-1.5 px-2 py-1 hover:bg-[var(--bg-tertiary)] cursor-pointer">
766
+ <input
767
+ type="checkbox"
768
+ checked={rulesBatchProjects.has(p.path)}
769
+ onChange={() => {
770
+ setRulesBatchProjects(prev => {
771
+ const next = new Set(prev);
772
+ if (next.has(p.path)) next.delete(p.path); else next.add(p.path);
773
+ return next;
774
+ });
775
+ }}
776
+ className="accent-[var(--accent)]"
777
+ />
778
+ <span className="text-[9px] text-[var(--text-primary)] truncate">{p.name}</span>
779
+ </label>
780
+ ))}
781
+ </div>
782
+ {rulesBatchProjects.size > 0 && (
783
+ <div className="p-2 border-t border-[var(--border)]">
784
+ <button
785
+ onClick={() => batchInject(tmpl.id)}
786
+ className="w-full text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
787
+ >
788
+ Apply to {rulesBatchProjects.size} project{rulesBatchProjects.size > 1 ? 's' : ''}
789
+ </button>
790
+ </div>
791
+ )}
792
+ </div>
793
+ </div>
794
+ </div>
795
+ );
796
+ })() : (
797
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
798
+ <p className="text-xs">Select a template or create a new one</p>
799
+ </div>
800
+ )}
801
+ </div>
802
+ </div>
803
+ )}
318
804
  </div>
319
805
  );
320
806
  }