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/src/cli.js CHANGED
@@ -11,22 +11,18 @@ import { parseLibrarySkillReference, skillName } from './specifier.js';
11
11
 
12
12
  function help() {
13
13
  console.log(`
14
- Library commands:
14
+ Personal folder library commands:
15
15
 
16
- skillshop library init
16
+ skillshop library init [directory]
17
17
  skillshop library list
18
- skillshop export skill:<name> <directory>
19
- skillshop export skill:<name> --from <package> [--skill skill:<source-name>]
20
- skillshop unexport skill:<name>
21
- skillshop validate
22
- skillshop build
23
- skillshop resolve <library>:<skill>
24
- skillshop import <library>:<skill> [--global] [--to <directory>] [--force]
25
- skillshop install-user [package-dir] [--if-global] [--to <skills-dir>]
18
+ skillshop download @<owner>/<skill> [--force]
19
+ skillshop install @<owner>/<skill> [--global]
20
+ skillshop install <name> [--global]
21
+ skillshop sync
26
22
 
27
- A library publishes skills by portable specifier, for example skill:review.
28
- Import uses the short <library>:<skill> form and copies to .agents/skills/<skill> by default.
29
- --global copies to ~/.agents/skills/<skill> and replaces an existing directory.`);
23
+ Create or select a source folder with skillshop library init. Download @owner/skill adds a GitHub skill to that folder without installing it.
24
+ Install @owner/skill downloads when needed, then installs the complete source tree. Bare install names use the selected personal library. Sync updates recorded project and global copies from their saved source roots.
25
+ No npm package manifest or author metadata is required. Existing npm package export/import APIs remain available for compatibility.`);
30
26
  }
31
27
 
32
28
  function flag(args, name) {
@@ -47,7 +43,7 @@ const libraryCommands = new Set(['export', 'unexport', 'validate', 'build', 'res
47
43
  async function main(argv, { libraryNamespace = false } = {}) {
48
44
  const [command, ...args] = argv;
49
45
  if (!libraryNamespace && command === 'library') {
50
- if (args[0] === 'init' || args[0] === 'list') return main(args, { libraryNamespace: true });
46
+ if (args[0] === 'init' || args[0] === 'list') return runLegacyCli(argv);
51
47
  if (!args.length || args[0] === '--help' || args[0] === '-h') return help();
52
48
  throw new Error('Usage: skillshop library init | list');
53
49
  }
@@ -146,13 +142,14 @@ async function main(argv, { libraryNamespace = false } = {}) {
146
142
 
147
143
  const argv = process.argv.slice(2);
148
144
  const usingLibraryCommands = argv[0] === 'library' || libraryCommands.has(argv[0]);
145
+ const personalLibraryCommand = argv[0] === 'library' && ['init', 'list'].includes(argv[1]);
149
146
 
150
147
  try {
151
- if (usingLibraryCommands) reconcileLegacyState();
148
+ if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
152
149
  try {
153
150
  await main(argv);
154
151
  } finally {
155
- if (usingLibraryCommands) reconcileLegacyState();
152
+ if (usingLibraryCommands && !personalLibraryCommand) reconcileLegacyState();
156
153
  }
157
154
  } catch (error) {
158
155
  console.error(`skillshop: ${error.message}`);
@@ -0,0 +1,310 @@
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) {
162
+ const bareRepository = path.join(temporaryRoot, 'repository.git');
163
+ fs.mkdirSync(bareRepository, { recursive: true });
164
+ git(['init', '--bare', '--quiet', bareRepository]);
165
+ const symbolicRefs = git(['ls-remote', '--symref', reference.repository, 'HEAD']);
166
+ const headReference = symbolicRefs.split(/\r?\n/).find((line) => line.startsWith('ref: ') && line.endsWith('\tHEAD'));
167
+ const headObject = symbolicRefs.split(/\r?\n/).find((line) => /^[a-f0-9]{40,64}\tHEAD$/.test(line));
168
+ if (!headReference || !headObject) throw fail(`could not discover the default branch for ${reference.repository}`);
169
+ const branchRef = headReference.slice('ref: '.length, -'\tHEAD'.length);
170
+ if (!branchRef.startsWith('refs/heads/')) throw fail('GitHub repository default is not a branch');
171
+ git(['-C', bareRepository, 'check-ref-format', branchRef]);
172
+ git(['-C', bareRepository, 'fetch', '--no-tags', '--no-recurse-submodules', '--depth=1', reference.repository, branchRef]);
173
+ const commit = git(['-C', bareRepository, 'rev-parse', '--verify', 'FETCH_HEAD^{commit}']).trim();
174
+ if (!/^[a-f0-9]{40,64}$/.test(commit)) throw fail('Git returned an invalid commit identifier');
175
+ const config = readRemoteConfig(bareRepository, commit);
176
+ const skillPath = config.skillsDirectory === '.' ? reference.name : `${config.skillsDirectory}/${reference.name}`;
177
+ if (config.skillsDirectory === '.' && reservedRootNames.has(reference.name)) {
178
+ throw fail(`skill name collides with repository metadata: ${reference.name}`);
179
+ }
180
+ const skillDirectory = stageSkill(bareRepository, commit, skillPath, reference.name, temporaryRoot);
181
+ return { skillDirectory, origin: { repository: reference.repository, commit } };
182
+ }
183
+
184
+ function snapshotFile(filePath) {
185
+ try {
186
+ const stat = fs.lstatSync(filePath);
187
+ if (!stat.isFile() || stat.isSymbolicLink()) throw fail(`${filePath} must be a regular file`);
188
+ return { contents: fs.readFileSync(filePath), mode: stat.mode & 0o777 };
189
+ } catch (error) {
190
+ if (error.code === 'ENOENT') return null;
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ function restoreFile(filePath, snapshot) {
196
+ if (!snapshot) {
197
+ fs.rmSync(filePath, { force: true });
198
+ return;
199
+ }
200
+ fs.writeFileSync(filePath, snapshot.contents, { mode: snapshot.mode });
201
+ fs.chmodSync(filePath, snapshot.mode);
202
+ }
203
+
204
+ function captureInitialization(root, stateHome) {
205
+ let rootExisted = true;
206
+ let rootEmpty = false;
207
+ try {
208
+ const stat = fs.lstatSync(root);
209
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`library root is not a regular directory: ${root}`);
210
+ rootEmpty = fs.readdirSync(root).length === 0;
211
+ } catch (error) {
212
+ if (error.code !== 'ENOENT') throw error;
213
+ rootExisted = false;
214
+ }
215
+ const skillsBefore = path.join(root, 'skills');
216
+ let skillsExisted = false;
217
+ try {
218
+ fs.lstatSync(skillsBefore);
219
+ skillsExisted = true;
220
+ } catch (error) {
221
+ if (error.code !== 'ENOENT') throw error;
222
+ }
223
+ return {
224
+ rootExisted,
225
+ rootEmpty,
226
+ skillsExisted,
227
+ stateHomeExisted: fs.existsSync(stateHome),
228
+ config: snapshotFile(path.join(root, libraryConfigFileName)),
229
+ readme: snapshotFile(path.join(root, 'README.md')),
230
+ selection: snapshotFile(path.join(stateHome, librarySelectionFileName))
231
+ };
232
+ }
233
+
234
+ function rollbackInitialization(root, stateHome, snapshot) {
235
+ if (!snapshot.rootExisted) {
236
+ fs.rmSync(root, { recursive: true, force: true });
237
+ } else if (snapshot.rootEmpty) {
238
+ for (const entry of fs.readdirSync(root)) fs.rmSync(path.join(root, entry), { recursive: true, force: true });
239
+ } else {
240
+ restoreFile(path.join(root, libraryConfigFileName), snapshot.config);
241
+ restoreFile(path.join(root, 'README.md'), snapshot.readme);
242
+ if (!snapshot.skillsExisted) fs.rmSync(path.join(root, 'skills'), { recursive: true, force: true });
243
+ }
244
+ restoreFile(path.join(stateHome, librarySelectionFileName), snapshot.selection);
245
+ if (!snapshot.stateHomeExisted && fs.existsSync(stateHome) && fs.readdirSync(stateHome).length === 0) {
246
+ fs.rmdirSync(stateHome);
247
+ }
248
+ }
249
+
250
+ function targetInfo(layout, name) {
251
+ if (path.resolve(layout.skillsDirectory) === path.resolve(layout.root) && reservedRootNames.has(name)) {
252
+ throw fail(`skill name collides with library metadata: ${name}`);
253
+ }
254
+ const target = path.join(layout.skillsDirectory, name);
255
+ try {
256
+ const stat = fs.lstatSync(target);
257
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail(`personal library target is not a regular skill directory: ${target}`);
258
+ validatePersonalSkillDirectory(layout.root, target, name);
259
+ return { target, exists: true };
260
+ } catch (error) {
261
+ if (error.code === 'ENOENT') return { target, exists: false };
262
+ throw error;
263
+ }
264
+ }
265
+
266
+ export function downloadPersonalSkill(referenceValue, { stateHome, templateRoot, force = false } = {}) {
267
+ const reference = parseGitHubSkillReference(referenceValue);
268
+ const selected = selectedLibraryRoot(stateHome);
269
+ const root = selected ?? path.resolve(os.homedir(), 'skillshop-library');
270
+ const layout = inspectPersonalLibrary(root);
271
+ const origins = layout.exists ? readPersonalLibraryOrigins(root) : { schemaVersion: 1, skills: {} };
272
+ const existingOrigin = origins.skills[reference.name];
273
+ const target = targetInfo(layout, reference.name);
274
+ if (target.exists && !force) {
275
+ if (existingOrigin?.repository === reference.repository) {
276
+ if (!selected) initPersonalLibrary(root, stateHome, templateRoot);
277
+ return { root, name: reference.name, origin: existingOrigin, alreadyPresent: true };
278
+ }
279
+ if (existingOrigin) throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
280
+ throw fail(`${reference.name} already exists as a personal skill; use --force to replace it`);
281
+ }
282
+ if (existingOrigin && existingOrigin.repository !== reference.repository && !force) {
283
+ throw fail(`${reference.name} already has a different GitHub source; use --force to replace it`);
284
+ }
285
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skillshop-github-download-'));
286
+ try {
287
+ const downloaded = fetchSkill(reference, temporaryRoot);
288
+ let initialized = false;
289
+ let initializationSnapshot;
290
+ let skillsDirectory = layout.skillsDirectory;
291
+ if (!selected) initializationSnapshot = captureInitialization(root, stateHome);
292
+ try {
293
+ if (!selected) {
294
+ initialized = true;
295
+ const result = initPersonalLibrary(root, stateHome, templateRoot, { includeExample: false });
296
+ skillsDirectory = result.skillsDirectory;
297
+ if (path.resolve(skillsDirectory) !== path.resolve(layout.skillsDirectory)) {
298
+ throw fail('default library layout changed during download');
299
+ }
300
+ }
301
+ const committed = commitDownloadedPersonalSkill(root, skillsDirectory, reference.name, downloaded.skillDirectory, downloaded.origin, { force });
302
+ return { root, name: reference.name, origin: downloaded.origin, target: committed.target, alreadyPresent: false };
303
+ } catch (error) {
304
+ if (initialized) rollbackInitialization(root, stateHome, initializationSnapshot);
305
+ throw error;
306
+ }
307
+ } finally {
308
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
309
+ }
310
+ }