agentcraft 0.0.3 → 0.0.5

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.1.0",
3
+ "version": "0.1.4",
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/README.md CHANGED
@@ -49,11 +49,12 @@ bun install -g agentcraft
49
49
  ```
50
50
 
51
51
  ```bash
52
- agentcraft pack install rohenaz/agentcraft-sounds # install a pack from GitHub
53
- agentcraft pack list # list installed packs
54
- agentcraft pack update rohenaz/agentcraft-sounds # update a pack (git pull)
55
- agentcraft pack update --all # update all packs
56
- agentcraft pack remove rohenaz/agentcraft-sounds # remove a pack
52
+ agentcraft init # first-time setup
53
+ agentcraft add rohenaz/agentcraft-sounds # install a pack from GitHub
54
+ agentcraft list # list installed packs
55
+ agentcraft update rohenaz/agentcraft-sounds # update a pack (git pull)
56
+ agentcraft update # update all packs
57
+ agentcraft remove rohenaz/agentcraft-sounds # remove a pack
57
58
  ```
58
59
 
59
60
  ## Sound Packs
package/TODO.md ADDED
@@ -0,0 +1,29 @@
1
+ # AgentCraft TODO
2
+
3
+ ## Features
4
+
5
+ ### Push-to-Talk / Dictation Mute Key
6
+ Assign a configurable hotkey (default: Fn key) that suppresses sound playback while held.
7
+ Use case: PTT dictation apps (Whisper, SuperWhisper, etc.) use a held key to record. Sounds
8
+ firing during active recording interrupt or bleed into the mic capture.
9
+
10
+ **Design notes:**
11
+ - Settings UI: "MUTE WHILE HELD" key picker in the dashboard (default `Fn`, allow any key)
12
+ - Stored in `assignments.json` under `settings.muteKey` (e.g. `"Fn"`, `"CapsLock"`, `"F13"`)
13
+ - Detection: the hook script can't read keyboard state directly. Options:
14
+ 1. **Lock file approach** — dashboard JS writes `/tmp/agentcraft-muted.lock` on `keydown`
15
+ and removes it on `keyup`. Hook script checks for file existence before playing.
16
+ Requires the dashboard to be open (reasonable — it's a config tool).
17
+ 2. **Daemon approach** — small background process (bun script) listens to keyboard events
18
+ via IOKit/CGEvent on macOS and manages the lock file. Survives without dashboard open.
19
+ 3. **Passive detection** — read `/proc/bus/input` on Linux or `hidutil` output on macOS
20
+ to poll key state at hook execution time (complex, likely too slow).
21
+ - Recommended: start with option 1 (lock file via dashboard), add option 2 as optional daemon.
22
+ - The `play-sound.sh` check would be: `[ -f "/tmp/agentcraft-muted.lock" ] && exit 0`
23
+ - Dashboard global keydown/keyup listeners write/remove the lock file via a small API route
24
+ (`POST /api/mute { active: boolean }`)
25
+
26
+ ### Sound Browser SOURCE Dropdown (multi-pack filter)
27
+ Gemini Option A: compact SOURCE dropdown above group tabs, invisible at 1 pack, appears at 2+.
28
+ Filters the sound browser to a specific pack or shows all with pack badges on cards.
29
+ See: conversation history / Gemini recommendation for full spec.
package/bin/agentcraft.js CHANGED
@@ -2,20 +2,30 @@
2
2
  'use strict';
3
3
 
4
4
  const { execSync, spawnSync } = require('child_process');
5
- const { existsSync, readdirSync, rmSync, statSync, mkdirSync } = require('fs');
6
- const { join } = require('path');
5
+ const { existsSync, readdirSync, rmSync, statSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync } = require('fs');
6
+ const { join, dirname } = require('path');
7
7
  const { homedir } = require('os');
8
8
 
9
9
  const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
10
+ const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
10
11
  const [,, cmd, sub, ...rest] = process.argv;
11
12
 
13
+ // ANSI colors — no dependencies
14
+ const c = {
15
+ cyan: s => `\x1b[36m${s}\x1b[0m`,
16
+ green: s => `\x1b[32m${s}\x1b[0m`,
17
+ red: s => `\x1b[31m${s}\x1b[0m`,
18
+ dim: s => `\x1b[2m${s}\x1b[0m`,
19
+ bold: s => `\x1b[1m${s}\x1b[0m`,
20
+ };
21
+
12
22
  function ensureDir(p) {
13
23
  mkdirSync(p, { recursive: true });
14
24
  }
15
25
 
16
26
  function parsePackId(arg) {
17
27
  if (!arg || !arg.includes('/')) {
18
- console.error(`Error: pack must be "publisher/name", got: ${arg ?? '(nothing)'}`);
28
+ console.error(c.red(`✗ pack must be "publisher/name", got: ${arg ?? '(nothing)'}`));
19
29
  process.exit(1);
20
30
  }
21
31
  const slash = arg.indexOf('/');
@@ -24,133 +34,297 @@ function parsePackId(arg) {
24
34
  return { publisher, name, id: arg, url: `https://github.com/${publisher}/${name}` };
25
35
  }
26
36
 
37
+ function readPackMeta(packPath) {
38
+ try { return JSON.parse(readFileSync(join(packPath, 'pack.json'), 'utf-8')); } catch { return {}; }
39
+ }
40
+
27
41
  function getInstalledPacks() {
28
42
  if (!existsSync(PACKS_DIR)) return [];
29
43
  const packs = [];
30
44
  for (const publisher of readdirSync(PACKS_DIR)) {
31
45
  const ppath = join(PACKS_DIR, publisher);
32
- try {
33
- if (!statSync(ppath).isDirectory()) continue;
34
- } catch { continue; }
46
+ try { if (!statSync(ppath).isDirectory()) continue; } catch { continue; }
35
47
  for (const name of readdirSync(ppath)) {
36
- try {
37
- if (statSync(join(ppath, name)).isDirectory()) packs.push(`${publisher}/${name}`);
38
- } catch { continue; }
48
+ try { if (statSync(join(ppath, name)).isDirectory()) packs.push(`${publisher}/${name}`); } catch { continue; }
39
49
  }
40
50
  }
41
51
  return packs;
42
52
  }
43
53
 
44
- function packInstall(packArg) {
54
+ function packAdd(packArg) {
45
55
  const { publisher, name, url } = parsePackId(packArg);
46
56
  const dest = join(PACKS_DIR, publisher, name);
47
57
  if (existsSync(dest)) {
48
- console.log(`Already installed: ${publisher}/${name}`);
58
+ console.log(c.dim(`Already installed: ${publisher}/${name}`));
49
59
  process.exit(0);
50
60
  }
51
61
  ensureDir(join(PACKS_DIR, publisher));
52
- console.log(`Installing ${publisher}/${name} from ${url} ...`);
62
+ console.log(`→ Installing ${c.cyan(`${publisher}/${name}`)} from ${c.dim(url)} ...`);
53
63
  const r = spawnSync('git', ['clone', url, dest], { stdio: 'inherit' });
54
64
  if (r.status !== 0) {
55
- console.error(`Failed to install ${publisher}/${name}`);
65
+ console.error(c.red(`✗ Failed to install ${publisher}/${name}`));
56
66
  process.exit(1);
57
67
  }
58
- console.log(`\nInstalled: ${publisher}/${name}`);
68
+ console.log(c.green(`✓ Installed: ${publisher}/${name}`));
59
69
  }
60
70
 
61
71
  function packRemove(packArg) {
62
72
  const { publisher, name } = parsePackId(packArg);
63
73
  const dest = join(PACKS_DIR, publisher, name);
64
74
  if (!existsSync(dest)) {
65
- console.error(`Not installed: ${publisher}/${name}`);
75
+ console.error(c.red(`✗ Not installed: ${publisher}/${name}`));
66
76
  process.exit(1);
67
77
  }
68
78
  rmSync(dest, { recursive: true, force: true });
69
- console.log(`Removed: ${publisher}/${name}`);
79
+ console.log(c.green(`✓ Removed: ${publisher}/${name}`));
70
80
  }
71
81
 
72
82
  function packUpdate(packArg) {
73
83
  const { publisher, name } = parsePackId(packArg);
74
84
  const dest = join(PACKS_DIR, publisher, name);
75
85
  if (!existsSync(dest)) {
76
- console.error(`Not installed: ${publisher}/${name}. Run: agentcraft pack install ${publisher}/${name}`);
86
+ console.error(c.red(`✗ Not installed: ${publisher}/${name}`));
87
+ console.error(c.dim(` Run: agentcraft add ${publisher}/${name}`));
77
88
  process.exit(1);
78
89
  }
79
- console.log(`Updating ${publisher}/${name} ...`);
90
+ console.log(`→ Updating ${c.cyan(`${publisher}/${name}`)} ...`);
80
91
  spawnSync('git', ['-C', dest, 'pull'], { stdio: 'inherit' });
81
92
  }
82
93
 
83
94
  function packList() {
84
95
  const packs = getInstalledPacks();
85
96
  if (!packs.length) {
86
- console.log('No packs installed.');
87
- console.log('\nInstall the official pack:');
88
- console.log(' agentcraft pack install rohenaz/agentcraft-sounds');
97
+ console.log(c.dim('No packs installed.'));
98
+ console.log('');
99
+ console.log('Install the official pack:');
100
+ console.log(` ${c.cyan('agentcraft add rohenaz/agentcraft-sounds')}`);
101
+ return;
102
+ }
103
+ console.log(c.bold('Installed packs:'));
104
+ for (const p of packs) {
105
+ const meta = readPackMeta(join(PACKS_DIR, ...p.split('/')));
106
+ const version = meta.version ? c.dim(` v${meta.version}`) : '';
107
+ const desc = meta.description ? c.dim(` ${meta.description}`) : '';
108
+ console.log(` ${c.cyan(p)}${version}${desc}`);
109
+ }
110
+ }
111
+
112
+ function packInit() {
113
+ console.log(c.bold('Initializing AgentCraft...'));
114
+ console.log('');
115
+
116
+ // 1. Install official pack if missing
117
+ const officialPack = join(PACKS_DIR, 'rohenaz', 'agentcraft-sounds');
118
+ if (!existsSync(officialPack)) {
119
+ packAdd('rohenaz/agentcraft-sounds');
120
+ } else {
121
+ console.log(c.dim(`✓ Official pack already installed`));
122
+ }
123
+
124
+ // 2. Create assignments.json from pack defaults if missing
125
+ if (!existsSync(ASSIGNMENTS_PATH)) {
126
+ const defaultsPath = join(officialPack, 'defaults', 'assignments.json');
127
+ if (existsSync(defaultsPath)) {
128
+ ensureDir(join(homedir(), '.agentcraft'));
129
+ copyFileSync(defaultsPath, ASSIGNMENTS_PATH);
130
+ console.log(c.green('✓ Created ~/.agentcraft/assignments.json'));
131
+ }
89
132
  } else {
90
- console.log('Installed packs:');
91
- for (const p of packs) console.log(` ${p}`);
133
+ console.log(c.dim(' assignments.json already exists'));
134
+ }
135
+
136
+ console.log('');
137
+ console.log(c.green('✓ AgentCraft ready!'));
138
+ console.log(` Dashboard: ${c.cyan('agentcraft start')}`);
139
+ console.log(` Browse packs: ${c.cyan('agentcraft list')}`);
140
+ }
141
+
142
+ function createPack(name) {
143
+ if (!name) {
144
+ console.error(c.red('Usage: agentcraft create-pack <name>'));
145
+ console.error(c.dim(' Example: agentcraft create-pack my-sounds'));
146
+ process.exit(1);
147
+ }
148
+ if (existsSync(name)) {
149
+ console.error(c.red(`✗ Directory already exists: ${name}`));
150
+ process.exit(1);
151
+ }
152
+
153
+ // Try to detect GitHub username from git config
154
+ let publisher = 'your-github-username';
155
+ try {
156
+ const gitUser = execSync('git config --global user.name 2>/dev/null', { encoding: 'utf-8' }).trim();
157
+ const gitEmail = execSync('git config --global github.user 2>/dev/null', { encoding: 'utf-8' }).trim();
158
+ if (gitEmail) publisher = gitEmail;
159
+ else if (gitUser) publisher = gitUser.toLowerCase().replace(/\s+/g, '-');
160
+ } catch { /* use default */ }
161
+
162
+ console.log(`→ Creating pack ${c.cyan(name)} ...`);
163
+
164
+ // Directory structure
165
+ ensureDir(join(name, 'sounds', 'session-start'));
166
+ ensureDir(join(name, 'sounds', 'task-complete'));
167
+ ensureDir(join(name, 'sounds', 'error'));
168
+ ensureDir(join(name, 'ui', 'my-theme'));
169
+
170
+ // pack.json
171
+ writeFileSync(join(name, 'pack.json'), JSON.stringify({
172
+ name,
173
+ publisher,
174
+ version: '1.0.0',
175
+ description: 'My AgentCraft sound pack',
176
+ types: ['sounds'],
177
+ }, null, 2) + '\n');
178
+
179
+ // .gitignore
180
+ writeFileSync(join(name, '.gitignore'), '.DS_Store\nThumbs.db\n');
181
+
182
+ // README.md
183
+ writeFileSync(join(name, 'README.md'), `# ${name}
184
+
185
+ An [AgentCraft](https://github.com/rohenaz/agentcraft) sound pack.
186
+
187
+ ## Install
188
+
189
+ \`\`\`bash
190
+ agentcraft add ${publisher}/${name}
191
+ \`\`\`
192
+
193
+ Or manually:
194
+
195
+ \`\`\`bash
196
+ git clone https://github.com/${publisher}/${name} ~/.agentcraft/packs/${publisher}/${name}
197
+ \`\`\`
198
+
199
+ ## Structure
200
+
201
+ Drop \`.mp3\`, \`.wav\`, \`.ogg\`, or \`.m4a\` files into directories. The dashboard auto-discovers them:
202
+
203
+ \`\`\`
204
+ sounds/
205
+ session-start/ ← sounds here appear under "sounds > session start" group
206
+ task-complete/
207
+ error/
208
+ ui/
209
+ my-theme/ ← optional: UI sounds for the dashboard itself
210
+ click.mp3
211
+ hover.mp3
212
+ confirm.mp3
213
+ error.mp3
214
+ pageChange.mp3
215
+ \`\`\`
216
+
217
+ Top-level directories become group tabs. Nested directories become sub-tabs and subcategories.
218
+ Any layout works — organise however makes sense for your sounds.
219
+
220
+ ## Publishing
221
+
222
+ 1. Push this repo to GitHub as \`${publisher}/${name}\`
223
+ 2. Go to **Settings → Topics** and add the topic \`agentcraft-pack\`
224
+ 3. The community registry picks it up within 6 hours
225
+
226
+ Users can then find and install it from the AgentCraft dashboard PACKS tab.
227
+ `);
228
+
229
+ console.log(c.green(`✓ Created ${name}/`));
230
+ console.log('');
231
+ console.log('Next steps:');
232
+ console.log(` 1. Drop ${c.cyan('.mp3/.wav/.ogg')} files into the subdirectories`);
233
+ console.log(` 2. Push to GitHub as ${c.cyan(`${publisher}/${name}`)}`);
234
+ console.log(` 3. Add topic ${c.cyan('agentcraft-pack')} in GitHub Settings → Topics`);
235
+ console.log('');
236
+ console.log('Test locally before publishing:');
237
+ console.log(c.dim(` git clone . ~/.agentcraft/packs/${publisher}/${name}`));
238
+ console.log(c.dim(' agentcraft start'));
239
+ }
240
+
241
+ function getWebDir() {
242
+ if (process.env.CLAUDE_PLUGIN_ROOT) {
243
+ return join(process.env.CLAUDE_PLUGIN_ROOT, 'web');
244
+ }
245
+ // Global bun/npm install: bin/agentcraft.js → package root → web/
246
+ try {
247
+ return join(dirname(dirname(realpathSync(__filename))), 'web');
248
+ } catch {
249
+ console.error(c.red('✗ Cannot locate web directory. Set CLAUDE_PLUGIN_ROOT or reinstall.'));
250
+ process.exit(1);
92
251
  }
93
252
  }
94
253
 
95
254
  function showHelp() {
96
255
  console.log(`
97
- AgentCraft CLI — assign sounds to AI agent lifecycle events
256
+ ${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
98
257
 
99
- Usage:
100
- agentcraft pack install <publisher/name> Install a sound pack from GitHub
101
- agentcraft pack remove <publisher/name> Remove an installed pack
102
- agentcraft pack update <publisher/name> Update a pack (git pull)
103
- agentcraft pack update --all Update all installed packs
104
- agentcraft pack list List installed packs
105
- agentcraft start Launch the dashboard (port 4040)
258
+ ${c.cyan('Usage:')}
259
+ agentcraft init Set up AgentCraft (install pack + config)
260
+ agentcraft add <publisher/name> Install a sound pack from GitHub
261
+ agentcraft remove <publisher/name> Remove an installed pack
262
+ agentcraft update [publisher/name] Update a pack, or all packs if no arg given
263
+ agentcraft list List installed packs
264
+ agentcraft start Launch the dashboard (port 4040)
265
+ agentcraft create-pack <name> Scaffold a new sound pack repo
106
266
 
107
- Examples:
108
- agentcraft pack install rohenaz/agentcraft-sounds
109
- agentcraft pack update --all
110
- agentcraft pack list
267
+ ${c.cyan('Examples:')}
268
+ agentcraft init
269
+ agentcraft add rohenaz/agentcraft-sounds
270
+ agentcraft add publisher/custom-pack
271
+ agentcraft update
272
+ agentcraft list
273
+ agentcraft create-pack my-sounds
111
274
 
112
- Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/
113
- Any git repo cloned there is automatically discovered by the dashboard.
275
+ ${c.dim('Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/')}
276
+ ${c.dim('Any git repo cloned there is automatically discovered by the dashboard.')}
114
277
 
115
- Install the Claude Code plugin:
278
+ ${c.cyan('Install the Claude Code plugin:')}
116
279
  claude plugin install agentcraft@rohenaz
117
280
  `);
118
281
  }
119
282
 
120
- if (cmd === 'pack') {
121
- if (sub === 'install') {
122
- if (!rest[0]) { console.error('Usage: agentcraft pack install <publisher/name>'); process.exit(1); }
123
- packInstall(rest[0]);
124
- } else if (sub === 'remove') {
125
- if (!rest[0]) { console.error('Usage: agentcraft pack remove <publisher/name>'); process.exit(1); }
126
- packRemove(rest[0]);
127
- } else if (sub === 'update') {
128
- if (rest[0] === '--all') {
129
- const packs = getInstalledPacks();
130
- if (!packs.length) { console.log('No packs installed.'); process.exit(0); }
131
- for (const p of packs) packUpdate(p);
132
- } else {
133
- if (!rest[0]) { console.error('Usage: agentcraft pack update <publisher/name> | --all'); process.exit(1); }
134
- packUpdate(rest[0]);
135
- }
136
- } else if (sub === 'list') {
137
- packList();
283
+ // Route commands
284
+ if (cmd === 'init') {
285
+ packInit();
286
+ } else if (cmd === 'add') {
287
+ if (!sub) { console.error(c.red('Usage: agentcraft add <publisher/name>')); process.exit(1); }
288
+ packAdd(sub);
289
+ } else if (cmd === 'remove') {
290
+ if (!sub) { console.error(c.red('Usage: agentcraft remove <publisher/name>')); process.exit(1); }
291
+ packRemove(sub);
292
+ } else if (cmd === 'update') {
293
+ if (!sub) {
294
+ const packs = getInstalledPacks();
295
+ if (!packs.length) { console.log(c.dim('No packs installed.')); process.exit(0); }
296
+ for (const p of packs) packUpdate(p);
138
297
  } else {
139
- console.error(`Unknown pack subcommand: ${sub ?? '(none)'}`);
140
- console.error('Usage: agentcraft pack <install|remove|update|list>');
141
- process.exit(1);
298
+ packUpdate(sub);
142
299
  }
300
+ } else if (cmd === 'list') {
301
+ packList();
143
302
  } else if (cmd === 'start') {
144
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
145
- if (!pluginRoot) {
146
- console.error('CLAUDE_PLUGIN_ROOT not set. This command is intended to run from the Claude Code plugin context.');
303
+ const webDir = getWebDir();
304
+ if (!existsSync(webDir)) {
305
+ console.error(c.red(`✗ Web directory not found: ${webDir}`));
147
306
  process.exit(1);
148
307
  }
149
- execSync(`cd "${pluginRoot}/web" && bun dev --port 4040`, { stdio: 'inherit' });
308
+ console.log(`→ Starting AgentCraft at ${c.cyan('http://localhost:4040')} ...`);
309
+ execSync(`cd "${webDir}" && bun install --silent && bun dev --port 4040`, { stdio: 'inherit' });
150
310
  } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
151
311
  showHelp();
312
+ } else if (cmd === 'create-pack') {
313
+ createPack(sub);
314
+ } else if (cmd === 'pack') {
315
+ // Legacy shim — print migration hint and route through
316
+ const newCmd = sub === 'install' ? 'add' : sub;
317
+ console.error(c.dim(`Note: "agentcraft pack ${sub}" → "agentcraft ${newCmd}"`));
318
+ if (sub === 'install') { if (!rest[0]) { console.error(c.red('Usage: agentcraft add <publisher/name>')); process.exit(1); } packAdd(rest[0]); }
319
+ else if (sub === 'remove') { if (!rest[0]) { console.error(c.red('Usage: agentcraft remove <publisher/name>')); process.exit(1); } packRemove(rest[0]); }
320
+ else if (sub === 'update') {
321
+ if (!rest[0] || rest[0] === '--all') { const packs = getInstalledPacks(); for (const p of packs) packUpdate(p); }
322
+ else packUpdate(rest[0]);
323
+ }
324
+ else if (sub === 'list') packList();
325
+ else { console.error(c.red(`Unknown: agentcraft pack ${sub}`)); process.exit(1); }
152
326
  } else {
153
- console.error(`Unknown command: ${cmd}`);
327
+ console.error(c.red(`✗ Unknown command: ${cmd}`));
154
328
  showHelp();
155
329
  process.exit(1);
156
330
  }
@@ -12,15 +12,9 @@ kill $(lsof -ti:4040) 2>/dev/null
12
12
  echo "AgentCraft server stopped."
13
13
  ```
14
14
 
15
- Check if the official sound pack is installed:
15
+ Run first-time setup (installs official pack + creates assignments.json if missing):
16
16
  ```bash
17
- ls ~/.agentcraft/packs/rohenaz/agentcraft-sounds 2>/dev/null | head -1
18
- ```
19
-
20
- If that returned nothing (empty or missing), install it:
21
- ```bash
22
- agentcraft pack install rohenaz/agentcraft-sounds 2>/dev/null || \
23
- git clone https://github.com/rohenaz/agentcraft-sounds ~/.agentcraft/packs/rohenaz/agentcraft-sounds
17
+ agentcraft init
24
18
  ```
25
19
 
26
20
  Check if server is already running:
@@ -17,6 +17,8 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
17
17
  ENABLED=$(jq -r '.settings.enabled // true' "$CONFIG")
18
18
  [ "$ENABLED" = "false" ] && exit 0
19
19
 
20
+ VOLUME=$(jq -r '.settings.masterVolume // 1' "$CONFIG")
21
+
20
22
  # Deduplicate: prevent the same event from firing within 3 seconds.
21
23
  # Claude Code fires SessionStart twice on resume (process init + session restore).
22
24
  LOCKFILE="/tmp/agentcraft-${EVENT}.lock"
@@ -70,9 +72,10 @@ fi
70
72
  [ ! -f "$FULL" ] && exit 0
71
73
 
72
74
  if [[ "$OSTYPE" == "darwin"* ]]; then
73
- afplay "$FULL" &
75
+ afplay -v "$VOLUME" "$FULL" &
74
76
  elif command -v paplay &>/dev/null; then
75
- paplay "$FULL" &
77
+ VOLUME_PAPLAY=$(awk "BEGIN{printf \"%d\", $VOLUME * 65536}")
78
+ paplay --volume="$VOLUME_PAPLAY" "$FULL" &
76
79
  elif command -v aplay &>/dev/null; then
77
80
  aplay "$FULL" &
78
81
  fi
package/opencode.js CHANGED
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Plays sounds on OpenCode lifecycle events using the same assignments.json
5
5
  * config as the Claude Code plugin. Shared config lives at:
6
- * ~/.claude/sounds/assignments.json
6
+ * ~/.agentcraft/assignments.json
7
+ *
8
+ * Packs are stored at:
9
+ * ~/.agentcraft/packs/<publisher>/<name>/
7
10
  *
8
11
  * To install for OpenCode, symlink or copy this file to:
9
12
  * ~/.config/opencode/plugins/agentcraft.js (global)
@@ -15,24 +18,42 @@ import { execSync } from 'child_process';
15
18
  import { join } from 'path';
16
19
  import { homedir } from 'os';
17
20
 
18
- const CONFIG_PATH = join(homedir(), '.claude', 'sounds', 'assignments.json');
19
- const LIBRARY_PATH = join(homedir(), 'code', 'claude-sounds');
21
+ const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
22
+ const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
20
23
 
21
24
  function getAssignments() {
22
25
  try {
23
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
26
+ return JSON.parse(readFileSync(ASSIGNMENTS_PATH, 'utf8'));
24
27
  } catch {
25
28
  return null;
26
29
  }
27
30
  }
28
31
 
32
+ function resolvePackPath(soundPath) {
33
+ if (!soundPath) return null;
34
+ if (soundPath.includes(':')) {
35
+ // "publisher/name:internal/path/to/sound.mp3"
36
+ const colon = soundPath.indexOf(':');
37
+ const packId = soundPath.slice(0, colon);
38
+ const internal = soundPath.slice(colon + 1);
39
+ const slash = packId.indexOf('/');
40
+ const publisher = packId.slice(0, slash);
41
+ const name = packId.slice(slash + 1);
42
+ return join(PACKS_DIR, publisher, name, internal);
43
+ }
44
+ // Legacy path — resolve against official pack
45
+ return join(PACKS_DIR, 'rohenaz', 'agentcraft-sounds', soundPath);
46
+ }
47
+
29
48
  function play(soundPath) {
30
- if (!soundPath) return;
31
- const full = join(LIBRARY_PATH, soundPath);
32
- if (!existsSync(full)) return;
49
+ const full = resolvePackPath(soundPath);
50
+ if (!full || !existsSync(full)) return;
33
51
  try {
34
52
  if (process.platform === 'darwin') execSync(`afplay "${full}" &`, { stdio: 'ignore' });
35
- else if (process.platform === 'linux') execSync(`paplay "${full}" &`, { stdio: 'ignore' });
53
+ else if (process.platform === 'linux') {
54
+ if (existsSync('/usr/bin/paplay')) execSync(`paplay "${full}" &`, { stdio: 'ignore' });
55
+ else execSync(`aplay "${full}" &`, { stdio: 'ignore' });
56
+ }
36
57
  } catch { /* non-blocking, ignore errors */ }
37
58
  }
38
59
 
@@ -62,7 +83,6 @@ export const AgentCraft = async () => {
62
83
  'tool.execute.before': async (input) => {
63
84
  const a = getAssignments();
64
85
  if (!a || a.settings?.enabled === false) return;
65
-
66
86
  if (input.tool === 'skill') {
67
87
  const key = input.args?.skill;
68
88
  if (!key) return;
@@ -75,7 +95,6 @@ export const AgentCraft = async () => {
75
95
  'tool.execute.after': async (input) => {
76
96
  const a = getAssignments();
77
97
  if (!a || a.settings?.enabled === false) return;
78
-
79
98
  if (input.tool === 'skill') {
80
99
  const key = input.args?.skill;
81
100
  if (!key) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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,169 @@
1
+ ---
2
+ name: agentcraft-packs
3
+ description: This skill should be used when the user asks to "install a sound pack", "add a pack", "find sound packs", "publish a pack", "create a pack", "create-pack", "scaffold a pack", "share my sounds", "agentcraft pack install", "browse packs", "remove a pack", "update packs", or wants to know how the AgentCraft pack system works.
4
+ ---
5
+
6
+ # AgentCraft Sound Packs
7
+
8
+ Sound packs are git repos containing audio files. Any GitHub repo can be a pack — no approval, no registry required. Install by `publisher/name` slug, same as GitHub.
9
+
10
+ ## Installing Packs
11
+
12
+ ### From the CLI
13
+
14
+ ```bash
15
+ agentcraft init # first-time setup
16
+ agentcraft add rohenaz/agentcraft-sounds # official pack
17
+ agentcraft add publisher/repo-name # any GitHub repo
18
+ agentcraft list # show installed packs
19
+ agentcraft update rohenaz/agentcraft-sounds # git pull one pack
20
+ agentcraft update # update all packs
21
+ agentcraft remove publisher/repo-name # uninstall
22
+ agentcraft create-pack my-sounds # scaffold a new pack repo
23
+ ```
24
+
25
+ `agentcraft pack install publisher/repo-name` resolves to `https://github.com/publisher/repo-name` and clones into `~/.agentcraft/packs/publisher/repo-name/`.
26
+
27
+ Install the CLI globally:
28
+ ```bash
29
+ bun install -g agentcraft # or: npm install -g agentcraft
30
+ ```
31
+
32
+ ### From the Dashboard
33
+
34
+ Open the **PACKS** tab in the AgentCraft dashboard. Installed packs show UPDATE/REMOVE buttons. The **BROWSE PACKS** section fetches the community registry and shows packs not yet installed with an INSTALL button.
35
+
36
+ ### Manual Install (identical result)
37
+
38
+ ```bash
39
+ git clone https://github.com/publisher/repo-name ~/.agentcraft/packs/publisher/repo-name
40
+ ```
41
+
42
+ Manual clone and CLI install are exactly equivalent — no manifest or registration step.
43
+
44
+ ## Pack Storage
45
+
46
+ Packs live at `~/.agentcraft/packs/<publisher>/<name>/`. The dashboard auto-discovers everything at that path depth — any directory placed there works.
47
+
48
+ ```
49
+ ~/.agentcraft/packs/
50
+ rohenaz/
51
+ agentcraft-sounds/ ← official pack
52
+ publisher/
53
+ custom-pack/ ← any git repo cloned here
54
+ ```
55
+
56
+ ## Sound Assignment Paths
57
+
58
+ Assigned sounds are stored in `~/.agentcraft/assignments.json` with a pack-prefixed path:
59
+
60
+ ```
61
+ rohenaz/agentcraft-sounds:sc2/terran/session-start/scv-ready.mp3
62
+ ```
63
+
64
+ Format: `publisher/name:internal/path/to/sound.mp3`
65
+
66
+ The hook script resolves this to the absolute path at runtime:
67
+ ```
68
+ ~/.agentcraft/packs/rohenaz/agentcraft-sounds/sc2/terran/session-start/scv-ready.mp3
69
+ ```
70
+
71
+ ## Publishing a Pack
72
+
73
+ Any GitHub repo with audio files (`.mp3`, `.wav`, `.ogg`, `.m4a`) is a valid pack. No manifest required — directory structure is the organization.
74
+
75
+ ### Step 0: Scaffold with the CLI (fastest path)
76
+
77
+ ```bash
78
+ agentcraft create-pack my-sounds
79
+ ```
80
+
81
+ This generates a ready-to-use pack directory with `pack.json`, `README.md`, example directories, and publishing instructions. Then drop your audio files in and push.
82
+
83
+ ### Step 1: Organize the repo
84
+
85
+ Recommended structure — group sounds into directories by game, theme, or purpose:
86
+
87
+ ```
88
+ my-sounds/
89
+ sc2/
90
+ terran/
91
+ session-start/
92
+ ready.mp3
93
+ task-complete/
94
+ salute.mp3
95
+ halo/
96
+ unsc/
97
+ session-start/
98
+ wake-up.mp3
99
+ ui/ ← optional: UI theme sounds
100
+ sc2/
101
+ click.mp3
102
+ hover.mp3
103
+ ```
104
+
105
+ Any directory layout works. The dashboard groups sounds by their directory path.
106
+
107
+ ### Step 2: Add `pack.json` (optional but recommended)
108
+
109
+ ```json
110
+ {
111
+ "name": "my-sounds",
112
+ "publisher": "your-github-username",
113
+ "version": "1.0.0",
114
+ "description": "Short description of the pack",
115
+ "types": ["sounds", "ui"]
116
+ }
117
+ ```
118
+
119
+ `types` is informational. Use `"ui"` if the pack includes a `ui/` directory with dashboard theme sounds.
120
+
121
+ ### Step 3: Tag the repo on GitHub
122
+
123
+ Add the `agentcraft-pack` topic to the GitHub repo. This makes it discoverable in:
124
+ - The community registry at `https://rohenaz.github.io/agentcraft-registry/`
125
+ - GitHub search: `https://github.com/topics/agentcraft-pack`
126
+
127
+ To tag: GitHub repo → **Settings** → **Topics** → type `agentcraft-pack` → Save.
128
+
129
+ The registry GitHub Action runs every 6 hours and automatically picks up newly tagged repos.
130
+
131
+ ### Step 4: Share the install command
132
+
133
+ ```bash
134
+ agentcraft add your-username/your-repo-name
135
+ ```
136
+
137
+ That's the entire publish workflow — push to GitHub, tag it, done.
138
+
139
+ ## Pack Discovery
140
+
141
+ Find community packs three ways:
142
+
143
+ 1. **Dashboard** — PACKS tab → BROWSE PACKS section shows the registry
144
+ 2. **Registry** — `https://github.com/topics/agentcraft-pack`
145
+ 3. **Registry JSON** — `https://rohenaz.github.io/agentcraft-registry/index.json`
146
+
147
+ ## UI Theme Sounds
148
+
149
+ Packs can include a `ui/` directory with sounds that play as you use the AgentCraft dashboard (hover, click, page change, etc.). The dashboard's **UI SFX** dropdown lets users pick which pack's UI theme to use.
150
+
151
+ Structure the `ui/` directory by theme name:
152
+
153
+ ```
154
+ ui/
155
+ sc2/
156
+ click.mp3
157
+ hover.mp3
158
+ confirm.mp3
159
+ error.mp3
160
+ pageChange.mp3
161
+ wc3/
162
+ click.mp3
163
+ ...
164
+ ```
165
+
166
+ ## Additional Resources
167
+
168
+ - **`references/pack-format.md`** — Full audio file requirements, directory naming conventions, and pack.json schema
169
+ - **Registry source** — `https://github.com/rohenaz/agentcraft-registry`
@@ -0,0 +1,122 @@
1
+ # Pack Format Reference
2
+
3
+ ## Audio File Requirements
4
+
5
+ Supported formats: `.mp3`, `.wav`, `.ogg`, `.m4a`
6
+
7
+ Recommended: `.mp3` at 128–192kbps for compatibility and small file size.
8
+
9
+ File names become the display name in the sound browser (dashes/underscores become spaces, title-cased). Keep names descriptive:
10
+ - `scv-ready.mp3` → "Scv Ready"
11
+ - `marine-salute-00.mp3` → "Marine Salute 00"
12
+
13
+ ## Directory Structure
14
+
15
+ No required structure. The dashboard reads depth dynamically:
16
+
17
+ - Top-level directories → **Group tabs** (e.g. `sc2`, `halo`, `classic-os`)
18
+ - Second-level directories → **Sub-tabs** (e.g. `sc2/terran`, `halo/unsc`)
19
+ - Third-level directories → **Subcategories** within a sub-tab (e.g. `sc2/terran/session-start`)
20
+ - Files at any depth → Sound cards
21
+
22
+ Example layouts that all work:
23
+
24
+ **Game-organized:**
25
+ ```
26
+ sc2/terran/session-start/scv-ready.mp3
27
+ sc2/terran/task-complete/marine-salute.mp3
28
+ sc2/protoss/session-start/probe-ready.mp3
29
+ ```
30
+
31
+ **Flat:**
32
+ ```
33
+ sounds/click.mp3
34
+ sounds/beep.mp3
35
+ ```
36
+
37
+ **Mood-organized:**
38
+ ```
39
+ ambient/forest/birds.mp3
40
+ action/alert/warning.mp3
41
+ ```
42
+
43
+ ## pack.json Schema
44
+
45
+ All fields are optional. Used for display in the PACKS tab and the community registry.
46
+
47
+ ```json
48
+ {
49
+ "name": "string", // Display name (defaults to directory name)
50
+ "publisher": "string", // GitHub username (defaults to directory publisher)
51
+ "version": "string", // Semantic version, e.g. "1.0.0"
52
+ "description": "string", // One-line description shown in dashboard
53
+ "types": ["sounds", "ui"] // Content types: "sounds", "ui", or both
54
+ }
55
+ ```
56
+
57
+ ## UI Theme Sounds
58
+
59
+ Include a `ui/` directory at the pack root for dashboard UI sounds. Subdirectories are theme names. Each theme can define any subset of these event sounds:
60
+
61
+ | Filename | Trigger |
62
+ |----------|---------|
63
+ | `click.mp3` | Button click |
64
+ | `hover.mp3` | Element hover |
65
+ | `confirm.mp3` | Save / success action |
66
+ | `error.mp3` | Error / failure |
67
+ | `pageChange.mp3` | Tab / page switch |
68
+ | `drag.mp3` | Drag start |
69
+ | `drop.mp3` | Drop onto slot |
70
+ | `open.mp3` | Panel/modal open |
71
+ | `close.mp3` | Panel/modal close |
72
+
73
+ Missing files are silently skipped. Themes with no `ui/` directory are sound-only packs.
74
+
75
+ Example:
76
+ ```
77
+ ui/
78
+ sc2/
79
+ click.mp3
80
+ hover.mp3
81
+ confirm.mp3
82
+ error.mp3
83
+ wc3/
84
+ click.mp3
85
+ hover.mp3
86
+ ```
87
+
88
+ ## Assignment Path Format
89
+
90
+ When a sound is assigned to a hook, it's stored as:
91
+
92
+ ```
93
+ publisher/pack-name:path/to/sound.mp3
94
+ ```
95
+
96
+ The colon (`:`) separates pack identity from the internal path. The hook script resolves this to:
97
+
98
+ ```
99
+ ~/.agentcraft/packs/publisher/pack-name/path/to/sound.mp3
100
+ ```
101
+
102
+ Sounds from your pack will automatically use this format when assigned through the dashboard.
103
+
104
+ ## GitHub Topics
105
+
106
+ Tag your repo with `agentcraft-pack` to appear in the community registry. Additional optional tags:
107
+
108
+ | Topic | Meaning |
109
+ |-------|---------|
110
+ | `agentcraft-pack` | Required for discovery |
111
+ | `agentcraft-type-sounds` | Pack contains hook sounds |
112
+ | `agentcraft-type-ui` | Pack contains UI theme sounds |
113
+
114
+ The `agentcraft-type-*` topics are parsed by the registry Action and populate the `types` field in `index.json` automatically (as a fallback when no `pack.json` is present).
115
+
116
+ ## File Size Guidelines
117
+
118
+ - Individual sounds: ideally < 500KB, max 2MB
119
+ - Total pack size: ideally < 50MB
120
+ - Large packs slow down `agentcraft pack install` (git clone)
121
+
122
+ Consider using `.gitattributes` with Git LFS for packs with many large audio files.
@@ -4,13 +4,14 @@ import { resolvePackPath } from '@/lib/packs';
4
4
 
5
5
  export async function POST(req: NextRequest) {
6
6
  try {
7
- const { path } = await req.json();
7
+ const { path, volume } = await req.json();
8
8
  if (!path || typeof path !== 'string') {
9
9
  return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
10
10
  }
11
11
  const fullPath = resolvePackPath(path);
12
12
  if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
13
- spawn('afplay', [fullPath], { detached: true, stdio: 'ignore' }).unref();
13
+ const vol = typeof volume === 'number' && volume >= 0 && volume <= 1 ? volume : 1.0;
14
+ spawn('afplay', ['-v', String(vol), fullPath], { detached: true, stdio: 'ignore' }).unref();
14
15
  return NextResponse.json({ ok: true });
15
16
  } catch {
16
17
  return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
package/web/app/page.tsx CHANGED
@@ -79,9 +79,9 @@ export default function Page() {
79
79
  await fetch('/api/preview', {
80
80
  method: 'POST',
81
81
  headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ path }),
82
+ body: JSON.stringify({ path, volume: assignments.settings.masterVolume }),
83
83
  });
84
- }, []);
84
+ }, [assignments.settings.masterVolume]);
85
85
 
86
86
  const handleSave = useCallback(async () => {
87
87
  await fetch('/api/assignments', {
@@ -100,6 +100,13 @@ export default function Page() {
100
100
  });
101
101
  }, [assignments, handleAssignmentChange]);
102
102
 
103
+ const handleVolumeChange = useCallback((volume: number) => {
104
+ handleAssignmentChange({
105
+ ...assignments,
106
+ settings: { ...assignments.settings, masterVolume: volume },
107
+ });
108
+ }, [assignments, handleAssignmentChange]);
109
+
103
110
  const handleUiThemeChange = useCallback((theme: UITheme) => {
104
111
  setUITheme(theme, assignments.settings?.uiSounds?.[theme]);
105
112
  handleAssignmentChange({
@@ -227,7 +234,7 @@ export default function Page() {
227
234
  return (
228
235
  <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
229
236
  <div className="h-screen w-screen overflow-hidden flex flex-col" style={{ backgroundColor: 'var(--sf-bg)' }}>
230
- <HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} />
237
+ <HudHeader enabled={assignments.settings.enabled} onToggle={handleToggleEnabled} uiTheme={assignments.settings.uiTheme ?? 'sc2'} onUiThemeChange={handleUiThemeChange} onConfigureUISounds={() => setShowUISoundsModal(true)} masterVolume={assignments.settings.masterVolume ?? 1.0} onVolumeChange={handleVolumeChange} />
231
238
  <div className="flex-1 grid overflow-hidden" style={{ gridTemplateColumns: '288px 1fr 320px' }}>
232
239
  <AgentRosterPanel
233
240
  assignments={assignments}
@@ -9,6 +9,8 @@ interface HudHeaderProps {
9
9
  uiTheme: UITheme;
10
10
  onUiThemeChange: (theme: UITheme) => void;
11
11
  onConfigureUISounds: () => void;
12
+ masterVolume: number;
13
+ onVolumeChange: (v: number) => void;
12
14
  }
13
15
 
14
16
  const UI_THEMES: { value: UITheme; label: string }[] = [
@@ -19,7 +21,7 @@ const UI_THEMES: { value: UITheme; label: string }[] = [
19
21
  { value: 'off', label: 'OFF' },
20
22
  ];
21
23
 
22
- export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds }: HudHeaderProps) {
24
+ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds, masterVolume, onVolumeChange }: HudHeaderProps) {
23
25
  const [showDropdown, setShowDropdown] = useState(false);
24
26
  const activeLabel = UI_THEMES.find((t) => t.value === uiTheme)?.label ?? uiTheme.toUpperCase();
25
27
 
@@ -116,6 +118,26 @@ export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfi
116
118
 
117
119
  <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
118
120
 
121
+ {/* Master volume */}
122
+ <div className="flex items-center gap-1.5">
123
+ <span className="text-[10px] tracking-widest uppercase opacity-40">VOL</span>
124
+ <input
125
+ type="range"
126
+ min={0}
127
+ max={100}
128
+ value={Math.round(masterVolume * 100)}
129
+ onChange={(e) => onVolumeChange(Number(e.target.value) / 100)}
130
+ data-no-ui-sound
131
+ className="w-16"
132
+ style={{ cursor: 'pointer', accentColor: 'var(--sf-cyan)', verticalAlign: 'middle' }}
133
+ />
134
+ <span className="text-[10px] w-7 text-right tabular-nums" style={{ color: 'var(--sf-cyan)', opacity: 0.7 }}>
135
+ {Math.round(masterVolume * 100)}%
136
+ </span>
137
+ </div>
138
+
139
+ <div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
140
+
119
141
  {/* Master enable/disable */}
120
142
  <button
121
143
  onClick={onToggle}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo, useCallback } from 'react';
3
+ import { useState, useMemo, useCallback, useEffect } from 'react';
4
4
  import { SoundUnit } from './sound-unit';
5
5
  import { groupSoundsByCategory, getGroupLabel, getSubTabLabel } from '@/lib/utils';
6
6
  import { playUISound } from '@/lib/ui-audio';
@@ -21,30 +21,53 @@ function internalCat(category: string): string {
21
21
  return idx === -1 ? category : category.slice(idx + 1);
22
22
  }
23
23
 
24
+ // Extract pack ID: "publisher/name:sc2/terran" → "publisher/name"
25
+ function packOfCat(category: string): string {
26
+ const idx = category.indexOf(':');
27
+ return idx === -1 ? '' : category.slice(0, idx);
28
+ }
29
+
30
+ // "publisher/name" → "name"
31
+ function packShortName(packId: string): string {
32
+ return packId.split('/')[1] ?? packId;
33
+ }
34
+
24
35
  export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
36
+ const [activePack, setActivePack] = useState<string | null>(null);
25
37
  const [activeGroup, setActiveGroup] = useState<string>('sc2');
26
38
  const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
27
39
  const [search, setSearch] = useState('');
28
40
 
29
- const handleGroupChange = useCallback((group: string) => {
30
- setActiveGroup(group);
31
- playUISound('pageChange', 0.4);
32
- }, []);
41
+ // All unique pack IDs present in the library
42
+ const allPacks = useMemo(() => {
43
+ return [...new Set(sounds.map((s) => packOfCat(s.category)))].filter(Boolean).sort();
44
+ }, [sounds]);
33
45
 
34
- const handleCategoryChange = useCallback((cat: string) => {
35
- setActiveCategory(cat);
36
- playUISound('pageChange', 0.35);
37
- }, []);
46
+ const showPackSelector = allPacks.length > 1;
47
+
48
+ // Sounds visible given the current pack filter
49
+ const visibleSounds = useMemo(() => {
50
+ if (!activePack) return sounds;
51
+ const prefix = activePack + ':';
52
+ return sounds.filter((s) => s.category.startsWith(prefix));
53
+ }, [sounds, activePack]);
38
54
 
39
55
  const allGroups = useMemo(() => {
40
- return [...new Set(sounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
41
- }, [sounds]);
56
+ return [...new Set(visibleSounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
57
+ }, [visibleSounds]);
58
+
59
+ // If activeGroup disappears after a pack switch, reset to the first available group
60
+ useEffect(() => {
61
+ if (allGroups.length > 0 && !allGroups.includes(activeGroup)) {
62
+ setActiveGroup(allGroups[0]);
63
+ }
64
+ }, [allGroups, activeGroup]);
42
65
 
43
66
  const groupCategories = useMemo(() => {
44
67
  return [...new Set(
45
- sounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
68
+ visibleSounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
46
69
  )].sort();
47
- }, [sounds, activeGroup]);
70
+ }, [visibleSounds, activeGroup]);
48
71
 
49
72
  // If activeCategory doesn't belong to current group, use first category of the group
50
73
  const effectiveCategory = groupCategories.includes(activeCategory)
@@ -64,21 +87,36 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
64
87
 
65
88
  const filteredSounds = useMemo(() => {
66
89
  if (isSearching) {
67
- // Global search across all sounds
68
90
  const q = search.toLowerCase();
69
- return sounds.filter((s) =>
91
+ return visibleSounds.filter((s) =>
70
92
  s.filename.toLowerCase().includes(q) ||
71
93
  s.category.toLowerCase().includes(q) ||
72
94
  s.subcategory.toLowerCase().includes(q)
73
95
  );
74
96
  }
75
- // Normal tab-filtered view
76
- return sounds.filter((s) => s.category === effectiveCategory);
77
- }, [sounds, effectiveCategory, search, isSearching]);
97
+ return visibleSounds.filter((s) => s.category === effectiveCategory);
98
+ }, [visibleSounds, effectiveCategory, search, isSearching]);
78
99
 
79
100
  const grouped = useMemo(() => groupSoundsByCategory(filteredSounds), [filteredSounds]);
80
101
 
81
102
  const showSubTabs = !isSearching && groupCategories.length > 1;
103
+ // Show pack label on cards when browsing all packs and multiple packs are installed
104
+ const showPackBadge = showPackSelector && !activePack;
105
+
106
+ const handleGroupChange = useCallback((group: string) => {
107
+ setActiveGroup(group);
108
+ playUISound('pageChange', 0.4);
109
+ }, []);
110
+
111
+ const handleCategoryChange = useCallback((cat: string) => {
112
+ setActiveCategory(cat);
113
+ playUISound('pageChange', 0.35);
114
+ }, []);
115
+
116
+ const handlePackChange = useCallback((pack: string | null) => {
117
+ setActivePack(pack);
118
+ playUISound('pageChange', 0.4);
119
+ }, []);
82
120
 
83
121
  return (
84
122
  <div className="flex flex-col overflow-hidden" style={{ borderLeft: '1px solid var(--sf-border)', borderRight: '1px solid var(--sf-border)' }}>
@@ -103,6 +141,45 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
103
141
  }}
104
142
  />
105
143
 
144
+ {/* SOURCE pack selector — only shown when 2+ packs installed */}
145
+ {showPackSelector && (
146
+ <div
147
+ className="flex items-center gap-1 mt-2 overflow-x-auto"
148
+ style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}
149
+ >
150
+ <span className="text-[9px] tracking-widest uppercase shrink-0" style={{ color: 'var(--sf-gold)', opacity: 0.6 }}>
151
+ SOURCE
152
+ </span>
153
+ <button
154
+ data-sf-hover
155
+ onClick={() => handlePackChange(null)}
156
+ className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
157
+ style={{
158
+ border: `1px solid ${!activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
159
+ color: !activePack ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
160
+ backgroundColor: !activePack ? 'rgba(255,192,0,0.08)' : 'transparent',
161
+ }}
162
+ >
163
+ ALL
164
+ </button>
165
+ {allPacks.map((id) => (
166
+ <button
167
+ key={id}
168
+ data-sf-hover
169
+ onClick={() => handlePackChange(id)}
170
+ className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-semibold uppercase tracking-wider transition-all"
171
+ style={{
172
+ border: `1px solid ${activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.2)'}`,
173
+ color: activePack === id ? 'var(--sf-gold)' : 'rgba(255,192,0,0.45)',
174
+ backgroundColor: activePack === id ? 'rgba(255,192,0,0.08)' : 'transparent',
175
+ }}
176
+ >
177
+ {packShortName(id)}
178
+ </button>
179
+ ))}
180
+ </div>
181
+ )}
182
+
106
183
  {/* Group tabs */}
107
184
  <div className="flex gap-1 mt-2 overflow-x-auto" style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}>
108
185
  {allGroups.map((group) => (
@@ -196,6 +273,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
196
273
  isAssigned={assignedPaths.has(sound.path)}
197
274
  onPreview={onPreview}
198
275
  onSelectAssign={selectMode ? () => onSelectModeAssign(sound.path) : undefined}
276
+ packLabel={showPackBadge ? packShortName(packOfCat(sound.category)) : undefined}
199
277
  />
200
278
  ))}
201
279
  </div>
@@ -11,11 +11,12 @@ interface SoundUnitProps {
11
11
  onPreview: (path: string) => void;
12
12
  isOverlay?: boolean;
13
13
  onSelectAssign?: () => void; // if set, card click assigns instead of plays
14
+ packLabel?: string; // shown when browsing multiple packs simultaneously
14
15
  }
15
16
 
16
17
  const BARS = 16;
17
18
 
18
- export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign }: SoundUnitProps) {
19
+ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign, packLabel }: SoundUnitProps) {
19
20
  const [isPlaying, setIsPlaying] = useState(false);
20
21
  const [isHovered, setIsHovered] = useState(false);
21
22
  const [bars, setBars] = useState<number[]>(sound.waveform.map(h => h / 10));
@@ -198,6 +199,11 @@ export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAss
198
199
  <span className="shrink-0 text-[9px] animate-pulse" style={{ color: 'var(--sf-cyan)' }}>♪</span>
199
200
  )}
200
201
  </div>
202
+ {packLabel && (
203
+ <span className="text-[8px] truncate leading-none" style={{ color: 'rgba(255,192,0,0.45)' }}>
204
+ {packLabel}
205
+ </span>
206
+ )}
201
207
  </div>
202
208
  );
203
209
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { previewUISound } from '@/lib/ui-audio';
5
5
  import type { UITheme, UISlotMap } from '@/lib/types';
6
6
 
@@ -12,6 +12,8 @@ interface UISound {
12
12
 
13
13
  type SlotName = 'click' | 'hover' | 'error' | 'pageChange' | 'toggle' | 'confirm';
14
14
 
15
+ const CANONICAL_SLOTS = new Set(['click', 'hover', 'error', 'pageChange', 'toggle', 'confirm']);
16
+
15
17
  const SLOTS: { name: SlotName; label: string; desc: string }[] = [
16
18
  { name: 'hover', label: 'HOVER', desc: 'Mouse over interactive element' },
17
19
  { name: 'click', label: 'CLICK', desc: 'Button or card clicked' },
@@ -30,8 +32,8 @@ interface Props {
30
32
 
31
33
  export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
32
34
  const [sounds, setSounds] = useState<UISound[]>([]);
33
- const [activeTheme, setActiveTheme] = useState<UITheme>(uiTheme === 'off' ? 'sc2' : uiTheme);
34
- const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? 'sc2' : uiTheme] ?? {});
35
+ const [activeTheme, setActiveTheme] = useState<string>(uiTheme === 'off' ? '' : uiTheme);
36
+ const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? '' : uiTheme] ?? {});
35
37
  const [activeSlot, setActiveSlot] = useState<SlotName>('hover');
