agentcraft 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.9",
3
+ "version": "0.1.2",
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/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, 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,194 @@ 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');
89
120
  } else {
90
- console.log('Installed packs:');
91
- for (const p of packs) console.log(` ${p}`);
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
+ }
132
+ } else {
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 getWebDir() {
143
+ if (process.env.CLAUDE_PLUGIN_ROOT) {
144
+ return join(process.env.CLAUDE_PLUGIN_ROOT, 'web');
145
+ }
146
+ // Global bun/npm install: bin/agentcraft.js → package root → web/
147
+ try {
148
+ return join(dirname(dirname(realpathSync(__filename))), 'web');
149
+ } catch {
150
+ console.error(c.red('✗ Cannot locate web directory. Set CLAUDE_PLUGIN_ROOT or reinstall.'));
151
+ process.exit(1);
92
152
  }
93
153
  }
94
154
 
95
155
  function showHelp() {
96
156
  console.log(`
97
- AgentCraft CLI — assign sounds to AI agent lifecycle events
157
+ ${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
98
158
 
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)
159
+ ${c.cyan('Usage:')}
160
+ agentcraft init Set up AgentCraft (install pack + config)
161
+ agentcraft add <publisher/name> Install a sound pack from GitHub
162
+ agentcraft remove <publisher/name> Remove an installed pack
163
+ agentcraft update [publisher/name] Update a pack, or all packs if no arg given
164
+ agentcraft list List installed packs
165
+ agentcraft start Launch the dashboard (port 4040)
106
166
 
107
- Examples:
108
- agentcraft pack install rohenaz/agentcraft-sounds
109
- agentcraft pack update --all
110
- agentcraft pack list
167
+ ${c.cyan('Examples:')}
168
+ agentcraft init
169
+ agentcraft add rohenaz/agentcraft-sounds
170
+ agentcraft add publisher/custom-pack
171
+ agentcraft update
172
+ agentcraft list
111
173
 
112
- Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/
113
- Any git repo cloned there is automatically discovered by the dashboard.
174
+ ${c.dim('Packs are stored at: ~/.agentcraft/packs/<publisher>/<name>/')}
175
+ ${c.dim('Any git repo cloned there is automatically discovered by the dashboard.')}
114
176
 
115
- Install the Claude Code plugin:
177
+ ${c.cyan('Install the Claude Code plugin:')}
116
178
  claude plugin install agentcraft@rohenaz
117
179
  `);
118
180
  }
119
181
 
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();
182
+ // Route commands
183
+ if (cmd === 'init') {
184
+ packInit();
185
+ } else if (cmd === 'add') {
186
+ if (!sub) { console.error(c.red('Usage: agentcraft add <publisher/name>')); process.exit(1); }
187
+ packAdd(sub);
188
+ } else if (cmd === 'remove') {
189
+ if (!sub) { console.error(c.red('Usage: agentcraft remove <publisher/name>')); process.exit(1); }
190
+ packRemove(sub);
191
+ } else if (cmd === 'update') {
192
+ if (!sub) {
193
+ const packs = getInstalledPacks();
194
+ if (!packs.length) { console.log(c.dim('No packs installed.')); process.exit(0); }
195
+ for (const p of packs) packUpdate(p);
138
196
  } else {
139
- console.error(`Unknown pack subcommand: ${sub ?? '(none)'}`);
140
- console.error('Usage: agentcraft pack <install|remove|update|list>');
141
- process.exit(1);
197
+ packUpdate(sub);
142
198
  }
199
+ } else if (cmd === 'list') {
200
+ packList();
143
201
  } 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.');
202
+ const webDir = getWebDir();
203
+ if (!existsSync(webDir)) {
204
+ console.error(c.red(`✗ Web directory not found: ${webDir}`));
147
205
  process.exit(1);
148
206
  }
149
- execSync(`cd "${pluginRoot}/web" && bun dev --port 4040`, { stdio: 'inherit' });
207
+ console.log(`→ Starting AgentCraft at ${c.cyan('http://localhost:4040')} ...`);
208
+ execSync(`cd "${webDir}" && bun install --silent && bun dev --port 4040`, { stdio: 'inherit' });
150
209
  } else if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
151
210
  showHelp();
