skillspub 0.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.
@@ -0,0 +1,273 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { stripVTControlCharacters } from 'node:util';
7
+ export const NPX_SKILLS_PACKAGE = 'skills@1.5.21';
8
+ const SHARED_TARGET_TRANSPORT = ['--agent', 'codex'];
9
+ export function normalizeNpxSkillsName(name) {
10
+ return name.toLowerCase()
11
+ .replace(/[^a-z0-9._]+/g, '-')
12
+ .replace(/^[.-]+|[.-]+$/g, '')
13
+ .substring(0, 255) || 'unnamed-skill';
14
+ }
15
+ export function parseNpxSkillsFindOutput(raw) {
16
+ const lines = stripVTControlCharacters(raw).split(/\r?\n/);
17
+ const candidates = [];
18
+ const resultLines = lines.filter((line) => /^\S+@\S+(?:\s+.+ installs)?$/.test(line.trim())).length;
19
+ const detailLines = lines.filter((line) => /^└\s+https:\/\/skills\.sh\/\S+$/.test(line.trim())).length;
20
+ for (let index = 0; index < lines.length; index++) {
21
+ const line = lines[index].trim();
22
+ if (index === lines.length - 1)
23
+ continue;
24
+ const detail = lines[index + 1].trim().match(/^└\s+(https:\/\/skills\.sh\/\S+)$/);
25
+ if (!detail)
26
+ continue;
27
+ const result = line.match(/^(\S+)@(\S+?)(?:\s+(.+ installs))?$/);
28
+ if (!result)
29
+ continue;
30
+ candidates.push({
31
+ source: result[1],
32
+ name: result[2],
33
+ ...(result[3] ? { installs: result[3] } : {}),
34
+ detailUrl: detail[1],
35
+ });
36
+ index++;
37
+ }
38
+ const marker = lines.findIndex((line) => line.includes('Install with') && line.includes('npx skills add'));
39
+ const bodyLines = marker < 0 ? [] : lines.slice(marker + 1).filter((line) => line.trim());
40
+ return {
41
+ candidates,
42
+ complete: marker >= 0
43
+ ? bodyLines.length === candidates.length * 2
44
+ : candidates.length === resultLines && candidates.length === detailLines,
45
+ raw,
46
+ };
47
+ }
48
+ export function runNpxSkills(args, cwd, capture = false) {
49
+ let stdio = 'inherit';
50
+ if (capture === 'output')
51
+ stdio = ['inherit', 'pipe', 'pipe'];
52
+ else if (capture)
53
+ stdio = 'pipe';
54
+ const result = spawnSync('npx', ['--yes', NPX_SKILLS_PACKAGE, ...args], {
55
+ cwd,
56
+ encoding: 'utf8',
57
+ env: { ...process.env, XDG_STATE_HOME: undefined },
58
+ stdio,
59
+ });
60
+ if (result.error)
61
+ throw result.error;
62
+ return {
63
+ status: result.status ?? 1,
64
+ stdout: result.stdout ?? '',
65
+ stderr: result.stderr ?? '',
66
+ };
67
+ }
68
+ export function npxSkillsFindArgs(query) {
69
+ return ['find', ...query];
70
+ }
71
+ export function npxSkillsDescribeArgs(source) {
72
+ return ['add', source, '--list'];
73
+ }
74
+ export function npxSkillsAddArgs(source, name, global) {
75
+ return ['add', source, '--skill', name, ...SHARED_TARGET_TRANSPORT, ...(global ? ['--global'] : []), '--copy'];
76
+ }
77
+ export function npxSkillsUpdateArgs(names, global) {
78
+ return ['update', ...names, ...(global ? ['--global'] : [])];
79
+ }
80
+ export function npxSkillsRemoveArgs(names, global) {
81
+ return ['remove', ...names, ...SHARED_TARGET_TRANSPORT, ...(global ? ['--global'] : [])];
82
+ }
83
+ function isRecord(value) {
84
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
85
+ }
86
+ export function readNpxSkillsLock(file) {
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
90
+ }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT')
93
+ return [];
94
+ throw new Error(`${file}: ${error.message}`);
95
+ }
96
+ if (!isRecord(parsed))
97
+ throw new Error(`${file}: lock must be a JSON object`);
98
+ if (parsed.version !== 3)
99
+ throw new Error(`${file}: lock.version must be 3`);
100
+ const skills = parsed.skills;
101
+ if (skills !== undefined && !isRecord(skills))
102
+ throw new Error(`${file}: lock.skills must be a JSON object`);
103
+ const slots = new Set();
104
+ return Object.entries(skills ?? {}).map(([name, entry]) => {
105
+ if (!isRecord(entry))
106
+ throw new Error(`${file}: lock skill entry must be an object: ${name}`);
107
+ for (const field of [
108
+ 'source', 'sourceUrl', 'skillPath', 'sourceType', 'ref', 'skillFolderHash', 'computedHash',
109
+ ]) {
110
+ if (entry[field] !== undefined && typeof entry[field] !== 'string')
111
+ throw new Error(`${file}: lock skill ${name}.${field} must be a string`);
112
+ }
113
+ const slot = normalizeNpxSkillsName(name);
114
+ if (slots.has(slot))
115
+ throw new Error(`${file}: duplicate normalized lock skill: ${slot}`);
116
+ slots.add(slot);
117
+ return {
118
+ name,
119
+ slot,
120
+ provenance: {
121
+ source: entry.source,
122
+ sourceUrl: entry.sourceUrl,
123
+ skillPath: entry.skillPath,
124
+ },
125
+ ...(entry.sourceType ? { sourceType: entry.sourceType } : {}),
126
+ ...(entry.ref ? { ref: entry.ref } : {}),
127
+ ...(entry.skillFolderHash ? { skillFolderHash: entry.skillFolderHash } : {}),
128
+ ...(entry.computedHash ? { computedHash: entry.computedHash } : {}),
129
+ };
130
+ });
131
+ }
132
+ function sourceLocation(skill) {
133
+ if (skill.provenance.sourceUrl)
134
+ return skill.provenance.sourceUrl;
135
+ const source = skill.provenance.source;
136
+ if ((!skill.sourceType || skill.sourceType === 'github') && source &&
137
+ /^[^/\s]+\/[^/\s]+$/.test(source))
138
+ return `https://github.com/${source}.git`;
139
+ return undefined;
140
+ }
141
+ export function npxSkillsSourceKey(skill) {
142
+ const location = sourceLocation(skill);
143
+ return location ? JSON.stringify([location, skill.ref ?? '']) : undefined;
144
+ }
145
+ function skillFolder(skillPath) {
146
+ const normalized = skillPath.replaceAll('\\', '/');
147
+ if (path.posix.isAbsolute(normalized) || normalized.includes('\0') ||
148
+ normalized.split('/').some((part) => part === '..') || normalized.includes(':') ||
149
+ path.posix.basename(normalized).toLowerCase() !== 'skill.md')
150
+ throw new Error(`unsafe installer skillPath: ${skillPath}`);
151
+ return path.posix.dirname(normalized);
152
+ }
153
+ function computeNpxSkillsFolderHash(root) {
154
+ const files = [];
155
+ const visit = (directory) => {
156
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
157
+ if (entry.name === '.git' || entry.name === 'node_modules')
158
+ continue;
159
+ const file = path.join(directory, entry.name);
160
+ if (entry.isDirectory())
161
+ visit(file);
162
+ else if (entry.isFile())
163
+ files.push({
164
+ relativePath: path.relative(root, file).split(path.sep).join('/'),
165
+ content: fs.readFileSync(file),
166
+ });
167
+ }
168
+ };
169
+ visit(root);
170
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
171
+ const hash = crypto.createHash('sha256');
172
+ for (const file of files)
173
+ hash.update(file.relativePath).update(file.content);
174
+ return hash.digest('hex');
175
+ }
176
+ export function checkNpxSkillsSource(skills) {
177
+ if (skills.length === 0)
178
+ return [];
179
+ const source = sourceLocation(skills[0]);
180
+ const key = npxSkillsSourceKey(skills[0]);
181
+ if (!source || !key || skills.some((skill) => npxSkillsSourceKey(skill) !== key))
182
+ throw new Error('installer lock has no consistent remote source');
183
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'skillspub-refresh-'));
184
+ const checkout = path.join(temporary, 'source');
185
+ try {
186
+ const args = ['clone', '--quiet', '--depth', '1'];
187
+ if (skills[0].ref)
188
+ args.push('--branch', skills[0].ref);
189
+ args.push('--', source, checkout);
190
+ const cloned = spawnSync('git', args, {
191
+ encoding: 'utf8',
192
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
193
+ stdio: 'pipe',
194
+ timeout: 30_000,
195
+ });
196
+ if (cloned.error)
197
+ throw cloned.error;
198
+ if (cloned.status !== 0)
199
+ throw new Error(`git clone failed: ${(cloned.stderr || `exit ${cloned.status ?? 1}`).trim()}`);
200
+ return skills.map((skill) => {
201
+ try {
202
+ const expectedHash = skill.computedHash ?? skill.skillFolderHash;
203
+ if (!skill.provenance.skillPath || !expectedHash)
204
+ throw new Error(`${skill.name}: installer lock lacks skillPath or content hash`);
205
+ const folder = skillFolder(skill.provenance.skillPath);
206
+ const directory = path.join(checkout, folder);
207
+ if (!fs.statSync(directory, { throwIfNoEntry: false })?.isDirectory())
208
+ return { slot: skill.slot, status: 'upstream-missing' };
209
+ let latestHash;
210
+ if (!skill.computedHash && (!skill.sourceType || skill.sourceType === 'github')) {
211
+ const revision = folder === '.' ? 'HEAD^{tree}' : `HEAD:${folder}`;
212
+ const result = spawnSync('git', ['-C', checkout, 'rev-parse', '--verify', revision], {
213
+ encoding: 'utf8',
214
+ stdio: 'pipe',
215
+ });
216
+ if (result.error)
217
+ throw result.error;
218
+ if (result.status !== 0)
219
+ return { slot: skill.slot, status: 'upstream-missing' };
220
+ latestHash = result.stdout.trim();
221
+ }
222
+ else
223
+ latestHash = computeNpxSkillsFolderHash(directory);
224
+ return {
225
+ slot: skill.slot,
226
+ status: latestHash === expectedHash ? 'current' : 'available',
227
+ };
228
+ }
229
+ catch (error) {
230
+ return { slot: skill.slot, status: 'check-failed', error: error.message };
231
+ }
232
+ });
233
+ }
234
+ finally {
235
+ fs.rmSync(temporary, { recursive: true, force: true });
236
+ }
237
+ }
238
+ function sourceParts(value) {
239
+ let source = value.trim();
240
+ const at = source.lastIndexOf('@');
241
+ const skill = at > source.indexOf('/') ? source.slice(at + 1) : undefined;
242
+ if (skill)
243
+ source = source.slice(0, at);
244
+ source = source.replace(/\.git$/, '');
245
+ const github = source.match(/github\.com[/:]([^/]+\/[^/]+)$/i);
246
+ return { source: (github?.[1] ?? source).toLowerCase(), skill };
247
+ }
248
+ function provenanceSkillName(skillPath) {
249
+ const normalized = skillPath.replaceAll('\\', '/').replace(/\/$/, '');
250
+ const parts = normalized.split('/');
251
+ return parts.at(-1)?.toLowerCase() === 'skill.md'
252
+ ? parts.at(-2) ?? ''
253
+ : parts.at(-1) ?? '';
254
+ }
255
+ export function npxSkillsProvenanceLabel(provenance) {
256
+ return provenance?.source ?? provenance?.sourceUrl ?? provenance?.skillPath ?? 'Source unknown';
257
+ }
258
+ export function sameNpxSkillsSource(source, skillName, provenance) {
259
+ if (!provenance)
260
+ return false;
261
+ const requested = sourceParts(source);
262
+ const sourceMatches = [provenance.source, provenance.sourceUrl]
263
+ .some((value) => value && sourceParts(value).source === requested.source);
264
+ if (!sourceMatches)
265
+ return false;
266
+ if (!provenance.skillPath)
267
+ return requested.skill === undefined;
268
+ const storedSkill = provenanceSkillName(provenance.skillPath);
269
+ if (!storedSkill)
270
+ return requested.skill === undefined;
271
+ const requestedSkill = normalizeNpxSkillsName(requested.skill ?? skillName);
272
+ return normalizeNpxSkillsName(storedSkill) === requestedSkill;
273
+ }