36
38
  const [playing, setPlaying] = useState<string | null>(null);
37
39
 
@@ -39,8 +41,32 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
39
41
  fetch('/api/ui-sounds').then((r) => r.json()).then(setSounds).catch(console.error);
40
42
  }, []);
41
43
 
44
+ // Derive theme tabs: a ui/ subdir qualifies as a theme only if it contains
45
+ // at least one file whose basename matches a canonical slot name.
46
+ // Folders starting with '_' are always excluded (utility library escape hatch).
47
+ const themes = useMemo(() => {
48
+ const validGroups = new Set(
49
+ sounds
50
+ .filter((s) => {
51
+ const base = s.filename.replace(/\.(mp3|wav|ogg|m4a)$/i, '');
52
+ return CANONICAL_SLOTS.has(base);
53
+ })
54
+ .map((s) => s.group)
55
+ );
56
+ return [...validGroups].filter((t) => t && !t.startsWith('_')).sort();
57
+ }, [sounds]);
58
+
59
+ // When themes load, initialize activeTheme to first available if current isn't in list
60
+ useEffect(() => {
61
+ if (themes.length > 0 && activeTheme === '') {
62
+ const initial = themes[0];
63
+ setActiveTheme(initial);
64
+ setSlots(uiSounds[initial] ?? {});
65
+ }
66
+ }, [themes, activeTheme, uiSounds]);
67
+
42
68
  // When theme changes, load existing slot config for that theme
