@aion0/forge 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -2
- package/README.md +9 -9
- package/app/api/claude-templates/route.ts +145 -0
- package/app/api/docs/sessions/route.ts +3 -3
- package/app/api/monitor/route.ts +2 -2
- package/app/api/pipelines/route.ts +2 -2
- package/app/api/preview/[...path]/route.ts +2 -2
- package/app/api/preview/route.ts +3 -4
- package/app/api/projects/route.ts +19 -0
- package/app/api/skills/local/route.ts +228 -0
- package/app/api/skills/route.ts +36 -11
- package/app/api/terminal-state/route.ts +2 -2
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +83 -33
- package/cli/mw.ts +27 -9
- package/components/Dashboard.tsx +14 -0
- package/components/ProjectManager.tsx +581 -42
- package/components/SessionView.tsx +1 -1
- package/components/SettingsModal.tsx +18 -1
- package/components/SkillsPanel.tsx +515 -29
- package/components/WebTerminal.tsx +50 -5
- package/instrumentation.ts +2 -2
- package/lib/claude-sessions.ts +2 -2
- package/lib/claude-templates.ts +227 -0
- package/lib/cloudflared.ts +4 -3
- package/lib/crypto.ts +3 -4
- package/lib/dirs.ts +99 -0
- package/lib/flows.ts +2 -2
- package/lib/init.ts +5 -2
- package/lib/password.ts +2 -2
- package/lib/pipeline.ts +3 -3
- package/lib/session-watcher.ts +2 -2
- package/lib/settings.ts +4 -2
- package/lib/skills.ts +444 -79
- package/lib/telegram-bot.ts +12 -5
- package/lib/terminal-standalone.ts +3 -2
- package/package.json +1 -1
- package/src/config/index.ts +6 -7
- package/src/core/db/database.ts +17 -5
package/lib/skills.ts
CHANGED
|
@@ -1,45 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills
|
|
2
|
+
* Skills & Commands marketplace — sync from registry, install/uninstall.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Skills: ~/.claude/skills/<name>/ (directory with SKILL.md + support files)
|
|
5
|
+
* Commands: ~/.claude/commands/<name>.md (single .md file)
|
|
6
|
+
*
|
|
7
|
+
* Install = download all files from GitHub repo directory → write to local.
|
|
8
|
+
* No tar.gz — direct file sync via GitHub raw URLs.
|
|
9
|
+
* Version tracking — compare registry version with installed version for update detection.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { getDb } from '@/src/core/db/database';
|
|
9
13
|
import { getDbPath } from '@/src/config';
|
|
10
14
|
import { loadSettings } from './settings';
|
|
11
|
-
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
|
|
12
|
-
import { join
|
|
13
|
-
import {
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync, rmSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { getClaudeDir } from './dirs';
|
|
19
|
+
|
|
20
|
+
export type ItemType = 'skill' | 'command';
|
|
14
21
|
|
|
15
|
-
export interface
|
|
22
|
+
export interface SkillItem {
|
|
16
23
|
name: string;
|
|
24
|
+
type: ItemType;
|
|
17
25
|
displayName: string;
|
|
18
26
|
description: string;
|
|
19
27
|
author: string;
|
|
20
28
|
version: string;
|
|
21
29
|
tags: string[];
|
|
22
30
|
score: number;
|
|
31
|
+
rating: number; // 0-5 star rating from registry
|
|
23
32
|
sourceUrl: string;
|
|
24
33
|
installedGlobal: boolean;
|
|
25
|
-
installedProjects: string[];
|
|
34
|
+
installedProjects: string[];
|
|
35
|
+
installedVersion: string; // version currently installed (empty if not installed)
|
|
36
|
+
hasUpdate: boolean; // true if registry version > installed version
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
function db() {
|
|
29
40
|
return getDb(getDbPath());
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
const
|
|
43
|
+
const GLOBAL_SKILLS_DIR = join(getClaudeDir(), 'skills');
|
|
44
|
+
const GLOBAL_COMMANDS_DIR = join(getClaudeDir(), 'commands');
|
|
45
|
+
|
|
46
|
+
function getBaseUrl(): string {
|
|
47
|
+
const settings = loadSettings();
|
|
48
|
+
return settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getRepoInfo(): string {
|
|
52
|
+
const baseUrl = getBaseUrl();
|
|
53
|
+
const match = baseUrl.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
54
|
+
return match ? match[1] : 'aiwatching/forge-skills';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Version comparison ──────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function compareVersions(a: string, b: string): number {
|
|
60
|
+
const pa = (a || '0.0.0').split('.').map(Number);
|
|
61
|
+
const pb = (b || '0.0.0').split('.').map(Number);
|
|
62
|
+
for (let i = 0; i < 3; i++) {
|
|
63
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
64
|
+
if (diff !== 0) return diff;
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
33
68
|
|
|
34
|
-
|
|
35
|
-
|
|
69
|
+
/** Read installed version from info.json (skills) or return DB version (commands) */
|
|
70
|
+
function getInstalledVersion(name: string, type: string, basePath?: string): string {
|
|
71
|
+
if (type === 'skill') {
|
|
72
|
+
const dir = basePath
|
|
73
|
+
? join(basePath, '.claude', 'skills', name)
|
|
74
|
+
: join(GLOBAL_SKILLS_DIR, name);
|
|
75
|
+
const infoPath = join(dir, 'info.json');
|
|
76
|
+
try {
|
|
77
|
+
const info = JSON.parse(readFileSync(infoPath, 'utf-8'));
|
|
78
|
+
return info.version || '';
|
|
79
|
+
} catch { return ''; }
|
|
80
|
+
}
|
|
81
|
+
// Commands don't have version files — use DB installed_version
|
|
82
|
+
const row = db().prepare('SELECT installed_version FROM skills WHERE name = ?').get(name) as any;
|
|
83
|
+
return row?.installed_version || '';
|
|
36
84
|
}
|
|
37
85
|
|
|
38
86
|
// ─── Sync from registry ──────────────────────────────────────
|
|
39
87
|
|
|
40
88
|
export async function syncSkills(): Promise<{ synced: number; error?: string }> {
|
|
41
|
-
const
|
|
42
|
-
const baseUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
89
|
+
const baseUrl = getBaseUrl();
|
|
43
90
|
|
|
44
91
|
try {
|
|
45
92
|
const controller = new AbortController();
|
|
@@ -53,130 +100,448 @@ export async function syncSkills(): Promise<{ synced: number; error?: string }>
|
|
|
53
100
|
if (!res.ok) return { synced: 0, error: `Registry fetch failed: ${res.status}` };
|
|
54
101
|
|
|
55
102
|
const data = await res.json();
|
|
56
|
-
|
|
103
|
+
|
|
104
|
+
// Support both v1 (flat skills array) and v2 (separate skills + commands)
|
|
105
|
+
let items: any[] = [];
|
|
106
|
+
if (data.version === 2) {
|
|
107
|
+
const rawItems = [
|
|
108
|
+
...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
|
|
109
|
+
...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
|
|
110
|
+
];
|
|
111
|
+
// Always enrich from info.json for latest rating/score/version
|
|
112
|
+
items = await Promise.all(rawItems.map(async (s: any) => {
|
|
113
|
+
try {
|
|
114
|
+
const repoDir = s.type === 'skill' ? 'skills' : 'commands';
|
|
115
|
+
let infoRes = await fetch(`${baseUrl}/${repoDir}/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
|
|
116
|
+
if (!infoRes.ok) {
|
|
117
|
+
const altDir = s.type === 'skill' ? 'commands' : 'skills';
|
|
118
|
+
infoRes = await fetch(`${baseUrl}/${altDir}/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
|
|
119
|
+
}
|
|
120
|
+
if (infoRes.ok) {
|
|
121
|
+
const info = await infoRes.json();
|
|
122
|
+
return {
|
|
123
|
+
...s,
|
|
124
|
+
version: info.version || s.version,
|
|
125
|
+
score: info.score ?? s.score ?? 0,
|
|
126
|
+
rating: info.rating ?? s.rating ?? 0,
|
|
127
|
+
description: info.description || s.description,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
return s;
|
|
132
|
+
}));
|
|
133
|
+
} else {
|
|
134
|
+
// v1: enrich from info.json in parallel
|
|
135
|
+
const rawItems = data.skills || [];
|
|
136
|
+
const enriched = await Promise.all(rawItems.map(async (s: any) => {
|
|
137
|
+
// Fetch info.json for metadata and type
|
|
138
|
+
try {
|
|
139
|
+
let infoRes = await fetch(`${baseUrl}/skills/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
|
|
140
|
+
if (!infoRes.ok) {
|
|
141
|
+
infoRes = await fetch(`${baseUrl}/commands/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
|
|
142
|
+
}
|
|
143
|
+
if (infoRes.ok) {
|
|
144
|
+
const info = await infoRes.json();
|
|
145
|
+
return {
|
|
146
|
+
...s,
|
|
147
|
+
type: info.type || s.type || 'command',
|
|
148
|
+
display_name: info.display_name || s.display_name,
|
|
149
|
+
description: info.description || s.description,
|
|
150
|
+
version: info.version || s.version,
|
|
151
|
+
tags: info.tags || s.tags,
|
|
152
|
+
score: info.score ?? s.score,
|
|
153
|
+
rating: info.rating ?? s.rating ?? 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
} catch {}
|
|
157
|
+
return { ...s, type: s.type || 'command' };
|
|
158
|
+
}));
|
|
159
|
+
items = enriched;
|
|
160
|
+
}
|
|
57
161
|
|
|
58
162
|
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,
|
|
61
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'),
|
|
163
|
+
INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive, synced_at,
|
|
164
|
+
installed_global, installed_projects, installed_version)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'),
|
|
62
166
|
COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
|
|
63
167
|
COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
|
|
64
|
-
COALESCE((SELECT
|
|
168
|
+
COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
|
|
65
169
|
`);
|
|
66
170
|
|
|
67
171
|
const tx = db().transaction(() => {
|
|
68
|
-
for (const s of
|
|
172
|
+
for (const s of items) {
|
|
69
173
|
stmt.run(
|
|
70
|
-
s.name, s.
|
|
174
|
+
s.name, s.type || 'skill',
|
|
175
|
+
s.display_name, s.description || '',
|
|
71
176
|
s.author?.name || '', s.version || '', JSON.stringify(s.tags || []),
|
|
72
|
-
s.score || 0, s.source?.url || '',
|
|
177
|
+
s.score || 0, s.rating || 0, s.source?.url || '',
|
|
178
|
+
'', // archive field (unused now, kept for compat)
|
|
73
179
|
s.name, s.name, s.name
|
|
74
180
|
);
|
|
75
181
|
}
|
|
76
182
|
});
|
|
77
183
|
tx();
|
|
78
184
|
|
|
79
|
-
|
|
185
|
+
// Remove items no longer in registry (not installed locally)
|
|
186
|
+
const registryNames = new Set(items.map(s => s.name));
|
|
187
|
+
const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
|
|
188
|
+
for (const row of dbItems) {
|
|
189
|
+
if (!registryNames.has(row.name)) {
|
|
190
|
+
const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
|
|
191
|
+
if (!hasLocal) {
|
|
192
|
+
db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { synced: items.length };
|
|
80
198
|
} catch (e) {
|
|
81
199
|
return { synced: 0, error: e instanceof Error ? e.message : String(e) };
|
|
82
200
|
}
|
|
83
201
|
}
|
|
84
202
|
|
|
85
|
-
// ─── List
|
|
203
|
+
// ─── List ────────────────────────────────────────────────────
|
|
86
204
|
|
|
87
|
-
export function listSkills():
|
|
88
|
-
const rows = db().prepare('SELECT * FROM skills ORDER BY score DESC, display_name ASC').all() as any[];
|
|
89
|
-
return rows.map(r =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
205
|
+
export function listSkills(): SkillItem[] {
|
|
206
|
+
const rows = db().prepare('SELECT * FROM skills ORDER BY type ASC, score DESC, display_name ASC').all() as any[];
|
|
207
|
+
return rows.map(r => {
|
|
208
|
+
const installedVersion = r.installed_version || '';
|
|
209
|
+
const registryVersion = r.version || '';
|
|
210
|
+
const isInstalled = !!r.installed_global || JSON.parse(r.installed_projects || '[]').length > 0;
|
|
211
|
+
return {
|
|
212
|
+
name: r.name,
|
|
213
|
+
type: r.type || 'skill',
|
|
214
|
+
displayName: r.display_name,
|
|
215
|
+
description: r.description,
|
|
216
|
+
author: r.author,
|
|
217
|
+
version: registryVersion,
|
|
218
|
+
tags: JSON.parse(r.tags || '[]'),
|
|
219
|
+
score: r.score,
|
|
220
|
+
rating: r.rating || 0,
|
|
221
|
+
sourceUrl: r.source_url,
|
|
222
|
+
installedGlobal: !!r.installed_global,
|
|
223
|
+
installedProjects: JSON.parse(r.installed_projects || '[]'),
|
|
224
|
+
installedVersion,
|
|
225
|
+
hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
101
228
|
}
|
|
102
229
|
|
|
103
|
-
// ───
|
|
230
|
+
// ─── Download directory from GitHub ──────────────────────────
|
|
104
231
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
232
|
+
/** Recursively list all files in a skill/command directory via GitHub API */
|
|
233
|
+
async function listRepoFiles(name: string, type: ItemType): Promise<{ path: string; download_url: string }[]> {
|
|
234
|
+
const repo = getRepoInfo();
|
|
235
|
+
const files: { path: string; download_url: string }[] = [];
|
|
109
236
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
237
|
+
async function recurse(apiUrl: string, prefix: string) {
|
|
238
|
+
const res = await fetch(apiUrl, {
|
|
239
|
+
headers: { 'Accept': 'application/vnd.github.v3+json' },
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok) return;
|
|
242
|
+
const items = await res.json();
|
|
243
|
+
if (!Array.isArray(items)) return;
|
|
244
|
+
|
|
245
|
+
for (const item of items) {
|
|
246
|
+
const relPath = prefix ? `${prefix}/${item.name}` : item.name;
|
|
247
|
+
if (item.type === 'file') {
|
|
248
|
+
files.push({ path: relPath, download_url: item.download_url });
|
|
249
|
+
} else if (item.type === 'dir') {
|
|
250
|
+
await recurse(item.url, relPath);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Try skills/ first, then commands/
|
|
256
|
+
await recurse(`https://api.github.com/repos/${repo}/contents/skills/${name}`, '');
|
|
257
|
+
if (files.length === 0) {
|
|
258
|
+
await recurse(`https://api.github.com/repos/${repo}/contents/commands/${name}`, '');
|
|
259
|
+
}
|
|
260
|
+
return files;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Download all files to a local directory */
|
|
264
|
+
async function downloadToDir(files: { path: string; download_url: string }[], destDir: string): Promise<void> {
|
|
265
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
266
|
+
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
const filePath = join(destDir, file.path);
|
|
269
|
+
const fileDir = join(destDir, file.path.split('/').slice(0, -1).join('/'));
|
|
270
|
+
if (fileDir !== destDir && !existsSync(fileDir)) mkdirSync(fileDir, { recursive: true });
|
|
116
271
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
272
|
+
const res = await fetch(file.download_url);
|
|
273
|
+
if (!res.ok) continue;
|
|
274
|
+
const content = await res.text();
|
|
275
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
276
|
+
}
|
|
120
277
|
}
|
|
121
278
|
|
|
279
|
+
// ─── Install ─────────────────────────────────────────────────
|
|
280
|
+
|
|
122
281
|
export async function installGlobal(name: string): Promise<void> {
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
|
|
282
|
+
const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
|
|
283
|
+
if (!row) throw new Error(`Not found: ${name}`);
|
|
284
|
+
const type: ItemType = row.type || 'skill';
|
|
285
|
+
const version = row.version || '';
|
|
286
|
+
|
|
287
|
+
const files = await listRepoFiles(name, type);
|
|
288
|
+
|
|
289
|
+
if (type === 'skill') {
|
|
290
|
+
const dest = join(GLOBAL_SKILLS_DIR, name);
|
|
291
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true });
|
|
292
|
+
await downloadToDir(files, dest);
|
|
293
|
+
} else {
|
|
294
|
+
// Command: download all files to commands/<name>/ directory
|
|
295
|
+
// If only a single .md, also copy as <name>.md directly for slash command registration
|
|
296
|
+
const dest = join(GLOBAL_COMMANDS_DIR);
|
|
297
|
+
if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
|
|
298
|
+
const mdFiles = files.filter(f => f.path.endsWith('.md') && f.path !== 'info.json');
|
|
299
|
+
if (mdFiles.length === 1 && files.filter(f => f.path !== 'info.json').length === 1) {
|
|
300
|
+
// Single .md command — write directly as <name>.md
|
|
301
|
+
const res = await fetch(mdFiles[0].download_url);
|
|
302
|
+
if (res.ok) writeFileSync(join(dest, `${name}.md`), await res.text(), 'utf-8');
|
|
303
|
+
} else {
|
|
304
|
+
// Multi-file command — copy all files (except info.json) to commands/<name>/
|
|
305
|
+
const cmdDir = join(dest, name);
|
|
306
|
+
if (existsSync(cmdDir)) rmSync(cmdDir, { recursive: true });
|
|
307
|
+
const nonInfo = files.filter(f => f.path !== 'info.json');
|
|
308
|
+
await downloadToDir(nonInfo, cmdDir);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
db().prepare('UPDATE skills SET installed_global = 1, installed_version = ? WHERE name = ?').run(version, name);
|
|
127
313
|
}
|
|
128
314
|
|
|
129
315
|
export async function installProject(name: string, projectPath: string): Promise<void> {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
316
|
+
const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
|
|
317
|
+
if (!row) throw new Error(`Not found: ${name}`);
|
|
318
|
+
const type: ItemType = row.type || 'skill';
|
|
319
|
+
const version = row.version || '';
|
|
320
|
+
|
|
321
|
+
const files = await listRepoFiles(name, type);
|
|
322
|
+
|
|
323
|
+
if (type === 'skill') {
|
|
324
|
+
const dest = join(projectPath, '.claude', 'skills', name);
|
|
325
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true });
|
|
326
|
+
await downloadToDir(files, dest);
|
|
327
|
+
} else {
|
|
328
|
+
const cmdDir = join(projectPath, '.claude', 'commands');
|
|
329
|
+
if (!existsSync(cmdDir)) mkdirSync(cmdDir, { recursive: true });
|
|
330
|
+
const mdFiles = files.filter(f => f.path.endsWith('.md') && f.path !== 'info.json');
|
|
331
|
+
if (mdFiles.length === 1 && files.filter(f => f.path !== 'info.json').length === 1) {
|
|
332
|
+
const res = await fetch(mdFiles[0].download_url);
|
|
333
|
+
if (res.ok) writeFileSync(join(cmdDir, `${name}.md`), await res.text(), 'utf-8');
|
|
334
|
+
} else {
|
|
335
|
+
const subDir = join(cmdDir, name);
|
|
336
|
+
if (existsSync(subDir)) rmSync(subDir, { recursive: true });
|
|
337
|
+
const nonInfo = files.filter(f => f.path !== 'info.json');
|
|
338
|
+
await downloadToDir(nonInfo, subDir);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Update installed_projects + version
|
|
343
|
+
const current = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
|
|
344
|
+
const projects: string[] = JSON.parse(current?.installed_projects || '[]');
|
|
138
345
|
if (!projects.includes(projectPath)) {
|
|
139
346
|
projects.push(projectPath);
|
|
140
|
-
db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?').run(JSON.stringify(projects), name);
|
|
141
347
|
}
|
|
348
|
+
db().prepare('UPDATE skills SET installed_projects = ?, installed_version = ? WHERE name = ?')
|
|
349
|
+
.run(JSON.stringify(projects), version, name);
|
|
142
350
|
}
|
|
143
351
|
|
|
144
352
|
// ─── Uninstall ───────────────────────────────────────────────
|
|
145
353
|
|
|
146
354
|
export function uninstallGlobal(name: string): void {
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
|
|
355
|
+
// Remove from all possible locations
|
|
356
|
+
try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
|
|
357
|
+
try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
|
|
358
|
+
try { rmSync(join(GLOBAL_COMMANDS_DIR, name), { recursive: true }); } catch {}
|
|
359
|
+
|
|
360
|
+
db().prepare('UPDATE skills SET installed_global = 0, installed_version = ? WHERE name = ?')
|
|
361
|
+
.run('', name);
|
|
150
362
|
}
|
|
151
363
|
|
|
152
364
|
export function uninstallProject(name: string, projectPath: string): void {
|
|
153
|
-
|
|
154
|
-
try {
|
|
365
|
+
// Remove from all possible locations
|
|
366
|
+
try { rmSync(join(projectPath, '.claude', 'skills', name), { recursive: true }); } catch {}
|
|
367
|
+
try { unlinkSync(join(projectPath, '.claude', 'commands', `${name}.md`)); } catch {}
|
|
368
|
+
try { rmSync(join(projectPath, '.claude', 'commands', name), { recursive: true }); } catch {}
|
|
155
369
|
|
|
156
|
-
const
|
|
157
|
-
const projects: string[] = JSON.parse(
|
|
370
|
+
const current = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
|
|
371
|
+
const projects: string[] = JSON.parse(current?.installed_projects || '[]');
|
|
158
372
|
const updated = projects.filter(p => p !== projectPath);
|
|
159
373
|
db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?').run(JSON.stringify(updated), name);
|
|
374
|
+
// Clear installed_version if no longer installed anywhere
|
|
375
|
+
if (updated.length === 0) {
|
|
376
|
+
const row2 = db().prepare('SELECT installed_global FROM skills WHERE name = ?').get(name) as any;
|
|
377
|
+
if (!row2?.installed_global) {
|
|
378
|
+
db().prepare('UPDATE skills SET installed_version = ? WHERE name = ?').run('', name);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
160
381
|
}
|
|
161
382
|
|
|
162
383
|
// ─── Scan installed state from filesystem ────────────────────
|
|
163
384
|
|
|
164
385
|
export function refreshInstallState(projectPaths: string[]): void {
|
|
165
|
-
const
|
|
386
|
+
const items = db().prepare('SELECT name, type FROM skills').all() as { name: string; type: string }[];
|
|
387
|
+
|
|
388
|
+
for (const { name, type } of items) {
|
|
389
|
+
let globalInstalled = false;
|
|
390
|
+
let installedVersion = '';
|
|
166
391
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
392
|
+
// Check BOTH locations — skill dir and command file/dir
|
|
393
|
+
const skillDir = join(GLOBAL_SKILLS_DIR, name);
|
|
394
|
+
const cmdFile = join(GLOBAL_COMMANDS_DIR, `${name}.md`);
|
|
395
|
+
const cmdDir = join(GLOBAL_COMMANDS_DIR, name);
|
|
396
|
+
|
|
397
|
+
if (existsSync(skillDir)) {
|
|
398
|
+
globalInstalled = true;
|
|
399
|
+
installedVersion = getInstalledVersion(name, 'skill');
|
|
400
|
+
} else if (existsSync(cmdFile)) {
|
|
401
|
+
globalInstalled = true;
|
|
402
|
+
} else if (existsSync(cmdDir)) {
|
|
403
|
+
globalInstalled = true;
|
|
404
|
+
}
|
|
170
405
|
|
|
171
|
-
// Check projects
|
|
172
406
|
const installedIn: string[] = [];
|
|
173
407
|
for (const pp of projectPaths) {
|
|
174
|
-
|
|
408
|
+
// Check all possible install locations for this project
|
|
409
|
+
const projSkillDir = join(pp, '.claude', 'skills', name);
|
|
410
|
+
const projCmdFile = join(pp, '.claude', 'commands', `${name}.md`);
|
|
411
|
+
const projCmdDir = join(pp, '.claude', 'commands', name);
|
|
412
|
+
if (existsSync(projSkillDir) || existsSync(projCmdFile) || existsSync(projCmdDir)) {
|
|
175
413
|
installedIn.push(pp);
|
|
176
414
|
}
|
|
177
415
|
}
|
|
178
416
|
|
|
179
|
-
db().prepare('UPDATE skills SET installed_global = ?, installed_projects = ? WHERE name = ?')
|
|
180
|
-
.run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), name);
|
|
417
|
+
db().prepare('UPDATE skills SET installed_global = ?, installed_projects = ?, installed_version = CASE WHEN ? != \'\' THEN ? ELSE installed_version END WHERE name = ?')
|
|
418
|
+
.run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), installedVersion, installedVersion, name);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Check local modifications ───────────────────────────────
|
|
423
|
+
|
|
424
|
+
/** Compare local installed files against remote. Returns true if any file was locally modified. */
|
|
425
|
+
export async function checkLocalModified(name: string): Promise<boolean> {
|
|
426
|
+
const row = db().prepare('SELECT type FROM skills WHERE name = ?').get(name) as any;
|
|
427
|
+
if (!row) return false;
|
|
428
|
+
const type: ItemType = row.type || 'command';
|
|
429
|
+
|
|
430
|
+
// Get remote file list
|
|
431
|
+
const remoteFiles = await listRepoFiles(name, type);
|
|
432
|
+
|
|
433
|
+
// Compare each file
|
|
434
|
+
for (const rf of remoteFiles) {
|
|
435
|
+
if (rf.path === 'info.json') continue; // skip metadata
|
|
436
|
+
|
|
437
|
+
let localPath: string;
|
|
438
|
+
if (type === 'skill') {
|
|
439
|
+
localPath = join(GLOBAL_SKILLS_DIR, name, rf.path);
|
|
440
|
+
} else {
|
|
441
|
+
// Single file command
|
|
442
|
+
const cmdFile = join(GLOBAL_COMMANDS_DIR, `${name}.md`);
|
|
443
|
+
const cmdDir = join(GLOBAL_COMMANDS_DIR, name);
|
|
444
|
+
if (existsSync(cmdDir)) {
|
|
445
|
+
localPath = join(cmdDir, rf.path);
|
|
446
|
+
} else {
|
|
447
|
+
localPath = cmdFile;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!existsSync(localPath)) continue;
|
|
452
|
+
const localContent = readFileSync(localPath, 'utf-8');
|
|
453
|
+
|
|
454
|
+
// Fetch remote content
|
|
455
|
+
try {
|
|
456
|
+
const res = await fetch(rf.download_url);
|
|
457
|
+
if (!res.ok) continue;
|
|
458
|
+
const remoteContent = await res.text();
|
|
459
|
+
const localHash = createHash('md5').update(localContent).digest('hex');
|
|
460
|
+
const remoteHash = createHash('md5').update(remoteContent).digest('hex');
|
|
461
|
+
if (localHash !== remoteHash) return true;
|
|
462
|
+
} catch { continue; }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ─── Local-to-local install (copy between projects/global) ───
|
|
469
|
+
|
|
470
|
+
/** Recursively copy a directory */
|
|
471
|
+
function copyDir(src: string, dest: string): void {
|
|
472
|
+
if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
|
|
473
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
474
|
+
const srcPath = join(src, entry.name);
|
|
475
|
+
const destPath = join(dest, entry.name);
|
|
476
|
+
if (entry.isDirectory()) {
|
|
477
|
+
copyDir(srcPath, destPath);
|
|
478
|
+
} else {
|
|
479
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Resolve the source path for a local skill/command */
|
|
485
|
+
function resolveLocalSource(name: string, type: string, sourceProject?: string): string | null {
|
|
486
|
+
const base = sourceProject ? join(sourceProject, '.claude') : join(getClaudeDir());
|
|
487
|
+
if (type === 'skill') {
|
|
488
|
+
const dir = join(base, 'skills', name);
|
|
489
|
+
if (existsSync(dir)) return dir;
|
|
490
|
+
} else {
|
|
491
|
+
const file = join(base, 'commands', `${name}.md`);
|
|
492
|
+
if (existsSync(file)) return file;
|
|
493
|
+
const dir = join(base, 'commands', name);
|
|
494
|
+
if (existsSync(dir)) return dir;
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Install a local skill/command to a target (global or project path). Force=overwrite existing. */
|
|
500
|
+
export function installLocal(name: string, type: string, sourceProject: string | undefined, target: string, force?: boolean): { ok: boolean; error?: string } {
|
|
501
|
+
const src = resolveLocalSource(name, type, sourceProject);
|
|
502
|
+
if (!src) return { ok: false, error: 'Source not found' };
|
|
503
|
+
|
|
504
|
+
const isGlobal = target === 'global';
|
|
505
|
+
const isFile = !existsSync(src) ? false : require('node:fs').statSync(src).isFile();
|
|
506
|
+
|
|
507
|
+
if (type === 'skill') {
|
|
508
|
+
const dest = isGlobal
|
|
509
|
+
? join(GLOBAL_SKILLS_DIR, name)
|
|
510
|
+
: join(target, '.claude', 'skills', name);
|
|
511
|
+
if (existsSync(dest) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
|
|
512
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true });
|
|
513
|
+
copyDir(src, dest);
|
|
514
|
+
} else {
|
|
515
|
+
const destBase = isGlobal ? GLOBAL_COMMANDS_DIR : join(target, '.claude', 'commands');
|
|
516
|
+
if (!existsSync(destBase)) mkdirSync(destBase, { recursive: true });
|
|
517
|
+
if (isFile) {
|
|
518
|
+
const destFile = join(destBase, `${name}.md`);
|
|
519
|
+
if (existsSync(destFile) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
|
|
520
|
+
writeFileSync(destFile, readFileSync(src));
|
|
521
|
+
} else {
|
|
522
|
+
const dest = join(destBase, name);
|
|
523
|
+
if (existsSync(dest) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
|
|
524
|
+
if (existsSync(dest)) rmSync(dest, { recursive: true });
|
|
525
|
+
copyDir(src, dest);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { ok: true };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Delete a local skill/command from a specific project or global */
|
|
533
|
+
export function deleteLocal(name: string, type: string, projectPath?: string): boolean {
|
|
534
|
+
const base = projectPath ? join(projectPath, '.claude') : getClaudeDir();
|
|
535
|
+
if (type === 'skill') {
|
|
536
|
+
const dir = join(base, 'skills', name);
|
|
537
|
+
try { rmSync(dir, { recursive: true }); return true; } catch { return false; }
|
|
538
|
+
} else {
|
|
539
|
+
// Try file first, then directory
|
|
540
|
+
const file = join(base, 'commands', `${name}.md`);
|
|
541
|
+
const dir = join(base, 'commands', name);
|
|
542
|
+
let deleted = false;
|
|
543
|
+
try { unlinkSync(file); deleted = true; } catch {}
|
|
544
|
+
try { rmSync(dir, { recursive: true }); deleted = true; } catch {}
|
|
545
|
+
return deleted;
|
|
181
546
|
}
|
|
182
547
|
}
|