agentcraft 0.0.1 → 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.
@@ -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]) => (
@@ -0,0 +1,82 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
6
+ export const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
7
+ export const WAVEFORM_CACHE = join(homedir(), '.agentcraft', 'waveforms.json');
8
+ const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a']);
9
+
10
+ export interface Pack {
11
+ publisher: string;
12
+ name: string;
13
+ path: string;
14
+ id: string; // "publisher/name"
15
+ }
16
+
17
+ export async function listPacks(): Promise<Pack[]> {
18
+ const packs: Pack[] = [];
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 publisherPath = join(PACKS_DIR, publisher);
27
+ const ps = await stat(publisherPath).catch(() => null);
28
+ if (!ps?.isDirectory()) continue;
29
+ const names = await readdir(publisherPath).catch(() => [] as string[]);
30
+ for (const name of names) {
31
+ const packPath = join(publisherPath, name);
32
+ const ns = await stat(packPath).catch(() => null);
33
+ if (!ns?.isDirectory()) continue;
34
+ packs.push({ publisher, name, path: packPath, id: `${publisher}/${name}` });
35
+ }
36
+ }
37
+ return packs;
38
+ }
39
+
40
+ export function resolvePackPath(soundPath: string): string | null {
41
+ if (!soundPath) return null;
42
+ const colonIdx = soundPath.indexOf(':');
43
+ if (colonIdx === -1) {
44
+ // Legacy path — assume rohenaz/agentcraft-sounds
45
+ return join(PACKS_DIR, 'rohenaz', 'agentcraft-sounds', soundPath);
46
+ }
47
+ const packId = soundPath.slice(0, colonIdx);
48
+ const internal = soundPath.slice(colonIdx + 1);
49
+ if (!packId || !internal || internal.includes('..')) return null;
50
+ const [publisher, name] = packId.split('/');
51
+ if (!publisher || !name) return null;
52
+ return join(PACKS_DIR, publisher, name, internal);
53
+ }
54
+
55
+ export async function walkPackDir(
56
+ dir: string,
57
+ base: string,
58
+ packId: string
59
+ ): Promise<Array<{ relPath: string; absPath: string }>> {
60
+ const results: Array<{ relPath: string; absPath: string }> = [];
61
+ let entries: string[];
62
+ try {
63
+ entries = await readdir(dir);
64
+ } catch {
65
+ return results;
66
+ }
67
+ for (const entry of entries) {
68
+ const abs = join(dir, entry);
69
+ const s = await stat(abs).catch(() => null);
70
+ if (!s) continue;
71
+ if (s.isDirectory()) {
72
+ results.push(...await walkPackDir(abs, base, packId));
73
+ } else {
74
+ const ext = entry.slice(entry.lastIndexOf('.')).toLowerCase();
75
+ if (AUDIO_EXTS.has(ext)) {
76
+ const rel = abs.slice(base.length + 1);
77
+ results.push({ relPath: `${packId}:${rel}`, absPath: abs });
78
+ }
79
+ }
80
+ }
81
+ return results;
82
+ }