@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/lib/skills.ts CHANGED
@@ -1,45 +1,92 @@
1
1
  /**
2
- * Skills management — sync from registry, install/uninstall to local.
2
+ * Skills & Commands marketplace — sync from registry, install/uninstall.
3
3
  *
4
- * Global install: ~/.claude/commands/<name>.md
5
- * Project install: <projectPath>/.claude/commands/<name>.md
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, dirname } from 'node:path';
13
- import { homedir } from 'node:os';
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 Skill {
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[]; // project paths where installed
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 GLOBAL_COMMANDS_DIR = join(homedir(), '.claude', 'commands');
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
- function projectCommandsDir(projectPath: string): string {
35
- return join(projectPath, '.claude', 'commands');
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 settings = loadSettings();
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
- const skills = data.skills || [];
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, skill_content)
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 skill_content FROM skills WHERE name = ?), NULL))
168
+ COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
65
169
  `);
66
170
 
67
171
  const tx = db().transaction(() => {
68
- for (const s of skills) {
172
+ for (const s of items) {
69
173
  stmt.run(
70
- s.name, s.display_name, s.description || '',
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
- return { synced: skills.length };
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 skills ─────────────────────────────────────────────
203
+ // ─── List ────────────────────────────────────────────────────
86
204
 
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
- }));
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
- // ─── Install ─────────────────────────────────────────────────
230
+ // ─── Download directory from GitHub ──────────────────────────
104
231
 
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;
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
- // 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();
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
- // Cache in DB
118
- db().prepare('UPDATE skills SET skill_content = ? WHERE name = ?').run(content, name);
119
- return content;
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 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);
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 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 || '[]');
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
- 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);
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
- const file = join(projectCommandsDir(projectPath), `${name}.md`);
154
- try { unlinkSync(file); } catch {}
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 row = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
157
- const projects: string[] = JSON.parse(row?.installed_projects || '[]');
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 skills = db().prepare('SELECT name FROM skills').all() as { name: string }[];
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
- for (const { name } of skills) {
168
- // Check global
169
- const globalInstalled = existsSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`));
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
- if (existsSync(join(projectCommandsDir(pp), `${name}.md`))) {
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
  }