@aion0/forge 0.4.16 → 0.5.0

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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
package/lib/skills.ts CHANGED
@@ -1,25 +1,17 @@
1
1
  /**
2
- * Skills & Commands marketplace — sync from registry, install/uninstall.
3
- *
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.
2
+ * Skills Marketplace — sync, install, uninstall from remote registry.
10
3
  */
11
4
 
5
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, cpSync } from 'node:fs';
6
+ import { join, dirname, basename, relative, sep } from 'node:path';
7
+ import { homedir } from 'node:os';
12
8
  import { getDb } from '@/src/core/db/database';
13
9
  import { getDbPath } from '@/src/config';
14
10
  import { loadSettings } from './settings';
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
11
 
20
- export type ItemType = 'skill' | 'command';
12
+ type ItemType = 'skill' | 'command';
21
13
 
22
- export interface SkillItem {
14
+ interface SkillItem {
23
15
  name: string;
24
16
  type: ItemType;
25
17
  displayName: string;
@@ -28,35 +20,32 @@ export interface SkillItem {
28
20
  version: string;
29
21
  tags: string[];
30
22
  score: number;
31
- rating: number; // 0-5 star rating from registry
23
+ rating: number;
32
24
  sourceUrl: string;
33
25
  installedGlobal: boolean;
34
26
  installedProjects: string[];
35
- installedVersion: string; // version currently installed (empty if not installed)
36
- hasUpdate: boolean; // true if registry version > installed version
37
- deletedRemotely: boolean; // true if removed from remote registry but still installed locally
27
+ installedVersion: string;
28
+ hasUpdate: boolean;
29
+ deletedRemotely: boolean;
38
30
  }
39
31
 
40
32
  function db() {
41
33
  return getDb(getDbPath());
42
34
  }
43
35
 
44
- const GLOBAL_SKILLS_DIR = join(getClaudeDir(), 'skills');
45
- const GLOBAL_COMMANDS_DIR = join(getClaudeDir(), 'commands');
46
-
47
36
  function getBaseUrl(): string {
48
37
  const settings = loadSettings();
49
38
  return settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
50
39
  }
51
40
 
52
- function getRepoInfo(): string {
53
- const baseUrl = getBaseUrl();
54
- const match = baseUrl.match(/github\.com\/([^/]+\/[^/]+)/);
55
- return match ? match[1] : 'aiwatching/forge-skills';
41
+ function getRepoInfo(): { owner: string; repo: string; branch: string } {
42
+ const url = getBaseUrl();
43
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+))?/) ||
44
+ url.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)/);
45
+ if (match) return { owner: match[1], repo: match[2], branch: match[3] || 'main' };
46
+ return { owner: 'aiwatching', repo: 'forge-skills', branch: 'main' };
56
47
  }
57
48
 
