agentcraft 0.0.2 → 0.0.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.9",
3
+ "version": "0.1.0",
4
4
  "description": "Assign sounds to AI coding agent lifecycle events. Works with Claude Code and OpenCode.",
5
5
  "author": { "name": "rohenaz" },
6
6
  "keywords": ["sounds", "hooks", "audio", "productivity", "opencode", "claude-code"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Assign sounds to AI coding agent lifecycle events. CLI for managing sound packs.",
5
5
  "license": "MIT",
6
6
  "author": "rohenaz",
@@ -0,0 +1,109 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { readdir, stat, readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ import { spawnSync } from 'child_process';
6
+
7
+ const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
8
+
9
+ interface PackInfo {
10
+ id: string; // "publisher/name"
11
+ publisher: string;
12
+ name: string;
13
+ description?: string;
14
+ version?: string;
15
+ }
16
+
17
+ async function getInstalledPacks(): Promise<PackInfo[]> {
18
+ const packs: PackInfo[] = [];
19
+ let publishers: string[];
20
+ try {
21
+ publishers = await readdir(PACKS_DIR);
22
+ } catch {
23
+ return packs;
24
+ }
25
+ for (const publisher of publishers) {
26
+ const pubPath = join(PACKS_DIR, publisher);
27
+ const ps = await stat(pubPath).catch(() => null);
28
+ if (!ps?.isDirectory()) continue;
29
+ const names = await readdir(pubPath).catch(() => [] as string[]);
30
+ for (const name of names) {
31
+ const packPath = join(pubPath, name);
32
+ const ns = await stat(packPath).catch(() => null);
33
+ if (!ns?.isDirectory()) continue;
34
+ // Try to read pack.json for metadata
35
+ let description: string | undefined;
36
+ let version: string | undefined;
37
+ try {
38
+ const manifest = JSON.parse(await readFile(join(packPath, 'pack.json'), 'utf-8'));
39
+ description = manifest.description;
40
+ version = manifest.version;
41
+ } catch { /* no manifest, that's fine */ }
42
+ packs.push({ id: `${publisher}/${name}`, publisher, name, description, version });
43
+ }
44
+ }
45
+ return packs;
46
+ }
47
+
48
+ // GET — list installed packs
49
+ export async function GET() {
50
+ const packs = await getInstalledPacks();
51
+ return NextResponse.json(packs);
52
+ }
53
+
54
+ // POST { repo: "publisher/name" } — install a pack
55
+ export async function POST(req: NextRequest) {
56
+ try {
57
+ const { repo } = await req.json();
58
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
59
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
60
+ }
61
+ const [publisher, name] = repo.split('/');
62
+ const dest = join(PACKS_DIR, publisher, name);
63
+ const url = `https://github.com/${repo}`;
64
+ // mkdir publisher dir
65
+ spawnSync('mkdir', ['-p', join(PACKS_DIR, publisher)]);
66
+ const result = spawnSync('git', ['clone', url, dest], { timeout: 60000 });
67
+ if (result.status !== 0) {
68
+ return NextResponse.json({ error: 'Clone failed' }, { status: 500 });
69
+ }
70
+ return NextResponse.json({ ok: true });
71
+ } catch {
72
+ return NextResponse.json({ error: 'Install failed' }, { status: 500 });
73
+ }
74
+ }
75
+
76
+ // DELETE { repo: "publisher/name" } — remove a pack
77
+ export async function DELETE(req: NextRequest) {
78
+ try {
79
+ const { repo } = await req.json();
80
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
81
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
82
+ }
83
+ const [publisher, name] = repo.split('/');
84
+ const dest = join(PACKS_DIR, publisher, name);
85
+ spawnSync('rm', ['-rf', dest]);
86
+ return NextResponse.json({ ok: true });
87
+ } catch {
88
+ return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
89
+ }
90
+ }
91
+
92
+ // PATCH { repo: "publisher/name" } — update a pack (git pull)
93
+ export async function PATCH(req: NextRequest) {
94
+ try {
95
+ const { repo } = await req.json();
96
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
97
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
98
+ }
99
+ const [publisher, name] = repo.split('/');
100
+ const dest = join(PACKS_DIR, publisher, name);
101
+ const result = spawnSync('git', ['-C', dest, 'pull'], { timeout: 30000 });
102
+ if (result.status !== 0) {
103
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
104
+ }
105
+ return NextResponse.json({ ok: true });
106
+ } catch {
107
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
108
+ }
109
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState, useMemo } from 'react';
4
4
  import { HookSlot } from './hook-slot';
5
5
  import { AgentForm } from './agent-form';
6
+ import { PacksPanel } from './packs-panel';
6
7
  import { playUISound } from '@/lib/ui-audio';
7
8
  import { getEventLabel } from '@/lib/utils';
8
9
  import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
@@ -220,7 +221,7 @@ interface AgentRosterPanelProps {
220
221
  }
221
222
 
222
223
  export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
223
- const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
224
+ const [activeView, setActiveView] = useState<'agents' | 'skills' | 'packs'>('agents');
224
225
  const [showForm, setShowForm] = useState(false);
225
226
  const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