211
+ } else if (cmd === 'pack') {
212
+ // Legacy shim — print migration hint and route through
213
+ const newCmd = sub === 'install' ? 'add' : sub;
214
+ console.error(c.dim(`Note: "agentcraft pack ${sub}" → "agentcraft ${newCmd}"`));
215
+ if (sub === 'install') { if (!rest[0]) { console.error(c.red('Usage: agentcraft add <publisher/name>')); process.exit(1); } packAdd(rest[0]); }
216
+ else if (sub === 'remove') { if (!rest[0]) { console.error(c.red('Usage: agentcraft remove <publisher/name>')); process.exit(1); } packRemove(rest[0]); }
217
+ else if (sub === 'update') {
218
+ if (!rest[0] || rest[0] === '--all') { const packs = getInstalledPacks(); for (const p of packs) packUpdate(p); }
219
+ else packUpdate(rest[0]);
220
+ }
221
+ else if (sub === 'list') packList();
222
+ else { console.error(c.red(`Unknown: agentcraft pack ${sub}`)); process.exit(1); }
152
223
  } else {
153
- console.error(`Unknown command: ${cmd}`);
224
+ console.error(c.red(`✗ Unknown command: ${cmd}`));
154
225
  showHelp();
155
226
  process.exit(1);
156
227
  }
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.2",
3
+ "version": "0.0.4",
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,160 @@
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", "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
+ ```
23
+
24
+ `agentcraft pack install publisher/repo-name` resolves to `https://github.com/publisher/repo-name` and clones into `~/.agentcraft/packs/publisher/repo-name/`.
25
+
26
+ Install the CLI globally:
27
+ ```bash
28
+ bun install -g agentcraft # or: npm install -g agentcraft
29
+ ```
30
+
31
+ ### From the Dashboard
32
+
33
+ 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.
34
+
35
+ ### Manual Install (identical result)
36
+
37
+ ```bash
38
+ git clone https://github.com/publisher/repo-name ~/.agentcraft/packs/publisher/repo-name
39
+ ```
40
+
41
+ Manual clone and CLI install are exactly equivalent — no manifest or registration step.
42
+
43
+ ## Pack Storage
44
+
45
+ Packs live at `~/.agentcraft/packs/<publisher>/<name>/`. The dashboard auto-discovers everything at that path depth — any directory placed there works.
46
+
47
+ ```
48
+ ~/.agentcraft/packs/
49
+ rohenaz/
50
+ agentcraft-sounds/ ← official pack
51
+ publisher/
52
+ custom-pack/ ← any git repo cloned here
53
+ ```
54
+
55
+ ## Sound Assignment Paths
56
+
57
+ Assigned sounds are stored in `~/.agentcraft/assignments.json` with a pack-prefixed path:
58
+
59
+ ```
60
+ rohenaz/agentcraft-sounds:sc2/terran/session-start/scv-ready.mp3
61
+ ```
62
+
63
+ Format: `publisher/name:internal/path/to/sound.mp3`
64
+
65
+ The hook script resolves this to the absolute path at runtime:
66
+ ```
67
+ ~/.agentcraft/packs/rohenaz/agentcraft-sounds/sc2/terran/session-start/scv-ready.mp3
68
+ ```
69
+
70
+ ## Publishing a Pack
71
+
72
+ Any GitHub repo with audio files (`.mp3`, `.wav`, `.ogg`, `.m4a`) is a valid pack. No manifest required — directory structure is the organization.
73
+
74
+ ### Step 1: Organize the repo
75
+
76
+ Recommended structure — group sounds into directories by game, theme, or purpose:
77
+
78
+ ```
79
+ my-sounds/
80
+ sc2/
81
+ terran/
82
+ session-start/
83
+ ready.mp3
84
+ task-complete/
85
+ salute.mp3
86
+ halo/
87
+ unsc/
88
+ session-start/
89
+ wake-up.mp3
90
+ ui/ ← optional: UI theme sounds
91
+ sc2/
92
+ click.mp3
93
+ hover.mp3
94
+ ```
95
+
96
+ Any directory layout works. The dashboard groups sounds by their directory path.
97
+
98
+ ### Step 2: Add `pack.json` (optional but recommended)
99
+
100
+ ```json
101
+ {
102
+ "name": "my-sounds",
103
+ "publisher": "your-github-username",
104
+ "version": "1.0.0",
105
+ "description": "Short description of the pack",
106
+ "types": ["sounds", "ui"]
107
+ }
108
+ ```
109
+
110
+ `types` is informational. Use `"ui"` if the pack includes a `ui/` directory with dashboard theme sounds.
111
+
112
+ ### Step 3: Tag the repo on GitHub
113
+
114
+ Add the `agentcraft-pack` topic to the GitHub repo. This makes it discoverable in:
115
+ - The community registry at `https://rohenaz.github.io/agentcraft-registry/`
116
+ - GitHub search: `https://github.com/topics/agentcraft-pack`
117
+
118
+ To tag: GitHub repo → **Settings** → **Topics** → type `agentcraft-pack` → Save.
119
+
120
+ The registry GitHub Action runs every 6 hours and automatically picks up newly tagged repos.
121
+
122
+ ### Step 4: Share the install command
123
+
124
+ ```bash
125
+ agentcraft pack install your-username/your-repo-name
126
+ ```
127
+
128
+ That's the entire publish workflow — push to GitHub, tag it, done.
129
+
130
+ ## Pack Discovery
131
+
132
+ Find community packs three ways:
133
+
134
+ 1. **Dashboard** — PACKS tab → BROWSE PACKS section shows the registry
135
+ 2. **Registry** — `https://github.com/topics/agentcraft-pack`
136
+ 3. **Registry JSON** — `https://rohenaz.github.io/agentcraft-registry/index.json`
137
+
138
+ ## UI Theme Sounds
139
+
140
+ 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.
141
+
142
+ Structure the `ui/` directory by theme name:
143
+
144
+ ```
145
+ ui/
146
+ sc2/
147
+ click.mp3
148
+ hover.mp3
149
+ confirm.mp3
150
+ error.mp3
151
+ pageChange.mp3
152
+ wc3/
153
+ click.mp3
154
+ ...
155
+ ```
156
+
157
+ ## Additional Resources
158
+
159
+ - **`references/pack-format.md`** — Full audio file requirements, directory naming conventions, and pack.json schema
160
+ - **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.
@@ -0,0 +1,109 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { readdir, stat, readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ import { spawnSync } from 'child_process';
6
+
7
+ const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
8
+
9
+ interface PackInfo {
10
+ id: string; // "publisher/name"
11
+ publisher: string;
12
+ name: string;
13
+ description?: string;
14
+ version?: string;
15
+ }
16
+
17
+ async function getInstalledPacks(): Promise<PackInfo[]> {
18
+ const packs: PackInfo[] = [];
19
+ let publishers: string[];
20
+ try {
21
+ publishers = await readdir(PACKS_DIR);
22
+ } catch {
23
+ return packs;
24
+ }
25
+ for (const publisher of publishers) {
26
+ const pubPath = join(PACKS_DIR, publisher);
27
+ const ps = await stat(pubPath).catch(() => null);
28
+ if (!ps?.isDirectory()) continue;
29
+ const names = await readdir(pubPath).catch(() => [] as string[]);
30
+ for (const name of names) {
31
+ const packPath = join(pubPath, name);
32
+ const ns = await stat(packPath).catch(() => null);
33
+ if (!ns?.isDirectory()) continue;
34
+ // Try to read pack.json for metadata
35
+ let description: string | undefined;
36
+ let version: string | undefined;
37
+ try {
38
+ const manifest = JSON.parse(await readFile(join(packPath, 'pack.json'), 'utf-8'));
39
+ description = manifest.description;
40
+ version = manifest.version;
41
+ } catch { /* no manifest, that's fine */ }
42
+ packs.push({ id: `${publisher}/${name}`, publisher, name, description, version });
43
+ }
44
+ }
45
+ return packs;
46
+ }
47
+
48
+ // GET — list installed packs
49
+ export async function GET() {
50
+ const packs = await getInstalledPacks();
51
+ return NextResponse.json(packs);
52
+ }
53
+
54
+ // POST { repo: "publisher/name" } — install a pack
55
+ export async function POST(req: NextRequest) {
56
+ try {
57
+ const { repo } = await req.json();
58
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
59
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
60
+ }
61
+ const [publisher, name] = repo.split('/');
62
+ const dest = join(PACKS_DIR, publisher, name);
63
+ const url = `https://github.com/${repo}`;
64
+ // mkdir publisher dir
65
+ spawnSync('mkdir', ['-p', join(PACKS_DIR, publisher)]);
66
+ const result = spawnSync('git', ['clone', url, dest], { timeout: 60000 });
67
+ if (result.status !== 0) {
68
+ return NextResponse.json({ error: 'Clone failed' }, { status: 500 });
69
+ }
70
+ return NextResponse.json({ ok: true });
71
+ } catch {
72
+ return NextResponse.json({ error: 'Install failed' }, { status: 500 });
73
+ }
74
+ }
75
+
76
+ // DELETE { repo: "publisher/name" } — remove a pack
77
+ export async function DELETE(req: NextRequest) {
78
+ try {
79
+ const { repo } = await req.json();
80
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
81
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
82
+ }
83
+ const [publisher, name] = repo.split('/');
84
+ const dest = join(PACKS_DIR, publisher, name);
85
+ spawnSync('rm', ['-rf', dest]);
86
+ return NextResponse.json({ ok: true });
87
+ } catch {
88
+ return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
89
+ }
90
+ }
91
+
92
+ // PATCH { repo: "publisher/name" } — update a pack (git pull)
93
+ export async function PATCH(req: NextRequest) {
94
+ try {
95
+ const { repo } = await req.json();
96
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
97
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
98
+ }
99
+ const [publisher, name] = repo.split('/');
100
+ const dest = join(PACKS_DIR, publisher, name);
101
+ const result = spawnSync('git', ['-C', dest, 'pull'], { timeout: 30000 });
102
+ if (result.status !== 0) {
103
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
104
+ }
105
+ return NextResponse.json({ ok: true });
106
+ } catch {
107
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
108
+ }
109
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState, useMemo } from 'react';
4
4
  import { HookSlot } from './hook-slot';
