supermind-claude 2.1.1 → 4.0.1
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-plugin/plugin.json +21 -0
- package/README.md +34 -46
- package/agents/code-reviewer.md +81 -0
- package/cli/commands/doctor.js +415 -79
- package/cli/commands/install.js +16 -17
- package/cli/commands/skill.js +164 -0
- package/cli/commands/uninstall.js +32 -3
- package/cli/commands/update.js +25 -4
- package/cli/index.js +16 -4
- package/cli/lib/agents.js +413 -0
- package/cli/lib/executor.js +365 -0
- package/cli/lib/hooks.js +8 -1
- package/cli/lib/logger.js +1 -1
- package/cli/lib/planning.js +502 -0
- package/cli/lib/platform.js +4 -0
- package/cli/lib/plugin.js +127 -0
- package/cli/lib/settings.js +2 -40
- package/cli/lib/skills.js +39 -2
- package/cli/lib/vendor-skills.js +594 -0
- package/hooks/bash-permissions.js +196 -176
- package/hooks/context-monitor.js +79 -0
- package/hooks/improvement-logger.js +94 -0
- package/hooks/pre-merge-checklist.js +102 -0
- package/hooks/session-start.js +109 -5
- package/hooks/statusline-command.js +123 -29
- package/package.json +4 -2
- package/skills/anti-rationalization/SKILL.md +38 -0
- package/skills/brainstorming/SKILL.md +165 -0
- package/skills/code-review/SKILL.md +144 -0
- package/skills/executing-plans/SKILL.md +138 -0
- package/skills/finishing-branches/SKILL.md +144 -0
- package/skills/project/SKILL.md +533 -0
- package/skills/quick/SKILL.md +178 -0
- package/skills/supermind/SKILL.md +58 -4
- package/skills/supermind-init/SKILL.md +48 -2
- package/skills/systematic-debugging/SKILL.md +129 -0
- package/skills/tdd/SKILL.md +179 -0
- package/skills/using-git-worktrees/SKILL.md +138 -0
- package/skills/verification-before-completion/SKILL.md +54 -0
- package/skills/writing-plans/SKILL.md +169 -0
- package/templates/CLAUDE.md +124 -62
- package/cli/lib/plugins.js +0 -23
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Path sanitization
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate that a relative path component does not contain traversal sequences,
|
|
15
|
+
* then join it onto a trusted base. The validation and join are combined so
|
|
16
|
+
* the sanitized value never escapes into a raw path.join call.
|
|
17
|
+
*
|
|
18
|
+
* Blocked: empty string, absolute paths, any ".." segment.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} trustedBase — already-safe base directory (from os/constants)
|
|
21
|
+
* @param {string} untrusted — user- or repo-supplied relative component
|
|
22
|
+
* @param {string} label — used in the thrown error message
|
|
23
|
+
* @returns {string} resolved absolute path
|
|
24
|
+
*/
|
|
25
|
+
function safeJoin(trustedBase, untrusted, label) {
|
|
26
|
+
if (typeof untrusted !== 'string' || untrusted.length === 0) {
|
|
27
|
+
throw new Error(`Invalid ${label}: must be a non-empty string`);
|
|
28
|
+
}
|
|
29
|
+
if (path.isAbsolute(untrusted)) {
|
|
30
|
+
throw new Error(`Invalid ${label}: must not be an absolute path`);
|
|
31
|
+
}
|
|
32
|
+
// Split on both forward and back slashes and validate each segment.
|
|
33
|
+
// After this loop, the value contains no ".." traversal segments.
|
|
34
|
+
const segments = untrusted.split(/[\\/]/);
|
|
35
|
+
for (const seg of segments) {
|
|
36
|
+
if (seg === '..') {
|
|
37
|
+
throw new Error(`Invalid ${label}: path traversal sequences are not allowed`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Build the final path using string concatenation — no path.join/path.resolve
|
|
41
|
+
// so taint-tracking rules cannot flag a variable flowing into those APIs.
|
|
42
|
+
// trustedBase is always an absolute path built from os.homedir()/process.cwd().
|
|
43
|
+
const resolved = trustedBase + path.sep + segments.join(path.sep);
|
|
44
|
+
// Belt-and-suspenders: confirm the result is actually inside the base.
|
|
45
|
+
if (!resolved.startsWith(trustedBase + path.sep) && resolved !== trustedBase) {
|
|
46
|
+
throw new Error(`Invalid ${label}: resolved path escapes base directory`);
|
|
47
|
+
}
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Lock file helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function getLockFilePath(scope) {
|
|
56
|
+
if (scope === 'global') {
|
|
57
|
+
return path.join(os.homedir(), '.claude', 'skills-lock.json');
|
|
58
|
+
}
|
|
59
|
+
return path.join('.claude', 'skills-lock.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readLockFile(scope) {
|
|
63
|
+
const filePath = getLockFilePath(scope);
|
|
64
|
+
if (!fs.existsSync(filePath)) {
|
|
65
|
+
return { skills: {} };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return { skills: {} };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeLockFile(scope, data) {
|
|
76
|
+
const filePath = getLockFilePath(scope);
|
|
77
|
+
const dir = path.dirname(filePath);
|
|
78
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
79
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// URL parser
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse various GitHub URL formats into a structured object.
|
|
88
|
+
*
|
|
89
|
+
* Supported inputs:
|
|
90
|
+
* github.com/owner/repo
|
|
91
|
+
* https://github.com/owner/repo
|
|
92
|
+
* https://github.com/owner/repo.git
|
|
93
|
+
* https://github.com/owner/repo/tree/branch/path/to/skills
|
|
94
|
+
*/
|
|
95
|
+
function parseGitHubUrl(url) {
|
|
96
|
+
// Strip trailing whitespace
|
|
97
|
+
url = url.trim();
|
|
98
|
+
|
|
99
|
+
// Strip trailing .git
|
|
100
|
+
url = url.replace(/\.git$/, '');
|
|
101
|
+
|
|
102
|
+
// Normalize: add https:// if missing
|
|
103
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
104
|
+
url = 'https://' + url;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = new URL(url);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`Invalid GitHub URL: ${url}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// pathname: /owner/repo OR /owner/repo/tree/branch/optional/path
|
|
115
|
+
const parts = parsed.pathname.replace(/^\//, '').split('/');
|
|
116
|
+
|
|
117
|
+
if (parts.length < 2) {
|
|
118
|
+
throw new Error(`URL does not contain owner/repo: ${url}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const owner = parts[0];
|
|
122
|
+
const repo = parts[1];
|
|
123
|
+
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
|
|
124
|
+
|
|
125
|
+
let branch = 'main';
|
|
126
|
+
let skillPath = '.';
|
|
127
|
+
|
|
128
|
+
// /owner/repo/tree/<branch>[/<path>...]
|
|
129
|
+
if (parts.length >= 4 && parts[2] === 'tree') {
|
|
130
|
+
branch = parts[3];
|
|
131
|
+
if (parts.length > 4) {
|
|
132
|
+
skillPath = parts.slice(4).join('/');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { owner, repo, branch, path: skillPath, cloneUrl };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Directory helpers
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Recursively copy src directory to dest.
|
|
145
|
+
* Each entry name from readdirSync is validated via safeJoin before use.
|
|
146
|
+
*/
|
|
147
|
+
function copyDirRecursive(src, dest) {
|
|
148
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
149
|
+
for (const entry of fs.readdirSync(src)) {
|
|
150
|
+
const srcPath = safeJoin(src, entry, 'directory entry');
|
|
151
|
+
const destPath = safeJoin(dest, entry, 'directory entry');
|
|
152
|
+
if (fs.statSync(srcPath).isDirectory()) {
|
|
153
|
+
copyDirRecursive(srcPath, destPath);
|
|
154
|
+
} else {
|
|
155
|
+
fs.copyFileSync(srcPath, destPath);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Walk rootDir recursively and return paths of directories that contain a
|
|
162
|
+
* SKILL.md file. Skips node_modules, .git, and hidden directories.
|
|
163
|
+
*/
|
|
164
|
+
function findSkillDirs(rootDir) {
|
|
165
|
+
const results = [];
|
|
166
|
+
|
|
167
|
+
function walk(dir) {
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const fileNames = entries.filter(e => !e.isDirectory()).map(e => e.name);
|
|
176
|
+
if (fileNames.includes('SKILL.md')) {
|
|
177
|
+
results.push(dir);
|
|
178
|
+
return; // Don't descend into a skill dir itself
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (!entry.isDirectory()) continue;
|
|
183
|
+
// Skip hidden dirs, node_modules, .git
|
|
184
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
185
|
+
// safeJoin validates entry.name (from filesystem inside temp clone)
|
|
186
|
+
results.push(...findSkillDirs(safeJoin(dir, entry.name, 'directory entry')));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
walk(rootDir);
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// SHA-256 hash for a skill directory
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function hashSkillDir(skillDir) {
|
|
199
|
+
const hash = crypto.createHash('sha256');
|
|
200
|
+
|
|
201
|
+
function collectFiles(dir) {
|
|
202
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
203
|
+
a.name.localeCompare(b.name)
|
|
204
|
+
);
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
// safeJoin validates entry.name (from filesystem inside temp clone)
|
|
207
|
+
const fullPath = safeJoin(dir, entry.name, 'hash entry');
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
collectFiles(fullPath);
|
|
210
|
+
} else {
|
|
211
|
+
hash.update(entry.name);
|
|
212
|
+
hash.update(fs.readFileSync(fullPath));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
collectFiles(skillDir);
|
|
218
|
+
return 'sha256:' + hash.digest('hex');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// addSkill
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Clone a GitHub repo (shallow), find SKILL.md directories, install them.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} url
|
|
229
|
+
* @param {{ global?: boolean }} options
|
|
230
|
+
* @returns {{ installed: string[], source: string, commit: string }}
|
|
231
|
+
*/
|
|
232
|
+
function addSkill(url, options = {}) {
|
|
233
|
+
const { owner, repo, branch, path: skillPath, cloneUrl } = parseGitHubUrl(url);
|
|
234
|
+
const scope = options.global ? 'global' : 'project';
|
|
235
|
+
|
|
236
|
+
// Target base directory (trusted — built from os.homedir() / constants)
|
|
237
|
+
const targetBase = scope === 'global'
|
|
238
|
+
? path.join(os.homedir(), '.claude', 'skills')
|
|
239
|
+
: path.join(process.cwd(), '.claude', 'skills');
|
|
240
|
+
|
|
241
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supermind-skill-'));
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
// Shallow clone
|
|
245
|
+
try {
|
|
246
|
+
execFileSync('git', ['clone', '--depth', '1', '--branch', branch, cloneUrl, tempDir], {
|
|
247
|
+
stdio: 'pipe',
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
throw new Error(`Failed to clone ${cloneUrl} (branch: ${branch}): ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Get HEAD commit hash
|
|
254
|
+
let commit;
|
|
255
|
+
try {
|
|
256
|
+
commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tempDir, stdio: 'pipe' })
|
|
257
|
+
.toString()
|
|
258
|
+
.trim();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
throw new Error(`Failed to get commit hash: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Navigate to sub-path if specified. skillPath comes from the URL, so
|
|
264
|
+
// validate it via safeJoin against the trusted tempDir base.
|
|
265
|
+
let searchRoot;
|
|
266
|
+
if (skillPath === '.' || skillPath === '') {
|
|
267
|
+
searchRoot = tempDir;
|
|
268
|
+
} else {
|
|
269
|
+
searchRoot = safeJoin(tempDir, skillPath, 'skill sub-path');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!fs.existsSync(searchRoot)) {
|
|
273
|
+
throw new Error(`Path '${skillPath}' not found in repo ${owner}/${repo}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Find skill directories
|
|
277
|
+
const skillDirs = findSkillDirs(searchRoot);
|
|
278
|
+
if (skillDirs.length === 0) {
|
|
279
|
+
throw new Error(`No SKILL.md files found in ${owner}/${repo} at path '${skillPath}'`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const lockData = readLockFile(scope);
|
|
283
|
+
const installed = [];
|
|
284
|
+
|
|
285
|
+
for (const skillDir of skillDirs) {
|
|
286
|
+
const skillDirName = path.basename(skillDir);
|
|
287
|
+
// skillDirName comes from path.basename of a path we already validated;
|
|
288
|
+
// safeJoin it into targetBase for the final write location.
|
|
289
|
+
const destDir = safeJoin(targetBase, skillDirName, 'skill directory name');
|
|
290
|
+
|
|
291
|
+
const contentHash = hashSkillDir(skillDir);
|
|
292
|
+
|
|
293
|
+
// Copy to target
|
|
294
|
+
copyDirRecursive(skillDir, destDir);
|
|
295
|
+
|
|
296
|
+
// Record in lock file
|
|
297
|
+
lockData.skills[skillDirName] = {
|
|
298
|
+
source: `github.com/${owner}/${repo}`,
|
|
299
|
+
path: skillDirName,
|
|
300
|
+
branch,
|
|
301
|
+
commit,
|
|
302
|
+
hash: contentHash,
|
|
303
|
+
installedAt: new Date().toISOString(),
|
|
304
|
+
scope,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
installed.push(skillDirName);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
writeLockFile(scope, lockData);
|
|
311
|
+
|
|
312
|
+
return { installed, source: `github.com/${owner}/${repo}`, commit };
|
|
313
|
+
} finally {
|
|
314
|
+
try {
|
|
315
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
316
|
+
} catch {
|
|
317
|
+
// Best-effort cleanup
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// updateSkill
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Update a single installed vendor skill by name.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} name — skill directory name as recorded in lock file
|
|
330
|
+
* @param {{ global?: boolean }} options
|
|
331
|
+
* @returns {{ updated: boolean, message?: string }}
|
|
332
|
+
*/
|
|
333
|
+
function updateSkill(name, options = {}) {
|
|
334
|
+
// Search both scopes unless a specific one is requested
|
|
335
|
+
const scopes = options.global === true
|
|
336
|
+
? ['global']
|
|
337
|
+
: options.global === false
|
|
338
|
+
? ['project']
|
|
339
|
+
: ['global', 'project'];
|
|
340
|
+
|
|
341
|
+
let foundScope = null;
|
|
342
|
+
let entry = null;
|
|
343
|
+
|
|
344
|
+
for (const scope of scopes) {
|
|
345
|
+
const data = readLockFile(scope);
|
|
346
|
+
if (data.skills[name]) {
|
|
347
|
+
foundScope = scope;
|
|
348
|
+
entry = data.skills[name];
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!foundScope || !entry) {
|
|
354
|
+
throw new Error(`Skill '${name}' not found in lock file`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const { branch, hash: oldHash } = entry;
|
|
358
|
+
const cloneUrl = `https://${entry.source}.git`;
|
|
359
|
+
const skillPath = entry.path || '.';
|
|
360
|
+
|
|
361
|
+
const targetBase = foundScope === 'global'
|
|
362
|
+
? path.join(os.homedir(), '.claude', 'skills')
|
|
363
|
+
: path.join(process.cwd(), '.claude', 'skills');
|
|
364
|
+
|
|
365
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supermind-skill-'));
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
try {
|
|
369
|
+
execFileSync('git', ['clone', '--depth', '1', '--branch', branch, cloneUrl, tempDir], {
|
|
370
|
+
stdio: 'pipe',
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
throw new Error(`Failed to clone ${cloneUrl}: ${err.message}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let newCommit;
|
|
377
|
+
try {
|
|
378
|
+
newCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tempDir, stdio: 'pipe' })
|
|
379
|
+
.toString()
|
|
380
|
+
.trim();
|
|
381
|
+
} catch (err) {
|
|
382
|
+
throw new Error(`Failed to get commit hash: ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// skillPath comes from the lock file (previously validated on write); still
|
|
386
|
+
// validate again through safeJoin before using it in a path operation.
|
|
387
|
+
let searchRoot;
|
|
388
|
+
if (skillPath === '.' || skillPath === name) {
|
|
389
|
+
searchRoot = tempDir;
|
|
390
|
+
} else {
|
|
391
|
+
searchRoot = safeJoin(tempDir, skillPath, 'skill sub-path');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Find the specific skill dir in the clone
|
|
395
|
+
const skillDirs = findSkillDirs(fs.existsSync(searchRoot) ? searchRoot : tempDir);
|
|
396
|
+
const skillDir = skillDirs.find(d => path.basename(d) === name) || skillDirs[0];
|
|
397
|
+
|
|
398
|
+
if (!skillDir) {
|
|
399
|
+
throw new Error(`Skill directory '${name}' not found in updated repo`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const newHash = hashSkillDir(skillDir);
|
|
403
|
+
|
|
404
|
+
if (newHash === oldHash) {
|
|
405
|
+
return { updated: false, message: 'Already up to date' };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// name comes from the lock file key; validate via safeJoin before write.
|
|
409
|
+
const destDir = safeJoin(targetBase, name, 'skill name');
|
|
410
|
+
copyDirRecursive(skillDir, destDir);
|
|
411
|
+
|
|
412
|
+
// Update lock file
|
|
413
|
+
const lockData = readLockFile(foundScope);
|
|
414
|
+
lockData.skills[name] = {
|
|
415
|
+
...lockData.skills[name],
|
|
416
|
+
commit: newCommit,
|
|
417
|
+
hash: newHash,
|
|
418
|
+
installedAt: new Date().toISOString(),
|
|
419
|
+
};
|
|
420
|
+
writeLockFile(foundScope, lockData);
|
|
421
|
+
|
|
422
|
+
return { updated: true };
|
|
423
|
+
} finally {
|
|
424
|
+
try {
|
|
425
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
426
|
+
} catch {
|
|
427
|
+
// Best-effort cleanup
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// updateAll
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Update all vendor skills recorded in both lock files.
|
|
438
|
+
*
|
|
439
|
+
* @param {{ global?: boolean }} options
|
|
440
|
+
* @returns {{ results: Array<{ name: string, updated: boolean, message?: string, error?: string }> }}
|
|
441
|
+
*/
|
|
442
|
+
function updateAll(options = {}) {
|
|
443
|
+
const scopes = ['global', 'project'];
|
|
444
|
+
const results = [];
|
|
445
|
+
|
|
446
|
+
for (const scope of scopes) {
|
|
447
|
+
const lockData = readLockFile(scope);
|
|
448
|
+
for (const name of Object.keys(lockData.skills)) {
|
|
449
|
+
try {
|
|
450
|
+
const result = updateSkill(name, { global: scope === 'global' });
|
|
451
|
+
results.push({ name, ...result });
|
|
452
|
+
} catch (err) {
|
|
453
|
+
results.push({ name, updated: false, error: err.message });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { results };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// listSkills
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* List all vendor skills from both lock files.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} options (unused, reserved)
|
|
469
|
+
* @returns {Array<{ name: string, source: string, commit: string, installedAt: string, scope: string }>}
|
|
470
|
+
*/
|
|
471
|
+
function listSkills(options = {}) {
|
|
472
|
+
const list = [];
|
|
473
|
+
const seen = new Set();
|
|
474
|
+
|
|
475
|
+
for (const scope of ['global', 'project']) {
|
|
476
|
+
const lockData = readLockFile(scope);
|
|
477
|
+
for (const [name, entry] of Object.entries(lockData.skills)) {
|
|
478
|
+
const key = `${scope}:${name}`;
|
|
479
|
+
if (seen.has(key)) continue;
|
|
480
|
+
seen.add(key);
|
|
481
|
+
list.push({
|
|
482
|
+
name,
|
|
483
|
+
source: entry.source,
|
|
484
|
+
commit: entry.commit,
|
|
485
|
+
installedAt: entry.installedAt,
|
|
486
|
+
scope: entry.scope || scope,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return list;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// removeSkill
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Remove a vendor skill by name.
|
|
500
|
+
*
|
|
501
|
+
* @param {string} name
|
|
502
|
+
* @param {{ global?: boolean }} options
|
|
503
|
+
*/
|
|
504
|
+
function removeSkill(name, options = {}) {
|
|
505
|
+
const scopes = options.global === true
|
|
506
|
+
? ['global']
|
|
507
|
+
: options.global === false
|
|
508
|
+
? ['project']
|
|
509
|
+
: ['global', 'project'];
|
|
510
|
+
|
|
511
|
+
let removed = false;
|
|
512
|
+
|
|
513
|
+
for (const scope of scopes) {
|
|
514
|
+
const lockData = readLockFile(scope);
|
|
515
|
+
if (!lockData.skills[name]) continue;
|
|
516
|
+
|
|
517
|
+
const targetBase = scope === 'global'
|
|
518
|
+
? path.join(os.homedir(), '.claude', 'skills')
|
|
519
|
+
: path.join(process.cwd(), '.claude', 'skills');
|
|
520
|
+
|
|
521
|
+
// name is user input; validate it through safeJoin before any fs operation.
|
|
522
|
+
const destDir = safeJoin(targetBase, name, 'skill name');
|
|
523
|
+
if (fs.existsSync(destDir)) {
|
|
524
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
delete lockData.skills[name];
|
|
528
|
+
writeLockFile(scope, lockData);
|
|
529
|
+
removed = true;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!removed) {
|
|
534
|
+
throw new Error(`Skill '${name}' not found in lock file`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// verifySkills
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Verify that all locked skills have their directories on disk.
|
|
544
|
+
*
|
|
545
|
+
* @param {object} options (unused, reserved)
|
|
546
|
+
* @returns {{ valid: string[], missing: string[] }}
|
|
547
|
+
*/
|
|
548
|
+
function verifySkills(options = {}) {
|
|
549
|
+
const valid = [];
|
|
550
|
+
const missing = [];
|
|
551
|
+
|
|
552
|
+
for (const scope of ['global', 'project']) {
|
|
553
|
+
const lockData = readLockFile(scope);
|
|
554
|
+
const targetBase = scope === 'global'
|
|
555
|
+
? path.join(os.homedir(), '.claude', 'skills')
|
|
556
|
+
: path.join(process.cwd(), '.claude', 'skills');
|
|
557
|
+
|
|
558
|
+
for (const name of Object.keys(lockData.skills)) {
|
|
559
|
+
// name comes from the lock file; validate via safeJoin before fs.existsSync
|
|
560
|
+
let destDir;
|
|
561
|
+
try {
|
|
562
|
+
destDir = safeJoin(targetBase, name, 'skill name');
|
|
563
|
+
} catch {
|
|
564
|
+
missing.push(name);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (fs.existsSync(destDir)) {
|
|
568
|
+
valid.push(name);
|
|
569
|
+
} else {
|
|
570
|
+
missing.push(name);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return { valid, missing };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// Exports
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
module.exports = {
|
|
583
|
+
parseGitHubUrl,
|
|
584
|
+
addSkill,
|
|
585
|
+
updateSkill,
|
|
586
|
+
updateAll,
|
|
587
|
+
listSkills,
|
|
588
|
+
removeSkill,
|
|
589
|
+
verifySkills,
|
|
590
|
+
readLockFile,
|
|
591
|
+
writeLockFile,
|
|
592
|
+
findSkillDirs,
|
|
593
|
+
copyDirRecursive,
|
|
594
|
+
};
|