@aion0/forge 0.4.15 → 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.
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -0
package/lib/skills.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills
|
|
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
|
-
|
|
12
|
+
type ItemType = 'skill' | 'command';
|
|
21
13
|
|
|
22
|
-
|
|
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;
|
|
23
|
+
rating: number;
|
|
32
24
|
sourceUrl: string;
|
|
33
25
|
installedGlobal: boolean;
|
|
34
26
|
installedProjects: string[];
|
|
35
|
-
installedVersion: string;
|
|
36
|
-
hasUpdate: boolean;
|
|
37
|
-
deletedRemotely: boolean;
|
|
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
|
|
54
|
-
const match =
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
107
|
-
let
|
|
83
|
+
// Parse registry items (v1 + v2 support)
|
|
84
|
+
let rawItems: any[] = [];
|
|
108
85
|
if (data.version === 2) {
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
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
|
|
175
|
-
|
|
176
|
-
s.name, s.type || 'skill',
|
|
177
|
-
s.display_name, s.description || '',
|
|
178
|
-
s.author?.name ||
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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,
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
352
|
-
const
|
|
353
|
-
|
|
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(
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
// ───
|
|
366
|
+
// ─── Refresh install state from filesystem ───────────────────
|
|
395
367
|
|
|
396
368
|
export function refreshInstallState(projectPaths: string[]): void {
|
|
397
|
-
const
|
|
369
|
+
const claudeHome = getClaudeHome();
|
|
370
|
+
const rows = db().prepare('SELECT name, type FROM skills').all() as any[];
|
|
398
371
|
|
|
399
|
-
for (const
|
|
400
|
-
|
|
401
|
-
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
const type: ItemType = row.type || 'skill';
|
|
374
|
+
const subdir = type === 'skill' ? 'skills' : 'commands';
|
|
402
375
|
|
|
403
|
-
// Check
|
|
404
|
-
const
|
|
405
|
-
const
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 =
|
|
429
|
-
.run(globalInstalled ? 1 : 0, JSON.stringify(installedIn), installedVersion,
|
|
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
|
|
438
|
-
if (!
|
|
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
|
-
|
|
442
|
-
const
|
|
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
|
-
|
|
445
|
-
for (const rf of remoteFiles) {
|
|
446
|
-
if (rf.path === 'info.json') continue; // skip metadata
|
|
417
|
+
if (!existsSync(localDir)) return false;
|
|
447
418
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
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
|
|
546
|
-
if (!
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
}
|