226
227
  const [skillSearch, setSkillSearch] = useState('');
@@ -341,7 +342,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
341
342
  {/* Tab bar header */}
342
343
  <div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
343
344
  <div className="flex items-stretch">
344
- {(['agents', 'skills'] as const).map((view) => (
345
+ {(['agents', 'skills', 'packs'] as const).map((view) => (
345
346
  <button
346
347
  key={view}
347
348
  data-sf-hover
@@ -359,7 +360,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
359
360
  backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
360
361
  }}
361
362
  >
362
- {view === 'agents' ? 'AGENTS' : 'SKILLS'}
363
+ {view.toUpperCase()}
363
364
  </button>
364
365
  ))}
365
366
  {activeView === 'agents' && (
@@ -496,6 +497,8 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
496
497
  })}
497
498
  </>
498
499
  )}
500
+
501
+ {activeView === 'packs' && <PacksPanel />}
499
502
  </div>
500
503
  </div>
501
504
  );
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { playUISound } from '@/lib/ui-audio';
5
+
6
+ interface PackInfo {
7
+ id: string;
8
+ publisher: string;
9
+ name: string;
10
+ description?: string;
11
+ version?: string;
12
+ }
13
+
14
+ interface RegistryPack {
15
+ id: string;
16
+ name: string;
17
+ publisher: string;
18
+ description: string;
19
+ stars: number;
20
+ updatedAt: string;
21
+ }
22
+
23
+ type PackAction = 'idle' | 'installing' | 'updating' | 'removing';
24
+
25
+ export function PacksPanel() {
26
+ const [installed, setInstalled] = useState<PackInfo[]>([]);
27
+ const [registry, setRegistry] = useState<RegistryPack[]>([]);
28
+ const [actions, setActions] = useState<Record<string, PackAction>>({});
29
+ const [registryError, setRegistryError] = useState(false);
30
+
31
+ const fetchInstalled = useCallback(() => {
32
+ fetch('/api/packs').then(r => r.json()).then(setInstalled).catch(console.error);
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ fetchInstalled();
37
+ // Fetch registry index
38
+ fetch('https://rohenaz.github.io/agentcraft-registry/index.json')
39
+ .then(r => r.json())
40
+ .then(setRegistry)
41
+ .catch(() => setRegistryError(true));
42
+ }, [fetchInstalled]);
43
+
44
+ const setAction = (id: string, action: PackAction) =>
45
+ setActions(prev => ({ ...prev, [id]: action }));
46
+
47
+ const handleInstall = async (id: string) => {
48
+ setAction(id, 'installing');
49
+ try {
50
+ const r = await fetch('/api/packs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
51
+ if (!r.ok) throw new Error();
52
+ playUISound('confirm', 0.5);
53
+ fetchInstalled();
54
+ } catch {
55
+ playUISound('error', 0.5);
56
+ }
57
+ setAction(id, 'idle');
58
+ };
59
+
60
+ const handleUpdate = async (id: string) => {
61
+ setAction(id, 'updating');
62
+ try {
63
+ const r = await fetch('/api/packs', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
64
+ if (!r.ok) throw new Error();
65
+ playUISound('confirm', 0.4);
66
+ } catch {
67
+ playUISound('error', 0.5);
68
+ }
69
+ setAction(id, 'idle');
70
+ };
71
+
72
+ const handleRemove = async (id: string) => {
73
+ setAction(id, 'removing');
74
+ try {
75
+ const r = await fetch('/api/packs', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
76
+ if (!r.ok) throw new Error();
77
+ fetchInstalled();
78
+ } catch {
79
+ playUISound('error', 0.5);
80
+ }
81
+ setAction(id, 'idle');
82
+ };
83
+
84
+ const installedIds = new Set(installed.map(p => p.id));
85
+ const browsePacks = registry.filter(p => !installedIds.has(p.id));
86
+
87
+ const btnStyle = (active: boolean, danger = false) => ({
88
+ border: `1px solid ${danger ? 'rgba(255,80,80,0.4)' : active ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
89
+ color: danger ? 'rgba(255,80,80,0.7)' : active ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
90
+ backgroundColor: 'transparent',
91
+ padding: '2px 8px',
92
+ fontSize: '9px',
93
+ fontFamily: 'inherit',
94
+ letterSpacing: '0.08em',
95
+ textTransform: 'uppercase' as const,
96
+ cursor: 'pointer',
97
+ transition: 'all 0.15s',
98
+ });
99
+
100
+ return (
101
+ <div className="flex flex-col overflow-hidden h-full">
102
+ {/* Installed */}
103
+ <div className="shrink-0 px-3 pt-3 pb-1">
104
+ <div className="text-[10px] sf-heading font-semibold tracking-widest uppercase mb-2" style={{ color: 'var(--sf-cyan)' }}>
105
+ INSTALLED PACKS
106
+ </div>
107
+ </div>
108
+
109
+ <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-1">
110
+ {installed.length === 0 && (
111
+ <div className="text-[10px] opacity-30 py-4 text-center">NO PACKS INSTALLED</div>
112
+ )}
113
+ {installed.map(pack => {
114
+ const action = actions[pack.id] ?? 'idle';
115
+ return (
116
+ <div key={pack.id} className="p-2.5" style={{ border: '1px solid var(--sf-border)', backgroundColor: 'rgba(0,229,255,0.02)' }}>
117
+ <div className="flex items-start justify-between gap-2">
118
+ <div className="overflow-hidden">
119
+ <div className="text-xs sf-heading font-semibold truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
120
+ {pack.name}
121
+ </div>
122
+ <div className="text-[10px] opacity-40">{pack.publisher}{pack.version ? ` · v${pack.version}` : ''}</div>
123
+ {pack.description && (
124
+ <div className="text-[10px] opacity-50 mt-0.5 line-clamp-2">{pack.description}</div>
125
+ )}
126
+ </div>
127
+ <div className="flex gap-1 shrink-0">
128
+ <button style={btnStyle(false)} disabled={action !== 'idle'} onClick={() => handleUpdate(pack.id)}>
129
+ {action === 'updating' ? '···' : 'UPDATE'}
130
+ </button>
131
+ <button style={btnStyle(false, true)} disabled={action !== 'idle'} onClick={() => handleRemove(pack.id)}>
132
+ {action === 'removing' ? '···' : 'REMOVE'}
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ })}
139
+
140
+ {/* Browse */}
141
+ <div className="text-[10px] sf-heading font-semibold tracking-widest uppercase mt-4 mb-2" style={{ color: 'var(--sf-cyan)' }}>
142
+ BROWSE PACKS
143
+ </div>
144
+
145
+ {registryError && (
146
+ <div className="text-[10px] opacity-30 py-2 text-center">REGISTRY UNAVAILABLE</div>
147
+ )}
148
+
149
+ {!registryError && browsePacks.length === 0 && registry.length > 0 && (
150
+ <div className="text-[10px] opacity-30 py-2 text-center">ALL AVAILABLE PACKS INSTALLED</div>
151
+ )}
152
+
153
+ {browsePacks.map(pack => {
154
+ const action = actions[pack.id] ?? 'idle';
155
+ return (
156
+ <div key={pack.id} className="p-2.5" style={{ border: '1px solid var(--sf-border)' }}>
157
+ <div className="flex items-start justify-between gap-2">
158
+ <div className="overflow-hidden">
159
+ <div className="flex items-center gap-2">
160
+ <span className="text-xs sf-heading font-semibold truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
161
+ {pack.name}
162
+ </span>
163
+ {pack.stars > 0 && (
164
+ <span className="text-[9px] opacity-40">★ {pack.stars}</span>
165
+ )}
166
+ </div>
167
+ <div className="text-[10px] opacity-40">{pack.publisher}</div>
168
+ <div className="text-[10px] opacity-50 mt-0.5 line-clamp-2">{pack.description}</div>
169
+ </div>
170
+ <button style={btnStyle(true)} disabled={action !== 'idle'} onClick={() => handleInstall(pack.id)}>
171
+ {action === 'installing' ? '···' : 'INSTALL'}
172
+ </button>
173
+ </div>
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -15,6 +15,12 @@ interface SoundBrowserPanelProps {
15
15
  onClearSelectMode: () => void;
16
16
  }
17
17
 
18
+ // Strip pack prefix from category: "publisher/name:sc2/terran" → "sc2/terran"
19
+ function internalCat(category: string): string {
20
+ const idx = category.indexOf(':');
21
+ return idx === -1 ? category : category.slice(idx + 1);
22
+ }
23
+
18
24
  export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
19
25
  const [activeGroup, setActiveGroup] = useState<string>('sc2');
20
26
  const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
@@ -31,12 +37,12 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
31
37
  }, []);
32
38
 
33
39
  const allGroups = useMemo(() => {
34
- return [...new Set(sounds.map((s) => s.category.split('/')[0]))].sort();
40
+ return [...new Set(sounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
35
41
  }, [sounds]);
36
42
 
37
43
  const groupCategories = useMemo(() => {
38
44
  return [...new Set(
39
- sounds.filter((s) => s.category.split('/')[0] === activeGroup).map((s) => s.category)
45
+ sounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
40
46
  )].sort();
41
47
  }, [sounds, activeGroup]);
42
48
 
@@ -131,7 +137,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
131
137
  backgroundColor: effectiveCategory === cat ? 'rgba(0,229,255,0.05)' : 'transparent',
132
138
  }}
133
139
  >
134
- {getSubTabLabel(cat)}
140
+ {getSubTabLabel(internalCat(cat))}
135
141
  </button>
136
142
  ))}
137
143
  </div>
@@ -176,7 +182,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
176
182
  <div key={cat}>
177
183
  {isSearching && (
178
184
  <div className="text-[9px] uppercase tracking-widest mb-1 mt-3 px-1 first:mt-0" style={{ color: 'var(--sf-cyan)', opacity: 0.6 }}>
179
- {cat.replace(/\//g, ' › ')}
185
+ {internalCat(cat).replace(/\//g, ' › ')}
180
186
  </div>
181
187
  )}
182
188
  {Object.entries(subcats).map(([subcat, catSounds]) => (