@h0tp/shucky 0.1.0 → 0.4.5
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/CHANGELOG.md +139 -29
- package/LICENSE +21 -21
- package/NOTICE +24 -0
- package/README.md +216 -119
- package/SKILL.md +168 -124
- package/bin/shucky.js +13 -13
- package/config.json +28 -28
- package/lib/agents.js +163 -0
- package/lib/approvals.js +50 -50
- package/lib/archive.js +173 -0
- package/lib/cli.js +782 -118
- package/lib/config.js +52 -52
- package/lib/discover.js +143 -0
- package/lib/fetch.js +303 -0
- package/lib/find.js +162 -0
- package/lib/lock.js +119 -0
- package/lib/place.js +247 -0
- package/lib/registry.js +141 -0
- package/lib/report.js +53 -53
- package/lib/rules.js +162 -162
- package/lib/safeurl.js +139 -0
- package/lib/scan.js +148 -148
- package/lib/sources.js +311 -0
- package/package.json +43 -41
package/lib/find.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky find — discover skills across the public registry (skills.sh) and the user's registered
|
|
4
|
+
// sources/lists, every hit annotated with source-trust. Selecting one just hands off to
|
|
5
|
+
// `shucky install <source>` — which re-fetches and SCANS before anything lands. find never installs.
|
|
6
|
+
|
|
7
|
+
const { safeGet } = require('./fetch');
|
|
8
|
+
const { loadConfig } = require('./config');
|
|
9
|
+
const registry = require('./registry');
|
|
10
|
+
|
|
11
|
+
const SKILLS_SH_SEARCH = 'https://skills.sh/api/search?q=';
|
|
12
|
+
|
|
13
|
+
async function searchSkillsSh(query, opts) {
|
|
14
|
+
try {
|
|
15
|
+
const buf = await safeGet(SKILLS_SH_SEARCH + encodeURIComponent(query || ''), { accept: 'application/json', timeout: (opts && opts.timeout) || 12000 });
|
|
16
|
+
const data = JSON.parse(buf.toString('utf8'));
|
|
17
|
+
const skills = Array.isArray(data.skills) ? data.skills : [];
|
|
18
|
+
return skills.map(function (s) {
|
|
19
|
+
const source = s.source || (s.id ? String(s.id).split('/').slice(0, 2).join('/') : null);
|
|
20
|
+
const skill = s.skillId || s.name;
|
|
21
|
+
return {
|
|
22
|
+
name: s.name || skill, source: source, skill: skill, installs: s.installs || 0,
|
|
23
|
+
registry: 'skills.sh', install: source ? source + (skill && skill !== source ? '@' + skill : '') : (s.id || '')
|
|
24
|
+
};
|
|
25
|
+
}).filter(function (r) { return r.install; });
|
|
26
|
+
} catch (e) { return { error: 'skills.sh: ' + e.message }; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function searchListSource(src, query, opts) {
|
|
30
|
+
try {
|
|
31
|
+
const members = await registry.resolveList(src.spec, opts && opts.cwd);
|
|
32
|
+
const q = (query || '').toLowerCase();
|
|
33
|
+
return members.filter(function (m) { return !q || m.toLowerCase().indexOf(q) !== -1; }).map(function (m) {
|
|
34
|
+
const parts = m.split('@');
|
|
35
|
+
return { name: m, source: parts[0], skill: parts[1] || null, installs: 0, registry: src.name, install: m, trust: src.trust };
|
|
36
|
+
});
|
|
37
|
+
} catch (e) { return { error: 'list ' + src.name + ': ' + e.message }; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function searchWellKnown(src, query, opts) {
|
|
41
|
+
try {
|
|
42
|
+
const origin = new URL(src.spec).origin;
|
|
43
|
+
const buf = await safeGet(origin + '/.well-known/agent-skills/index.json', { accept: 'application/json', timeout: (opts && opts.timeout) || 12000 });
|
|
44
|
+
const data = JSON.parse(buf.toString('utf8'));
|
|
45
|
+
const skills = Array.isArray(data.skills) ? data.skills : [];
|
|
46
|
+
const q = (query || '').toLowerCase();
|
|
47
|
+
return skills.filter(function (s) {
|
|
48
|
+
return s && s.name && (!q || (s.name + ' ' + (s.description || '')).toLowerCase().indexOf(q) !== -1);
|
|
49
|
+
}).map(function (s) {
|
|
50
|
+
return { name: s.name, source: src.spec, skill: s.name, installs: 0, registry: src.name, install: src.spec, trust: src.trust };
|
|
51
|
+
});
|
|
52
|
+
} catch (e) { return { error: 'registry ' + src.name + ': ' + e.message }; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// GitHub search — code search for SKILL.md when a token is present (precise), else unauthenticated
|
|
56
|
+
// repo search (works for everyone, lower rate limit). Opt-in: --github flag or GITHUB_TOKEN.
|
|
57
|
+
async function searchGitHub(query, opts) {
|
|
58
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
59
|
+
const headers = token ? { authorization: 'Bearer ' + token } : {};
|
|
60
|
+
const timeout = (opts && opts.timeout) || 12000;
|
|
61
|
+
try {
|
|
62
|
+
if (token) {
|
|
63
|
+
const q = encodeURIComponent((query || '') + ' filename:SKILL.md');
|
|
64
|
+
const buf = await safeGet('https://api.github.com/search/code?q=' + q + '&per_page=20', { accept: 'application/vnd.github+json', headers: headers, timeout: timeout });
|
|
65
|
+
const data = JSON.parse(buf.toString('utf8'));
|
|
66
|
+
return (data.items || []).map(function (it) {
|
|
67
|
+
const repo = it.repository ? it.repository.full_name : null;
|
|
68
|
+
const dir = it.path ? it.path.replace(/\/?SKILL\.md$/i, '') : '';
|
|
69
|
+
const src = repo ? (dir ? repo + '/' + dir : repo) : null;
|
|
70
|
+
return { name: dir ? dir.split('/').pop() : repo, source: repo, skill: null, installs: 0, registry: 'github', install: src };
|
|
71
|
+
}).filter(function (r) { return r.install; });
|
|
72
|
+
}
|
|
73
|
+
const q = encodeURIComponent((query || 'skill') + ' skill in:name,description,readme');
|
|
74
|
+
const buf = await safeGet('https://api.github.com/search/repositories?q=' + q + '&per_page=15&sort=stars', { accept: 'application/vnd.github+json', headers: headers, timeout: timeout });
|
|
75
|
+
const data = JSON.parse(buf.toString('utf8'));
|
|
76
|
+
// Unauthenticated repo search is broad — keep only repos that actually look skill-related.
|
|
77
|
+
return (data.items || []).filter(function (r) {
|
|
78
|
+
const hay = (r.full_name + ' ' + (r.description || '')).toLowerCase();
|
|
79
|
+
return hay.indexOf('skill') !== -1 || hay.indexOf('agent') !== -1;
|
|
80
|
+
}).map(function (r) {
|
|
81
|
+
return { name: r.full_name, source: r.full_name, skill: null, installs: r.stargazers_count || 0, stars: true, registry: 'github', install: r.full_name };
|
|
82
|
+
});
|
|
83
|
+
} catch (e) { return { error: 'github: ' + e.message }; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function findSkills(query, opts) {
|
|
87
|
+
opts = opts || {};
|
|
88
|
+
const cwd = opts.cwd;
|
|
89
|
+
const results = [];
|
|
90
|
+
const searched = [];
|
|
91
|
+
const errors = [];
|
|
92
|
+
|
|
93
|
+
if (!opts.localOnly) {
|
|
94
|
+
searched.push('skills.sh');
|
|
95
|
+
const r = await searchSkillsSh(query, opts);
|
|
96
|
+
if (Array.isArray(r)) results.push.apply(results, r);
|
|
97
|
+
else if (r && r.error) errors.push(r.error);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ghToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
101
|
+
if (!opts.localOnly && (opts.github || ghToken)) {
|
|
102
|
+
searched.push('github');
|
|
103
|
+
const r = await searchGitHub(query, opts);
|
|
104
|
+
if (Array.isArray(r)) {
|
|
105
|
+
results.push.apply(results, r);
|
|
106
|
+
if (!ghToken) errors.push('github: showing repo matches — set GITHUB_TOKEN for precise SKILL.md code search');
|
|
107
|
+
} else if (r && r.error) errors.push(r.error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const src of registry.listSources(cwd)) {
|
|
111
|
+
if (src.type === 'list') {
|
|
112
|
+
searched.push(src.name);
|
|
113
|
+
const r = await searchListSource(src, query, opts);
|
|
114
|
+
if (Array.isArray(r)) results.push.apply(results, r); else if (r && r.error) errors.push(r.error);
|
|
115
|
+
} else if (src.type === 'registry') {
|
|
116
|
+
searched.push(src.name);
|
|
117
|
+
const r = await searchWellKnown(src, query, opts);
|
|
118
|
+
if (Array.isArray(r)) results.push.apply(results, r); else if (r && r.error) errors.push(r.error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Annotate trust from the built-in trustedSources + any registered `trusted` owners.
|
|
123
|
+
const trusted = new Set();
|
|
124
|
+
(loadConfig(null, {}).trustedSources || []).concat(registry.trustedOwners(cwd)).forEach(function (t) { trusted.add(String(t).toLowerCase()); });
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
if (r.trust) continue;
|
|
127
|
+
const owner = r.source ? String(r.source).toLowerCase().split('/')[0] : '';
|
|
128
|
+
if (owner && (trusted.has(owner) || trusted.has(String(r.source).toLowerCase()))) r.trust = 'trusted';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
results.sort(function (a, b) { return (b.installs || 0) - (a.installs || 0) || String(a.name).localeCompare(String(b.name)); });
|
|
132
|
+
return { results: results, searched: searched, errors: errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function cmdFind(args) {
|
|
136
|
+
const query = args._.slice(1).join(' ').trim();
|
|
137
|
+
const cwd = process.cwd();
|
|
138
|
+
let out;
|
|
139
|
+
try { out = await findSkills(query, { cwd: cwd, localOnly: args.flags.local, github: args.flags.github }); }
|
|
140
|
+
catch (e) { console.error('find: ' + e.message); return 3; }
|
|
141
|
+
|
|
142
|
+
if (args.flags.json) { console.log(JSON.stringify(Object.assign({ query: query }, out), null, 2)); return 0; }
|
|
143
|
+
|
|
144
|
+
for (const e of out.errors) console.error(' (note) ' + e);
|
|
145
|
+
if (!out.results.length) {
|
|
146
|
+
console.log('no skills found' + (query ? ' for "' + query + '"' : '') + '. searched: ' + (out.searched.join(', ') || 'nothing'));
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const limit = Number(args.flags.limit) || 25;
|
|
150
|
+
console.log('found ' + out.results.length + ' skill(s)' + (query ? ' for "' + query + '"' : '') + ' — searched ' + out.searched.join(', ') + ':\n');
|
|
151
|
+
for (const r of out.results.slice(0, limit)) {
|
|
152
|
+
const trust = r.trust === 'trusted' ? ' ✓trusted' : '';
|
|
153
|
+
const metric = r.installs ? (r.stars ? ' (' + r.installs + ' ⭐)' : ' (' + r.installs + ' installs)') : '';
|
|
154
|
+
console.log(' ' + r.name + ' ← ' + r.registry + trust + metric);
|
|
155
|
+
console.log(' shucky install ' + r.install);
|
|
156
|
+
}
|
|
157
|
+
if (out.results.length > limit) console.log('\n … ' + (out.results.length - limit) + ' more (use --limit <n> or --json)');
|
|
158
|
+
console.log('\nEvery install is scanned before it lands.');
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { findSkills, cmdFind, searchSkillsSh };
|
package/lib/lock.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky install lockfiles — what got installed, from where, at which commit, and what the
|
|
4
|
+
// scan said about it. Two files (mirrors vercel-labs/skills' split, MIT-inspired):
|
|
5
|
+
// global ~/.shucky/installed-skills.json (timestamps; for `list`/`update`)
|
|
6
|
+
// project ./shucky-skills.json (committed, sorted, timestamp-free → clean diffs)
|
|
7
|
+
//
|
|
8
|
+
// Recording the verdict + commit SHA is what lets a future `update` re-fetch, RE-SCAN, and warn
|
|
9
|
+
// if a once-clean skill now blocks.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
function globalDir() {
|
|
17
|
+
const st = process.env.XDG_STATE_HOME;
|
|
18
|
+
if (st && path.isAbsolute(st)) return path.join(st, 'shucky');
|
|
19
|
+
return path.join(os.homedir(), '.shucky');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getLockPath(scope, cwd) {
|
|
23
|
+
if (scope === 'global') return path.join(globalDir(), 'installed-skills.json');
|
|
24
|
+
return path.join(cwd || process.cwd(), 'shucky-skills.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readLock(scope, cwd) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(fs.readFileSync(getLockPath(scope, cwd), 'utf8'));
|
|
30
|
+
if (raw && raw.skills && typeof raw.skills === 'object') return { version: raw.version || 1, skills: raw.skills };
|
|
31
|
+
} catch (e) { /* missing/invalid → fresh */ }
|
|
32
|
+
return { version: 1, skills: {} };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeLock(scope, cwd, data) {
|
|
36
|
+
const p = getLockPath(scope, cwd);
|
|
37
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
38
|
+
const names = Object.keys(data.skills);
|
|
39
|
+
if (scope !== 'global') names.sort(); // deterministic order for committed project lock
|
|
40
|
+
const out = { version: 1, skills: {} };
|
|
41
|
+
for (const n of names) out.skills[n] = data.skills[n];
|
|
42
|
+
fs.writeFileSync(p, JSON.stringify(out, null, 2) + '\n');
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// entry: { source, sourceType, sourceUrl, ref, skillPath, hash, verdict, rawVerdict,
|
|
47
|
+
// overriddenByApproval, agents }
|
|
48
|
+
function addSkill(scope, name, entry, cwd) {
|
|
49
|
+
const data = readLock(scope, cwd);
|
|
50
|
+
const e = Object.assign({}, entry);
|
|
51
|
+
if (scope === 'global') {
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const prior = data.skills[name];
|
|
54
|
+
e.installedAt = (prior && prior.installedAt) || now;
|
|
55
|
+
e.updatedAt = now;
|
|
56
|
+
if (!e.scannedAt) e.scannedAt = now;
|
|
57
|
+
} else {
|
|
58
|
+
delete e.installedAt; delete e.updatedAt; delete e.scannedAt; // committed lock stays timestamp-free
|
|
59
|
+
}
|
|
60
|
+
data.skills[name] = e;
|
|
61
|
+
return writeLock(scope, cwd, data);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeSkill(scope, name, cwd) {
|
|
65
|
+
const data = readLock(scope, cwd);
|
|
66
|
+
if (!data.skills[name]) return false;
|
|
67
|
+
delete data.skills[name];
|
|
68
|
+
writeLock(scope, cwd, data);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getSkill(scope, name, cwd) {
|
|
73
|
+
return readLock(scope, cwd).skills[name] || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listSkills(scope, cwd) {
|
|
77
|
+
const skills = readLock(scope, cwd).skills;
|
|
78
|
+
return Object.keys(skills).sort().map(function (name) {
|
|
79
|
+
return Object.assign({ name: name }, skills[name]);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Stable sha256 over a folder's relative paths + contents (skips .git/node_modules + symlinks).
|
|
84
|
+
// On-disk hash → zero-network, provider-agnostic; used to detect silent content drift.
|
|
85
|
+
function computeFolderHash(dir) {
|
|
86
|
+
const files = [];
|
|
87
|
+
(function walk(d, rel) {
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return; }
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
92
|
+
const full = path.join(d, e.name);
|
|
93
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
94
|
+
let st;
|
|
95
|
+
try { st = fs.lstatSync(full); } catch (er) { continue; }
|
|
96
|
+
if (st.isSymbolicLink()) continue;
|
|
97
|
+
if (st.isDirectory()) walk(full, r);
|
|
98
|
+
else if (st.isFile()) files.push([r, full]);
|
|
99
|
+
}
|
|
100
|
+
})(dir, '');
|
|
101
|
+
files.sort(function (a, b) { return a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : 0); });
|
|
102
|
+
const h = crypto.createHash('sha256');
|
|
103
|
+
for (const pair of files) {
|
|
104
|
+
h.update(pair[0]); h.update('\0');
|
|
105
|
+
try { h.update(fs.readFileSync(pair[1])); } catch (e) { /* ignore */ }
|
|
106
|
+
h.update('\0');
|
|
107
|
+
}
|
|
108
|
+
return 'sha256:' + h.digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
getLockPath: getLockPath,
|
|
113
|
+
readLock: readLock,
|
|
114
|
+
addSkill: addSkill,
|
|
115
|
+
removeSkill: removeSkill,
|
|
116
|
+
getSkill: getSkill,
|
|
117
|
+
listSkills: listSkills,
|
|
118
|
+
computeFolderHash: computeFolderHash
|
|
119
|
+
};
|
package/lib/place.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky placement — install a VETTED skill directory into the user's agent environments.
|
|
4
|
+
// Ported from vercel-labs/skills `src/installer.ts` (MIT). See NOTICE.
|
|
5
|
+
//
|
|
6
|
+
// One security divergence from upstream: copyTree() DROPS symlinks instead of dereferencing
|
|
7
|
+
// them. scanTarget() skips symlinks when it scans, so dereferencing on copy would smuggle
|
|
8
|
+
// UNSCANNED content (the link target) into the install — a scan bypass. We refuse to copy any
|
|
9
|
+
// symlink out of the fetched tree.
|
|
10
|
+
//
|
|
11
|
+
// placeSkill(skillDir, name, agentList, { scope, copy, cwd, forceCreate })
|
|
12
|
+
// -> { name, scope, mode, canonicalPath, results: [{ agent, success, path, mode, skipped?, symlinkFailed?, universal?, error? }] }
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { agents, isUniversalAgent, getCanonicalSkillsDir, getAgentBaseDir } = require('./agents');
|
|
17
|
+
|
|
18
|
+
const EXCLUDE_FILES = new Set(['metadata.json']);
|
|
19
|
+
const EXCLUDE_DIRS = new Set(['.git', '__pycache__', '__pypackages__', 'node_modules']);
|
|
20
|
+
|
|
21
|
+
// kebab-case, path-traversal-safe install name.
|
|
22
|
+
function sanitizeName(name) {
|
|
23
|
+
const s = String(name).toLowerCase().replace(/[^a-z0-9._]+/g, '-').replace(/^[.\-]+|[.\-]+$/g, '');
|
|
24
|
+
return s.substring(0, 255) || 'unnamed-skill';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPathSafe(base, target) {
|
|
28
|
+
const nb = path.normalize(path.resolve(base));
|
|
29
|
+
const nt = path.normalize(path.resolve(target));
|
|
30
|
+
return nt === nb || nt.indexOf(nb + path.sep) === 0;
|
|
31
|
+
}
|
|
32
|
+
function pathsOverlap(a, b) { return isPathSafe(a, b) || isPathSafe(b, a); }
|
|
33
|
+
|
|
34
|
+
function cleanAndCreateDirectory(p) {
|
|
35
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch (e) { /* mkdir will surface real errors */ }
|
|
36
|
+
fs.mkdirSync(p, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveSymlinkTarget(linkPath, linkTarget) {
|
|
40
|
+
return path.resolve(path.dirname(linkPath), linkTarget);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve a path's PARENT through symlinks, keeping the final component. Handles e.g.
|
|
44
|
+
// ~/.claude/skills being a symlink to ~/.agents/skills, so we don't compute broken links.
|
|
45
|
+
function resolveParentSymlinks(p) {
|
|
46
|
+
const resolved = path.resolve(p);
|
|
47
|
+
const dir = path.dirname(resolved);
|
|
48
|
+
const base = path.basename(resolved);
|
|
49
|
+
try { return path.join(fs.realpathSync(dir), base); } catch (e) { return resolved; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Copy a tree, DROPPING symlinks and excluded files/dirs. Returns count of skipped symlinks.
|
|
53
|
+
function copyTree(src, dest, skipped) {
|
|
54
|
+
skipped = skipped || { links: 0 };
|
|
55
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
56
|
+
let entries;
|
|
57
|
+
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch (e) { return skipped; }
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const name = entry.name;
|
|
60
|
+
const srcPath = path.join(src, name);
|
|
61
|
+
const destPath = path.join(dest, name);
|
|
62
|
+
let st;
|
|
63
|
+
try { st = fs.lstatSync(srcPath); } catch (e) { continue; }
|
|
64
|
+
if (st.isSymbolicLink()) { skipped.links++; continue; } // SECURITY: never copy symlinks out
|
|
65
|
+
if (st.isDirectory()) {
|
|
66
|
+
if (EXCLUDE_DIRS.has(name)) continue;
|
|
67
|
+
copyTree(srcPath, destPath, skipped);
|
|
68
|
+
} else if (st.isFile()) {
|
|
69
|
+
if (EXCLUDE_FILES.has(name)) continue;
|
|
70
|
+
fs.copyFileSync(srcPath, destPath);
|
|
71
|
+
}
|
|
72
|
+
// ignore fifo/socket/device entries
|
|
73
|
+
}
|
|
74
|
+
return skipped;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Create a symlink (junction on win32), reconciling existing links/dirs. Returns true on success.
|
|
78
|
+
function createSymlink(target, linkPath) {
|
|
79
|
+
try {
|
|
80
|
+
const resolvedTarget = path.resolve(target);
|
|
81
|
+
const resolvedLink = path.resolve(linkPath);
|
|
82
|
+
|
|
83
|
+
let realTarget, realLink;
|
|
84
|
+
try { realTarget = fs.realpathSync(resolvedTarget); } catch (e) { realTarget = resolvedTarget; }
|
|
85
|
+
try { realLink = fs.realpathSync(resolvedLink); } catch (e) { realLink = resolvedLink; }
|
|
86
|
+
if (realTarget === realLink) return true;
|
|
87
|
+
if (resolveParentSymlinks(target) === resolveParentSymlinks(linkPath)) return true;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const st = fs.lstatSync(linkPath);
|
|
91
|
+
if (st.isSymbolicLink()) {
|
|
92
|
+
const existing = fs.readlinkSync(linkPath);
|
|
93
|
+
if (resolveSymlinkTarget(linkPath, existing) === resolvedTarget) return true;
|
|
94
|
+
fs.rmSync(linkPath, { force: true });
|
|
95
|
+
} else {
|
|
96
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err && err.code === 'ELOOP') { try { fs.rmSync(linkPath, { force: true }); } catch (e) { /* fall through */ } }
|
|
100
|
+
// ENOENT etc. → proceed to create
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const linkDir = path.dirname(linkPath);
|
|
104
|
+
fs.mkdirSync(linkDir, { recursive: true });
|
|
105
|
+
const realLinkDir = resolveParentSymlinks(linkDir);
|
|
106
|
+
const isWin = process.platform === 'win32';
|
|
107
|
+
const symlinkType = isWin ? 'junction' : undefined;
|
|
108
|
+
const symlinkTarget = isWin ? resolvedTarget : path.relative(realLinkDir, target);
|
|
109
|
+
fs.symlinkSync(symlinkTarget, linkPath, symlinkType);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (e) { return false; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- per-agent placement --------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function guardAgent(agentType, isGlobal) {
|
|
117
|
+
const agent = agents[agentType];
|
|
118
|
+
if (!agent) return { agent: agentType, success: false, error: 'unknown agent: ' + agentType };
|
|
119
|
+
if (isGlobal && agent.globalSkillsDir === undefined) {
|
|
120
|
+
return { agent: agentType, success: false, error: agent.displayName + ' does not support global install' };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function placeCopy(skillDir, skillName, agentType, isGlobal, cwd) {
|
|
126
|
+
const bad = guardAgent(agentType, isGlobal);
|
|
127
|
+
if (bad) return bad;
|
|
128
|
+
const agentBase = getAgentBaseDir(agentType, isGlobal ? 'global' : 'project', cwd);
|
|
129
|
+
const agentDir = path.join(agentBase, skillName);
|
|
130
|
+
if (!isPathSafe(agentBase, agentDir)) return { agent: agentType, success: false, error: 'path traversal in skill name' };
|
|
131
|
+
if (pathsOverlap(skillDir, agentDir)) return { agent: agentType, success: true, path: agentDir, mode: 'copy', skipped: true };
|
|
132
|
+
cleanAndCreateDirectory(agentDir);
|
|
133
|
+
copyTree(skillDir, agentDir);
|
|
134
|
+
return { agent: agentType, success: true, path: agentDir, mode: 'copy' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function placeSymlink(skillDir, skillName, agentType, isGlobal, cwd, canonicalDir, forceCreate) {
|
|
138
|
+
const bad = guardAgent(agentType, isGlobal);
|
|
139
|
+
if (bad) return bad;
|
|
140
|
+
const scope = isGlobal ? 'global' : 'project';
|
|
141
|
+
|
|
142
|
+
// Universal agents read straight from the canonical dir — already written, no symlink needed.
|
|
143
|
+
if (isUniversalAgent(agentType)) {
|
|
144
|
+
return { agent: agentType, success: true, path: canonicalDir, mode: 'symlink', universal: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const agentBase = getAgentBaseDir(agentType, scope, cwd);
|
|
148
|
+
const agentDir = path.join(agentBase, skillName);
|
|
149
|
+
if (!isPathSafe(agentBase, agentDir)) return { agent: agentType, success: false, error: 'path traversal in skill name' };
|
|
150
|
+
|
|
151
|
+
// Project installs: don't materialise an agent dir (e.g. .windsurf/) for an agent that isn't
|
|
152
|
+
// used here — unless the user explicitly asked for it. The skill is already in .agents/skills.
|
|
153
|
+
if (!isGlobal && !forceCreate) {
|
|
154
|
+
const agentRootDir = path.join(cwd, agents[agentType].skillsDir.split('/')[0]);
|
|
155
|
+
if (!fs.existsSync(agentRootDir)) {
|
|
156
|
+
return { agent: agentType, success: true, path: canonicalDir, mode: 'symlink', skipped: true };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (pathsOverlap(skillDir, agentDir)) return { agent: agentType, success: true, path: agentDir, mode: 'symlink', skipped: true };
|
|
161
|
+
|
|
162
|
+
if (createSymlink(canonicalDir, agentDir)) {
|
|
163
|
+
return { agent: agentType, success: true, path: agentDir, mode: 'symlink' };
|
|
164
|
+
}
|
|
165
|
+
// Symlink unsupported (e.g. Windows w/o privilege) → copy fallback.
|
|
166
|
+
cleanAndCreateDirectory(agentDir);
|
|
167
|
+
copyTree(skillDir, agentDir);
|
|
168
|
+
return { agent: agentType, success: true, path: agentDir, mode: 'symlink', symlinkFailed: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- public --------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function placeSkill(skillDir, name, agentList, opts) {
|
|
174
|
+
opts = opts || {};
|
|
175
|
+
const scope = opts.scope === 'global' ? 'global' : 'project';
|
|
176
|
+
const isGlobal = scope === 'global';
|
|
177
|
+
const copy = !!opts.copy;
|
|
178
|
+
const cwd = opts.cwd || process.cwd();
|
|
179
|
+
const forceCreate = !!opts.forceCreate;
|
|
180
|
+
const skillName = sanitizeName(name);
|
|
181
|
+
agentList = agentList || [];
|
|
182
|
+
const results = [];
|
|
183
|
+
|
|
184
|
+
if (copy) {
|
|
185
|
+
for (const agentType of agentList) results.push(placeCopy(skillDir, skillName, agentType, isGlobal, cwd));
|
|
186
|
+
return { name: skillName, scope: scope, mode: 'copy', canonicalPath: null, results: results };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const canonicalBase = getCanonicalSkillsDir(scope, cwd);
|
|
190
|
+
const canonicalDir = path.join(canonicalBase, skillName);
|
|
191
|
+
if (!isPathSafe(canonicalBase, canonicalDir)) throw new Error('invalid skill name (path traversal): ' + name);
|
|
192
|
+
|
|
193
|
+
// Write the canonical copy ONCE (unless the source already is the canonical dir).
|
|
194
|
+
if (!pathsOverlap(skillDir, canonicalDir)) {
|
|
195
|
+
cleanAndCreateDirectory(canonicalDir);
|
|
196
|
+
copyTree(skillDir, canonicalDir);
|
|
197
|
+
}
|
|
198
|
+
for (const agentType of agentList) {
|
|
199
|
+
results.push(placeSymlink(skillDir, skillName, agentType, isGlobal, cwd, canonicalDir, forceCreate));
|
|
200
|
+
}
|
|
201
|
+
return { name: skillName, scope: scope, mode: 'symlink', canonicalPath: canonicalDir, results: results };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function removeEntry(p) {
|
|
205
|
+
try {
|
|
206
|
+
const st = fs.lstatSync(p);
|
|
207
|
+
if (st.isSymbolicLink()) { fs.rmSync(p, { force: true }); return true; }
|
|
208
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
209
|
+
return true;
|
|
210
|
+
} catch (e) { return false; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Remove a skill's canonical copy and its per-agent dirs/symlinks. Path-guarded to <base>/<name>
|
|
214
|
+
// so it can only ever delete the skill's own directory, never a parent.
|
|
215
|
+
function unplaceSkill(name, agentList, opts) {
|
|
216
|
+
opts = opts || {};
|
|
217
|
+
const scope = opts.scope === 'global' ? 'global' : 'project';
|
|
218
|
+
const cwd = opts.cwd || process.cwd();
|
|
219
|
+
const skillName = sanitizeName(name);
|
|
220
|
+
const removed = [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (const agentType of (agentList || [])) {
|
|
223
|
+
const base = getAgentBaseDir(agentType, scope, cwd);
|
|
224
|
+
if (!base) continue;
|
|
225
|
+
const dir = path.join(base, skillName);
|
|
226
|
+
if (!isPathSafe(base, dir) || seen.has(dir)) continue;
|
|
227
|
+
seen.add(dir);
|
|
228
|
+
if (removeEntry(dir)) removed.push({ agent: agentType, path: dir });
|
|
229
|
+
}
|
|
230
|
+
const canonicalBase = getCanonicalSkillsDir(scope, cwd);
|
|
231
|
+
const canonicalDir = path.join(canonicalBase, skillName);
|
|
232
|
+
if (isPathSafe(canonicalBase, canonicalDir) && !seen.has(canonicalDir)) {
|
|
233
|
+
if (removeEntry(canonicalDir)) removed.push({ agent: 'canonical', path: canonicalDir });
|
|
234
|
+
}
|
|
235
|
+
return { name: skillName, scope: scope, removed: removed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
placeSkill,
|
|
240
|
+
unplaceSkill,
|
|
241
|
+
sanitizeName,
|
|
242
|
+
isPathSafe,
|
|
243
|
+
pathsOverlap,
|
|
244
|
+
copyTree,
|
|
245
|
+
createSymlink,
|
|
246
|
+
cleanAndCreateDirectory
|
|
247
|
+
};
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky sources registry — the repos / registries / curated lists a user trusts and searches.
|
|
4
|
+
// Register a source once, then `find` across them and `install --list <name>` a curated bundle.
|
|
5
|
+
// Two files: global ~/.shucky/sources.json + project ./shucky-sources.json (committed).
|
|
6
|
+
//
|
|
7
|
+
// source entry = { name, type: 'repo' | 'registry' | 'list', spec, trust? }
|
|
8
|
+
// repo a git source you install/find within (spec: owner/repo or URL)
|
|
9
|
+
// registry a search endpoint (well-known host or skills.sh-style) — for `find`
|
|
10
|
+
// list a manifest enumerating skills, installable as a set (spec: URL or local .json)
|
|
11
|
+
// trust: 'trusted' feeds config.trustedSources (low/medium relax; high/critical still blocks)
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { safeGet } = require('./fetch');
|
|
17
|
+
const { parseSource, getOwnerRepo } = require('./sources');
|
|
18
|
+
|
|
19
|
+
function globalDir() {
|
|
20
|
+
const cfg = process.env.XDG_CONFIG_HOME;
|
|
21
|
+
if (cfg && path.isAbsolute(cfg)) return path.join(cfg, 'shucky');
|
|
22
|
+
return path.join(os.homedir(), '.shucky');
|
|
23
|
+
}
|
|
24
|
+
function sourcesPath(scope, cwd) {
|
|
25
|
+
return scope === 'global' ? path.join(globalDir(), 'sources.json') : path.join(cwd || process.cwd(), 'shucky-sources.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function read(scope, cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const r = JSON.parse(fs.readFileSync(sourcesPath(scope, cwd), 'utf8'));
|
|
31
|
+
if (Array.isArray(r.sources)) return { version: r.version || 1, sources: r.sources };
|
|
32
|
+
} catch (e) { /* fresh */ }
|
|
33
|
+
return { version: 1, sources: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function write(scope, cwd, data) {
|
|
37
|
+
const p = sourcesPath(scope, cwd);
|
|
38
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
39
|
+
const sources = data.sources.slice().sort(function (a, b) { return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
|
40
|
+
fs.writeFileSync(p, JSON.stringify({ version: 1, sources: sources }, null, 2) + '\n');
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inferType(spec) {
|
|
45
|
+
if (/\.json(\?.*)?$/i.test(spec)) return 'list'; // a .json manifest (local or remote) is a list
|
|
46
|
+
const parsed = parseSource(spec);
|
|
47
|
+
if (parsed.type === 'well-known') return 'registry';
|
|
48
|
+
return 'repo';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deriveName(spec) {
|
|
52
|
+
const parsed = parseSource(spec);
|
|
53
|
+
const or = getOwnerRepo(parsed);
|
|
54
|
+
if (or) return or.replace(/\//g, '-');
|
|
55
|
+
try { return new URL(spec).hostname; } catch (e) { /* fall through */ }
|
|
56
|
+
return String(spec).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'source';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function addSource(scope, spec, opts, cwd) {
|
|
60
|
+
opts = opts || {};
|
|
61
|
+
const data = read(scope, cwd);
|
|
62
|
+
const type = opts.type || inferType(spec);
|
|
63
|
+
const name = opts.name || deriveName(spec);
|
|
64
|
+
data.sources = data.sources.filter(function (s) { return s.name !== name; });
|
|
65
|
+
const entry = { name: name, type: type, spec: spec };
|
|
66
|
+
if (opts.trust) entry.trust = opts.trust;
|
|
67
|
+
data.sources.push(entry);
|
|
68
|
+
return { path: write(scope, cwd, data), entry: entry };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function removeSource(scope, name, cwd) {
|
|
72
|
+
const data = read(scope, cwd);
|
|
73
|
+
const before = data.sources.length;
|
|
74
|
+
data.sources = data.sources.filter(function (s) { return s.name !== name; });
|
|
75
|
+
if (data.sources.length === before) return false;
|
|
76
|
+
write(scope, cwd, data);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function listSources(cwd) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const scope of ['project', 'global']) {
|
|
83
|
+
for (const s of read(scope, cwd).sources) out.push(Object.assign({ scope: scope }, s));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getSource(name, cwd) {
|
|
89
|
+
return listSources(cwd).find(function (s) { return s.name === name; }) || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Resolve a `list` source (by registered name OR a direct manifest spec) → install source strings.
|
|
93
|
+
// Manifest may be `["owner/repo@skill", …]` or `{ "skills": [{ "source": "...", "skill": "..." }] }`.
|
|
94
|
+
async function resolveList(nameOrSpec, cwd) {
|
|
95
|
+
let spec = nameOrSpec;
|
|
96
|
+
const registered = getSource(nameOrSpec, cwd);
|
|
97
|
+
if (registered) {
|
|
98
|
+
if (registered.type !== 'list') throw new Error('source "' + nameOrSpec + '" is type ' + registered.type + ', not a list');
|
|
99
|
+
spec = registered.spec;
|
|
100
|
+
}
|
|
101
|
+
let text;
|
|
102
|
+
if (/^https?:\/\//.test(spec)) {
|
|
103
|
+
const buf = await safeGet(spec, { accept: 'application/json' });
|
|
104
|
+
text = buf.toString('utf8');
|
|
105
|
+
} else {
|
|
106
|
+
text = fs.readFileSync(path.resolve(cwd || process.cwd(), spec), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
let data;
|
|
109
|
+
try { data = JSON.parse(text); } catch (e) { throw new Error('list manifest is not valid JSON: ' + spec); }
|
|
110
|
+
const arr = Array.isArray(data) ? data : (Array.isArray(data.skills) ? data.skills : []);
|
|
111
|
+
return arr.map(function (item) {
|
|
112
|
+
if (typeof item === 'string') return item;
|
|
113
|
+
if (item && item.source) return item.source + (item.skill ? '@' + item.skill : '');
|
|
114
|
+
return null;
|
|
115
|
+
}).filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Owner/host prefixes for sources the user marked `trusted` → merged into config.trustedSources.
|
|
119
|
+
function trustedOwners(cwd) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const s of listSources(cwd)) {
|
|
122
|
+
if (s.trust !== 'trusted') continue;
|
|
123
|
+
const or = getOwnerRepo(parseSource(s.spec));
|
|
124
|
+
if (or) out.push(or.split('/')[0]);
|
|
125
|
+
else { try { out.push(new URL(s.spec).hostname); } catch (e) { /* skip */ } }
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
sourcesPath: sourcesPath,
|
|
132
|
+
read: read,
|
|
133
|
+
addSource: addSource,
|
|
134
|
+
removeSource: removeSource,
|
|
135
|
+
listSources: listSources,
|
|
136
|
+
getSource: getSource,
|
|
137
|
+
resolveList: resolveList,
|
|
138
|
+
trustedOwners: trustedOwners,
|
|
139
|
+
inferType: inferType,
|
|
140
|
+
deriveName: deriveName
|
|
141
|
+
};
|