43
- const switchTheme = useCallback((theme: UITheme) => {
69
+ const switchTheme = useCallback((theme: string) => {
44
70
  setActiveTheme(theme);
45
71
  setSlots(uiSounds[theme] ?? {});
46
72
  }, [uiSounds]);
@@ -110,14 +136,14 @@ export function UISoundsModal({ uiTheme, uiSounds, onSave, onClose }: Props) {
110
136
  </div>
111
137
 
112
138
  {/* Theme selector */}
113
- <div className="flex items-center gap-2 px-5 py-2 shrink-0" style={{ borderBottom: '1px solid var(--sf-border)' }}>
114
- <span className="text-[10px] tracking-widest uppercase opacity-40">THEME</span>
115
- {(['sc2', 'wc3', 'ff7', 'ff9'] as const).map((t) => (
139
+ <div className="flex items-center gap-2 px-5 py-2 shrink-0 overflow-x-auto" style={{ borderBottom: '1px solid var(--sf-border)' }}>
140
+ <span className="text-[10px] tracking-widest uppercase opacity-40 shrink-0">THEME</span>
141
+ {themes.map((t) => (
116
142
  <button
117
143
  key={t}
118
144
  data-sf-hover
119
145
  onClick={() => switchTheme(t)}
120
- className="px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
146
+ className="shrink-0 px-3 py-0.5 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
121
147
  style={{
122
148
  border: `1px solid ${activeTheme === t ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
123
149
  color: activeTheme === t ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.35)',
package/web/lib/types.ts CHANGED
@@ -21,7 +21,7 @@ export interface SkillConfig {
21
21
  hooks: Partial<Record<SkillHookEvent, string>>;
22
22
  }
23
23
 
24
- export type UITheme = 'sc2' | 'wc3' | 'ff7' | 'ff9' | 'off';
24
+ export type UITheme = string; // Dynamic: derives from installed pack ui/ directory names; 'off' disables UI sounds
25
25
 
26
26
  export interface SelectMode {
27
27
  scope: string; // 'global' | agent-name | 'skill/qualifiedName'