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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -5
- package/TODO.md +29 -0
- package/bin/agentcraft.js +238 -64
- package/commands/agentcraft.md +2 -8
- package/hooks/play-sound.sh +5 -2
- package/opencode.js +29 -10
- package/package.json +1 -1
- package/skills/packs/SKILL.md +169 -0
- package/skills/packs/references/pack-format.md +122 -0
- package/web/app/api/preview/route.ts +3 -2
- package/web/app/page.tsx +10 -3
- package/web/components/hud-header.tsx +23 -1
- package/web/components/sound-browser-panel.tsx +96 -18
- package/web/components/sound-unit.tsx +7 -1
- package/web/components/ui-sounds-modal.tsx +34 -8
- package/web/lib/types.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentcraft",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
53
|
-
agentcraft
|
|
54
|
-
agentcraft
|
|
55
|
-
agentcraft
|
|
56
|
-
agentcraft
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
65
|
+
console.error(c.red(`✗ Failed to install ${publisher}/${name}`));
|
|
56
66
|
process.exit(1);
|
|
57
67
|
}
|
|
58
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
88
|
-
console.log('
|
|
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('
|
|
91
|
-
|
|
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
|
|
256
|
+
${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
|
|
98
257
|
|
|
99
|
-
Usage:
|
|
100
|
-
agentcraft
|
|
101
|
-
agentcraft
|
|
102
|
-
agentcraft
|
|
103
|
-
agentcraft
|
|
104
|
-
agentcraft
|
|
105
|
-
agentcraft start
|
|
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
|
|
109
|
-
agentcraft
|
|
110
|
-
agentcraft pack
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
if (!
|
|
146
|
-
console.error(
|
|
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
|
-
|
|
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(
|
|
327
|
+
console.error(c.red(`✗ Unknown command: ${cmd}`));
|
|
154
328
|
showHelp();
|
|
155
329
|
process.exit(1);
|
|
156
330
|
}
|
package/commands/agentcraft.md
CHANGED
|
@@ -12,15 +12,9 @@ kill $(lsof -ti:4040) 2>/dev/null
|
|
|
12
12
|
echo "AgentCraft server stopped."
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Run first-time setup (installs official pack + creates assignments.json if missing):
|
|
16
16
|
```bash
|
|
17
|
-
|
|
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:
|
package/hooks/play-sound.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
* ~/.
|
|
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
|
|
19
|
-
const
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
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')
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
41
|
-
}, [
|
|
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
|
-
|
|
68
|
+
visibleSounds.filter((s) => internalCat(s.category).split('/')[0] === activeGroup).map((s) => s.category)
|
|
46
69
|
)].sort();
|
|
47
|
-
}, [
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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<
|
|
34
|
-
const [slots, setSlots] = useState<UISlotMap>(uiSounds[uiTheme === 'off' ? '
|
|
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:
|
|
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
|
-
{
|
|
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 =
|
|
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'
|