5
5
  import { AgentForm } from './agent-form';
6
+ import { PacksPanel } from './packs-panel';
6
7
  import { playUISound } from '@/lib/ui-audio';
7
8
  import { getEventLabel } from '@/lib/utils';
8
9
  import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
@@ -220,7 +221,7 @@ interface AgentRosterPanelProps {
220
221
  }
221
222
 
222
223
  export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
223
- const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
224
+ const [activeView, setActiveView] = useState<'agents' | 'skills' | 'packs'>('agents');
224
225
  const [showForm, setShowForm] = useState(false);
225
226
  const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
226
227
  const [skillSearch, setSkillSearch] = useState('');
@@ -341,7 +342,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
341
342
  {/* Tab bar header */}
342
343
  <div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
343
344
  <div className="flex items-stretch">
344
- {(['agents', 'skills'] as const).map((view) => (
345
+ {(['agents', 'skills', 'packs'] as const).map((view) => (
345
346
  <button
346
347
  key={view}
347
348
  data-sf-hover
@@ -359,7 +360,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
359
360
  backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
360
361
  }}
361
362
  >
362
- {view === 'agents' ? 'AGENTS' : 'SKILLS'}
363
+ {view.toUpperCase()}
363
364
  </button>
364
365
  ))}
365
366
  {activeView === 'agents' && (
@@ -496,6 +497,8 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
496
497
  })}
