@aion0/forge 0.2.28 → 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.
@@ -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
+ }
@@ -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
- <button
172
- onClick={() => setViewMode('terminal')}
173
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
174
- viewMode === 'terminal'
175
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
176
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
177
- }`}
178
- >
179
- Vibe Coding
180
- </button>
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('projects')}
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 === 'preview'
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
- Demo Preview
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={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
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={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
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 --resume${sf}\n` }));
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 --resume${sf}`); }}
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
- }, [fetchGitInfo, fetchTree]);
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" style={{ width: 0 }}>
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-auto p-4 space-y-2">
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
+ }
@@ -710,7 +710,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
710
710
  hasSession = Array.isArray(sData) ? sData.length > 0 : (Array.isArray(sData.sessions) && sData.sessions.length > 0);
711
711
  } catch {}
712
712
  const skipFlag = skipPermissions ? ' --dangerously-skip-permissions' : '';
713
- const resumeFlag = hasSession ? ' --resume' : '';
713
+ const resumeFlag = hasSession ? ' -c' : '';
714
714
  const tree = makeTerminal(undefined, p.path);
715
715
  const paneId = firstTerminalId(tree);
716
716
  pendingCommands.set(paneId, `cd "${p.path}" && claude${resumeFlag}${skipFlag}\n`);
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
+ }
@@ -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 --resume${sf}`, 'Enter'], { timeout: 5000 });
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 --resume${sf}`, 'Enter'], { timeout: 5000 });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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,