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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -5
- package/bin/agentcraft.js +135 -64
- package/opencode.js +29 -10
- package/package.json +1 -1
- package/skills/packs/SKILL.md +160 -0
- package/skills/packs/references/pack-format.md +122 -0
- package/web/app/api/packs/route.ts +109 -0
- package/web/components/agent-roster-panel.tsx +6 -3
- package/web/components/packs-panel.tsx +180 -0
- package/web/components/sound-browser-panel.tsx +10 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentcraft",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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/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(
|
|
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
|
|
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');
|
|
89
120
|
} else {
|
|
90
|
-
console.log(
|
|
91
|
-
|
|
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
|
|
157
|
+
${c.bold('AgentCraft')} — assign sounds to AI agent lifecycle events
|
|
98
158
|
|
|
99
|
-
Usage:
|
|
100
|
-
agentcraft
|
|
101
|
-
agentcraft
|
|
102
|
-
agentcraft
|
|
103
|
-
agentcraft
|
|
104
|
-
agentcraft
|
|
105
|
-
agentcraft start
|
|
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
|
|
109
|
-
agentcraft
|
|
110
|
-
agentcraft pack
|
|
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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
|
145
|
-
if (!
|
|
146
|
-
console.error(
|
|
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
|
-
|
|
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(
|
|
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
|
-
* ~/.
|
|
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,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
|
|
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]) => (
|