skillshop 2.2.0 → 4.1.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 +71 -190
- package/docs/installation-and-dependency-state.md +76 -0
- package/docs/package-library-compatibility.md +194 -0
- package/docs/releases/4.1.0.md +12 -0
- package/package.json +7 -3
- package/skills-content/skillshop/SKILL.md +48 -13
- package/src/cli.js +40 -16
- package/src/dependencies.js +282 -0
- package/src/github-library.js +322 -0
- package/src/import.js +15 -0
- package/src/index.js +2 -0
- package/src/installation-state.js +113 -0
- package/src/legacy-cli.js +858 -103
- package/src/personal-library.js +591 -0
- package/src/user-install.js +34 -1
- package/templates/skillshop-library/README.md +40 -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,322 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
commitDownloadedPersonalSkill,
|
|
7
|
+
initPersonalLibrary,
|
|
8
|
+
inspectPersonalLibrary,
|
|
9
|
+
libraryConfigFileName,
|
|
10
|
+
libraryOriginsFileName,
|
|
11
|
+
librarySelectionFileName,
|
|
12
|
+
readPersonalLibraryOrigins,
|
|
13
|
+
selectedLibraryRoot,
|
|
14
|
+
validatePersonalSkillDirectory
|
|
15
|
+
} from './personal-library.js';
|
|
16
|
+
|
|
17
|
+
const githubOwnerPattern = /^[A-Za-z0-9-]+$/;
|
|
18
|
+
const skillNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
|
+
const reservedRootNames = new Set([libraryConfigFileName, 'README.md', libraryOriginsFileName]);
|
|
20
|
+
|
|
21
|
+
export class GitHubLibraryError extends Error {}
|
|
22
|
+
|
|
23
|
+
function fail(message) {
|
|
24
|
+
return new GitHubLibraryError(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseGitHubSkillReference(reference) {
|
|
28
|
+
if (typeof reference !== 'string') throw fail('expected GitHub skill reference @<owner>/<skill>');
|
|
29
|
+
if (!reference.startsWith('@')) {
|
|
30
|
+
throw fail(`invalid GitHub skill reference ${reference}; prefix remote references with @ as @<owner>/<skill>`);
|
|
31
|
+
}
|
|
32
|
+
const parts = reference.slice(1).split('/');
|
|
33
|
+
if (parts.length !== 2 || !githubOwnerPattern.test(parts[0]) || !skillNamePattern.test(parts[1])) {
|
|
34
|
+
throw fail(`invalid GitHub skill reference ${reference}; expected @<owner>/<skill>`);
|
|
35
|
+
}
|
|
36
|
+
return { owner: parts[0], name: parts[1], repository: `https://github.com/${parts[0]}/skillshop-library.git` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gitEnvironment() {
|
|
40
|
+
return {
|
|
41
|
+
...process.env,
|
|
42
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
43
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
44
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
45
|
+
GIT_ALLOW_PROTOCOL: 'https:file'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function git(args, { cwd, encoding = 'utf8', maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
50
|
+
const result = spawnSync('git', args, { cwd, encoding, env: gitEnvironment(), maxBuffer });
|
|
51
|
+
if (result.error) throw fail(`could not run Git: ${result.error.message}`);
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const message = encoding === null ? result.stderr?.toString('utf8') : result.stderr;
|
|
54
|
+
throw fail(`Git ${args[0]} failed${message?.trim() ? `: ${message.trim()}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
return result.stdout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseTreeEntries(buffer) {
|
|
60
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const chunk of buffer.toString('binary').split('\0').filter(Boolean)) {
|
|
63
|
+
const separator = chunk.indexOf('\t');
|
|
64
|
+
if (separator < 0) throw fail('Git returned a malformed tree entry');
|
|
65
|
+
const [mode, type, object] = chunk.slice(0, separator).split(' ');
|
|
66
|
+
const rawPath = Buffer.from(chunk.slice(separator + 1), 'binary');
|
|
67
|
+
let entryPath;
|
|
68
|
+
try {
|
|
69
|
+
entryPath = decoder.decode(rawPath);
|
|
70
|
+
} catch {
|
|
71
|
+
throw fail('repository contains a non-UTF-8 path');
|
|
72
|
+
}
|
|
73
|
+
entries.push({ mode, type, object, path: entryPath });
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function literalPath(value) {
|
|
79
|
+
return `:(literal)${value}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function treeEntry(repositoryDir, commit, filePath) {
|
|
83
|
+
const result = git(['-C', repositoryDir, 'ls-tree', '-z', commit, '--', literalPath(filePath)], { encoding: null });
|
|
84
|
+
const entries = parseTreeEntries(result);
|
|
85
|
+
return entries.find((entry) => entry.path === filePath) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeSkillsDirectory(value) {
|
|
89
|
+
if (typeof value !== 'string' || !value || value.includes('\\') || value.startsWith('/')) {
|
|
90
|
+
throw fail('skillshop-library.json skillsDirectory must be a relative repository path');
|
|
91
|
+
}
|
|
92
|
+
const normalized = path.posix.normalize(value);
|
|
93
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.startsWith('/')) {
|
|
94
|
+
throw fail('skillshop-library.json skillsDirectory contains an unsafe path');
|
|
95
|
+
}
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readRemoteConfig(repositoryDir, commit) {
|
|
100
|
+
const entry = treeEntry(repositoryDir, commit, libraryConfigFileName);
|
|
101
|
+
if (!entry || entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) {
|
|
102
|
+
throw fail(`repository is missing a regular ${libraryConfigFileName}`);
|
|
103
|
+
}
|
|
104
|
+
let config;
|
|
105
|
+
try {
|
|
106
|
+
config = JSON.parse(git(['-C', repositoryDir, 'show', `${commit}:${libraryConfigFileName}`], { encoding: null }).toString('utf8'));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof GitHubLibraryError) throw error;
|
|
109
|
+
throw fail(`cannot parse ${libraryConfigFileName}: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
if (config?.schemaVersion !== 1) throw fail(`${libraryConfigFileName} has an unsupported schemaVersion`);
|
|
112
|
+
const skillsDirectory = normalizeSkillsDirectory(config.skillsDirectory);
|
|
113
|
+
return { skillsDirectory };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function safeRelativeFilePath(skillPath, repoPath) {
|
|
117
|
+
if (!repoPath.startsWith(`${skillPath}/`)) throw fail(`repository path escapes requested skill: ${repoPath}`);
|
|
118
|
+
const relative = repoPath.slice(skillPath.length + 1);
|
|
119
|
+
const parts = relative.split('/');
|
|
120
|
+
if (!relative || relative.includes('\\') || parts.some((part) => !part || part === '.' || part === '..')) {
|
|
121
|
+
throw fail(`repository contains an unsafe skill path: ${repoPath}`);
|
|
122
|
+
}
|
|
123
|
+
return relative;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stageSkill(repositoryDir, commit, skillPath, name, stagingRoot) {
|
|
127
|
+
const rootEntry = treeEntry(repositoryDir, commit, skillPath);
|
|
128
|
+
if (!rootEntry) throw fail(`repository has no skill named ${name}`);
|
|
129
|
+
if (rootEntry.mode !== '040000' || rootEntry.type !== 'tree') {
|
|
130
|
+
throw fail(`repository path for ${name} is not a skill directory`);
|
|
131
|
+
}
|
|
132
|
+
const entries = parseTreeEntries(git([
|
|
133
|
+
'-C', repositoryDir, 'ls-tree', '-r', '-z', '--full-tree', commit, '--', literalPath(skillPath)
|
|
134
|
+
], { encoding: null }));
|
|
135
|
+
if (!entries.length) throw fail(`repository skill ${name} is empty`);
|
|
136
|
+
|
|
137
|
+
const skillDirectory = path.join(stagingRoot, 'skill');
|
|
138
|
+
fs.mkdirSync(skillDirectory, { recursive: true, mode: 0o755 });
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const relative = safeRelativeFilePath(skillPath, entry.path);
|
|
142
|
+
if (seen.has(relative)) throw fail(`repository contains a duplicate skill path: ${relative}`);
|
|
143
|
+
seen.add(relative);
|
|
144
|
+
if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) {
|
|
145
|
+
throw fail(`repository skill contains a symlink, submodule, or non-regular entry: ${entry.path}`);
|
|
146
|
+
}
|
|
147
|
+
const target = path.join(skillDirectory, ...relative.split('/'));
|
|
148
|
+
const contained = path.relative(skillDirectory, target);
|
|
149
|
+
if (contained === '..' || contained.startsWith(`..${path.sep}`) || path.isAbsolute(contained)) {
|
|
150
|
+
throw fail(`repository path escapes requested skill: ${entry.path}`);
|
|
151
|
+
}
|
|
152
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 });
|
|
153
|
+
const contents = git(['-C', repositoryDir, 'cat-file', 'blob', entry.object], { encoding: null });
|
|
154
|
+
fs.writeFileSync(target, contents, { flag: 'wx', mode: entry.mode === '100755' ? 0o755 : 0o644 });
|
|
155
|
+
fs.chmodSync(target, entry.mode === '100755' ? 0o755 : 0o644);
|
|
156
|
+
}
|
|
157
|
+
validatePersonalSkillDirectory(skillDirectory, skillDirectory, name);
|
|
158
|
+
return skillDirectory;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fetchSkill(reference, temporaryRoot, { ref = null, commit: expectedCommit = null } = {}) {
|
|
162
|
+
const bareRepository = path.join(temporaryRoot, 'repository.git');
|
|
163
|
+
fs.mkdirSync(bareRepository, { recursive: true });
|
|
164
|
+
git(['init', '--bare', '--quiet', bareRepository]);
|
|
165
|
+
let fetchRef = expectedCommit ?? ref;
|
|
166
|
+
if (expectedCommit && !/^[a-f0-9]{40,64}$/.test(expectedCommit)) throw fail('invalid locked commit identifier');
|
|
167
|
+
if (fetchRef) {
|
|
168
|
+
if (typeof fetchRef !== 'string' || fetchRef.startsWith('-')) throw fail('invalid Git skill ref');
|
|
169
|
+
if (!/^[a-f0-9]{40,64}$/.test(fetchRef)) git(['check-ref-format', '--allow-onelevel', fetchRef]);
|
|
170
|
+
} else {
|
|
171
|
+
const symbolicRefs = git(['ls-remote', '--symref', reference.repository, 'HEAD']);
|
|
172
|
+
const headReference = symbolicRefs.split(/\r?\n/).find((line) => line.startsWith('ref: ') && line.endsWith('\tHEAD'));
|
|
173
|
+
const headObject = symbolicRefs.split(/\r?\n/).find((line) => /^[a-f0-9]{40,64}\tHEAD$/.test(line));
|
|
174
|
+
if (!headReference || !headObject) throw fail(`could not discover the default branch for ${reference.repository}`);
|
|
175
|
+
fetchRef = headReference.slice('ref: '.length, -'\tHEAD'.length);
|
|
176
|
+
if (!fetchRef.startsWith('refs/heads/')) throw fail('GitHub repository default is not a branch');
|
|
177
|
+
git(['-C', bareRepository, 'check-ref-format', fetchRef]);
|
|
178
|
+
}
|
|
179
|
+
git(['-C', bareRepository, 'fetch', '--no-tags', '--no-recurse-submodules', '--depth=1', reference.repository, fetchRef]);
|
|
180
|
+
const commit = git(['-C', bareRepository, 'rev-parse', '--verify', 'FETCH_HEAD^{commit}']).trim();
|
|
181
|
+
if (!/^[a-f0-9]{40,64}$/.test(commit)) throw fail('Git returned an invalid commit identifier');
|
|
182
|
+
if (expectedCommit && commit !== expectedCommit) throw fail(`fetched commit does not match locked commit ${expectedCommit}`);
|
|
183
|
+
const config = readRemoteConfig(bareRepository, commit);
|
|
184
|
+
const skillPath = config.skillsDirectory === '.' ? reference.name : `${config.skillsDirectory}/${reference.name}`;
|
|
185
|
+
if (config.skillsDirectory === '.' && reservedRootNames.has(reference.name)) {
|
|
186
|
+
throw fail(`skill name collides with repository metadata: ${reference.name}`);
|
|
187
|
+
}
|
|
188
|
+
const skillDirectory = stageSkill(bareRepository, commit, skillPath, reference.name, temporaryRoot);
|
|
189
|
+
return { skillDirectory, origin: { repository: reference.repository, commit } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function fetchGitHubSkill(reference, temporaryRoot, options = {}) {
|
|
193
|
+
return fetchSkill(parseGitHubSkillReference(reference), temporaryRoot, options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function snapshotFile(filePath) {
|
|
197
|
+
try {
|
|
198
|
+
const stat = fs.lstatSync(filePath);
|
|
199
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw fail(`${filePath} must be a regular file`);
|
|
200
|
+
return { contents: fs.readFileSync(filePath), mode: stat.mode & 0o777 };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error.code === 'ENOENT') return null;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function restoreFile(filePath, snapshot) {
|
|
208
|
+
if (!snapshot) {
|
|
209
|
+
fs.rmSync(filePath, { force: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
fs.writeFileSync(filePath, snapshot.contents, { mode: snapshot.mode });
|
|
213
|
+
fs.chmodSync(filePath, snapshot.mode);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function captureInitialization(root, stateHome) {
|
|
217
|
+
let rootExisted = true;
|
|
218
|
+
let rootEmpty = false;
|
|
219
|
+
try {
|
|
220
|
+
const stat = fs.lstatSync(root);
|
|
221
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`library root is not a regular directory: ${root}`);
|
|
222
|
+
rootEmpty = fs.readdirSync(root).length === 0;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code !== 'ENOENT') throw error;
|
|
225
|
+
rootExisted = false;
|
|
226
|
+
}
|
|
227
|
+
const skillsBefore = path.join(root, 'skills');
|
|
228
|
+
let skillsExisted = false;
|
|
229
|
+
try {
|
|
230
|
+
fs.lstatSync(skillsBefore);
|
|
231
|
+
skillsExisted = true;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error.code !== 'ENOENT') throw error;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
rootExisted,
|
|
237
|
+
rootEmpty,
|
|
238
|
+
skillsExisted,
|
|
239
|
+
stateHomeExisted: fs.existsSync(stateHome),
|
|
240
|
+
config: snapshotFile(path.join(root, libraryConfigFileName)),
|
|
241
|
+
readme: snapshotFile(path.join(root, 'README.md')),
|
|
242
|
+
selection: snapshotFile(path.join(stateHome, librarySelectionFileName))
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function rollbackInitialization(root, stateHome, snapshot) {
|
|
247
|
+
if (!snapshot.rootExisted) {
|
|
248
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
249
|
+
} else if (snapshot.rootEmpty) {
|
|
250
|
+
for (const entry of fs.readdirSync(root)) fs.rmSync(path.join(root, entry), { recursive: true, force: true });
|
|
251
|
+
} else {
|
|
252
|
+
restoreFile(path.join(root, libraryConfigFileName), snapshot.config);
|
|
253
|
+
restoreFile(path.join(root, 'README.md'), snapshot.readme);
|
|
254
|
+
if (!snapshot.skillsExisted) fs.rmSync(path.join(root, 'skills'), { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
restoreFile(path.join(stateHome, librarySelectionFileName), snapshot.selection);
|
|
257
|
+
if (!snapshot.stateHomeExisted && fs.existsSync(stateHome) && fs.readdirSync(stateHome).length === 0) {
|
|
258
|
+
fs.rmdirSync(stateHome);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function targetInfo(layout, name) {
|
|
263
|
+
if (path.resolve(layout.skillsDirectory) === path.resolve(layout.root) && reservedRootNames.has(name)) {
|
|
264
|
+
throw fail(`skill name collides with library metadata: ${name}`);
|
|
265
|
+
}
|
|
266
|
+
const target = path.join(layout.skillsDirectory, name);
|
|
267
|
+
try {
|
|
268
|
+
const stat = fs.lstatSync(target);
|
|
269
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`personal library target is not a regular skill directory: ${target}`);
|
|
270
|
+
validatePersonalSkillDirectory(layout.root, target, name);
|
|
271
|
+
return { target, exists: true };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error.code === 'ENOENT') return { target, exists: false };
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function downloadPersonalSkill(referenceValue, { stateHome, templateRoot, force = false } = {}) {
|
|
279
|
+
const reference = parseGitHubSkillReference(referenceValue);
|
|
280
|
+
const selected = selectedLibraryRoot(stateHome);
|
|
281
|
+
const root = selected ?? path.resolve(os.homedir(), 'skillshop-library');
|
|
282
|
+
const layout = inspectPersonalLibrary(root);
|
|
283
|
+
const origins = layout.exists ? readPersonalLibraryOrigins(root) : { schemaVersion: 1, skills: {} };
|
|
284
|
+
const existingOrigin = origins.skills[reference.name];
|
|
285
|
+
const target = targetInfo(layout, reference.name);
|
|
286
|
+
if (target.exists && !force) {
|
|
287
|
+
if (existingOrigin?.repository === reference.repository) {
|
|
288
|
+
if (!selected) initPersonalLibrary(root, stateHome, templateRoot);
|
|
289
|
+
return { root, name: reference.name, origin: existingOrigin, alreadyPresent: true };
|
|
290
|
+
}
|
|
291
|
+
if (existingOrigin) throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
|
|
292
|
+
throw fail(`${reference.name} already exists as a personal skill; use --force to replace it`);
|
|
293
|
+
}
|
|
294
|
+
if (existingOrigin && existingOrigin.repository !== reference.repository && !force) {
|
|
295
|
+
throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
|
|
296
|
+
}
|
|
297
|
+
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skillshop-github-download-'));
|
|
298
|
+
try {
|
|
299
|
+
const downloaded = fetchSkill(reference, temporaryRoot);
|
|
300
|
+
let initialized = false;
|
|
301
|
+
let initializationSnapshot;
|
|
302
|
+
let skillsDirectory = layout.skillsDirectory;
|
|
303
|
+
if (!selected) initializationSnapshot = captureInitialization(root, stateHome);
|
|
304
|
+
try {
|
|
305
|
+
if (!selected) {
|
|
306
|
+
initialized = true;
|
|
307
|
+
const result = initPersonalLibrary(root, stateHome, templateRoot, { includeExample: false });
|
|
308
|
+
skillsDirectory = result.skillsDirectory;
|
|
309
|
+
if (path.resolve(skillsDirectory) !== path.resolve(layout.skillsDirectory)) {
|
|
310
|
+
throw fail('default library layout changed during download');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const committed = commitDownloadedPersonalSkill(root, skillsDirectory, reference.name, downloaded.skillDirectory, downloaded.origin, { force });
|
|
314
|
+
return { root, name: reference.name, origin: downloaded.origin, target: committed.target, alreadyPresent: false };
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (initialized) rollbackInitialization(root, stateHome, initializationSnapshot);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
package/src/import.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
6
6
|
import { normalizeSkillSpecifier, parseSkillSpecifier, parseLibrarySkillReference } from './specifier.js';
|
|
7
7
|
import { SKILLSHOP_MANIFEST_FILE, SKILLSHOP_SCHEMA_VERSION } from './library.js';
|
|
8
|
+
import { assertInstallationOwner, writeInstallationManifest } from './installation-state.js';
|
|
8
9
|
|
|
9
10
|
async function importManifestFile(file) {
|
|
10
11
|
// Content-based cache keys also invalidate in long-lived consumers when two
|
|
@@ -95,11 +96,23 @@ export async function importSkill({
|
|
|
95
96
|
cwd = process.cwd(),
|
|
96
97
|
overwrite = false,
|
|
97
98
|
merge = false,
|
|
99
|
+
ownerKind = 'package-import',
|
|
98
100
|
}) {
|
|
99
101
|
if (targetDir !== undefined && (typeof targetDir !== 'string' || !targetDir.trim())) throw new Error('targetDir must be a nonempty path');
|
|
100
102
|
if (merge && overwrite) throw new Error('merge and overwrite cannot both be enabled');
|
|
101
103
|
const skill = await resolveSkill({ reference, library, specifier, cwd });
|
|
102
104
|
const target = path.resolve(cwd, targetDir ?? path.join('.agents', 'skills', skill.name));
|
|
105
|
+
const source = path.resolve(skill.sourceDir);
|
|
106
|
+
if ([path.relative(source, target), path.relative(target, source)].some((relative) => relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)))) {
|
|
107
|
+
throw new Error('skill import destination overlaps its source directory');
|
|
108
|
+
}
|
|
109
|
+
listSkillFiles(skill.sourceDir);
|
|
110
|
+
const owner = { kind: ownerKind, id: skill.packageName || skill.sourceDir };
|
|
111
|
+
const installation = assertInstallationOwner(path.dirname(target), path.basename(target), owner);
|
|
112
|
+
const saveOwnership = () => {
|
|
113
|
+
installation.skills[path.basename(target)] = { owner, source: { kind: 'package', package: skill.packageName, specifier: skill.specifier } };
|
|
114
|
+
writeInstallationManifest(path.dirname(target), installation);
|
|
115
|
+
};
|
|
103
116
|
const exists = fs.existsSync(target);
|
|
104
117
|
|
|
105
118
|
if (exists && merge) {
|
|
@@ -121,6 +134,7 @@ export async function importSkill({
|
|
|
121
134
|
addedFiles += 1;
|
|
122
135
|
}
|
|
123
136
|
}
|
|
137
|
+
saveOwnership();
|
|
124
138
|
return { ...skill, targetDir: target, installed: addedFiles > 0, updated: updatedFiles > 0, addedFiles, updatedFiles };
|
|
125
139
|
}
|
|
126
140
|
|
|
@@ -132,6 +146,7 @@ export async function importSkill({
|
|
|
132
146
|
fs.cpSync(skill.sourceDir, temporary, { recursive: true, force: true });
|
|
133
147
|
if (overwrite) fs.rmSync(target, { recursive: true, force: true });
|
|
134
148
|
fs.renameSync(temporary, target);
|
|
149
|
+
saveOwnership();
|
|
135
150
|
} finally {
|
|
136
151
|
fs.rmSync(temporary, { recursive: true, force: true });
|
|
137
152
|
}
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const installationManifestName = '.skillshop-lock';
|
|
7
|
+
const skillNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
8
|
+
|
|
9
|
+
export function assertRegularPath(target) {
|
|
10
|
+
const absolute = path.resolve(target);
|
|
11
|
+
let current = path.parse(absolute).root;
|
|
12
|
+
for (const segment of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
13
|
+
current = path.join(current, segment);
|
|
14
|
+
try {
|
|
15
|
+
// macOS exposes its system temporary directories through these aliases.
|
|
16
|
+
const systemAlias = process.platform === 'darwin' && ['/var', '/tmp'].includes(current);
|
|
17
|
+
if (fs.lstatSync(current).isSymbolicLink() && !systemAlias) throw new Error(`installation path contains a symlink: ${current}`);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT') throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function atomicJson(file, value) {
|
|
25
|
+
assertRegularPath(file);
|
|
26
|
+
const contents = `${JSON.stringify(value, null, 2)}\n`;
|
|
27
|
+
if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === contents) return;
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
const temporary = `${file}.tmp-${randomUUID()}`;
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(temporary, contents, { flag: 'wx' });
|
|
32
|
+
fs.renameSync(temporary, file);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.rmSync(temporary, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sameOwner(left, right) {
|
|
39
|
+
return left?.kind === right?.kind && left?.id === right?.id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readInstallationManifest(directory) {
|
|
43
|
+
const file = path.join(directory, installationManifestName);
|
|
44
|
+
assertRegularPath(file);
|
|
45
|
+
if (!fs.existsSync(file)) return { manifestVersion: 1, skills: {} };
|
|
46
|
+
let manifest;
|
|
47
|
+
try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
48
|
+
catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
|
|
49
|
+
if (manifest?.manifestVersion !== 1 || !manifest.skills || typeof manifest.skills !== 'object' || Array.isArray(manifest.skills)) {
|
|
50
|
+
throw new Error(`${file} is not a supported Skillshop installation manifest`);
|
|
51
|
+
}
|
|
52
|
+
for (const [name, entry] of Object.entries(manifest.skills)) {
|
|
53
|
+
if (!skillNamePattern.test(name) || !entry || typeof entry !== 'object' ||
|
|
54
|
+
typeof entry.owner?.kind !== 'string' || !entry.owner.kind || typeof entry.owner?.id !== 'string' || !entry.owner.id) {
|
|
55
|
+
throw new Error(`${file} has an invalid ownership entry for ${name}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return manifest;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeInstallationManifest(directory, manifest) {
|
|
62
|
+
atomicJson(path.join(directory, installationManifestName), {
|
|
63
|
+
manifestVersion: 1,
|
|
64
|
+
skills: Object.fromEntries(Object.entries(manifest.skills).sort(([a], [b]) => a.localeCompare(b)))
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assertInstallationOwner(directory, name, owner, { requireOwned = false } = {}) {
|
|
69
|
+
if (!skillNamePattern.test(name)) throw new Error(`invalid skill name: ${name}`);
|
|
70
|
+
const target = path.join(directory, name);
|
|
71
|
+
assertRegularPath(target);
|
|
72
|
+
const manifest = readInstallationManifest(directory);
|
|
73
|
+
const existing = manifest.skills[name];
|
|
74
|
+
if (existing && !sameOwner(existing.owner, owner)) {
|
|
75
|
+
throw new Error(`${target} is owned by ${existing.owner.kind}:${existing.owner.id}; refusing to replace or remove it`);
|
|
76
|
+
}
|
|
77
|
+
if (!existing && (requireOwned || fs.existsSync(target))) {
|
|
78
|
+
throw new Error(`${target} exists or was requested for removal but is not a Skillshop-managed copy owned by ${owner.kind}:${owner.id}`);
|
|
79
|
+
}
|
|
80
|
+
return manifest;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function stateHome() {
|
|
84
|
+
if (process.env.SKILLSHOP_HOME) return process.env.SKILLSHOP_HOME;
|
|
85
|
+
if (process.env.SKILLZ_HOME) return process.env.SKILLZ_HOME;
|
|
86
|
+
const current = path.join(os.homedir(), '.skillshop');
|
|
87
|
+
const legacy = path.join(os.homedir(), '.skillz');
|
|
88
|
+
return fs.existsSync(current) || !fs.existsSync(legacy) ? current : legacy;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readInstallationIndex(home = stateHome()) {
|
|
92
|
+
const file = path.join(home, 'installations.json');
|
|
93
|
+
assertRegularPath(file);
|
|
94
|
+
if (!fs.existsSync(file)) return [];
|
|
95
|
+
let index;
|
|
96
|
+
try { index = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
97
|
+
catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
|
|
98
|
+
if (index?.indexVersion !== 1 || !Array.isArray(index.installations) || index.installations.some((entry) =>
|
|
99
|
+
!entry || !path.isAbsolute(entry.directory ?? '') || !['project', 'global'].includes(entry.scope) ||
|
|
100
|
+
(entry.scope === 'project' && !path.isAbsolute(entry.root ?? '')))) {
|
|
101
|
+
throw new Error(`${file} is not a supported installation discovery index`);
|
|
102
|
+
}
|
|
103
|
+
return index.installations;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function registerInstallationDirectory(directory, { root = null, home = stateHome() } = {}) {
|
|
107
|
+
const entry = root ? { directory: path.resolve(directory), scope: 'project', root: path.resolve(root) }
|
|
108
|
+
: { directory: path.resolve(directory), scope: 'global' };
|
|
109
|
+
const entries = readInstallationIndex(home).filter((item) => item.directory !== entry.directory);
|
|
110
|
+
entries.push(entry);
|
|
111
|
+
entries.sort((a, b) => a.directory.localeCompare(b.directory));
|
|
112
|
+
atomicJson(path.join(home, 'installations.json'), { indexVersion: 1, installations: entries });
|
|
113
|
+
}
|