@aion0/forge 0.2.27 → 0.2.30
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/app/api/skills/route.ts +135 -0
- package/components/Dashboard.tsx +56 -57
- package/components/DocTerminal.tsx +2 -2
- package/components/ProjectManager.tsx +35 -1
- package/components/SessionView.tsx +3 -3
- package/components/SkillsPanel.tsx +320 -0
- package/components/WebTerminal.tsx +33 -5
- package/lib/init.ts +7 -0
- package/lib/settings.ts +2 -0
- package/lib/skills.ts +182 -0
- package/lib/telegram-bot.ts +2 -2
- package/package.json +1 -1
- package/src/core/db/database.ts +16 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
syncSkills,
|
|
4
|
+
listSkills,
|
|
5
|
+
installGlobal,
|
|
6
|
+
installProject,
|
|
7
|
+
uninstallGlobal,
|
|
8
|
+
uninstallProject,
|
|
9
|
+
refreshInstallState,
|
|
10
|
+
} from '@/lib/skills';
|
|
11
|
+
import { loadSettings } from '@/lib/settings';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
function getProjectPaths(): string[] {
|
|
15
|
+
const settings = loadSettings();
|
|
16
|
+
const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
17
|
+
const paths: string[] = [];
|
|
18
|
+
for (const root of roots) {
|
|
19
|
+
try {
|
|
20
|
+
const { readdirSync, statSync } = require('node:fs');
|
|
21
|
+
const { join } = require('node:path');
|
|
22
|
+
for (const name of readdirSync(root)) {
|
|
23
|
+
const p = join(root, name);
|
|
24
|
+
try { if (statSync(p).isDirectory() && !name.startsWith('.')) paths.push(p); } catch {}
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return paths;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GET /api/skills — list skills, get file list, or get file content
|
|
32
|
+
export async function GET(req: Request) {
|
|
33
|
+
const { searchParams } = new URL(req.url);
|
|
34
|
+
const action = searchParams.get('action');
|
|
35
|
+
const name = searchParams.get('name');
|
|
36
|
+
|
|
37
|
+
// List files in a skill directory
|
|
38
|
+
if (action === 'files' && name) {
|
|
39
|
+
try {
|
|
40
|
+
const settings = loadSettings();
|
|
41
|
+
const repoUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
42
|
+
// Extract owner/repo from raw URL
|
|
43
|
+
const match = repoUrl.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
44
|
+
const repo = match ? match[1] : 'aiwatching/forge-skills';
|
|
45
|
+
|
|
46
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/contents/skills/${name}`, {
|
|
47
|
+
headers: { 'Accept': 'application/vnd.github.v3+json' },
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) return NextResponse.json({ files: [] });
|
|
50
|
+
|
|
51
|
+
const items = await res.json();
|
|
52
|
+
const files: { name: string; path: string; type: string }[] = [];
|
|
53
|
+
|
|
54
|
+
const flatten = (list: any[], prefix = '') => {
|
|
55
|
+
for (const item of list) {
|
|
56
|
+
if (item.type === 'file') {
|
|
57
|
+
files.push({ name: item.name, path: prefix + item.name, type: 'file' });
|
|
58
|
+
} else if (item.type === 'dir') {
|
|
59
|
+
files.push({ name: item.name, path: prefix + item.name, type: 'dir' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
flatten(Array.isArray(items) ? items : []);
|
|
64
|
+
|
|
65
|
+
// Sort: dirs first, then files
|
|
66
|
+
files.sort((a, b) => {
|
|
67
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
68
|
+
return a.name.localeCompare(b.name);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return NextResponse.json({ files });
|
|
72
|
+
} catch {
|
|
73
|
+
return NextResponse.json({ files: [] });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Get content of a specific file
|
|
78
|
+
if (action === 'file' && name) {
|
|
79
|
+
const filePath = searchParams.get('path') || 'skill.md';
|
|
80
|
+
try {
|
|
81
|
+
const settings = loadSettings();
|
|
82
|
+
const baseUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
83
|
+
const res = await fetch(`${baseUrl}/skills/${name}/${filePath}`);
|
|
84
|
+
if (!res.ok) return NextResponse.json({ content: '(Not found)' });
|
|
85
|
+
const content = await res.text();
|
|
86
|
+
return NextResponse.json({ content });
|
|
87
|
+
} catch {
|
|
88
|
+
return NextResponse.json({ content: '(Failed to load)' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Refresh install state from filesystem
|
|
92
|
+
refreshInstallState(getProjectPaths());
|
|
93
|
+
const skills = listSkills();
|
|
94
|
+
const projects = getProjectPaths().map(p => ({ path: p, name: p.split('/').pop() || p }));
|
|
95
|
+
return NextResponse.json({ skills, projects });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// POST /api/skills — sync, install, uninstall
|
|
99
|
+
export async function POST(req: Request) {
|
|
100
|
+
const body = await req.json();
|
|
101
|
+
|
|
102
|
+
if (body.action === 'sync') {
|
|
103
|
+
const result = await syncSkills();
|
|
104
|
+
if (result.synced > 0) {
|
|
105
|
+
refreshInstallState(getProjectPaths());
|
|
106
|
+
}
|
|
107
|
+
return NextResponse.json(result);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (body.action === 'install') {
|
|
111
|
+
const { name, target } = body; // target: 'global' | projectPath
|
|
112
|
+
try {
|
|
113
|
+
if (target === 'global') {
|
|
114
|
+
await installGlobal(name);
|
|
115
|
+
} else {
|
|
116
|
+
await installProject(name, target);
|
|
117
|
+
}
|
|
118
|
+
return NextResponse.json({ ok: true });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (body.action === 'uninstall') {
|
|
125
|
+
const { name, target } = body;
|
|
126
|
+
if (target === 'global') {
|
|
127
|
+
uninstallGlobal(name);
|
|
128
|
+
} else {
|
|
129
|
+
uninstallProject(name, target);
|
|
130
|
+
}
|
|
131
|
+
return NextResponse.json({ ok: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
135
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -18,6 +18,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
|
18
18
|
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
19
19
|
const PreviewPanel = lazy(() => import('./PreviewPanel'));
|
|
20
20
|
const PipelineView = lazy(() => import('./PipelineView'));
|
|
21
|
+
const SkillsPanel = lazy(() => import('./SkillsPanel'));
|
|
21
22
|
|
|
22
23
|
interface UsageSummary {
|
|
23
24
|
provider: string;
|
|
@@ -40,7 +41,7 @@ interface ProjectInfo {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export default function Dashboard({ user }: { user: any }) {
|
|
43
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines'>('terminal');
|
|
44
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills'>('terminal');
|
|
44
45
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
45
46
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
46
47
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
@@ -167,17 +168,23 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
167
168
|
)}
|
|
168
169
|
|
|
169
170
|
{/* View mode toggle */}
|
|
170
|
-
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
171
|
+
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
172
|
+
{/* Workspace */}
|
|
173
|
+
{(['terminal', 'projects', 'sessions'] as const).map(mode => (
|
|
174
|
+
<button
|
|
175
|
+
key={mode}
|
|
176
|
+
onClick={() => setViewMode(mode)}
|
|
177
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
178
|
+
viewMode === mode
|
|
179
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
180
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
181
|
+
}`}
|
|
182
|
+
>
|
|
183
|
+
{{ terminal: 'Vibe Coding', projects: 'Projects', sessions: 'Sessions' }[mode]}
|
|
184
|
+
</button>
|
|
185
|
+
))}
|
|
186
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
187
|
+
{/* Docs */}
|
|
181
188
|
<button
|
|
182
189
|
onClick={() => setViewMode('docs')}
|
|
183
190
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
@@ -188,55 +195,32 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
188
195
|
>
|
|
189
196
|
Docs
|
|
190
197
|
</button>
|
|
198
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
199
|
+
{/* Automation */}
|
|
200
|
+
{(['tasks', 'pipelines'] as const).map(mode => (
|
|
201
|
+
<button
|
|
202
|
+
key={mode}
|
|
203
|
+
onClick={() => setViewMode(mode)}
|
|
204
|
+
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
205
|
+
viewMode === mode
|
|
206
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
207
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
208
|
+
}`}
|
|
209
|
+
>
|
|
210
|
+
{{ tasks: 'Tasks', pipelines: 'Pipelines' }[mode]}
|
|
211
|
+
</button>
|
|
212
|
+
))}
|
|
213
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
214
|
+
{/* Skills */}
|
|
191
215
|
<button
|
|
192
|
-
onClick={() => setViewMode('
|
|
193
|
-
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
194
|
-
viewMode === 'projects'
|
|
195
|
-
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
196
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
197
|
-
}`}
|
|
198
|
-
>
|
|
199
|
-
Projects
|
|
200
|
-
</button>
|
|
201
|
-
<button
|
|
202
|
-
onClick={() => setViewMode('tasks')}
|
|
203
|
-
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
204
|
-
viewMode === 'tasks'
|
|
205
|
-
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
206
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
207
|
-
}`}
|
|
208
|
-
>
|
|
209
|
-
Tasks
|
|
210
|
-
</button>
|
|
211
|
-
<button
|
|
212
|
-
onClick={() => setViewMode('pipelines')}
|
|
213
|
-
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
214
|
-
viewMode === 'pipelines'
|
|
215
|
-
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
216
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
217
|
-
}`}
|
|
218
|
-
>
|
|
219
|
-
Pipelines
|
|
220
|
-
</button>
|
|
221
|
-
<button
|
|
222
|
-
onClick={() => setViewMode('sessions')}
|
|
223
|
-
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
224
|
-
viewMode === 'sessions'
|
|
225
|
-
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
226
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
227
|
-
}`}
|
|
228
|
-
>
|
|
229
|
-
Sessions
|
|
230
|
-
</button>
|
|
231
|
-
<button
|
|
232
|
-
onClick={() => setViewMode('preview')}
|
|
216
|
+
onClick={() => setViewMode('skills')}
|
|
233
217
|
className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
|
|
234
|
-
viewMode === '
|
|
218
|
+
viewMode === 'skills'
|
|
235
219
|
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
236
220
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
237
221
|
}`}
|
|
238
222
|
>
|
|
239
|
-
|
|
223
|
+
Skills
|
|
240
224
|
</button>
|
|
241
225
|
</div>
|
|
242
226
|
|
|
@@ -255,13 +239,21 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
255
239
|
+ New Task
|
|
256
240
|
</button>
|
|
257
241
|
)}
|
|
242
|
+
{/* Tunnel + Preview */}
|
|
258
243
|
<TunnelToggle />
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setViewMode('preview')}
|
|
246
|
+
className={`text-[10px] ${viewMode === 'preview' ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
247
|
+
>
|
|
248
|
+
Preview
|
|
249
|
+
</button>
|
|
259
250
|
{onlineCount.total > 0 && (
|
|
260
251
|
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
261
252
|
<span className="text-green-500">●</span>
|
|
262
253
|
{onlineCount.total}
|
|
263
254
|
</span>
|
|
264
255
|
)}
|
|
256
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30" />
|
|
265
257
|
{/* Alerts */}
|
|
266
258
|
<div className="relative">
|
|
267
259
|
<button
|
|
@@ -536,15 +528,22 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
536
528
|
</Suspense>
|
|
537
529
|
)}
|
|
538
530
|
|
|
531
|
+
{/* Skills */}
|
|
532
|
+
{viewMode === 'skills' && (
|
|
533
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
534
|
+
<SkillsPanel />
|
|
535
|
+
</Suspense>
|
|
536
|
+
)}
|
|
537
|
+
|
|
539
538
|
{/* Docs — always mounted to keep terminal session alive */}
|
|
540
|
-
<div className={
|
|
539
|
+
<div className={viewMode === 'docs' ? 'flex-1 min-h-0 flex' : 'hidden'}>
|
|
541
540
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
542
541
|
<DocsViewer />
|
|
543
542
|
</Suspense>
|
|
544
543
|
</div>
|
|
545
544
|
|
|
546
545
|
{/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
|
|
547
|
-
<div className={
|
|
546
|
+
<div className={viewMode === 'terminal' ? 'flex-1 min-h-0 flex' : 'hidden'}>
|
|
548
547
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
549
548
|
<CodeViewer terminalRef={terminalRef} />
|
|
550
549
|
</Suspense>
|
|
@@ -88,7 +88,7 @@ export default function DocTerminal({ docRoot }: { docRoot: string }) {
|
|
|
88
88
|
setTimeout(() => {
|
|
89
89
|
if (socket.readyState === WebSocket.OPEN) {
|
|
90
90
|
const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
91
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && claude
|
|
91
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && claude -c${sf}\n` }));
|
|
92
92
|
}
|
|
93
93
|
}, 300);
|
|
94
94
|
}
|
|
@@ -163,7 +163,7 @@ export default function DocTerminal({ docRoot }: { docRoot: string }) {
|
|
|
163
163
|
New
|
|
164
164
|
</button>
|
|
165
165
|
<button
|
|
166
|
-
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && claude
|
|
166
|
+
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && claude -c${sf}`); }}
|
|
167
167
|
className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
|
|
168
168
|
>
|
|
169
169
|
Resume
|
|
@@ -37,6 +37,7 @@ export default function ProjectManager() {
|
|
|
37
37
|
const [fileLanguage, setFileLanguage] = useState('');
|
|
38
38
|
const [fileLoading, setFileLoading] = useState(false);
|
|
39
39
|
const [showLog, setShowLog] = useState(false);
|
|
40
|
+
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; scope: string }[]>([]);
|
|
40
41
|
|
|
41
42
|
// Fetch projects
|
|
42
43
|
useEffect(() => {
|
|
@@ -67,6 +68,23 @@ export default function ProjectManager() {
|
|
|
67
68
|
} catch { setFileTree([]); }
|
|
68
69
|
}, []);
|
|
69
70
|
|
|
71
|
+
const fetchProjectSkills = useCallback(async (projectPath: string) => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch('/api/skills');
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
const skills = (data.skills || []).filter((s: any) =>
|
|
76
|
+
s.installedGlobal || (s.installedProjects || []).includes(projectPath)
|
|
77
|
+
).map((s: any) => ({
|
|
78
|
+
name: s.name,
|
|
79
|
+
displayName: s.displayName,
|
|
80
|
+
scope: s.installedGlobal && (s.installedProjects || []).includes(projectPath) ? 'global + project'
|
|
81
|
+
: s.installedGlobal ? 'global'
|
|
82
|
+
: 'project',
|
|
83
|
+
}));
|
|
84
|
+
setProjectSkills(skills);
|
|
85
|
+
} catch { setProjectSkills([]); }
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
70
88
|
const selectProject = useCallback((p: Project) => {
|
|
71
89
|
setSelectedProject(p);
|
|
72
90
|
setSelectedFile(null);
|
|
@@ -75,7 +93,8 @@ export default function ProjectManager() {
|
|
|
75
93
|
setCommitMsg('');
|
|
76
94
|
fetchGitInfo(p);
|
|
77
95
|
fetchTree(p);
|
|
78
|
-
|
|
96
|
+
fetchProjectSkills(p.path);
|
|
97
|
+
}, [fetchGitInfo, fetchTree, fetchProjectSkills]);
|
|
79
98
|
|
|
80
99
|
const openFile = useCallback(async (path: string) => {
|
|
81
100
|
if (!selectedProject) return;
|
|
@@ -231,6 +250,21 @@ export default function ProjectManager() {
|
|
|
231
250
|
<span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
|
|
232
251
|
)}
|
|
233
252
|
</div>
|
|
253
|
+
{projectSkills.length > 0 && (
|
|
254
|
+
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
|
255
|
+
<span className="text-[9px] text-[var(--text-secondary)]">Skills:</span>
|
|
256
|
+
{projectSkills.map(s => (
|
|
257
|
+
<span
|
|
258
|
+
key={s.name}
|
|
259
|
+
className="text-[8px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)]"
|
|
260
|
+
title={`/${s.name} (${s.scope})`}
|
|
261
|
+
>
|
|
262
|
+
/{s.displayName}
|
|
263
|
+
<span className="text-[var(--text-secondary)] ml-0.5">({s.scope})</span>
|
|
264
|
+
</span>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
234
268
|
{gitInfo?.lastCommit && (
|
|
235
269
|
<div className="flex items-center gap-2 mt-0.5">
|
|
236
270
|
<span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
|
|
@@ -452,7 +452,7 @@ export default function SessionView({
|
|
|
452
452
|
</div>
|
|
453
453
|
|
|
454
454
|
{/* Right: session content */}
|
|
455
|
-
<div className="flex-1 flex flex-col min-w-0 overflow-hidden"
|
|
455
|
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
456
456
|
{activeSession && (
|
|
457
457
|
<div className="border-b border-[var(--border)] px-4 py-2 shrink-0">
|
|
458
458
|
<div className="flex items-center gap-2">
|
|
@@ -508,7 +508,7 @@ export default function SessionView({
|
|
|
508
508
|
</div>
|
|
509
509
|
)}
|
|
510
510
|
|
|
511
|
-
<div className="flex-1 overflow-y-auto overflow-x-
|
|
511
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-2">
|
|
512
512
|
{!activeSessionId && (
|
|
513
513
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] h-full">
|
|
514
514
|
<p>Select a session from the tree to view</p>
|
|
@@ -577,7 +577,7 @@ function SessionEntryView({
|
|
|
577
577
|
|
|
578
578
|
if (entry.type === 'assistant_text') {
|
|
579
579
|
return (
|
|
580
|
-
<div className="py-1 overflow-hidden">
|
|
580
|
+
<div className="py-1 overflow-hidden" style={{ maxWidth: 0, minWidth: '100%' }}>
|
|
581
581
|
<MarkdownContent content={entry.content} />
|
|
582
582
|
</div>
|
|
583
583
|
);
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Skill {
|
|
6
|
+
name: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
description: string;
|
|
9
|
+
author: string;
|
|
10
|
+
version: string;
|
|
11
|
+
tags: string[];
|
|
12
|
+
score: number;
|
|
13
|
+
sourceUrl: string;
|
|
14
|
+
installedGlobal: boolean;
|
|
15
|
+
installedProjects: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ProjectInfo {
|
|
19
|
+
path: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function SkillsPanel({ projectFilter }: { projectFilter?: string }) {
|
|
24
|
+
const [skills, setSkills] = useState<Skill[]>([]);
|
|
25
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
26
|
+
const [syncing, setSyncing] = useState(false);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
29
|
+
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
|
|
30
|
+
const [skillFiles, setSkillFiles] = useState<{ name: string; path: string; type: string }[]>([]);
|
|
31
|
+
const [activeFile, setActiveFile] = useState<string | null>(null);
|
|
32
|
+
const [fileContent, setFileContent] = useState<string>('');
|
|
33
|
+
|
|
34
|
+
const fetchSkills = useCallback(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch('/api/skills');
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
setSkills(data.skills || []);
|
|
39
|
+
setProjects(data.projects || []);
|
|
40
|
+
} catch {}
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => { fetchSkills(); }, [fetchSkills]);
|
|
45
|
+
|
|
46
|
+
const sync = async () => {
|
|
47
|
+
setSyncing(true);
|
|
48
|
+
await fetch('/api/skills', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
52
|
+
});
|
|
53
|
+
await fetchSkills();
|
|
54
|
+
setSyncing(false);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const install = async (name: string, target: string) => {
|
|
58
|
+
await fetch('/api/skills', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ action: 'install', name, target }),
|
|
62
|
+
});
|
|
63
|
+
setInstallTarget({ skill: '', show: false });
|
|
64
|
+
fetchSkills();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const toggleDetail = async (name: string) => {
|
|
68
|
+
if (expandedSkill === name) {
|
|
69
|
+
setExpandedSkill(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setExpandedSkill(name);
|
|
73
|
+
setSkillFiles([]);
|
|
74
|
+
setActiveFile(null);
|
|
75
|
+
setFileContent('');
|
|
76
|
+
// Fetch file list from GitHub API
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`/api/skills?action=files&name=${encodeURIComponent(name)}`);
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
const files = data.files || [];
|
|
81
|
+
setSkillFiles(files);
|
|
82
|
+
// Auto-select skill.md if exists, otherwise first file
|
|
83
|
+
const defaultFile = files.find((f: any) => f.name === 'skill.md') || files.find((f: any) => f.type === 'file');
|
|
84
|
+
if (defaultFile) loadFile(name, defaultFile.path);
|
|
85
|
+
} catch { setSkillFiles([]); }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const loadFile = async (skillName: string, filePath: string) => {
|
|
89
|
+
setActiveFile(filePath);
|
|
90
|
+
setFileContent('Loading...');
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`/api/skills?action=file&name=${encodeURIComponent(skillName)}&path=${encodeURIComponent(filePath)}`);
|
|
93
|
+
const data = await res.json();
|
|
94
|
+
setFileContent(data.content || '(Empty)');
|
|
95
|
+
} catch { setFileContent('(Failed to load)'); }
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const uninstall = async (name: string, target: string) => {
|
|
99
|
+
await fetch('/api/skills', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ action: 'uninstall', name, target }),
|
|
103
|
+
});
|
|
104
|
+
fetchSkills();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Filter skills if viewing a specific project
|
|
108
|
+
const filtered = projectFilter
|
|
109
|
+
? skills.filter(s => s.installedGlobal || s.installedProjects.includes(projectFilter))
|
|
110
|
+
: skills;
|
|
111
|
+
|
|
112
|
+
if (loading) {
|
|
113
|
+
return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading skills...</div>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
118
|
+
{/* Header */}
|
|
119
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
120
|
+
<div className="flex items-center gap-2">
|
|
121
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Skills</span>
|
|
122
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{filtered.length} available</span>
|
|
123
|
+
</div>
|
|
124
|
+
<button
|
|
125
|
+
onClick={sync}
|
|
126
|
+
disabled={syncing}
|
|
127
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
128
|
+
>
|
|
129
|
+
{syncing ? 'Syncing...' : 'Sync'}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{skills.length === 0 ? (
|
|
134
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
135
|
+
<p className="text-xs">No skills yet</p>
|
|
136
|
+
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
137
|
+
Sync from Registry
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<div className="flex-1 flex min-h-0">
|
|
142
|
+
{/* Left: skill list */}
|
|
143
|
+
<div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0">
|
|
144
|
+
{filtered.map(skill => {
|
|
145
|
+
const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
|
|
146
|
+
const isActive = expandedSkill === skill.name;
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
key={skill.name}
|
|
150
|
+
className={`px-3 py-2.5 border-b border-[var(--border)]/50 cursor-pointer ${
|
|
151
|
+
isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
|
|
152
|
+
}`}
|
|
153
|
+
onClick={() => toggleDetail(skill.name)}
|
|
154
|
+
>
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
|
|
157
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
|
|
158
|
+
{skill.score > 0 && (
|
|
159
|
+
<span className="text-[8px] text-[var(--yellow)] shrink-0">{skill.score}pt</span>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{skill.description}</p>
|
|
163
|
+
<div className="flex items-center gap-1.5 mt-1">
|
|
164
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{skill.author}</span>
|
|
165
|
+
{skill.tags.slice(0, 2).map(t => (
|
|
166
|
+
<span key={t} className="text-[7px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
|
|
167
|
+
))}
|
|
168
|
+
{isInstalled && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Right: detail panel */}
|
|
176
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
177
|
+
{expandedSkill ? (() => {
|
|
178
|
+
const skill = skills.find(s => s.name === expandedSkill);
|
|
179
|
+
if (!skill) return null;
|
|
180
|
+
const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
{/* Skill header */}
|
|
184
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{skill.displayName}</span>
|
|
187
|
+
<span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>
|
|
188
|
+
{skill.score > 0 && <span className="text-[9px] text-[var(--yellow)]">{skill.score}pt</span>}
|
|
189
|
+
|
|
190
|
+
{/* Install dropdown */}
|
|
191
|
+
<div className="relative ml-auto">
|
|
192
|
+
<button
|
|
193
|
+
onClick={() => setInstallTarget(prev =>
|
|
194
|
+
prev.skill === skill.name && prev.show ? { skill: '', show: false } : { skill: skill.name, show: true }
|
|
195
|
+
)}
|
|
196
|
+
className="text-[9px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
197
|
+
>
|
|
198
|
+
Install
|
|
199
|
+
</button>
|
|
200
|
+
{installTarget.skill === skill.name && installTarget.show && (
|
|
201
|
+
<>
|
|
202
|
+
<div className="fixed inset-0 z-40" onClick={() => setInstallTarget({ skill: '', show: false })} />
|
|
203
|
+
<div className="absolute right-0 top-7 w-[180px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => install(skill.name, 'global')}
|
|
206
|
+
className={`w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] ${
|
|
207
|
+
skill.installedGlobal ? 'text-[var(--green)]' : 'text-[var(--text-primary)]'
|
|
208
|
+
}`}
|
|
209
|
+
>
|
|
210
|
+
{skill.installedGlobal ? '✓ ' : ''}Global (~/.claude)
|
|
211
|
+
</button>
|
|
212
|
+
<div className="border-t border-[var(--border)] my-0.5" />
|
|
213
|
+
{projects.map(p => {
|
|
214
|
+
const inst = skill.installedProjects.includes(p.path);
|
|
215
|
+
return (
|
|
216
|
+
<button
|
|
217
|
+
key={p.path}
|
|
218
|
+
onClick={() => install(skill.name, p.path)}
|
|
219
|
+
className={`w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] truncate ${
|
|
220
|
+
inst ? 'text-[var(--green)]' : 'text-[var(--text-primary)]'
|
|
221
|
+
}`}
|
|
222
|
+
title={p.path}
|
|
223
|
+
>
|
|
224
|
+
{inst ? '✓ ' : ''}{p.name}
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill.description}</p>
|
|
234
|
+
{/* Installed indicators */}
|
|
235
|
+
{isInstalled && (
|
|
236
|
+
<div className="flex items-center gap-2 mt-1">
|
|
237
|
+
{skill.installedGlobal && (
|
|
238
|
+
<span className="flex items-center gap-1 text-[8px] text-[var(--green)]">
|
|
239
|
+
Global
|
|
240
|
+
<button onClick={() => uninstall(skill.name, 'global')} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
{skill.installedProjects.map(pp => (
|
|
244
|
+
<span key={pp} className="flex items-center gap-1 text-[8px] text-[var(--accent)]">
|
|
245
|
+
{pp.split('/').pop()}
|
|
246
|
+
<button onClick={() => uninstall(skill.name, pp)} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
247
|
+
</span>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* File browser */}
|
|
254
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
255
|
+
{/* File list */}
|
|
256
|
+
<div className="w-32 border-r border-[var(--border)] overflow-y-auto shrink-0">
|
|
257
|
+
{skillFiles.length === 0 ? (
|
|
258
|
+
<div className="p-2 text-[9px] text-[var(--text-secondary)]">Loading...</div>
|
|
259
|
+
) : (
|
|
260
|
+
skillFiles.map(f => (
|
|
261
|
+
f.type === 'file' ? (
|
|
262
|
+
<button
|
|
263
|
+
key={f.path}
|
|
264
|
+
onClick={() => loadFile(skill.name, f.path)}
|
|
265
|
+
className={`w-full text-left px-2 py-1 text-[10px] truncate ${
|
|
266
|
+
activeFile === f.path
|
|
267
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)]'
|
|
268
|
+
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
|
|
269
|
+
}`}
|
|
270
|
+
title={f.path}
|
|
271
|
+
>
|
|
272
|
+
{f.name}
|
|
273
|
+
</button>
|
|
274
|
+
) : (
|
|
275
|
+
<div key={f.path} className="px-2 py-1 text-[9px] text-[var(--text-secondary)] font-semibold">
|
|
276
|
+
{f.name}/
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
))
|
|
280
|
+
)}
|
|
281
|
+
{skill.sourceUrl && (
|
|
282
|
+
<div className="border-t border-[var(--border)] p-2">
|
|
283
|
+
<a
|
|
284
|
+
href={skill.sourceUrl.replace(/\/blob\/main\/.*/, '')}
|
|
285
|
+
target="_blank"
|
|
286
|
+
rel="noopener noreferrer"
|
|
287
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
288
|
+
>
|
|
289
|
+
GitHub
|
|
290
|
+
</a>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
{/* File content */}
|
|
295
|
+
<div className="flex-1 flex flex-col" style={{ width: 0 }}>
|
|
296
|
+
{activeFile && (
|
|
297
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[9px] text-[var(--text-secondary)] font-mono shrink-0 truncate">
|
|
298
|
+
{activeFile}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
<div className="flex-1 overflow-auto">
|
|
302
|
+
<pre className="p-3 text-[11px] text-[var(--text-primary)] font-mono whitespace-pre-wrap break-all">
|
|
303
|
+
{fileContent}
|
|
304
|
+
</pre>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
})() : (
|
|
311
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
312
|
+
<p className="text-xs">Select a skill to view details</p>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -506,7 +506,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
506
506
|
</div>
|
|
507
507
|
))}
|
|
508
508
|
<button
|
|
509
|
-
onClick={() =>
|
|
509
|
+
onClick={() => {
|
|
510
|
+
setShowNewTabModal(true);
|
|
511
|
+
// Refresh projects list when opening modal
|
|
512
|
+
fetch('/api/projects').then(r => r.json())
|
|
513
|
+
.then((p: { name: string; path: string; root: string }[]) => {
|
|
514
|
+
if (!Array.isArray(p)) return;
|
|
515
|
+
setAllProjects(p);
|
|
516
|
+
setProjectRoots([...new Set(p.map(proj => proj.root))]);
|
|
517
|
+
})
|
|
518
|
+
.catch(() => {});
|
|
519
|
+
}}
|
|
510
520
|
className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[var(--term-border)]"
|
|
511
521
|
title="New tab"
|
|
512
522
|
>
|
|
@@ -690,7 +700,25 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
690
700
|
{rootProjects.map(p => (
|
|
691
701
|
<button
|
|
692
702
|
key={p.path}
|
|
693
|
-
onClick={() => {
|
|
703
|
+
onClick={async () => {
|
|
704
|
+
setShowNewTabModal(false); setExpandedRoot(null);
|
|
705
|
+
// Pre-check sessions before creating tab
|
|
706
|
+
let hasSession = false;
|
|
707
|
+
try {
|
|
708
|
+
const sRes = await fetch(`/api/claude-sessions/${encodeURIComponent(p.name)}`);
|
|
709
|
+
const sData = await sRes.json();
|
|
710
|
+
hasSession = Array.isArray(sData) ? sData.length > 0 : (Array.isArray(sData.sessions) && sData.sessions.length > 0);
|
|
711
|
+
} catch {}
|
|
712
|
+
const skipFlag = skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
713
|
+
const resumeFlag = hasSession ? ' -c' : '';
|
|
714
|
+
const tree = makeTerminal(undefined, p.path);
|
|
715
|
+
const paneId = firstTerminalId(tree);
|
|
716
|
+
pendingCommands.set(paneId, `cd "${p.path}" && claude${resumeFlag}${skipFlag}\n`);
|
|
717
|
+
const tabNum = tabs.length + 1;
|
|
718
|
+
const newTab: TabState = { id: nextId++, label: p.name || `Terminal ${tabNum}`, tree, ratios: {}, activeId: paneId, projectPath: p.path };
|
|
719
|
+
setTabs(prev => [...prev, newTab]);
|
|
720
|
+
setActiveTabId(newTab.id);
|
|
721
|
+
}}
|
|
694
722
|
className="w-full text-left px-3 py-1.5 rounded hover:bg-[var(--term-border)] text-[11px] text-gray-300 flex items-center gap-2 truncate"
|
|
695
723
|
title={p.path}
|
|
696
724
|
>
|
|
@@ -1090,13 +1118,13 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1090
1118
|
createRetries = 0;
|
|
1091
1119
|
reconnectAttempts = 0;
|
|
1092
1120
|
onSessionConnected(id, msg.sessionName);
|
|
1093
|
-
// Auto-run claude
|
|
1094
|
-
if (isNewlyCreated && projectPathRef.current) {
|
|
1121
|
+
// Auto-run claude for project tabs (only if no pendingCommand already set)
|
|
1122
|
+
if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
|
|
1095
1123
|
isNewlyCreated = false;
|
|
1096
1124
|
setTimeout(() => {
|
|
1097
1125
|
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1098
1126
|
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1099
|
-
ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude
|
|
1127
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude${skipFlag}\n` }));
|
|
1100
1128
|
}
|
|
1101
1129
|
}, 300);
|
|
1102
1130
|
}
|
package/lib/init.ts
CHANGED
|
@@ -76,6 +76,13 @@ export function ensureInitialized() {
|
|
|
76
76
|
// Auto-detect claude path if not configured
|
|
77
77
|
autoDetectClaude();
|
|
78
78
|
|
|
79
|
+
// Sync skills registry (async, non-blocking) — on startup + every 30 min
|
|
80
|
+
try {
|
|
81
|
+
const { syncSkills } = require('./skills');
|
|
82
|
+
syncSkills().catch(() => {});
|
|
83
|
+
setInterval(() => { syncSkills().catch(() => {}); }, 30 * 60 * 1000);
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
79
86
|
// Task runner is safe in every worker (DB-level coordination)
|
|
80
87
|
ensureRunnerStarted();
|
|
81
88
|
|
package/lib/settings.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface Settings {
|
|
|
22
22
|
telegramModel: string; // Model for Telegram AI features (default: sonnet)
|
|
23
23
|
skipPermissions: boolean; // Add --dangerously-skip-permissions to all claude invocations
|
|
24
24
|
notificationRetentionDays: number; // Auto-cleanup notifications older than N days
|
|
25
|
+
skillsRepoUrl: string; // GitHub raw URL for skills registry
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const defaults: Settings = {
|
|
@@ -39,6 +40,7 @@ const defaults: Settings = {
|
|
|
39
40
|
telegramModel: 'sonnet',
|
|
40
41
|
skipPermissions: false,
|
|
41
42
|
notificationRetentionDays: 30,
|
|
43
|
+
skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
|
|
42
44
|
};
|
|
43
45
|
|
|
44
46
|
/** Load settings with secrets decrypted (for internal use) */
|
package/lib/skills.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills management — sync from registry, install/uninstall to local.
|
|
3
|
+
*
|
|
4
|
+
* Global install: ~/.claude/commands/<name>.md
|
|
5
|
+
* Project install: <projectPath>/.claude/commands/<name>.md
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getDb } from '@/src/core/db/database';
|
|
9
|
+
import { getDbPath } from '@/src/config';
|
|
10
|
+
import { loadSettings } from './settings';
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
export interface Skill {
|
|
16
|
+
name: string;
|
|
17
|
+
displayName: string;
|
|
18
|
+
description: string;
|
|
19
|
+
author: string;
|
|
20
|
+
version: string;
|
|
21
|
+
tags: string[];
|
|
22
|
+
score: number;
|
|
23
|
+
sourceUrl: string;
|
|
24
|
+
installedGlobal: boolean;
|
|
25
|
+
installedProjects: string[]; // project paths where installed
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function db() {
|
|
29
|
+
return getDb(getDbPath());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const GLOBAL_COMMANDS_DIR = join(homedir(), '.claude', 'commands');
|
|
33
|
+
|
|
34
|
+
function projectCommandsDir(projectPath: string): string {
|
|
35
|
+
return join(projectPath, '.claude', 'commands');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Sync from registry ──────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export async function syncSkills(): Promise<{ synced: number; error?: string }> {
|
|
41
|
+
const settings = loadSettings();
|
|
42
|
+
const baseUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
47
|
+
const res = await fetch(`${baseUrl}/registry.json`, {
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
headers: { 'Accept': 'application/json' },
|
|
50
|
+
});
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
|
|
53
|
+
if (!res.ok) return { synced: 0, error: `Registry fetch failed: ${res.status}` };
|
|
54
|
+
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
const skills = data.skills || [];
|
|
57
|
+
|
|
58
|
+
const stmt = db().prepare(`
|
|
59
|
+
INSERT OR REPLACE INTO skills (name, display_name, description, author, version, tags, score, source_url, synced_at,
|
|
60
|
+
installed_global, installed_projects, skill_content)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'),
|
|
62
|
+
COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
|
|
63
|
+
COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
|
|
64
|
+
COALESCE((SELECT skill_content FROM skills WHERE name = ?), NULL))
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
const tx = db().transaction(() => {
|
|
68
|
+
for (const s of skills) {
|
|
69
|
+
stmt.run(
|
|
70
|
+
s.name, s.display_name, s.description || '',
|
|
71
|
+
s.author?.name || '', s.version || '', JSON.stringify(s.tags || []),
|
|
72
|
+
s.score || 0, s.source?.url || '',
|
|
73
|
+
s.name, s.name, s.name
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
tx();
|
|
78
|
+
|
|
79
|
+
return { synced: skills.length };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { synced: 0, error: e instanceof Error ? e.message : String(e) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── List skills ─────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function listSkills(): Skill[] {
|
|
88
|
+
const rows = db().prepare('SELECT * FROM skills ORDER BY score DESC, display_name ASC').all() as any[];
|
|
89
|
+
return rows.map(r => ({
|
|
90
|
+
name: r.name,
|
|
91
|
+
displayName: r.display_name,
|
|
92
|
+
description: r.description,
|
|
93
|
+
author: r.author,
|
|
94
|
+
version: r.version,
|
|
95
|
+
tags: JSON.parse(r.tags || '[]'),
|
|
96
|
+
score: r.score,
|
|
97
|
+
sourceUrl: r.source_url,
|
|
98
|
+
installedGlobal: !!r.installed_global,
|
|
99
|
+
installedProjects: JSON.parse(r.installed_projects || '[]'),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Install ─────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async function fetchSkillContent(name: string): Promise<string> {
|
|
106
|
+
// Check if already cached in DB
|
|
107
|
+
const row = db().prepare('SELECT skill_content FROM skills WHERE name = ?').get(name) as any;
|
|
108
|
+
if (row?.skill_content) return row.skill_content;
|
|
109
|
+
|
|
110
|
+
// Fetch from GitHub
|
|
111
|
+
const settings = loadSettings();
|
|
112
|
+
const baseUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
113
|
+
const res = await fetch(`${baseUrl}/skills/${name}/skill.md`, { headers: { 'Accept': 'text/plain' } });
|
|
114
|
+
if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status}`);
|
|
115
|
+
const content = await res.text();
|
|
116
|
+
|
|
117
|
+
// Cache in DB
|
|
118
|
+
db().prepare('UPDATE skills SET skill_content = ? WHERE name = ?').run(content, name);
|
|
119
|
+
return content;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function installGlobal(name: string): Promise<void> {
|
|
123
|
+
const content = await fetchSkillContent(name);
|
|
124
|
+
if (!existsSync(GLOBAL_COMMANDS_DIR)) mkdirSync(GLOBAL_COMMANDS_DIR, { recursive: true });
|
|
125
|
+
writeFileSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`), content, 'utf-8');
|
|
126
|
+
db().prepare('UPDATE skills SET installed_global = 1 WHERE name = ?').run(name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function installProject(name: string, projectPath: string): Promise<void> {
|
|
130
|
+
const content = await fetchSkillContent(name);
|
|
131
|
+
const dir = projectCommandsDir(projectPath);
|
|
132
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
133
|
+
writeFileSync(join(dir, `${name}.md`), content, 'utf-8');
|
|
134
|
+
|
|
135
|
+
// Update installed_projects list
|
|
136
|
+
const row = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
|
|
137
|
+
const projects: string[] = JSON.parse(row?.installed_projects || '[]');
|
|
138
|
+
if (!projects.includes(projectPath)) {
|
|
139
|
+
projects.push(projectPath);
|
|
140
|
+
db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?').run(JSON.stringify(projects), name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Uninstall ───────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function uninstallGlobal(name: string): void {
|
|
147
|
+
const file = join(GLOBAL_COMMANDS_DIR, `${name}.md`);
|
|
148
|
+
try { unlinkSync(file); } catch {}
|
|
149
|
+
db().prepare('UPDATE skills SET installed_global = 0 WHERE name = ?').run(name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function uninstallProject(name: string, projectPath: string): void {
|
|
153
|
+
const file = join(projectCommandsDir(projectPath), `${name}.md`);
|
|
154
|
+
try { unlinkSync(file); } catch {}
|
|
155
|
+
|
|
156
|
+
const row = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
|
|
157
|
+
const projects: string[] = JSON.parse(row?.installed_projects || '[]');
|
|
158
|
+
const updated = projects.filter(p => p !== projectPath);
|
|
159
|
+
db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?').run(JSON.stringify(updated), name);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Scan installed state from filesystem ────────────────────
|
|
163
|
+
|
|
164
|
+
export function refreshInstallState(projectPaths: string[]): void {
|
|
165
|
+
const skills = db().prepare('SELECT name FROM skills').all() as { name: string }[];
|
|
166
|
+
|
|
167
|
+
for (const { name } of skills) {
|
|
168
|
+
// Check global
|
|
169
|
+
const globalInstalled = existsSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`));
|
|
170
|
+
|
|
171
|
+
// Check projects
|
|
172
|
+
const installedIn: string[] = [];
|
|
173
|
+
for (const pp of projectPaths) {
|
|
174
|
+
if (existsSync(join(projectCommandsDir(pp), `${name}.md`))) {
|
|
175
|
+
installedIn.push(pp);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
db().prepare('UPDATE skills SET installed_global = ?, installed_projects = ? WHERE name = ?')
|
|
180
|
+
.run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), name);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -1210,7 +1210,7 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
|
|
|
1210
1210
|
await new Promise(r => setTimeout(r, 500));
|
|
1211
1211
|
// cd to doc root and start claude
|
|
1212
1212
|
const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
1213
|
-
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude
|
|
1213
|
+
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude -c${sf}`, 'Enter'], { timeout: 5000 });
|
|
1214
1214
|
// Wait for Claude to start up
|
|
1215
1215
|
await new Promise(r => setTimeout(r, 3000));
|
|
1216
1216
|
await send(chatId, '🚀 Auto-started Docs Claude session.');
|
|
@@ -1230,7 +1230,7 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
|
|
|
1230
1230
|
if (paneCmd === 'zsh' || paneCmd === 'bash' || paneCmd === 'fish' || !paneCmd) {
|
|
1231
1231
|
try {
|
|
1232
1232
|
const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
1233
|
-
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude
|
|
1233
|
+
spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude -c${sf}`, 'Enter'], { timeout: 5000 });
|
|
1234
1234
|
await new Promise(r => setTimeout(r, 3000));
|
|
1235
1235
|
await send(chatId, '🚀 Auto-started Claude in Docs session.');
|
|
1236
1236
|
} catch {
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -120,6 +120,22 @@ function initSchema(db: Database.Database) {
|
|
|
120
120
|
|
|
121
121
|
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read, created_at);
|
|
122
122
|
|
|
123
|
+
-- Skills registry cache
|
|
124
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
125
|
+
name TEXT PRIMARY KEY,
|
|
126
|
+
display_name TEXT NOT NULL,
|
|
127
|
+
description TEXT,
|
|
128
|
+
author TEXT,
|
|
129
|
+
version TEXT,
|
|
130
|
+
tags TEXT,
|
|
131
|
+
score INTEGER DEFAULT 0,
|
|
132
|
+
source_url TEXT,
|
|
133
|
+
skill_content TEXT,
|
|
134
|
+
installed_global INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
installed_projects TEXT NOT NULL DEFAULT '[]',
|
|
136
|
+
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
137
|
+
);
|
|
138
|
+
|
|
123
139
|
-- Session watchers — monitor sessions and notify via Telegram
|
|
124
140
|
CREATE TABLE IF NOT EXISTS session_watchers (
|
|
125
141
|
id TEXT PRIMARY KEY,
|