skillshop 2.2.0 → 4.0.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/README.md +45 -188
- package/docs/package-library-compatibility.md +194 -0
- package/package.json +4 -2
- package/skills-content/skillshop/SKILL.md +30 -9
- package/src/cli.js +13 -16
- package/src/github-library.js +310 -0
- package/src/legacy-cli.js +689 -33
- package/src/personal-library.js +591 -0
- package/templates/skillshop-library/README.md +38 -0
- package/templates/skillshop-library/skills/example/SKILL.md +8 -0
- package/templates/skillshop-library/skills/example/references/example.md +3 -0
- package/templates/skillshop-library/skills/example/scripts/run.sh +2 -0
- package/templates/skillshop-library/skillshop-library.json +4 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const libraryConfigFileName = 'skillshop-library.json';
|
|
6
|
+
export const librarySelectionFileName = 'library-selection.json';
|
|
7
|
+
export const libraryOriginsFileName = 'skillshop-library-sources.json';
|
|
8
|
+
|
|
9
|
+
export class PersonalLibraryError extends Error {}
|
|
10
|
+
|
|
11
|
+
function cliError(message) {
|
|
12
|
+
return new PersonalLibraryError(message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeName(name) {
|
|
16
|
+
return typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validOrigin(origin) {
|
|
20
|
+
return origin && typeof origin.repository === 'string' &&
|
|
21
|
+
/^https:\/\/github\.com\/[A-Za-z0-9-]+\/skillshop-library\.git$/.test(origin.repository) &&
|
|
22
|
+
typeof origin.commit === 'string' && /^[a-f0-9]{40,64}$/.test(origin.commit);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureNoSymlink(root, target, label) {
|
|
26
|
+
const absoluteRoot = path.resolve(root);
|
|
27
|
+
const absoluteTarget = path.resolve(target);
|
|
28
|
+
const relative = path.relative(absoluteRoot, absoluteTarget);
|
|
29
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
30
|
+
throw cliError(`${label} escapes its library root`);
|
|
31
|
+
}
|
|
32
|
+
let current = absoluteRoot;
|
|
33
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
34
|
+
current = path.join(current, part);
|
|
35
|
+
try {
|
|
36
|
+
if (fs.lstatSync(current).isSymbolicLink()) throw cliError(`${label} contains a symlink`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT') break;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateTree(root, target = root) {
|
|
45
|
+
ensureNoSymlink(root, target, 'skill source');
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = fs.lstatSync(target);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT') throw cliError(`skill source does not exist: ${target}`);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (stat.isSymbolicLink()) throw cliError(`skill source contains a symlink: ${target}`);
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
for (const entry of fs.readdirSync(target)) validateTree(root, path.join(target, entry));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!stat.isFile()) throw cliError(`skill source contains a non-regular file: ${target}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function unquote(value) {
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
if (trimmed.length >= 2 && ((trimmed[0] === '"' && trimmed.at(-1) === '"') || (trimmed[0] === "'" && trimmed.at(-1) === "'"))) {
|
|
64
|
+
return trimmed.slice(1, -1);
|
|
65
|
+
}
|
|
66
|
+
return trimmed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skillManifest(skillDirectory, name) {
|
|
70
|
+
const manifestPath = path.join(skillDirectory, 'SKILL.md');
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = fs.lstatSync(manifestPath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT') throw cliError(`${name}/SKILL.md is missing`);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw cliError(`${name}/SKILL.md must be a regular file`);
|
|
79
|
+
const contents = fs.readFileSync(manifestPath, 'utf8');
|
|
80
|
+
const lines = contents.split(/\r?\n/);
|
|
81
|
+
if (lines[0] !== '---') throw cliError(`${name}/SKILL.md has no front matter`);
|
|
82
|
+
const end = lines.indexOf('---', 1);
|
|
83
|
+
if (end < 0) throw cliError(`${name}/SKILL.md has unterminated front matter`);
|
|
84
|
+
const frontMatter = lines.slice(1, end);
|
|
85
|
+
const field = (key) => frontMatter.find((line) => new RegExp(`^${key}:\\s*`).test(line))?.replace(new RegExp(`^${key}:\\s*`), '');
|
|
86
|
+
const declaredName = unquote(field('name') ?? '');
|
|
87
|
+
const description = field('description');
|
|
88
|
+
if (declaredName !== name) throw cliError(`${name}/SKILL.md name must match its directory`);
|
|
89
|
+
if (!description || !unquote(description)) throw cliError(`${name}/SKILL.md must declare a description`);
|
|
90
|
+
const metadataStart = frontMatter.findIndex((line) => /^metadata:\s*$/.test(line));
|
|
91
|
+
let author;
|
|
92
|
+
let version;
|
|
93
|
+
if (metadataStart >= 0) {
|
|
94
|
+
for (const line of frontMatter.slice(metadataStart + 1)) {
|
|
95
|
+
if (line && !/^\s/.test(line)) break;
|
|
96
|
+
const authorMatch = line.match(/^\s+author:\s*(.+)$/);
|
|
97
|
+
const versionMatch = line.match(/^\s+version:\s*(.+)$/);
|
|
98
|
+
if (authorMatch) author = unquote(authorMatch[1]);
|
|
99
|
+
if (versionMatch) version = unquote(versionMatch[1]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { name, description: unquote(description), ...(author ? { author } : {}), ...(version ? { version } : {}), sourceDir: skillDirectory };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseConfig(root) {
|
|
106
|
+
const configPath = path.join(root, libraryConfigFileName);
|
|
107
|
+
let config;
|
|
108
|
+
try {
|
|
109
|
+
const stat = fs.lstatSync(configPath);
|
|
110
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw cliError(`${configPath} must be a regular file`);
|
|
111
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT') return null;
|
|
114
|
+
if (error instanceof SyntaxError) throw cliError(`cannot parse ${configPath}: ${error.message}`);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (config?.schemaVersion !== 1 || typeof config.skillsDirectory !== 'string' || !config.skillsDirectory.trim()) {
|
|
118
|
+
throw cliError(`${configPath} is not a supported Skillshop personal library config`);
|
|
119
|
+
}
|
|
120
|
+
if (path.isAbsolute(config.skillsDirectory)) throw cliError(`${configPath} skillsDirectory must be relative`);
|
|
121
|
+
const skillsDirectory = path.resolve(root, config.skillsDirectory);
|
|
122
|
+
const relative = path.relative(root, skillsDirectory);
|
|
123
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
124
|
+
throw cliError(`${configPath} skillsDirectory escapes the library root`);
|
|
125
|
+
}
|
|
126
|
+
ensureNoSymlink(root, skillsDirectory, 'library skills directory');
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.lstatSync(skillsDirectory);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.code === 'ENOENT') throw cliError(`library skillsDirectory does not exist: ${skillsDirectory}`);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cliError(`library skillsDirectory is not a directory: ${skillsDirectory}`);
|
|
135
|
+
return { skillsDirectory, config };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function checkLibraryRoot(root) {
|
|
139
|
+
const absolute = path.resolve(root);
|
|
140
|
+
let stat;
|
|
141
|
+
try {
|
|
142
|
+
stat = fs.lstatSync(absolute);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code === 'ENOENT') throw cliError(`personal library directory does not exist: ${absolute}`);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cliError(`library root is not a regular directory: ${absolute}`);
|
|
148
|
+
return absolute;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sourceSkills(root, skillsDirectory) {
|
|
152
|
+
ensureNoSymlink(root, skillsDirectory, 'library skills directory');
|
|
153
|
+
return fs.readdirSync(skillsDirectory, { withFileTypes: true })
|
|
154
|
+
.map((entry) => {
|
|
155
|
+
if (entry.isSymbolicLink()) throw cliError(`library skills directory contains a symlink: ${path.join(skillsDirectory, entry.name)}`);
|
|
156
|
+
if (!entry.isDirectory() || !safeName(entry.name)) return null;
|
|
157
|
+
const skillDirectory = path.join(skillsDirectory, entry.name);
|
|
158
|
+
const manifestPath = path.join(skillDirectory, 'SKILL.md');
|
|
159
|
+
let manifestStat;
|
|
160
|
+
try {
|
|
161
|
+
manifestStat = fs.lstatSync(manifestPath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error.code === 'ENOENT') return null;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
if (manifestStat.isSymbolicLink()) throw cliError(`${manifestPath} contains a symlink`);
|
|
167
|
+
if (!manifestStat.isFile()) throw cliError(`${manifestPath} must be a regular file`);
|
|
168
|
+
validateTree(root, skillDirectory);
|
|
169
|
+
return skillManifest(skillDirectory, entry.name);
|
|
170
|
+
})
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function inspectPersonalLibrary(directory) {
|
|
176
|
+
const root = path.resolve(directory);
|
|
177
|
+
let rootStat;
|
|
178
|
+
try {
|
|
179
|
+
rootStat = fs.lstatSync(root);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error.code === 'ENOENT') return { root, exists: false, configured: false, skillsDirectory: path.join(root, 'skills'), skills: [] };
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw cliError(`library root is not a regular directory: ${root}`);
|
|
185
|
+
|
|
186
|
+
const config = parseConfig(root);
|
|
187
|
+
if (config) return { root, exists: true, configured: true, ...config, skills: sourceSkills(root, config.skillsDirectory) };
|
|
188
|
+
if (fs.readdirSync(root).length === 0) {
|
|
189
|
+
return { root, exists: true, configured: false, skillsDirectory: path.join(root, 'skills'), skills: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const direct = directSkillNames(root);
|
|
193
|
+
const nested = nestedSkillNames(root);
|
|
194
|
+
const nestedDirectory = path.join(root, 'skills');
|
|
195
|
+
let nestedExists = false;
|
|
196
|
+
try {
|
|
197
|
+
fs.lstatSync(nestedDirectory);
|
|
198
|
+
nestedExists = true;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error.code !== 'ENOENT') throw error;
|
|
201
|
+
}
|
|
202
|
+
if (direct.length && (nested.length || nestedExists)) {
|
|
203
|
+
throw cliError('library has both direct skill directories and skills/; set skillsDirectory in skillshop-library.json');
|
|
204
|
+
}
|
|
205
|
+
const skillsDirectory = direct.length ? root : nestedDirectory;
|
|
206
|
+
return { root, exists: true, configured: false, skillsDirectory, skills: direct.length ? sourceSkills(root, root) : nested };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function readPersonalLibraryOrigins(root) {
|
|
210
|
+
const filePath = path.join(root, libraryOriginsFileName);
|
|
211
|
+
ensureNoSymlink(root, filePath, 'library provenance');
|
|
212
|
+
let contents;
|
|
213
|
+
try {
|
|
214
|
+
const stat = fs.lstatSync(filePath);
|
|
215
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw cliError(`${filePath} must be a regular provenance file`);
|
|
216
|
+
contents = fs.readFileSync(filePath, 'utf8');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error.code === 'ENOENT') return { schemaVersion: 1, skills: {} };
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
let origins;
|
|
222
|
+
try {
|
|
223
|
+
origins = JSON.parse(contents);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw cliError(`cannot parse ${filePath}: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
if (origins?.schemaVersion !== 1 || !origins.skills || typeof origins.skills !== 'object' || Array.isArray(origins.skills)) {
|
|
228
|
+
throw cliError(`${filePath} is not a supported provenance file`);
|
|
229
|
+
}
|
|
230
|
+
for (const [name, origin] of Object.entries(origins.skills)) {
|
|
231
|
+
if (!safeName(name) || !validOrigin(origin)) throw cliError(`${filePath} contains invalid provenance for ${name}`);
|
|
232
|
+
}
|
|
233
|
+
return origins;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function validatePersonalSkillDirectory(root, directory, name) {
|
|
237
|
+
ensureNoSymlink(root, directory, `personal skill ${name}`);
|
|
238
|
+
let stat;
|
|
239
|
+
try {
|
|
240
|
+
stat = fs.lstatSync(directory);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error.code === 'ENOENT') throw cliError(`personal skill ${name} does not exist: ${directory}`);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cliError(`personal skill source is not a regular directory: ${directory}`);
|
|
246
|
+
validateTree(root, directory);
|
|
247
|
+
return skillManifest(directory, name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function commitDownloadedPersonalSkill(root, skillsDirectory, name, stagedDirectory, origin, { force = false } = {}) {
|
|
251
|
+
if (!safeName(name)) throw cliError(`invalid skill name: ${name}`);
|
|
252
|
+
if (!validOrigin(origin)) throw cliError('download provenance is invalid');
|
|
253
|
+
const libraryRoot = checkLibraryRoot(root);
|
|
254
|
+
const config = parseConfig(libraryRoot);
|
|
255
|
+
if (!config || path.resolve(config.skillsDirectory) !== path.resolve(skillsDirectory)) {
|
|
256
|
+
throw cliError(`library skills directory changed during download: ${libraryRoot}`);
|
|
257
|
+
}
|
|
258
|
+
const target = path.resolve(skillsDirectory, name);
|
|
259
|
+
const relative = path.relative(libraryRoot, target);
|
|
260
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
261
|
+
throw cliError(`download target escapes personal library: ${target}`);
|
|
262
|
+
}
|
|
263
|
+
if (path.resolve(skillsDirectory) === libraryRoot && [libraryConfigFileName, 'README.md', libraryOriginsFileName].includes(name)) {
|
|
264
|
+
throw cliError(`skill name collides with library metadata: ${name}`);
|
|
265
|
+
}
|
|
266
|
+
validatePersonalSkillDirectory(stagedDirectory, stagedDirectory, name);
|
|
267
|
+
const origins = readPersonalLibraryOrigins(libraryRoot);
|
|
268
|
+
const existingOrigin = origins.skills[name];
|
|
269
|
+
if (existingOrigin && existingOrigin.repository !== origin.repository && !force) {
|
|
270
|
+
throw cliError(`${name} already has a different GitHub source; use --force to replace it`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let targetExists = false;
|
|
274
|
+
try {
|
|
275
|
+
const stat = fs.lstatSync(target);
|
|
276
|
+
targetExists = true;
|
|
277
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cliError(`download target is not a regular skill directory: ${target}`);
|
|
278
|
+
validatePersonalSkillDirectory(libraryRoot, target, name);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error.code !== 'ENOENT') throw error;
|
|
281
|
+
}
|
|
282
|
+
if (targetExists && !force) throw cliError(`${name} already exists in the personal library; use --force to replace it`);
|
|
283
|
+
|
|
284
|
+
const nextOrigins = { schemaVersion: 1, skills: { ...origins.skills, [name]: origin } };
|
|
285
|
+
const transactionDirectory = fs.mkdtempSync(path.join(libraryRoot, '.skillshop-download-'));
|
|
286
|
+
const temporaryTarget = path.join(transactionDirectory, 'skill');
|
|
287
|
+
const temporaryOrigins = path.join(transactionDirectory, 'sources.json');
|
|
288
|
+
const backupTarget = path.join(transactionDirectory, 'previous-skill');
|
|
289
|
+
const backupOrigins = path.join(transactionDirectory, 'previous-sources.json');
|
|
290
|
+
const originsPath = path.join(libraryRoot, libraryOriginsFileName);
|
|
291
|
+
let targetBackedUp = false;
|
|
292
|
+
let originsBackedUp = false;
|
|
293
|
+
let newTargetInstalled = false;
|
|
294
|
+
let newOriginsInstalled = false;
|
|
295
|
+
try {
|
|
296
|
+
copyTree(stagedDirectory, temporaryTarget);
|
|
297
|
+
fs.writeFileSync(temporaryOrigins, `${JSON.stringify(nextOrigins, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
298
|
+
ensureNoSymlink(libraryRoot, originsPath, 'library provenance');
|
|
299
|
+
try {
|
|
300
|
+
const stat = fs.lstatSync(originsPath);
|
|
301
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw cliError(`${originsPath} must be a regular provenance file`);
|
|
302
|
+
fs.renameSync(originsPath, backupOrigins);
|
|
303
|
+
originsBackedUp = true;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error.code !== 'ENOENT') throw error;
|
|
306
|
+
}
|
|
307
|
+
if (targetExists) {
|
|
308
|
+
fs.renameSync(target, backupTarget);
|
|
309
|
+
targetBackedUp = true;
|
|
310
|
+
}
|
|
311
|
+
fs.renameSync(temporaryTarget, target);
|
|
312
|
+
newTargetInstalled = true;
|
|
313
|
+
fs.renameSync(temporaryOrigins, originsPath);
|
|
314
|
+
newOriginsInstalled = true;
|
|
315
|
+
try { fs.rmSync(backupTarget, { recursive: true, force: true }); } catch {}
|
|
316
|
+
try { fs.rmSync(backupOrigins, { force: true }); } catch {}
|
|
317
|
+
return { target, origin };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (newOriginsInstalled) fs.rmSync(originsPath, { force: true });
|
|
320
|
+
if (originsBackedUp && fs.existsSync(backupOrigins)) fs.renameSync(backupOrigins, originsPath);
|
|
321
|
+
if (newTargetInstalled) fs.rmSync(target, { recursive: true, force: true });
|
|
322
|
+
if (targetBackedUp && fs.existsSync(backupTarget)) fs.renameSync(backupTarget, target);
|
|
323
|
+
throw error;
|
|
324
|
+
} finally {
|
|
325
|
+
try { fs.rmSync(transactionDirectory, { recursive: true, force: true }); } catch {}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function importTrackedPersonalSkill(root, sourceDirectory, name) {
|
|
330
|
+
if (!safeName(name)) throw cliError(`invalid skill name: ${name}`);
|
|
331
|
+
const libraryRoot = checkLibraryRoot(root);
|
|
332
|
+
const config = parseConfig(libraryRoot);
|
|
333
|
+
if (!config) throw cliError(`personal library is not initialized: ${libraryRoot}`);
|
|
334
|
+
const skillsDirectory = config.skillsDirectory;
|
|
335
|
+
ensureNoSymlink(libraryRoot, skillsDirectory, 'library skills directory');
|
|
336
|
+
validatePersonalSkillDirectory(sourceDirectory, sourceDirectory, name);
|
|
337
|
+
const target = path.join(skillsDirectory, name);
|
|
338
|
+
if (path.resolve(skillsDirectory) === libraryRoot && [libraryConfigFileName, 'README.md', libraryOriginsFileName].includes(name)) {
|
|
339
|
+
throw cliError(`skill name collides with library metadata: ${name}`);
|
|
340
|
+
}
|
|
341
|
+
ensureNoSymlink(libraryRoot, target, `personal skill ${name}`);
|
|
342
|
+
try {
|
|
343
|
+
fs.lstatSync(target);
|
|
344
|
+
throw cliError(`personal library already contains ${name}; its source was not replaced`);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.code !== 'ENOENT') throw error;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const transaction = fs.mkdtempSync(path.join(skillsDirectory, '.skillshop-adopt-'));
|
|
350
|
+
const staged = path.join(transaction, 'skill');
|
|
351
|
+
try {
|
|
352
|
+
copyTree(sourceDirectory, staged);
|
|
353
|
+
validatePersonalSkillDirectory(libraryRoot, staged, name);
|
|
354
|
+
fs.renameSync(staged, target);
|
|
355
|
+
return target;
|
|
356
|
+
} finally {
|
|
357
|
+
try { fs.rmSync(transaction, { recursive: true, force: true }); } catch {}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function atomicJson(filePath, value) {
|
|
362
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
363
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
|
364
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
365
|
+
fs.renameSync(temporaryPath, filePath);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function copyTree(source, target) {
|
|
369
|
+
const stat = fs.lstatSync(source);
|
|
370
|
+
if (stat.isSymbolicLink()) throw cliError(`template contains a symlink: ${source}`);
|
|
371
|
+
if (stat.isDirectory()) {
|
|
372
|
+
fs.mkdirSync(target, { recursive: true, mode: stat.mode & 0o777 });
|
|
373
|
+
for (const entry of fs.readdirSync(source)) copyTree(path.join(source, entry), path.join(target, entry));
|
|
374
|
+
fs.chmodSync(target, stat.mode & 0o777);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!stat.isFile()) throw cliError(`template contains a non-regular file: ${source}`);
|
|
378
|
+
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
379
|
+
fs.chmodSync(target, stat.mode & 0o777);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function copyLibraryTemplate(source, target, { includeExample = true } = {}) {
|
|
383
|
+
const stat = fs.lstatSync(source);
|
|
384
|
+
if (!stat.isDirectory()) throw cliError(`template root is not a directory: ${source}`);
|
|
385
|
+
fs.mkdirSync(target, { recursive: true, mode: stat.mode & 0o777 });
|
|
386
|
+
for (const entry of fs.readdirSync(source)) {
|
|
387
|
+
if (!includeExample && entry === 'skills') {
|
|
388
|
+
const sourceSkills = path.join(source, entry);
|
|
389
|
+
const skillsStat = fs.lstatSync(sourceSkills);
|
|
390
|
+
if (!skillsStat.isDirectory() || skillsStat.isSymbolicLink()) throw cliError(`template contains an unsafe skills directory: ${sourceSkills}`);
|
|
391
|
+
const targetSkills = path.join(target, entry);
|
|
392
|
+
fs.mkdirSync(targetSkills, { recursive: true, mode: skillsStat.mode & 0o777 });
|
|
393
|
+
for (const skillEntry of fs.readdirSync(sourceSkills)) {
|
|
394
|
+
if (skillEntry !== 'example') copyTree(path.join(sourceSkills, skillEntry), path.join(targetSkills, skillEntry));
|
|
395
|
+
}
|
|
396
|
+
fs.chmodSync(targetSkills, skillsStat.mode & 0o777);
|
|
397
|
+
} else {
|
|
398
|
+
copyTree(path.join(source, entry), path.join(target, entry));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
fs.chmodSync(target, stat.mode & 0o777);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function directSkillNames(root) {
|
|
405
|
+
const names = [];
|
|
406
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
407
|
+
if (entry.isSymbolicLink() && fs.existsSync(path.join(root, entry.name, 'SKILL.md'))) {
|
|
408
|
+
throw cliError(`library contains a symlinked skill directory: ${path.join(root, entry.name)}`);
|
|
409
|
+
}
|
|
410
|
+
if (!entry.isDirectory() || entry.name === '.git' || entry.name === 'skills') continue;
|
|
411
|
+
if (fs.existsSync(path.join(root, entry.name, 'SKILL.md'))) {
|
|
412
|
+
skillManifest(path.join(root, entry.name), entry.name);
|
|
413
|
+
names.push(entry.name);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return names;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function nestedSkillNames(root) {
|
|
420
|
+
const directory = path.join(root, 'skills');
|
|
421
|
+
let stat;
|
|
422
|
+
try {
|
|
423
|
+
stat = fs.lstatSync(directory);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error.code === 'ENOENT') return [];
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw cliError(`${directory} must be a regular directory`);
|
|
429
|
+
return sourceSkills(root, directory).map(({ name }) => name);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function writeSelection(stateHome, root) {
|
|
433
|
+
atomicJson(path.join(stateHome, librarySelectionFileName), { selectionVersion: 1, root });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function selectedLibraryRoot(stateHome) {
|
|
437
|
+
const filePath = path.join(stateHome, librarySelectionFileName);
|
|
438
|
+
let selectionStat;
|
|
439
|
+
try {
|
|
440
|
+
selectionStat = fs.lstatSync(filePath);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error.code === 'ENOENT') return null;
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
if (selectionStat.isSymbolicLink() || !selectionStat.isFile()) {
|
|
446
|
+
throw cliError(`${filePath} must be a regular file`);
|
|
447
|
+
}
|
|
448
|
+
let selection;
|
|
449
|
+
try {
|
|
450
|
+
selection = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof SyntaxError) throw cliError(`cannot parse ${filePath}: ${error.message}`);
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
if (selection?.selectionVersion !== 1 || typeof selection.root !== 'string' || !path.isAbsolute(selection.root)) {
|
|
456
|
+
throw cliError(`${filePath} is not a supported personal library selection`);
|
|
457
|
+
}
|
|
458
|
+
const root = checkLibraryRoot(selection.root);
|
|
459
|
+
if (!parseConfig(root)) throw cliError(`personal library is missing ${libraryConfigFileName}: ${root}`);
|
|
460
|
+
return root;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function initPersonalLibrary(directory, stateHome, templateRoot, { includeExample = true } = {}) {
|
|
464
|
+
const root = path.resolve(directory ?? path.join(os.homedir(), 'skillshop-library'));
|
|
465
|
+
const rootExists = fs.existsSync(root);
|
|
466
|
+
if (rootExists) checkLibraryRoot(root);
|
|
467
|
+
|
|
468
|
+
const templateConfigPath = path.join(templateRoot, libraryConfigFileName);
|
|
469
|
+
if (!fs.existsSync(templateConfigPath)) throw cliError(`bundled library template is missing: ${templateRoot}`);
|
|
470
|
+
const existing = rootExists ? parseConfig(root) : null;
|
|
471
|
+
let skillsDirectory;
|
|
472
|
+
if (existing) {
|
|
473
|
+
skillsDirectory = existing.skillsDirectory;
|
|
474
|
+
sourceSkills(root, skillsDirectory);
|
|
475
|
+
} else if (rootExists && fs.readdirSync(root).length === 0) {
|
|
476
|
+
copyLibraryTemplate(templateRoot, root, { includeExample });
|
|
477
|
+
skillsDirectory = path.join(root, parseConfig(root).config.skillsDirectory);
|
|
478
|
+
sourceSkills(root, skillsDirectory);
|
|
479
|
+
} else if (rootExists) {
|
|
480
|
+
const direct = directSkillNames(root);
|
|
481
|
+
const nested = nestedSkillNames(root);
|
|
482
|
+
const nestedDirectoryExists = fs.existsSync(path.join(root, 'skills'));
|
|
483
|
+
if (direct.length && (nested.length || nestedDirectoryExists)) {
|
|
484
|
+
throw cliError('library has both direct skill directories and skills/; set skillsDirectory in skillshop-library.json');
|
|
485
|
+
}
|
|
486
|
+
const relativeSkillsDirectory = direct.length ? '.' : 'skills';
|
|
487
|
+
skillsDirectory = path.resolve(root, relativeSkillsDirectory);
|
|
488
|
+
if (!fs.existsSync(skillsDirectory)) fs.mkdirSync(skillsDirectory, { recursive: true });
|
|
489
|
+
const configPath = path.join(root, libraryConfigFileName);
|
|
490
|
+
if (fs.existsSync(configPath)) throw cliError(`invalid existing config: ${configPath}`);
|
|
491
|
+
|
|
492
|
+
// Validate every existing source before adding config or selecting the root.
|
|
493
|
+
sourceSkills(root, skillsDirectory);
|
|
494
|
+
const config = { schemaVersion: 1, skillsDirectory: relativeSkillsDirectory };
|
|
495
|
+
atomicJson(configPath, config);
|
|
496
|
+
const readmeTarget = path.join(root, 'README.md');
|
|
497
|
+
if (!fs.existsSync(readmeTarget)) {
|
|
498
|
+
const templateReadme = path.join(templateRoot, 'README.md');
|
|
499
|
+
if (fs.existsSync(templateReadme)) copyTree(templateReadme, readmeTarget);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
const parent = path.dirname(root);
|
|
503
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
504
|
+
const temporary = `${root}.skillshop-init-${process.pid}`;
|
|
505
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
506
|
+
copyLibraryTemplate(templateRoot, temporary, { includeExample });
|
|
507
|
+
try {
|
|
508
|
+
fs.renameSync(temporary, root);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
skillsDirectory = path.join(root, parseConfig(root).config.skillsDirectory);
|
|
514
|
+
sourceSkills(root, skillsDirectory);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const validatedRoot = checkLibraryRoot(root);
|
|
518
|
+
parseConfig(validatedRoot);
|
|
519
|
+
writeSelection(stateHome, validatedRoot);
|
|
520
|
+
const skills = sourceSkills(validatedRoot, skillsDirectory ?? parseConfig(validatedRoot).skillsDirectory);
|
|
521
|
+
return { root: validatedRoot, skillsDirectory: skillsDirectory ?? parseConfig(validatedRoot).skillsDirectory, skills };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function listPersonalSkills(stateHome) {
|
|
525
|
+
const root = selectedLibraryRoot(stateHome);
|
|
526
|
+
if (!root) throw cliError('no personal skill library selected; run "skillshop library init"');
|
|
527
|
+
const { skillsDirectory } = parseConfig(root);
|
|
528
|
+
return { root, skills: sourceSkills(root, skillsDirectory) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function resolvePersonalSkill(name, stateHome, explicitRoot = null) {
|
|
532
|
+
if (!safeName(name)) throw cliError(`invalid skill name: ${name}`);
|
|
533
|
+
const root = explicitRoot ? checkLibraryRoot(explicitRoot) : selectedLibraryRoot(stateHome);
|
|
534
|
+
if (!root) return null;
|
|
535
|
+
const config = parseConfig(root);
|
|
536
|
+
if (!config) throw cliError(`personal library is missing ${libraryConfigFileName}: ${root}`);
|
|
537
|
+
const { skillsDirectory } = config;
|
|
538
|
+
const sourceDir = path.join(skillsDirectory, name);
|
|
539
|
+
ensureNoSymlink(root, sourceDir, `personal skill ${name}`);
|
|
540
|
+
let stat;
|
|
541
|
+
try {
|
|
542
|
+
stat = fs.lstatSync(sourceDir);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error.code === 'ENOENT') return null;
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cliError(`personal skill source is not a regular directory: ${sourceDir}`);
|
|
548
|
+
validateTree(root, sourceDir);
|
|
549
|
+
const metadata = skillManifest(sourceDir, name);
|
|
550
|
+
const origin = readPersonalLibraryOrigins(root).skills[name];
|
|
551
|
+
return {
|
|
552
|
+
...metadata,
|
|
553
|
+
kind: 'personal',
|
|
554
|
+
source: {
|
|
555
|
+
kind: 'personal-library',
|
|
556
|
+
root,
|
|
557
|
+
skill: name,
|
|
558
|
+
skillDirectory: path.relative(root, sourceDir).split(path.sep).join('/'),
|
|
559
|
+
...(origin ? { origin } : {})
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function resolveRecordedPersonalSkill(source) {
|
|
565
|
+
if (source?.kind !== 'personal-library' || typeof source.root !== 'string' || !path.isAbsolute(source.root) || !safeName(source.skill)) {
|
|
566
|
+
throw cliError('recorded personal skill source is invalid');
|
|
567
|
+
}
|
|
568
|
+
if (source.skillDirectory !== undefined && (typeof source.skillDirectory !== 'string' || !source.skillDirectory || source.skillDirectory.includes('\\') || path.posix.isAbsolute(source.skillDirectory))) {
|
|
569
|
+
throw cliError('recorded personal skill path is invalid');
|
|
570
|
+
}
|
|
571
|
+
if (!source.skillDirectory) return resolvePersonalSkill(source.skill, path.dirname(source.root), source.root);
|
|
572
|
+
const root = checkLibraryRoot(source.root);
|
|
573
|
+
const relative = path.posix.normalize(source.skillDirectory);
|
|
574
|
+
if (relative === '..' || relative.startsWith('../') || relative.startsWith('/')) throw cliError('recorded personal skill path escapes its library root');
|
|
575
|
+
const sourceDir = path.resolve(root, ...relative.split('/'));
|
|
576
|
+
ensureNoSymlink(root, sourceDir, `recorded personal skill ${source.skill}`);
|
|
577
|
+
validatePersonalSkillDirectory(root, sourceDir, source.skill);
|
|
578
|
+
const metadata = skillManifest(sourceDir, source.skill);
|
|
579
|
+
const origin = readPersonalLibraryOrigins(root).skills[source.skill];
|
|
580
|
+
return {
|
|
581
|
+
...metadata,
|
|
582
|
+
kind: 'personal',
|
|
583
|
+
source: {
|
|
584
|
+
kind: 'personal-library',
|
|
585
|
+
root,
|
|
586
|
+
skill: source.skill,
|
|
587
|
+
skillDirectory: path.relative(root, sourceDir).split(path.sep).join('/'),
|
|
588
|
+
...(origin ? { origin } : {})
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Skillshop personal library template
|
|
2
|
+
|
|
3
|
+
This is a forkable, plain-folder library for user-authored skills. No `package.json`, build step, or npm metadata is required. Create a GitHub repository named `skillshop-library` under your account to share skills with `skillshop download @owner/skill`.
|
|
4
|
+
|
|
5
|
+
The repository uses `skillshop-library.json` to name its source directory. The default is `skills/`:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"schemaVersion": 1,
|
|
10
|
+
"skillsDirectory": "skills"
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Add one directory under `skills/` per skill. Each needs a `SKILL.md` with front matter containing `name` and `description`. Author/version metadata is optional. References, scripts, hidden files, binary assets, nested files, and executable modes stay beside the manifest and are copied as part of the complete directory.
|
|
15
|
+
|
|
16
|
+
## Use the library
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
skillshop library init [directory] # select or adopt a local source folder (no migration)
|
|
20
|
+
skillshop library list
|
|
21
|
+
skillshop download @alice/review # add source only
|
|
22
|
+
skillshop install @alice/review # download if absent, then install locally
|
|
23
|
+
skillshop install @alice/review --global
|
|
24
|
+
skillshop install review # install an existing selected source
|
|
25
|
+
skillshop sync # update recorded local and global copies
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`library init` defaults to the visible `~/skillshop-library` directory. It can also create or select an existing folder with this layout. Existing skill sources and valid config are retained. Add skills by copying their directories into the configured source folder.
|
|
29
|
+
|
|
30
|
+
`download @owner/skill` reads `https://github.com/owner/skillshop-library.git` and adds the selected skill to the personal library without installing it. The `@` is CLI syntax and is stripped from the GitHub owner. Unprefixed `owner/skill` inputs are rejected. A same-name authored or different-origin source fails safely. Use `download @owner/skill --force` only when you intend to replace that library source.
|
|
31
|
+
|
|
32
|
+
`install @owner/skill` downloads when the source is absent and then installs from the personal library. If a same-origin source already exists, local edits are reused without fetching. `install name` uses the selected local source. `--global` selects configured user skill directories.
|
|
33
|
+
|
|
34
|
+
`sync` propagates full-tree changes from the library roots recorded with installed copies. It includes file additions, edits, deletions, binary/reference assets, and mode changes. It replaces content inside those Skillshop-owned directories, repairs divergent installed edits, and removes stale extras. An unchanged copy is left in place. It does not fetch GitHub. Switching the active library does not redirect older installs.
|
|
35
|
+
|
|
36
|
+
`sync` is also the explicit migration step for tracked copies made by older bundled Skillshop installs; `library init` never migrates. A source already in the selected library wins and updates the tracked copies. If the source is missing, Skillshop imports it only when every surviving tracked project and global copy agrees in paths, file bytes, and modes. Conflicts, unknown source kinds, malformed ownership state, unmanaged directories, and package-import snapshots are preserved and reported. Missing recorded destinations are pruned, never recreated.
|
|
37
|
+
|
|
38
|
+
The `skillsDirectory` field can name another safe relative directory inside this library. Set it to `.` to use skill folders directly in the library root.
|