agentcraft 0.0.1 → 0.0.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +40 -11
- package/bin/agentcraft.js +151 -7
- package/commands/agentcraft.md +6 -5
- package/docs/plans/2026-02-22-soundpack-implementation.md +667 -0
- package/docs/plans/2026-02-22-soundpack-system-design.md +121 -0
- package/hooks/play-sound.sh +13 -2
- package/package.json +1 -1
- package/web/app/api/assignments/route.ts +2 -4
- package/web/app/api/audio/[...path]/route.ts +7 -16
- package/web/app/api/preview/route.ts +4 -8
- package/web/app/api/sounds/route.ts +37 -59
- package/web/app/api/ui-sounds/route.ts +17 -34
- package/web/lib/packs.ts +82 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
# Sound Pack System Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Implement isolated, git-installable sound packs under `~/.agentcraft/packs/<publisher>/<name>/`, with a real `agentcraft` npm CLI and seamless migration from the old flat `~/.agentcraft/sounds/` layout.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Each pack is a bare directory at `~/.agentcraft/packs/<publisher>/<name>/` — no manifest required. Assignment paths gain a `publisher/name:internal/path` prefix. A shared `web/lib/packs.ts` module handles all resolution so API routes stay thin. The `agentcraft` npm CLI wraps git for install/update/remove and launches the dashboard.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun, Next.js 15 App Router, TypeScript, Bash, Node.js (CLI)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Save current config as defaults in agentcraft-sounds
|
|
14
|
+
|
|
15
|
+
Save the user's current `assignments.json` as the starter config shipped with the official pack.
|
|
16
|
+
|
|
17
|
+
**Files:**
|
|
18
|
+
- Create: `~/.agentcraft/packs/rohenaz/agentcraft-sounds/defaults/assignments.json` (after Task 2 migration)
|
|
19
|
+
- In agentcraft-sounds repo: create `defaults/assignments.json`
|
|
20
|
+
|
|
21
|
+
**Step 1: Copy current assignments into the agentcraft-sounds repo**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
mkdir -p ~/code/claude-sounds/defaults
|
|
25
|
+
cp ~/.agentcraft/assignments.json ~/code/claude-sounds/defaults/assignments.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Step 2: Strip agent-specific hooks (keep only global + settings)**
|
|
29
|
+
|
|
30
|
+
Edit `~/code/claude-sounds/defaults/assignments.json` — keep `global`, `settings`, `skills` but clear `agents` to `{}` since agent names are user-specific.
|
|
31
|
+
|
|
32
|
+
**Step 3: Commit and push to agentcraft-sounds**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd ~/code/claude-sounds
|
|
36
|
+
git add defaults/assignments.json
|
|
37
|
+
git commit -m "Add default assignments — starter configuration for new installs"
|
|
38
|
+
git push
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Task 2: Migrate local directory structure
|
|
44
|
+
|
|
45
|
+
Move the existing `~/.agentcraft/sounds/` into the packs layout. The old path disappears; everything lives under `packs/` going forward.
|
|
46
|
+
|
|
47
|
+
**Step 1: Create packs directory and move sounds**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
mkdir -p ~/.agentcraft/packs/rohenaz
|
|
51
|
+
mv ~/.agentcraft/sounds ~/.agentcraft/packs/rohenaz/agentcraft-sounds
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Step 2: Verify structure**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
ls ~/.agentcraft/packs/rohenaz/agentcraft-sounds/
|
|
58
|
+
# Expected: sc2 wc3 ff7 ff9 apps classic-os phones ui README.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Step 3: Update existing assignments.json paths to use pack prefix**
|
|
62
|
+
|
|
63
|
+
Run this one-liner to prefix all sound paths:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cd ~/.agentcraft
|
|
67
|
+
python3 -c "
|
|
68
|
+
import json, re, sys
|
|
69
|
+
|
|
70
|
+
with open('assignments.json') as f:
|
|
71
|
+
data = json.load(f)
|
|
72
|
+
|
|
73
|
+
PREFIX = 'rohenaz/agentcraft-sounds:'
|
|
74
|
+
|
|
75
|
+
def prefix_path(p):
|
|
76
|
+
if p and ':' not in p:
|
|
77
|
+
return PREFIX + p
|
|
78
|
+
return p
|
|
79
|
+
|
|
80
|
+
def walk(obj):
|
|
81
|
+
if isinstance(obj, dict):
|
|
82
|
+
return {k: walk(v) for k, v in obj.items()}
|
|
83
|
+
if isinstance(obj, str) and obj.endswith(('.mp3', '.wav', '.ogg', '.m4a')):
|
|
84
|
+
return prefix_path(obj)
|
|
85
|
+
return obj
|
|
86
|
+
|
|
87
|
+
result = walk(data)
|
|
88
|
+
with open('assignments.json', 'w') as f:
|
|
89
|
+
json.dump(result, f, indent=2)
|
|
90
|
+
print('Done')
|
|
91
|
+
"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Step 4: Verify assignments look correct**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cat ~/.agentcraft/assignments.json | grep -o '"rohenaz/agentcraft-sounds:[^"]*"' | head -5
|
|
98
|
+
# Each sound path should now start with rohenaz/agentcraft-sounds:
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Task 3: Create `web/lib/packs.ts` — shared pack resolution module
|
|
104
|
+
|
|
105
|
+
All path logic lives here. API routes import from this module.
|
|
106
|
+
|
|
107
|
+
**Files:**
|
|
108
|
+
- Create: `web/lib/packs.ts`
|
|
109
|
+
|
|
110
|
+
**Step 1: Write the module**
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
// web/lib/packs.ts
|
|
114
|
+
import { readdir, stat } from 'fs/promises';
|
|
115
|
+
import { join } from 'path';
|
|
116
|
+
import { homedir } from 'os';
|
|
117
|
+
|
|
118
|
+
export const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
|
|
119
|
+
export const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
|
|
120
|
+
export const WAVEFORM_CACHE = join(homedir(), '.agentcraft', 'waveforms.json');
|
|
121
|
+
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a']);
|
|
122
|
+
|
|
123
|
+
export interface Pack {
|
|
124
|
+
publisher: string;
|
|
125
|
+
name: string;
|
|
126
|
+
path: string; // absolute path to pack root
|
|
127
|
+
id: string; // "publisher/name"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** List all installed packs */
|
|
131
|
+
export async function listPacks(): Promise<Pack[]> {
|
|
132
|
+
const packs: Pack[] = [];
|
|
133
|
+
let publishers: string[];
|
|
134
|
+
try {
|
|
135
|
+
publishers = await readdir(PACKS_DIR);
|
|
136
|
+
} catch {
|
|
137
|
+
return packs;
|
|
138
|
+
}
|
|
139
|
+
for (const publisher of publishers) {
|
|
140
|
+
const publisherPath = join(PACKS_DIR, publisher);
|
|
141
|
+
const ps = await stat(publisherPath).catch(() => null);
|
|
142
|
+
if (!ps?.isDirectory()) continue;
|
|
143
|
+
const names = await readdir(publisherPath).catch(() => [] as string[]);
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
const packPath = join(publisherPath, name);
|
|
146
|
+
const ns = await stat(packPath).catch(() => null);
|
|
147
|
+
if (!ns?.isDirectory()) continue;
|
|
148
|
+
packs.push({ publisher, name, path: packPath, id: `${publisher}/${name}` });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return packs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a pack-prefixed path to an absolute filesystem path.
|
|
156
|
+
* Format: "publisher/name:internal/path/to/sound.mp3"
|
|
157
|
+
* Legacy (no prefix): resolved against first installed pack that contains the file.
|
|
158
|
+
*/
|
|
159
|
+
export function resolvePackPath(soundPath: string): string | null {
|
|
160
|
+
if (!soundPath) return null;
|
|
161
|
+
const colonIdx = soundPath.indexOf(':');
|
|
162
|
+
if (colonIdx === -1) {
|
|
163
|
+
// Legacy path — assume rohenaz/agentcraft-sounds
|
|
164
|
+
return join(PACKS_DIR, 'rohenaz', 'agentcraft-sounds', soundPath);
|
|
165
|
+
}
|
|
166
|
+
const packId = soundPath.slice(0, colonIdx);
|
|
167
|
+
const internal = soundPath.slice(colonIdx + 1);
|
|
168
|
+
if (!packId || !internal || internal.includes('..')) return null;
|
|
169
|
+
const [publisher, name] = packId.split('/');
|
|
170
|
+
if (!publisher || !name) return null;
|
|
171
|
+
return join(PACKS_DIR, publisher, name, internal);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Walk a directory recursively, returning all audio files.
|
|
176
|
+
* Returns paths relative to `base`, prefixed with `packId:`.
|
|
177
|
+
*/
|
|
178
|
+
export async function walkPackDir(
|
|
179
|
+
dir: string,
|
|
180
|
+
base: string,
|
|
181
|
+
packId: string
|
|
182
|
+
): Promise<Array<{ relPath: string; absPath: string }>> {
|
|
183
|
+
const results: Array<{ relPath: string; absPath: string }> = [];
|
|
184
|
+
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
185
|
+
try {
|
|
186
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
187
|
+
} catch {
|
|
188
|
+
return results;
|
|
189
|
+
}
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const abs = join(dir, entry.name);
|
|
192
|
+
if (entry.isDirectory()) {
|
|
193
|
+
results.push(...await walkPackDir(abs, base, packId));
|
|
194
|
+
} else {
|
|
195
|
+
const ext = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase();
|
|
196
|
+
if (AUDIO_EXTS.has(ext)) {
|
|
197
|
+
const rel = abs.slice(base.length + 1); // relative to pack root
|
|
198
|
+
results.push({ relPath: `${packId}:${rel}`, absPath: abs });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return results;
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Step 2: Verify TypeScript compiles**
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
cd ~/code/agentcraft/web && bunx tsc --noEmit 2>&1 | grep packs
|
|
210
|
+
# Should produce no errors
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Task 4: Update `/api/sounds` route
|
|
216
|
+
|
|
217
|
+
**Files:**
|
|
218
|
+
- Modify: `web/app/api/sounds/route.ts`
|
|
219
|
+
|
|
220
|
+
**Step 1: Rewrite to use packs.ts**
|
|
221
|
+
|
|
222
|
+
Replace the entire file:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { NextResponse } from 'next/server';
|
|
226
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
227
|
+
import { join, dirname } from 'path';
|
|
228
|
+
import { spawnSync } from 'child_process';
|
|
229
|
+
import { listPacks, walkPackDir, WAVEFORM_CACHE } from '@/lib/packs';
|
|
230
|
+
import type { SoundAsset } from '@/lib/types';
|
|
231
|
+
|
|
232
|
+
const BARS = 16;
|
|
233
|
+
const FALLBACK = [5, 7, 4, 9, 6, 8, 3, 7, 5, 8, 4, 6, 7, 5, 8, 4];
|
|
234
|
+
|
|
235
|
+
function computeWaveform(filePath: string): number[] {
|
|
236
|
+
const result = spawnSync('ffmpeg', [
|
|
237
|
+
'-i', filePath, '-ac', '1', '-ar', '1000', '-f', 'f32le', '-',
|
|
238
|
+
], { maxBuffer: 1024 * 1024 * 4 });
|
|
239
|
+
if (result.status !== 0 || !result.stdout?.length) return FALLBACK;
|
|
240
|
+
const buf = result.stdout as Buffer;
|
|
241
|
+
const samples = buf.length / 4;
|
|
242
|
+
const blockSize = Math.floor(samples / BARS);
|
|
243
|
+
const values: number[] = [];
|
|
244
|
+
for (let i = 0; i < BARS; i++) {
|
|
245
|
+
let sum = 0;
|
|
246
|
+
for (let j = 0; j < blockSize; j++) {
|
|
247
|
+
sum += Math.abs(buf.readFloatLE((i * blockSize + j) * 4));
|
|
248
|
+
}
|
|
249
|
+
values.push(sum / blockSize);
|
|
250
|
+
}
|
|
251
|
+
const max = Math.max(...values, 0.0001);
|
|
252
|
+
return values.map((v) => Math.max(1, Math.round((v / max) * 10)));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function loadCache(): Promise<Record<string, number[]>> {
|
|
256
|
+
try { return JSON.parse(await readFile(WAVEFORM_CACHE, 'utf-8')); }
|
|
257
|
+
catch { return {}; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function saveCache(cache: Record<string, number[]>) {
|
|
261
|
+
await mkdir(dirname(WAVEFORM_CACHE), { recursive: true });
|
|
262
|
+
await writeFile(WAVEFORM_CACHE, JSON.stringify(cache), 'utf-8');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function GET() {
|
|
266
|
+
try {
|
|
267
|
+
const packs = await listPacks();
|
|
268
|
+
const allFiles: Array<{ relPath: string; absPath: string }> = [];
|
|
269
|
+
for (const pack of packs) {
|
|
270
|
+
const files = await walkPackDir(pack.path, pack.path, pack.id);
|
|
271
|
+
allFiles.push(...files);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const cache = await loadCache();
|
|
275
|
+
let dirty = false;
|
|
276
|
+
|
|
277
|
+
const results: SoundAsset[] = allFiles.map(({ relPath, absPath }) => {
|
|
278
|
+
// relPath: "rohenaz/agentcraft-sounds:sc2/terran/session-start/scv-ready.mp3"
|
|
279
|
+
const internal = relPath.slice(relPath.indexOf(':') + 1);
|
|
280
|
+
const parts = internal.split('/');
|
|
281
|
+
const packId = relPath.slice(0, relPath.indexOf(':'));
|
|
282
|
+
const category = parts.length > 2
|
|
283
|
+
? `${packId}:${parts.slice(0, -2).join('/')}`
|
|
284
|
+
: `${packId}:${parts[0]}`;
|
|
285
|
+
const subcategory = parts.length > 2 ? parts[parts.length - 2] : '';
|
|
286
|
+
|
|
287
|
+
const waveform = cache[relPath] ?? (() => {
|
|
288
|
+
const wf = computeWaveform(absPath);
|
|
289
|
+
cache[relPath] = wf;
|
|
290
|
+
dirty = true;
|
|
291
|
+
return wf;
|
|
292
|
+
})();
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
id: relPath.replace(/\.[^/.]+$/, ''),
|
|
296
|
+
filename: parts[parts.length - 1],
|
|
297
|
+
category,
|
|
298
|
+
subcategory,
|
|
299
|
+
path: relPath,
|
|
300
|
+
waveform,
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (dirty) await saveCache(cache);
|
|
305
|
+
return NextResponse.json(results);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return NextResponse.json({ error: 'Failed to read sound library' }, { status: 500 });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Step 2: Start dev server and verify sounds load**
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
cd ~/code/agentcraft/web && bun dev --port 4040 &
|
|
316
|
+
sleep 5
|
|
317
|
+
curl -s http://localhost:4040/api/sounds | jq '.[0]'
|
|
318
|
+
# Expect: path starts with "rohenaz/agentcraft-sounds:"
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Task 5: Update `/api/audio` and `/api/preview` routes
|
|
324
|
+
|
|
325
|
+
**Files:**
|
|
326
|
+
- Modify: `web/app/api/audio/[...path]/route.ts`
|
|
327
|
+
- Modify: `web/app/api/preview/route.ts`
|
|
328
|
+
|
|
329
|
+
**Step 1: Update audio route**
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
333
|
+
import { readFile } from 'fs/promises';
|
|
334
|
+
import { resolvePackPath } from '@/lib/packs';
|
|
335
|
+
|
|
336
|
+
export async function GET(
|
|
337
|
+
req: NextRequest,
|
|
338
|
+
{ params }: { params: Promise<{ path: string[] }> }
|
|
339
|
+
) {
|
|
340
|
+
try {
|
|
341
|
+
const { path: pathParts } = await params;
|
|
342
|
+
// Reconstruct the full pack path — URL encodes ":" as %3A
|
|
343
|
+
const soundPath = decodeURIComponent(pathParts.join('/'));
|
|
344
|
+
if (soundPath.includes('..')) {
|
|
345
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
346
|
+
}
|
|
347
|
+
const fullPath = resolvePackPath(soundPath);
|
|
348
|
+
if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
349
|
+
const data = await readFile(fullPath);
|
|
350
|
+
const ext = soundPath.split('.').pop()?.toLowerCase();
|
|
351
|
+
const contentType = ext === 'mp3' ? 'audio/mpeg' : ext === 'wav' ? 'audio/wav' : 'audio/mpeg';
|
|
352
|
+
return new NextResponse(data, {
|
|
353
|
+
headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' },
|
|
354
|
+
});
|
|
355
|
+
} catch {
|
|
356
|
+
return NextResponse.json({ error: 'Audio file not found' }, { status: 404 });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Step 2: Update preview route**
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
365
|
+
import { spawn } from 'child_process';
|
|
366
|
+
import { resolvePackPath } from '@/lib/packs';
|
|
367
|
+
|
|
368
|
+
export async function POST(req: NextRequest) {
|
|
369
|
+
try {
|
|
370
|
+
const { path } = await req.json();
|
|
371
|
+
if (!path || typeof path !== 'string') {
|
|
372
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
373
|
+
}
|
|
374
|
+
const fullPath = resolvePackPath(path);
|
|
375
|
+
if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
376
|
+
spawn('afplay', [fullPath], { detached: true, stdio: 'ignore' }).unref();
|
|
377
|
+
return NextResponse.json({ ok: true });
|
|
378
|
+
} catch {
|
|
379
|
+
return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Task 6: Update `/api/ui-sounds` route
|
|
387
|
+
|
|
388
|
+
**Files:**
|
|
389
|
+
- Modify: `web/app/api/ui-sounds/route.ts`
|
|
390
|
+
|
|
391
|
+
**Step 1: Rewrite to scan ui/ in all packs**
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { NextResponse } from 'next/server';
|
|
395
|
+
import { listPacks, walkPackDir } from '@/lib/packs';
|
|
396
|
+
|
|
397
|
+
interface UISound {
|
|
398
|
+
path: string; // pack-prefixed, e.g. "rohenaz/agentcraft-sounds:ui/sc2/click.mp3"
|
|
399
|
+
filename: string;
|
|
400
|
+
group: string; // e.g. "sc2"
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function GET() {
|
|
404
|
+
const packs = await listPacks();
|
|
405
|
+
const results: UISound[] = [];
|
|
406
|
+
|
|
407
|
+
for (const pack of packs) {
|
|
408
|
+
const uiDir = `${pack.path}/ui`;
|
|
409
|
+
const files = await walkPackDir(uiDir, pack.path, pack.id).catch(() => []);
|
|
410
|
+
for (const { relPath } of files) {
|
|
411
|
+
const internal = relPath.slice(relPath.indexOf(':') + 1); // "ui/sc2/click.mp3"
|
|
412
|
+
const parts = internal.split('/'); // ["ui", "sc2", "click.mp3"]
|
|
413
|
+
if (parts[0] !== 'ui') continue;
|
|
414
|
+
const group = parts[1] ?? ''; // "sc2"
|
|
415
|
+
results.push({ path: relPath, filename: parts[parts.length - 1], group });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return NextResponse.json(results);
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Task 7: Update hook script to resolve pack paths
|
|
426
|
+
|
|
427
|
+
**Files:**
|
|
428
|
+
- Modify: `hooks/play-sound.sh`
|
|
429
|
+
|
|
430
|
+
**Step 1: Replace `LIBRARY` constant and resolution logic**
|
|
431
|
+
|
|
432
|
+
Replace the `LIBRARY` line and the `FULL=` line at the bottom:
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
PACKS="$HOME/.agentcraft/packs"
|
|
436
|
+
|
|
437
|
+
# ...existing lookup logic unchanged...
|
|
438
|
+
|
|
439
|
+
[ -z "$SOUND" ] && exit 0
|
|
440
|
+
|
|
441
|
+
# Resolve pack-prefixed path: "publisher/name:internal/path"
|
|
442
|
+
if echo "$SOUND" | grep -q ':'; then
|
|
443
|
+
PACK_ID="${SOUND%%:*}"
|
|
444
|
+
INTERNAL="${SOUND#*:}"
|
|
445
|
+
PUBLISHER="${PACK_ID%%/*}"
|
|
446
|
+
PACKNAME="${PACK_ID##*/}"
|
|
447
|
+
FULL="$PACKS/$PUBLISHER/$PACKNAME/$INTERNAL"
|
|
448
|
+
else
|
|
449
|
+
# Legacy path — fall back to rohenaz/agentcraft-sounds
|
|
450
|
+
FULL="$PACKS/rohenaz/agentcraft-sounds/$SOUND"
|
|
451
|
+
fi
|
|
452
|
+
|
|
453
|
+
[ ! -f "$FULL" ] && exit 0
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Step 2: Test the hook manually**
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
echo '{"hook_event_name":"SessionStart","agent_type":""}' | bash ~/code/agentcraft/hooks/play-sound.sh
|
|
460
|
+
# Should play the SessionStart sound
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Task 8: Update slash command
|
|
466
|
+
|
|
467
|
+
**Files:**
|
|
468
|
+
- Modify: `commands/agentcraft.md`
|
|
469
|
+
|
|
470
|
+
**Step 1: Update first-run check to use packs/ directory**
|
|
471
|
+
|
|
472
|
+
Replace the sounds presence check and clone command:
|
|
473
|
+
|
|
474
|
+
```markdown
|
|
475
|
+
Check if the official sound pack is installed:
|
|
476
|
+
```bash
|
|
477
|
+
ls ~/.agentcraft/packs/rohenaz/agentcraft-sounds 2>/dev/null | head -1
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
If that returned nothing, install it:
|
|
481
|
+
```bash
|
|
482
|
+
agentcraft pack install rohenaz/agentcraft-sounds 2>/dev/null || \
|
|
483
|
+
git clone https://github.com/rohenaz/agentcraft-sounds ~/.agentcraft/packs/rohenaz/agentcraft-sounds
|
|
484
|
+
```
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Also add `Bash(agentcraft:*)` to `allowed-tools`.
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Task 9: Build real `agentcraft` CLI
|
|
492
|
+
|
|
493
|
+
**Files:**
|
|
494
|
+
- Modify: `bin/agentcraft.js`
|
|
495
|
+
|
|
496
|
+
**Step 1: Write the full CLI**
|
|
497
|
+
|
|
498
|
+
```javascript
|
|
499
|
+
#!/usr/bin/env node
|
|
500
|
+
'use strict';
|
|
501
|
+
|
|
502
|
+
const { execSync, spawnSync } = require('child_process');
|
|
503
|
+
const { existsSync, readdirSync, rmSync, statSync } = require('fs');
|
|
504
|
+
const { join } = require('path');
|
|
505
|
+
const { homedir } = require('os');
|
|
506
|
+
|
|
507
|
+
const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
|
|
508
|
+
const [,, cmd, sub, ...rest] = process.argv;
|
|
509
|
+
|
|
510
|
+
function ensurePacksDir() {
|
|
511
|
+
require('fs').mkdirSync(PACKS_DIR, { recursive: true });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parsePackId(arg) {
|
|
515
|
+
if (!arg || !arg.includes('/')) {
|
|
516
|
+
console.error(`Error: pack must be "publisher/name", got: ${arg}`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
const [publisher, name] = arg.split('/');
|
|
520
|
+
return { publisher, name, url: `https://github.com/${publisher}/${name}` };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function listPacks() {
|
|
524
|
+
if (!existsSync(PACKS_DIR)) { console.log('No packs installed.'); return []; }
|
|
525
|
+
const packs = [];
|
|
526
|
+
for (const publisher of readdirSync(PACKS_DIR)) {
|
|
527
|
+
const ppath = join(PACKS_DIR, publisher);
|
|
528
|
+
if (!statSync(ppath).isDirectory()) continue;
|
|
529
|
+
for (const name of readdirSync(ppath)) {
|
|
530
|
+
if (statSync(join(ppath, name)).isDirectory()) packs.push(`${publisher}/${name}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return packs;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (cmd === 'pack') {
|
|
537
|
+
if (sub === 'install') {
|
|
538
|
+
const { publisher, name, url } = parsePackId(rest[0]);
|
|
539
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
540
|
+
if (existsSync(dest)) { console.log(`Already installed: ${publisher}/${name}`); process.exit(0); }
|
|
541
|
+
ensurePacksDir();
|
|
542
|
+
require('fs').mkdirSync(join(PACKS_DIR, publisher), { recursive: true });
|
|
543
|
+
console.log(`Installing ${publisher}/${name} from ${url}...`);
|
|
544
|
+
const r = spawnSync('git', ['clone', url, dest], { stdio: 'inherit' });
|
|
545
|
+
if (r.status !== 0) { console.error('Install failed.'); process.exit(1); }
|
|
546
|
+
console.log(`Installed: ${publisher}/${name}`);
|
|
547
|
+
|
|
548
|
+
} else if (sub === 'remove') {
|
|
549
|
+
const { publisher, name } = parsePackId(rest[0]);
|
|
550
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
551
|
+
if (!existsSync(dest)) { console.error(`Not installed: ${publisher}/${name}`); process.exit(1); }
|
|
552
|
+
rmSync(dest, { recursive: true, force: true });
|
|
553
|
+
console.log(`Removed: ${publisher}/${name}`);
|
|
554
|
+
|
|
555
|
+
} else if (sub === 'update') {
|
|
556
|
+
const all = rest[0] === '--all';
|
|
557
|
+
const targets = all ? listPacks() : [rest[0]];
|
|
558
|
+
for (const packId of targets) {
|
|
559
|
+
const { publisher, name } = parsePackId(packId);
|
|
560
|
+
const dest = join(PACKS_DIR, publisher, name);
|
|
561
|
+
if (!existsSync(dest)) { console.error(`Not installed: ${packId}`); continue; }
|
|
562
|
+
console.log(`Updating ${packId}...`);
|
|
563
|
+
spawnSync('git', ['-C', dest, 'pull'], { stdio: 'inherit' });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
} else if (sub === 'list') {
|
|
567
|
+
const packs = listPacks();
|
|
568
|
+
if (!packs.length) { console.log('No packs installed.'); }
|
|
569
|
+
else { console.log('Installed packs:\n' + packs.map(p => ` ${p}`).join('\n')); }
|
|
570
|
+
|
|
571
|
+
} else {
|
|
572
|
+
console.log('Usage: agentcraft pack <install|remove|update|list> [publisher/name] [--all]');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
} else if (cmd === 'start') {
|
|
576
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
577
|
+
if (!pluginRoot) { console.error('CLAUDE_PLUGIN_ROOT not set. Run from the Claude Code plugin.'); process.exit(1); }
|
|
578
|
+
execSync(`cd "${pluginRoot}/web" && bun dev --port 4040`, { stdio: 'inherit' });
|
|
579
|
+
|
|
580
|
+
} else {
|
|
581
|
+
console.log(`AgentCraft CLI
|
|
582
|
+
|
|
583
|
+
Usage:
|
|
584
|
+
agentcraft pack install <publisher/name> Install a sound pack
|
|
585
|
+
agentcraft pack remove <publisher/name> Remove a sound pack
|
|
586
|
+
agentcraft pack update <publisher/name> Update a pack (git pull)
|
|
587
|
+
agentcraft pack update --all Update all packs
|
|
588
|
+
agentcraft pack list List installed packs
|
|
589
|
+
agentcraft start Launch the dashboard
|
|
590
|
+
|
|
591
|
+
Install the Claude Code plugin:
|
|
592
|
+
claude plugin install agentcraft@rohenaz
|
|
593
|
+
`);
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
**Step 2: Test locally**
|
|
598
|
+
|
|
599
|
+
```bash
|
|
600
|
+
node ~/code/agentcraft/bin/agentcraft.js pack list
|
|
601
|
+
# Should list rohenaz/agentcraft-sounds
|
|
602
|
+
|
|
603
|
+
node ~/code/agentcraft/bin/agentcraft.js
|
|
604
|
+
# Should show usage
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Task 10: Add `pack.json` to agentcraft-sounds
|
|
610
|
+
|
|
611
|
+
**Files:**
|
|
612
|
+
- Create: `~/code/claude-sounds/pack.json`
|
|
613
|
+
|
|
614
|
+
```json
|
|
615
|
+
{
|
|
616
|
+
"name": "agentcraft-sounds",
|
|
617
|
+
"publisher": "rohenaz",
|
|
618
|
+
"version": "2.0.0",
|
|
619
|
+
"description": "Official AgentCraft sound library — SC2, WC3, FF7, FF9, phones, apps, classic OS",
|
|
620
|
+
"types": ["sounds", "ui"]
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
cd ~/code/claude-sounds
|
|
626
|
+
git add pack.json
|
|
627
|
+
git commit -m "Add pack.json manifest"
|
|
628
|
+
git push
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
---
|
|
632
|
+
|
|
633
|
+
## Task 11: Bump versions and publish
|
|
634
|
+
|
|
635
|
+
**Step 1: Bump Claude plugin to 0.0.9**
|
|
636
|
+
|
|
637
|
+
In `.claude-plugin/plugin.json`: `"version": "0.0.9"`
|
|
638
|
+
|
|
639
|
+
**Step 2: Publish npm CLI as 0.0.2**
|
|
640
|
+
|
|
641
|
+
In `package.json`: `"version": "0.0.2"`
|
|
642
|
+
|
|
643
|
+
```bash
|
|
644
|
+
cd ~/code/agentcraft
|
|
645
|
+
git add -A
|
|
646
|
+
git commit -m "v0.0.9: Sound pack system — packs/, CLI pack commands, path migration"
|
|
647
|
+
git push
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Then publish with OTP.
|
|
651
|
+
|
|
652
|
+
**Step 3: Update plugin**
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
CLAUDECODE=1 claude plugin update agentcraft@rohenaz
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
660
|
+
## Task 12: Update README
|
|
661
|
+
|
|
662
|
+
Update `README.md` to document the pack system:
|
|
663
|
+
|
|
664
|
+
- Replace "Sound Library" section with "Sound Packs"
|
|
665
|
+
- Show `agentcraft pack install` as the way to get sounds
|
|
666
|
+
- Document `agentcraft pack list/update/remove`
|
|
667
|
+
- Note that manual `git clone` into `~/.agentcraft/packs/publisher/name/` works identically
|