497
498
  </>
498
499
  )}
500
+
501
+ {activeView === 'packs' && <PacksPanel />}
499
502
  </div>
500
503
  </div>
501
504
  );
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { playUISound } from '@/lib/ui-audio';
5
+
6
+ interface PackInfo {
7
+ id: string;
8
+ publisher: string;
9
+ name: string;
10
+ description?: string;
11
+ version?: string;
12
+ }
13
+
14
+ interface RegistryPack {
15
+ id: string;
16
+ name: string;
17
+ publisher: string;
18
+ description: string;
19
+ stars: number;
20
+ updatedAt: string;
21
+ }
22
+
23
+ type PackAction = 'idle' | 'installing' | 'updating' | 'removing';
24
+
25
+ export function PacksPanel() {
26
+ const [installed, setInstalled] = useState<PackInfo[]>([]);
27
+ const [registry, setRegistry] = useState<RegistryPack[]>([]);
28
+ const [actions, setActions] = useState<Record<string, PackAction>>({});
29
+ const [registryError, setRegistryError] = useState(false);
30
+
31
+ const fetchInstalled = useCallback(() => {
32
+ fetch('/api/packs').then(r => r.json()).then(setInstalled).catch(console.error);
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ fetchInstalled();
37
+ // Fetch registry index
38
+ fetch('https://rohenaz.github.io/agentcraft-registry/index.json')
39
+ .then(r => r.json())
40
+ .then(setRegistry)
41
+ .catch(() => setRegistryError(true));
42
+ }, [fetchInstalled]);
43
+
44
+ const setAction = (id: string, action: PackAction) =>
45
+ setActions(prev => ({ ...prev, [id]: action }));
46
+
47
+ const handleInstall = async (id: string) => {
48
+ setAction(id, 'installing');
49
+ try {
50
+ const r = await fetch('/api/packs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
51
+ if (!r.ok) throw new Error();
52
+ playUISound('confirm', 0.5);
53
+ fetchInstalled();
54
+ } catch {
55
+ playUISound('error', 0.5);
56
+ }
57
+ setAction(id, 'idle');
58
+ };
59
+
60
+ const handleUpdate = async (id: string) => {
61
+ setAction(id, 'updating');
62
+ try {
63
+ const r = await fetch('/api/packs', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
64
+ if (!r.ok) throw new Error();
65
+ playUISound('confirm', 0.4);
66
+ } catch {
67
+ playUISound('error', 0.5);
68
+ }
69
+ setAction(id, 'idle');
70
+ };
71
+
72
+ const handleRemove = async (id: string) => {
73
+ setAction(id, 'removing');
74
+ try {
75
+ const r = await fetch('/api/packs', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: id }) });
76
+ if (!r.ok) throw new Error();
77
+ fetchInstalled();
78
+ } catch {
79
+ playUISound('error', 0.5);
80
+ }
81
+ setAction(id, 'idle');
82
+ };
83
+
84
+ const installedIds = new Set(installed.map(p => p.id));
85
+ const browsePacks = registry.filter(p => !installedIds.has(p.id));
86
+
87
+ const btnStyle = (active: boolean, danger = false) => ({
88
+ border: `1px solid ${danger ? 'rgba(255,80,80,0.4)' : active ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
89
+ color: danger ? 'rgba(255,80,80,0.7)' : active ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
90
+ backgroundColor: 'transparent',
91
+ padding: '2px 8px',
92
+ fontSize: '9px',
93
+ fontFamily: 'inherit',
94
+ letterSpacing: '0.08em',
95
+ textTransform: 'uppercase' as const,
96
+ cursor: 'pointer',
97
+ transition: 'all 0.15s',
98
+ });
99
+
100
+ return (
101
+ <div className="flex flex-col overflow-hidden h-full">
102
+ {/* Installed */}
103
+ <div className="shrink-0 px-3 pt-3 pb-1">
104
+ <div className="text-[10px] sf-heading font-semibold tracking-widest uppercase mb-2" style={{ color: 'var(--sf-cyan)' }}>
105
+ INSTALLED PACKS
106
+ </div>
107
+ </div>
108
+
109
+ <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-1">
110
+ {installed.length === 0 && (
111
+ <div className="text-[10px] opacity-30 py-4 text-center">NO PACKS INSTALLED</div>
112
+ )}
113
+ {installed.map(pack => {
114
+ const action = actions[pack.id] ?? 'idle';
115
+ return (
116
+ <div key={pack.id} className="p-2.5" style={{ border: '1px solid var(--sf-border)', backgroundColor: 'rgba(0,229,255,0.02)' }}>
117
+ <div className="flex items-start justify-between gap-2">
118
+ <div className="overflow-hidden">
119
+ <div className="text-xs sf-heading font-semibold truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
120
+ {pack.name}
121
+ </div>
122
+ <div className="text-[10px] opacity-40">{pack.publisher}{pack.version ? ` · v${pack.version}` : ''}</div>
123
+ {pack.description && (
124
+ <div className="text-[10px] opacity-50 mt-0.5 line-clamp-2">{pack.description}</div>
125
+ )}
126
+ </div>
127
+ <div className="flex gap-1 shrink-0">
128
+ <button style={btnStyle(false)} disabled={action !== 'idle'} onClick={() => handleUpdate(pack.id)}>
129
+ {action === 'updating' ? '···' : 'UPDATE'}
130
+ </button>
131
+ <button style={btnStyle(false, true)} disabled={action !== 'idle'} onClick={() => handleRemove(pack.id)}>
132
+ {action === 'removing' ? '···' : 'REMOVE'}
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ })}
139
+
140
+ {/* Browse */}
141
+ <div className="text-[10px] sf-heading font-semibold tracking-widest uppercase mt-4 mb-2" style={{ color: 'var(--sf-cyan)' }}>
142
+ BROWSE PACKS
143
+ </div>
144
+
145
+ {registryError && (
146
+ <div className="text-[10px] opacity-30 py-2 text-center">REGISTRY UNAVAILABLE</div>
147
+ )}
148
+
149
+ {!registryError && browsePacks.length === 0 && registry.length > 0 && (
150
+ <div className="text-[10px] opacity-30 py-2 text-center">ALL AVAILABLE PACKS INSTALLED</div>
151
+ )}
152
+
153
+ {browsePacks.map(pack => {
154
+ const action = actions[pack.id] ?? 'idle';
155
+ return (
156
+ <div key={pack.id} className="p-2.5" style={{ border: '1px solid var(--sf-border)' }}>
157
+ <div className="flex items-start justify-between gap-2">
158
+ <div className="overflow-hidden">
159
+ <div className="flex items-center gap-2">
160
+ <span className="text-xs sf-heading font-semibold truncate" style={{ color: 'rgba(255,255,255,0.85)' }}>
161
+ {pack.name}
162
+ </span>
163
+ {pack.stars > 0 && (
164
+ <span className="text-[9px] opacity-40">★ {pack.stars}</span>
165
+ )}
166
+ </div>
167
+ <div className="text-[10px] opacity-40">{pack.publisher}</div>
168
+ <div className="text-[10px] opacity-50 mt-0.5 line-clamp-2">{pack.description}</div>
169
+ </div>
170
+ <button style={btnStyle(true)} disabled={action !== 'idle'} onClick={() => handleInstall(pack.id)}>
171
+ {action === 'installing' ? '···' : 'INSTALL'}
172
+ </button>
173
+ </div>
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -15,6 +15,12 @@ interface SoundBrowserPanelProps {
15
15
  onClearSelectMode: () => void;
16
16
  }
17
17
 
18
+ // Strip pack prefix from category: "publisher/name:sc2/terran" → "sc2/terran"
19
+ function internalCat(category: string): string {
20
+ const idx = category.indexOf(':');
21
+ return idx === -1 ? category : category.slice(idx + 1);
22
+ }
23
+
18
24
  export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
19
25
  const [activeGroup, setActiveGroup] = useState<string>('sc2');
20
26
  const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
@@ -31,12 +37,12 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
31
37
  }, []);
32
38
 
33
39
  const allGroups = useMemo(() => {
34
- return [...new Set(sounds.map((s) => s.category.split('/')[0]))].sort();
40
+ return [...new Set(sounds.map((s) => internalCat(s.category).split('/')[0]))].sort();
35
41
  }, [sounds]);
36
42
 
37
43
  const groupCategories = useMemo(() => {
38
44
  return [...new Set(
39
- sounds.filter((s) => s.category.split('/')[0] === activeGroup).map((s) => s.category)
45
+ sounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
40
46
  )].sort();
41
47
  }, [sounds, activeGroup]);
42
48
 
@@ -131,7 +137,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
131
137
  backgroundColor: effectiveCategory === cat ? 'rgba(0,229,255,0.05)' : 'transparent',
132
138
  }}
133
139
  >
134
- {getSubTabLabel(cat)}
140
+ {getSubTabLabel(internalCat(cat))}
135
141
  </button>
136
142
  ))}
137
143
  </div>
@@ -176,7 +182,7 @@ export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode,
176
182
  <div key={cat}>
177
183
  {isSearching && (
178
184
  <div className="text-[9px] uppercase tracking-widest mb-1 mt-3 px-1 first:mt-0" style={{ color: 'var(--sf-cyan)', opacity: 0.6 }}>
179
- {cat.replace(/\//g, ' › ')}
185
+ {internalCat(cat).replace(/\//g, ' › ')}
180
186
  </div>
181
187
  )}
182
188
  {Object.entries(subcats).map(([subcat, catSounds]) => (