agentcraft 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentcraft",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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/package.json
CHANGED
|
@@ -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]) => (
|