58
- // ─── Version comparison ──────────────────────────────────────
59
-
60
49
  function compareVersions(a: string, b: string): number {
61
50
  const pa = (a || '0.0.0').split('.').map(Number);
62
51
  const pb = (b || '0.0.0').split('.').map(Number);
@@ -67,131 +56,74 @@ function compareVersions(a: string, b: string): number {
67
56
  return 0;
68
57
  }
69
58
 
70
- /** Read installed version from info.json (skills) or return DB version (commands) */
71
- function getInstalledVersion(name: string, type: string, basePath?: string): string {
72
- if (type === 'skill') {
73
- const dir = basePath
74
- ? join(basePath, '.claude', 'skills', name)
75
- : join(GLOBAL_SKILLS_DIR, name);
76
- const infoPath = join(dir, 'info.json');
77
- try {
78
- const info = JSON.parse(readFileSync(infoPath, 'utf-8'));
79
- return info.version || '';
80
- } catch { return ''; }
81
- }
82
- // Commands don't have version files — use DB installed_version
83
- const row = db().prepare('SELECT installed_version FROM skills WHERE name = ?').get(name) as any;
84
- return row?.installed_version || '';
85
- }
59
+ // ─── Sync ─────────────────────────────────────────────────────
86
60
 
87
- // ─── Sync from registry ──────────────────────────────────────
61
+ /** Max info.json enrichments per sync (incremental) */
62
+ const ENRICH_BATCH_SIZE = 10;
88
63
 
89
- export async function syncSkills(): Promise<{ synced: number; error?: string }> {
64
+ export async function syncSkills(): Promise<{ synced: number; enriched: number; total?: number; remaining?: number; error?: string }> {
90
65
  console.log('[skills] Syncing from registry...');
91
66
  const baseUrl = getBaseUrl();
92
67
 
93
68
  try {
69
+ // Step 1: Fetch registry.json (always fresh)
94
70
  const controller = new AbortController();
95
71
  const timeout = setTimeout(() => controller.abort(), 10000);
96
- const res = await fetch(`${baseUrl}/registry.json`, {
72
+ const cacheBust = `_t=${Date.now()}`;
73
+ const res = await fetch(`${baseUrl}/registry.json?${cacheBust}`, {
97
74
  signal: controller.signal,
98
- headers: { 'Accept': 'application/json' },
75
+ headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
99
76
  });
100
77
  clearTimeout(timeout);
101
78
 
102
- if (!res.ok) return { synced: 0, error: `Registry fetch failed: ${res.status}` };
79
+ if (!res.ok) return { synced: 0, enriched: 0, error: `Registry fetch failed: ${res.status}` };
103
80
 
104
81
  const data = await res.json();
105
82
 
106
- // Support both v1 (flat skills array) and v2 (separate skills + commands)
107
- let items: any[] = [];
83
+ // Parse registry items (v1 + v2 support)
84
+ let rawItems: any[] = [];
108
85
  if (data.version === 2) {
109
- const rawItems = [
86
+ rawItems = [
110
87
  ...(data.skills || []).map((s: any) => ({ ...s, type: s.type || 'skill' })),
111
88
  ...(data.commands || []).map((c: any) => ({ ...c, type: c.type || 'command' })),
112
89
  ];
113
- // Always enrich from info.json for latest rating/score/version
114
- items = await Promise.all(rawItems.map(async (s: any) => {
115
- try {
116
- const repoDir = s.type === 'skill' ? 'skills' : 'commands';
117
- let infoRes = await fetch(`${baseUrl}/${repoDir}/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
118
- if (!infoRes.ok) {
119
- const altDir = s.type === 'skill' ? 'commands' : 'skills';
120
- infoRes = await fetch(`${baseUrl}/${altDir}/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
121
- }
122
- if (infoRes.ok) {
123
- const info = await infoRes.json();
124
- return {
125
- ...s,
126
- version: info.version || s.version,
127
- score: info.score ?? s.score ?? 0,
128
- rating: info.rating ?? s.rating ?? 0,
129
- description: info.description || s.description,
130
- };
131
- }
132
- } catch {}
133
- return s;
134
- }));
135
90
  } else {
136
- // v1: enrich from info.json in parallel
137
- const rawItems = data.skills || [];
138
- const enriched = await Promise.all(rawItems.map(async (s: any) => {
139
- // Fetch info.json for metadata and type
140
- try {
141
- let infoRes = await fetch(`${baseUrl}/skills/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
142
- if (!infoRes.ok) {
143
- infoRes = await fetch(`${baseUrl}/commands/${s.name}/info.json`, { signal: AbortSignal.timeout(5000) });
144
- }
145
- if (infoRes.ok) {
146
- const info = await infoRes.json();
147
- return {
148
- ...s,
149
- type: info.type || s.type || 'command',
150
- display_name: info.display_name || s.display_name,
151
- description: info.description || s.description,
152
- version: info.version || s.version,
153
- tags: info.tags || s.tags,
154
- score: info.score ?? s.score,
155
- rating: info.rating ?? s.rating ?? 0,
156
- };
157
- }
158
- } catch {}
159
- return { ...s, type: s.type || 'command' };
160
- }));
161
- items = enriched;
91
+ rawItems = (data.skills || []).map((s: any) => ({ ...s, type: s.type || 'command' }));
162
92
  }
163
93
 
164
- const stmt = db().prepare(`
94
+ // Step 2: Upsert all items from registry.json directly (fast, no extra fetch)
95
+ const upsertStmt = db().prepare(`
165
96
  INSERT OR REPLACE INTO skills (name, type, display_name, description, author, version, tags, score, rating, source_url, archive, synced_at,
166
97
  installed_global, installed_projects, installed_version)
167
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'),
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
99
+ COALESCE((SELECT synced_at FROM skills WHERE name = ?), datetime('now')),
168
100
  COALESCE((SELECT installed_global FROM skills WHERE name = ?), 0),
169
101
  COALESCE((SELECT installed_projects FROM skills WHERE name = ?), '[]'),
170
102
  COALESCE((SELECT installed_version FROM skills WHERE name = ?), ''))
171
103
  `);
172
104
 
173
105
  const tx = db().transaction(() => {
174
- for (const s of items) {
175
- stmt.run(
176
- s.name, s.type || 'skill',
177
- s.display_name, s.description || '',
178
- s.author?.name || '', s.version || '', JSON.stringify(s.tags || []),
179
- s.score || 0, s.rating || 0, s.source?.url || '',
180
- '', // archive field (unused now, kept for compat)
181
- s.name, s.name, s.name
106
+ for (const s of rawItems) {
107
+ upsertStmt.run(
108
+ s.name || '', s.type || 'skill',
109
+ s.display_name || '', s.description || '',
110
+ (s.author?.name || s.author || '').toString(), s.version || '',
111
+ JSON.stringify(s.tags || []),
112
+ s.score ?? 0, s.rating ?? 0, s.source?.url || s.source_url || '',
113
+ '', // archive
114
+ s.name || '', s.name || '', s.name || '', s.name || ''
182
115
  );
183
116
  }
184
117
  });
185
118
  tx();
186
119
 
187
- // Handle items no longer in registry
188
- const registryNames = new Set(items.map(s => s.name));
120
+ // Step 3: Handle items no longer in registry
121
+ const registryNames = new Set(rawItems.map((s: any) => s.name));
189
122
  const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
190
123
  for (const row of dbItems) {
191
124
  if (!registryNames.has(row.name)) {
192
125
  const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
193
126
  if (hasLocal) {
194
- // Still installed locally — mark as deleted remotely so the user can decide
195
127
  db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
196
128
  } else {
197
129
  db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
@@ -199,10 +131,64 @@ export async function syncSkills(): Promise<{ synced: number; error?: string }>
199
131
  }
200
132
  }
201
133
 
202
- console.log(`[skills] Synced ${items.length} items`);
203
- return { synced: items.length };
134
+ // Step 4: Incremental enrichment — fetch info.json for oldest-synced items
135
+ // Pick items whose synced_at is oldest (or version changed since last enrich)
136
+ const staleItems = db().prepare(`
137
+ SELECT name, type, version FROM skills
138
+ WHERE deleted_remotely = 0
139
+ ORDER BY synced_at ASC
140
+ LIMIT ?
141
+ `).all(ENRICH_BATCH_SIZE) as any[];
142
+
143
+ let enriched = 0;
144
+ const enrichStmt = db().prepare(`
145
+ UPDATE skills SET
146
+ version = COALESCE(?, version),
147
+ tags = COALESCE(?, tags),
148
+ score = COALESCE(?, score),
149
+ rating = COALESCE(?, rating),
150
+ description = COALESCE(?, description),
151
+ synced_at = datetime('now')
152
+ WHERE name = ?
153
+ `);
154
+
155
+ await Promise.all(staleItems.map(async (s: any) => {
156
+ try {
157
+ const repoDir = s.type === 'skill' ? 'skills' : 'commands';
158
+ let infoRes = await fetch(`${baseUrl}/${repoDir}/${s.name}/info.json?${cacheBust}`, { signal: AbortSignal.timeout(5000) });
159
+ if (!infoRes.ok) {
160
+ const altDir = s.type === 'skill' ? 'commands' : 'skills';
161
+ infoRes = await fetch(`${baseUrl}/${altDir}/${s.name}/info.json?${cacheBust}`, { signal: AbortSignal.timeout(5000) });
162
+ }
163
+ if (infoRes.ok) {
164
+ const info = await infoRes.json();
165
+ enrichStmt.run(
166
+ info.version || null,
167
+ info.tags?.length ? JSON.stringify(info.tags) : null,
168
+ info.score ?? null,
169
+ info.rating ?? null,
170
+ info.description || null,
171
+ s.name
172
+ );
173
+ enriched++;
174
+ } else {
175
+ // No info.json — just update synced_at so it rotates to the back
176
+ db().prepare('UPDATE skills SET synced_at = datetime(\'now\') WHERE name = ?').run(s.name);
177
+ }
178
+ } catch {
179
+ // Timeout/error — update synced_at to avoid retrying immediately
180
+ db().prepare('UPDATE skills SET synced_at = datetime(\'now\') WHERE name = ?').run(s.name);
181
+ }
182
+ }));
183
+
184
+ const totalCount = (db().prepare('SELECT count(*) as c FROM skills WHERE deleted_remotely = 0').get() as any).c;
185
+ const remaining = totalCount - ENRICH_BATCH_SIZE; // approximate items not yet enriched this round
186
+ console.log(`[skills] Synced ${rawItems.length} items, enriched ${enriched}/${staleItems.length} from info.json`);
187
+ return { synced: rawItems.length, enriched, total: totalCount, remaining: Math.max(0, remaining) };
204
188
  } catch (e) {
205
- return { synced: 0, error: e instanceof Error ? e.message : String(e) };
189
+ const msg = e instanceof Error ? e.message : String(e);
190
+ console.error(`[skills] Sync failed:`, msg);
191
+ return { synced: 0, enriched: 0, error: msg };
206
192
  }
207
193
  }
208
194
 
@@ -248,334 +234,225 @@ async function listRepoFiles(name: string, type: ItemType): Promise<{ path: stri
248
234
  if (!res.ok) return;
249
235
  const items = await res.json();
250
236
  if (!Array.isArray(items)) return;
251
-
252
237
  for (const item of items) {
253
- const relPath = prefix ? `${prefix}/${item.name}` : item.name;
254
- if (item.type === 'file') {
255
- files.push({ path: relPath, download_url: item.download_url });
238
+ if (item.type === 'file' && item.download_url) {
239
+ files.push({ path: join(prefix, item.name), download_url: item.download_url });
256
240
  } else if (item.type === 'dir') {
257
- await recurse(item.url, relPath);
241
+ await recurse(item.url, join(prefix, item.name));
258
242
  }
259
243
  }
260
244
  }
261
245
 
262
246
  // Try skills/ first, then commands/
263
- await recurse(`https://api.github.com/repos/${repo}/contents/skills/${name}`, '');
264
- if (files.length === 0) {
265
- await recurse(`https://api.github.com/repos/${repo}/contents/commands/${name}`, '');
247
+ const dirs = type === 'skill' ? ['skills', 'commands'] : ['commands', 'skills'];
248
+ for (const dir of dirs) {
249
+ const url = `https://api.github.com/repos/${repo.owner}/${repo.repo}/contents/${dir}/${name}?ref=${repo.branch}`;
250
+ await recurse(url, '');
251
+ if (files.length > 0) return files;
266
252
  }
267
253
  return files;
268
254
  }
269
255
 
270
- /** Download all files to a local directory */
271
- async function downloadToDir(files: { path: string; download_url: string }[], destDir: string): Promise<void> {
272
- if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
256
+ async function downloadFile(url: string): Promise<string> {
257
+ const res = await fetch(url);
258
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
259
+ return res.text();
260
+ }
273
261
 
274
- for (const file of files) {
275
- const filePath = join(destDir, file.path);
276
- const fileDir = join(destDir, file.path.split('/').slice(0, -1).join('/'));
277
- if (fileDir !== destDir && !existsSync(fileDir)) mkdirSync(fileDir, { recursive: true });
262
+ // ─── Install ─────────────────────────────────────────────────
278
263
 
279
- const res = await fetch(file.download_url);
280
- if (!res.ok) continue;
281
- const content = await res.text();
282
- writeFileSync(filePath, content, 'utf-8');
283
- }
264
+ function getClaudeHome(): string {
265
+ const settings = loadSettings();
266
+ return settings.claudeHome || join(homedir(), '.claude');
284
267
  }
285
268
 
286
- // ─── Install ─────────────────────────────────────────────────
269
+ function getSkillDir(name: string, type: ItemType, projectPath?: string): string {
270
+ const base = projectPath || getClaudeHome();
271
+ const subdir = type === 'skill' ? 'skills' : 'commands';
272
+ return join(base, '.claude', subdir, name);
273
+ }
287
274
 
288
275
  export async function installGlobal(name: string): Promise<void> {
289
- console.log(`[skills] Installing "${name}" globally`);
290
- const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
291
- if (!row) throw new Error(`Not found: ${name}`);
292
- const type: ItemType = row.type || 'skill';
293
- const version = row.version || '';
276
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
277
+ if (!skill) throw new Error(`Skill "${name}" not found`);
294
278
 
295
- const files = await listRepoFiles(name, type);
279
+ const type: ItemType = skill.type || 'skill';
280
+ const claudeHome = getClaudeHome();
281
+ const subdir = type === 'skill' ? 'skills' : 'commands';
282
+ const targetDir = join(claudeHome, subdir, name);
296
283
 
297
- if (type === 'skill') {
298
- const dest = join(GLOBAL_SKILLS_DIR, name);
299
- if (existsSync(dest)) rmSync(dest, { recursive: true });
300
- await downloadToDir(files, dest);
301
- } else {
302
- // Command: download all files to commands/<name>/ directory
303
- // If only a single .md, also copy as <name>.md directly for slash command registration
304
- const dest = join(GLOBAL_COMMANDS_DIR);
305
- if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
306
- const mdFiles = files.filter(f => f.path.endsWith('.md') && f.path !== 'info.json');
307
- if (mdFiles.length === 1 && files.filter(f => f.path !== 'info.json').length === 1) {
308
- // Single .md command — write directly as <name>.md
309
- const res = await fetch(mdFiles[0].download_url);
310
- if (res.ok) writeFileSync(join(dest, `${name}.md`), await res.text(), 'utf-8');
311
- } else {
312
- // Multi-file command — copy all files (except info.json) to commands/<name>/
313
- const cmdDir = join(dest, name);
314
- if (existsSync(cmdDir)) rmSync(cmdDir, { recursive: true });
315
- const nonInfo = files.filter(f => f.path !== 'info.json');
316
- await downloadToDir(nonInfo, cmdDir);
317
- }
284
+ const files = await listRepoFiles(name, type);
285
+ if (files.length === 0) throw new Error(`No files found for ${name}`);
286
+
287
+ mkdirSync(targetDir, { recursive: true });
288
+ for (const f of files) {
289
+ const content = await downloadFile(f.download_url);
290
+ const targetPath = join(targetDir, f.path);
291
+ mkdirSync(dirname(targetPath), { recursive: true });
292
+ writeFileSync(targetPath, content);
318
293
  }
319
294
 
320
- db().prepare('UPDATE skills SET installed_global = 1, installed_version = ? WHERE name = ?').run(version, name);
295
+ // Update installed state
296
+ db().prepare('UPDATE skills SET installed_global = 1, installed_version = ? WHERE name = ?')
297
+ .run(skill.version || '', name);
321
298
  }
322
299
 
323
300
  export async function installProject(name: string, projectPath: string): Promise<void> {
324
- console.log(`[skills] Installing "${name}" to ${projectPath.split('/').pop()}`);
325
- const row = db().prepare('SELECT type, version FROM skills WHERE name = ?').get(name) as any;
326
- if (!row) throw new Error(`Not found: ${name}`);
327
- const type: ItemType = row.type || 'skill';
328
- const version = row.version || '';
301
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
302
+ if (!skill) throw new Error(`Skill "${name}" not found`);
329
303
 
330
- const files = await listRepoFiles(name, type);
304
+ const type: ItemType = skill.type || 'skill';
305
+ const subdir = type === 'skill' ? 'skills' : 'commands';
306
+ const targetDir = join(projectPath, '.claude', subdir, name);
331
307
 
332
- if (type === 'skill') {
333
- const dest = join(projectPath, '.claude', 'skills', name);
334
- if (existsSync(dest)) rmSync(dest, { recursive: true });
335
- await downloadToDir(files, dest);
336
- } else {
337
- const cmdDir = join(projectPath, '.claude', 'commands');
338
- if (!existsSync(cmdDir)) mkdirSync(cmdDir, { recursive: true });
339
- const mdFiles = files.filter(f => f.path.endsWith('.md') && f.path !== 'info.json');
340
- if (mdFiles.length === 1 && files.filter(f => f.path !== 'info.json').length === 1) {
341
- const res = await fetch(mdFiles[0].download_url);
342
- if (res.ok) writeFileSync(join(cmdDir, `${name}.md`), await res.text(), 'utf-8');
343
- } else {
344
- const subDir = join(cmdDir, name);
345
- if (existsSync(subDir)) rmSync(subDir, { recursive: true });
346
- const nonInfo = files.filter(f => f.path !== 'info.json');
347
- await downloadToDir(nonInfo, subDir);
348
- }
308
+ const files = await listRepoFiles(name, type);
309
+ if (files.length === 0) throw new Error(`No files found for ${name}`);
310
+
311
+ mkdirSync(targetDir, { recursive: true });
312
+ for (const f of files) {
313
+ const content = await downloadFile(f.download_url);
314
+ const targetPath = join(targetDir, f.path);
315
+ mkdirSync(dirname(targetPath), { recursive: true });
316
+ writeFileSync(targetPath, content);
349
317
  }
350
318
 
351
- // Update installed_projects + version
352
- const current = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
353
- const projects: string[] = JSON.parse(current?.installed_projects || '[]');
354
- if (!projects.includes(projectPath)) {
355
- projects.push(projectPath);
356
- }
319
+ // Update installed state
320
+ const existing = JSON.parse(skill.installed_projects || '[]');
321
+ if (!existing.includes(projectPath)) existing.push(projectPath);
357
322
  db().prepare('UPDATE skills SET installed_projects = ?, installed_version = ? WHERE name = ?')
358
- .run(JSON.stringify(projects), version, name);
323
+ .run(JSON.stringify(existing), skill.version || '', name);
359
324
  }
360
325
 
361
326
  // ─── Uninstall ───────────────────────────────────────────────
362
327
 
363
328
  export function uninstallGlobal(name: string): void {
364
- console.log(`[skills] Uninstalling "${name}" from global`);
365
- // Remove from all possible locations
366
- try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
367
- try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
368
- try { rmSync(join(GLOBAL_COMMANDS_DIR, name), { recursive: true }); } catch {}
369
-
370
- db().prepare('UPDATE skills SET installed_global = 0, installed_version = ? WHERE name = ?')
371
- .run('', name);
329
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
330
+ if (!skill) return;
331
+
332
+ const type: ItemType = skill.type || 'skill';
333
+ const claudeHome = getClaudeHome();
334
+ const subdir = type === 'skill' ? 'skills' : 'commands';
335
+ const targetDir = join(claudeHome, subdir, name);
336
+
337
+ if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
338
+
339
+ db().prepare('UPDATE skills SET installed_global = 0 WHERE name = ?').run(name);
340
+ // Clear installed_version if no project installs remain
341
+ const remaining = JSON.parse(skill.installed_projects || '[]');
342
+ if (remaining.length === 0) {
343
+ db().prepare('UPDATE skills SET installed_version = ? WHERE name = ?').run('', name);
344
+ }
372
345
  }
373
346
 
374
347
  export function uninstallProject(name: string, projectPath: string): void {
375
- console.log(`[skills] Uninstalling "${name}" from ${projectPath.split('/').pop()}`);
376
- // Remove from all possible locations
377
- try { rmSync(join(projectPath, '.claude', 'skills', name), { recursive: true }); } catch {}
378
- try { unlinkSync(join(projectPath, '.claude', 'commands', `${name}.md`)); } catch {}
379
- try { rmSync(join(projectPath, '.claude', 'commands', name), { recursive: true }); } catch {}
380
-
381
- const current = db().prepare('SELECT installed_projects FROM skills WHERE name = ?').get(name) as any;
382
- const projects: string[] = JSON.parse(current?.installed_projects || '[]');
383
- const updated = projects.filter(p => p !== projectPath);
384
- db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?').run(JSON.stringify(updated), name);
385
- // Clear installed_version if no longer installed anywhere
386
- if (updated.length === 0) {
387
- const row2 = db().prepare('SELECT installed_global FROM skills WHERE name = ?').get(name) as any;
388
- if (!row2?.installed_global) {
389
- db().prepare('UPDATE skills SET installed_version = ? WHERE name = ?').run('', name);
390
- }
348
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
349
+ if (!skill) return;
350
+
351
+ const type: ItemType = skill.type || 'skill';
352
+ const subdir = type === 'skill' ? 'skills' : 'commands';
353
+ const targetDir = join(projectPath, '.claude', subdir, name);
354
+
355
+ if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
356
+
357
+ const existing = JSON.parse(skill.installed_projects || '[]').filter((p: string) => p !== projectPath);
358
+ db().prepare('UPDATE skills SET installed_projects = ? WHERE name = ?')
359
+ .run(JSON.stringify(existing), name);
360
+ // Clear installed_version if nothing remains
361
+ if (!skill.installed_global && existing.length === 0) {
362
+ db().prepare('UPDATE skills SET installed_version = ? WHERE name = ?').run('', name);
391
363
  }
392
364
  }
393
365
 
394
- // ─── Scan installed state from filesystem ────────────────────
366
+ // ─── Refresh install state from filesystem ───────────────────
395
367
 
396
368
  export function refreshInstallState(projectPaths: string[]): void {
397
- const items = db().prepare('SELECT name, type FROM skills').all() as { name: string; type: string }[];
369
+ const claudeHome = getClaudeHome();
370
+ const rows = db().prepare('SELECT name, type FROM skills').all() as any[];
398
371
 
399
- for (const { name, type } of items) {
400
- let globalInstalled = false;
401
- let installedVersion = '';
372
+ for (const row of rows) {
373
+ const type: ItemType = row.type || 'skill';
374
+ const subdir = type === 'skill' ? 'skills' : 'commands';
402
375
 
403
- // Check BOTH locations — skill dir and command file/dir
404
- const skillDir = join(GLOBAL_SKILLS_DIR, name);
405
- const cmdFile = join(GLOBAL_COMMANDS_DIR, `${name}.md`);
406
- const cmdDir = join(GLOBAL_COMMANDS_DIR, name);
407
-
408
- if (existsSync(skillDir)) {
409
- globalInstalled = true;
410
- installedVersion = getInstalledVersion(name, 'skill');
411
- } else if (existsSync(cmdFile)) {
412
- globalInstalled = true;
413
- } else if (existsSync(cmdDir)) {
414
- globalInstalled = true;
415
- }
376
+ // Check global
377
+ const globalDir = join(claudeHome, subdir, row.name);
378
+ const globalInstalled = existsSync(globalDir);
416
379
 
380
+ // Check projects
417
381
  const installedIn: string[] = [];
418
382
  for (const pp of projectPaths) {
419
- // Check all possible install locations for this project
420
- const projSkillDir = join(pp, '.claude', 'skills', name);
421
- const projCmdFile = join(pp, '.claude', 'commands', `${name}.md`);
422
- const projCmdDir = join(pp, '.claude', 'commands', name);
423
- if (existsSync(projSkillDir) || existsSync(projCmdFile) || existsSync(projCmdDir)) {
424
- installedIn.push(pp);
383
+ const projDir = join(pp, '.claude', subdir, row.name);
384
+ if (existsSync(projDir)) installedIn.push(pp);
385
+ }
386
+
387
+ // Read installed version from info.json if available
388
+ let installedVersion = '';
389
+ const checkDirs = globalInstalled ? [globalDir] : installedIn.length > 0 ? [join(installedIn[0], '.claude', subdir, row.name)] : [];
390
+ for (const d of checkDirs) {
391
+ const infoPath = join(d, 'info.json');
392
+ if (existsSync(infoPath)) {
393
+ try {
394
+ const info = JSON.parse(readFileSync(infoPath, 'utf-8'));
395
+ installedVersion = info.version || '';
396
+ } catch {}
397
+ break;
425
398
  }
426
399
  }
427
400
 
428
- db().prepare('UPDATE skills SET installed_global = ?, installed_projects = ?, installed_version = CASE WHEN ? != \'\' THEN ? ELSE installed_version END WHERE name = ?')
429
- .run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), installedVersion, installedVersion, name);
401
+ db().prepare('UPDATE skills SET installed_global = ?, installed_projects = ?, installed_version = ? WHERE name = ?')
402
+ .run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), installedVersion, row.name);
430
403
  }
431
404
  }
432
405
 
433
406
  // ─── Check local modifications ───────────────────────────────
434
407
 
435
- /** Compare local installed files against remote. Returns true if any file was locally modified. */
436
408
  export async function checkLocalModified(name: string): Promise<boolean> {
437
- const row = db().prepare('SELECT type FROM skills WHERE name = ?').get(name) as any;
438
- if (!row) return false;
439
- const type: ItemType = row.type || 'command';
409
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
410
+ if (!skill) return false;
440
411
 
441
- // Get remote file list
442
- const remoteFiles = await listRepoFiles(name, type);
412
+ const type: ItemType = skill.type || 'skill';
413
+ const claudeHome = getClaudeHome();
414
+ const subdir = type === 'skill' ? 'skills' : 'commands';
415
+ const localDir = join(claudeHome, subdir, name);
443
416
 
444
- // Compare each file
445
- for (const rf of remoteFiles) {
446
- if (rf.path === 'info.json') continue; // skip metadata
417
+ if (!existsSync(localDir)) return false;
447
418
 
448
- let localPath: string;
449
- if (type === 'skill') {
450
- localPath = join(GLOBAL_SKILLS_DIR, name, rf.path);
451
- } else {
452
- // Single file command
453
- const cmdFile = join(GLOBAL_COMMANDS_DIR, `${name}.md`);
454
- const cmdDir = join(GLOBAL_COMMANDS_DIR, name);
455
- if (existsSync(cmdDir)) {
456
- localPath = join(cmdDir, rf.path);
457
- } else {
458
- localPath = cmdFile;
459
- }
419
+ // Compare with remote files
420
+ try {
421
+ const remoteFiles = await listRepoFiles(name, type);
422
+ for (const rf of remoteFiles) {
423
+ const localPath = join(localDir, rf.path);
424
+ if (!existsSync(localPath)) return true;
425
+ const localContent = readFileSync(localPath, 'utf-8');
426
+ const remoteContent = await downloadFile(rf.download_url);
427
+ if (localContent !== remoteContent) return true;
460
428
  }
461
-
462
- if (!existsSync(localPath)) continue;
463
- const localContent = readFileSync(localPath, 'utf-8');
464
-
465
- // Fetch remote content
466
- try {
467
- const res = await fetch(rf.download_url);
468
- if (!res.ok) continue;
469
- const remoteContent = await res.text();
470
- const localHash = createHash('md5').update(localContent).digest('hex');
471
- const remoteHash = createHash('md5').update(remoteContent).digest('hex');
472
- if (localHash !== remoteHash) return true;
473
- } catch { continue; }
429
+ } catch {
430
+ return false;
474
431
  }
475
432
 
476
433
  return false;
477
434
  }
478
435
 
479
- // ─── Local-to-local install (copy between projects/global) ───
480
-
481
- /** Recursively copy a directory */
482
- function copyDir(src: string, dest: string): void {
483
- if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
484
- for (const entry of readdirSync(src, { withFileTypes: true })) {
485
- const srcPath = join(src, entry.name);
486
- const destPath = join(dest, entry.name);
487
- if (entry.isDirectory()) {
488
- copyDir(srcPath, destPath);
489
- } else {
490
- writeFileSync(destPath, readFileSync(srcPath));
491
- }
492
- }
493
- }
494
-
495
- /** Resolve the source path for a local skill/command */
496
- function resolveLocalSource(name: string, type: string, sourceProject?: string): string | null {
497
- const base = sourceProject ? join(sourceProject, '.claude') : join(getClaudeDir());
498
- if (type === 'skill') {
499
- const dir = join(base, 'skills', name);
500
- if (existsSync(dir)) return dir;
501
- } else {
502
- const file = join(base, 'commands', `${name}.md`);
503
- if (existsSync(file)) return file;
504
- const dir = join(base, 'commands', name);
505
- if (existsSync(dir)) return dir;
506
- }
507
- return null;
508
- }
436
+ // ─── Purge deleted skill ─────────────────────────────────────
509
437
 
510
- /** Install a local skill/command to a target (global or project path). Force=overwrite existing. */
511
- export function installLocal(name: string, type: string, sourceProject: string | undefined, target: string, force?: boolean): { ok: boolean; error?: string } {
512
- const src = resolveLocalSource(name, type, sourceProject);
513
- if (!src) return { ok: false, error: 'Source not found' };
514
-
515
- const isGlobal = target === 'global';
516
- const isFile = !existsSync(src) ? false : require('node:fs').statSync(src).isFile();
517
-
518
- if (type === 'skill') {
519
- const dest = isGlobal
520
- ? join(GLOBAL_SKILLS_DIR, name)
521
- : join(target, '.claude', 'skills', name);
522
- if (existsSync(dest) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
523
- if (existsSync(dest)) rmSync(dest, { recursive: true });
524
- copyDir(src, dest);
525
- } else {
526
- const destBase = isGlobal ? GLOBAL_COMMANDS_DIR : join(target, '.claude', 'commands');
527
- if (!existsSync(destBase)) mkdirSync(destBase, { recursive: true });
528
- if (isFile) {
529
- const destFile = join(destBase, `${name}.md`);
530
- if (existsSync(destFile) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
531
- writeFileSync(destFile, readFileSync(src));
532
- } else {
533
- const dest = join(destBase, name);
534
- if (existsSync(dest) && !force) return { ok: false, error: 'Already installed. Use force to overwrite.' };
535
- if (existsSync(dest)) rmSync(dest, { recursive: true });
536
- copyDir(src, dest);
537
- }
538
- }
539
-
540
- return { ok: true };
541
- }
542
-
543
- /** Remove all local files for a skill deleted from the remote registry, then drop its DB record. */
544
438
  export function purgeDeletedSkill(name: string): void {
545
- const row = db().prepare('SELECT type, installed_projects FROM skills WHERE name = ?').get(name) as any;
546
- if (!row) return;
547
- const type: string = row.type || 'skill';
548
- const projects: string[] = JSON.parse(row.installed_projects || '[]');
439
+ const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
440
+ if (!skill) return;
549
441
 
550
- // Remove global files
551
- try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
552
- try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
553
- try { rmSync(join(GLOBAL_COMMANDS_DIR, name), { recursive: true }); } catch {}
442
+ const type: ItemType = skill.type || 'skill';
443
+ const claudeHome = getClaudeHome();
444
+ const subdir = type === 'skill' ? 'skills' : 'commands';
554
445
 
555
- // Remove project-local files
446
+ // Remove global
447
+ const globalDir = join(claudeHome, subdir, name);
448
+ if (existsSync(globalDir)) rmSync(globalDir, { recursive: true, force: true });
449
+
450
+ // Remove from projects
451
+ const projects = JSON.parse(skill.installed_projects || '[]');
556
452
  for (const pp of projects) {
557
- try { rmSync(join(pp, '.claude', 'skills', name), { recursive: true }); } catch {}
558
- try { unlinkSync(join(pp, '.claude', 'commands', `${name}.md`)); } catch {}
559
- try { rmSync(join(pp, '.claude', 'commands', name), { recursive: true }); } catch {}
453
+ const projDir = join(pp, '.claude', subdir, name);
454
+ if (existsSync(projDir)) rmSync(projDir, { recursive: true, force: true });
560
455
  }
561
456
 
562
- // Remove DB record entirely
563
457
  db().prepare('DELETE FROM skills WHERE name = ?').run(name);
564
458
  }
565
-
566
- /** Delete a local skill/command from a specific project or global */
567
- export function deleteLocal(name: string, type: string, projectPath?: string): boolean {
568
- const base = projectPath ? join(projectPath, '.claude') : getClaudeDir();
569
- if (type === 'skill') {
570
- const dir = join(base, 'skills', name);
571
- try { rmSync(dir, { recursive: true }); return true; } catch { return false; }
572
- } else {
573
- // Try file first, then directory
574
- const file = join(base, 'commands', `${name}.md`);
575
- const dir = join(base, 'commands', name);
576
- let deleted = false;
577
- try { unlinkSync(file); deleted = true; } catch {}
578
- try { rmSync(dir, { recursive: true }); deleted = true; } catch {}
579
- return deleted;
580
- }
581
- }