skills-atlas-cli 0.1.2 → 0.3.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 +13 -1
- package/bin/skills.js +18 -4
- package/package.json +1 -1
- package/src/commands/doctor.js +64 -0
- package/src/commands/install.js +80 -47
- package/src/commands/installed.js +46 -0
- package/src/commands/outdated.js +44 -0
- package/src/commands/remove.js +38 -0
- package/src/commands/upgrade.js +91 -0
- package/src/fsutil.js +36 -2
- package/src/github.js +32 -2
- package/src/installer.js +39 -0
- package/src/manifest.js +57 -0
package/README.md
CHANGED
|
@@ -46,14 +46,26 @@ skills-atlas info brainstorming
|
|
|
46
46
|
# 📥 Install it (into .claude/skills/)
|
|
47
47
|
skills-atlas install brainstorming # → ~/.claude/skills/ (default, all projects)
|
|
48
48
|
skills-atlas install brainstorming --project # → ./.claude/skills/ (this project only)
|
|
49
|
+
skills-atlas install brainstorming --chain # install the whole ⛓ workflow it belongs to
|
|
49
50
|
skills-atlas install brainstorming --dry-run # preview the files, write nothing
|
|
50
51
|
|
|
51
|
-
# 🗂️
|
|
52
|
+
# 🗂️ Manage what you've installed (like a package manager)
|
|
53
|
+
skills-atlas installed # list installed (global + project)
|
|
54
|
+
skills-atlas outdated # which have a newer upstream version
|
|
55
|
+
skills-atlas upgrade brainstorming # re-fetch to latest (--all; won't clobber local edits)
|
|
56
|
+
skills-atlas remove brainstorming # delete it
|
|
57
|
+
skills-atlas doctor # health check: orphans, drift, license/script risks
|
|
58
|
+
|
|
59
|
+
# 🌐 Catalog
|
|
52
60
|
skills-atlas categories # the 20 top-level categories
|
|
53
61
|
skills-atlas list marketing # skill groups within a category
|
|
54
62
|
skills-atlas update # pull the latest catalog
|
|
55
63
|
```
|
|
56
64
|
|
|
65
|
+
**⛓ Workflows, not just skills.** Many skills belong to a curated chain (e.g.
|
|
66
|
+
`brainstorming → writing-plans → executing-plans → …`). `install <skill> --chain`
|
|
67
|
+
installs the whole pipeline in one archive download, ready to run in order.
|
|
68
|
+
|
|
57
69
|
Output is English by default; add `--zh` for Chinese, or `--json` to any command for machine-readable output.
|
|
58
70
|
After installing a skill, start a new Claude Code session to load it.
|
|
59
71
|
|
package/bin/skills.js
CHANGED
|
@@ -4,20 +4,34 @@
|
|
|
4
4
|
const search = require('../src/commands/search');
|
|
5
5
|
const info = require('../src/commands/info');
|
|
6
6
|
const install = require('../src/commands/install');
|
|
7
|
+
const installed = require('../src/commands/installed');
|
|
8
|
+
const upgrade = require('../src/commands/upgrade');
|
|
9
|
+
const remove = require('../src/commands/remove');
|
|
10
|
+
const outdated = require('../src/commands/outdated');
|
|
11
|
+
const doctor = require('../src/commands/doctor');
|
|
7
12
|
const update = require('../src/commands/update');
|
|
8
13
|
const { categories, list } = require('../src/commands/categories');
|
|
9
14
|
|
|
10
15
|
const VERSION = require('../package.json').version;
|
|
11
|
-
const commands = { search, info, install, update, categories, list };
|
|
16
|
+
const commands = { search, info, install, installed, upgrade, remove, outdated, doctor, update, categories, list };
|
|
12
17
|
|
|
13
|
-
const HELP = `skills-atlas — search, install &
|
|
18
|
+
const HELP = `skills-atlas — search, install & manage AI agent skills
|
|
14
19
|
|
|
15
20
|
usage: skills-atlas <command> [args]
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
find & install:
|
|
18
23
|
search <query> find skills (filters: -c category, -p persona, -t type, --chain)
|
|
19
24
|
info <skill> show description, usage guidance, sources & install command
|
|
20
|
-
install <skill> download
|
|
25
|
+
install <skill> download into .claude/skills/ (--chain for the whole workflow)
|
|
26
|
+
|
|
27
|
+
manage what you've installed:
|
|
28
|
+
installed list installed skills (global + project)
|
|
29
|
+
outdated show which installed skills have a newer upstream version
|
|
30
|
+
upgrade [skill] re-fetch to the latest (--all; refuses to clobber local edits)
|
|
31
|
+
remove <skill> delete an installed skill
|
|
32
|
+
doctor health check: orphans, drift, missing SKILL.md, license/script risks
|
|
33
|
+
|
|
34
|
+
catalog:
|
|
21
35
|
update refresh the catalog from the public data feed
|
|
22
36
|
categories list the top-level categories
|
|
23
37
|
list [category] list skill groups (optionally within one category)
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { parse } = require('../args');
|
|
6
|
+
const { loadData } = require('../data');
|
|
7
|
+
const { buildIndices, rowsFor } = require('../index-build');
|
|
8
|
+
const fsu = require('../fsutil');
|
|
9
|
+
const manifest = require('../manifest');
|
|
10
|
+
const { dim, yellow, green } = require('../format');
|
|
11
|
+
|
|
12
|
+
const RISKY_LICENSE = /\bNC\b|GPL|AGPL|Commons[- ]?Clause/i;
|
|
13
|
+
|
|
14
|
+
module.exports = async function doctor(argv) {
|
|
15
|
+
const { values } = parse(argv, ['global', 'project', 'json']);
|
|
16
|
+
if (values.help) { console.log('usage: skills-atlas doctor [--global|--project] [--json]'); return; }
|
|
17
|
+
|
|
18
|
+
const { data } = loadData({ quiet: values.json });
|
|
19
|
+
const idx = buildIndices(data);
|
|
20
|
+
const findings = [];
|
|
21
|
+
const seen = {}; // skill -> [scopes]
|
|
22
|
+
|
|
23
|
+
for (const s of fsu.scopesFor(values)) {
|
|
24
|
+
const root = s.root;
|
|
25
|
+
const m = manifest.read(root);
|
|
26
|
+
|
|
27
|
+
let folders = [];
|
|
28
|
+
try {
|
|
29
|
+
folders = fs.readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
|
30
|
+
} catch { /* no skills dir yet */ }
|
|
31
|
+
|
|
32
|
+
for (const [skill, e] of Object.entries(m.skills)) {
|
|
33
|
+
seen[skill] = (seen[skill] || []).concat(s.name);
|
|
34
|
+
const dir = path.join(root, skill);
|
|
35
|
+
if (!fsu.dirExists(dir)) { findings.push({ scope: s.name, skill, level: 'warn', msg: 'recorded but folder missing on disk' }); continue; }
|
|
36
|
+
if (!fs.existsSync(path.join(dir, 'SKILL.md'))) findings.push({ scope: s.name, skill, level: 'error', msg: 'no SKILL.md in folder' });
|
|
37
|
+
if (!data.vendors[e.source] || !rowsFor(idx.skillIndex, skill).length) findings.push({ scope: s.name, skill, level: 'info', msg: 'no longer in the catalog' });
|
|
38
|
+
if (e.scripts) findings.push({ scope: s.name, skill, level: 'info', msg: `ships ${e.scripts} script file(s) — review` });
|
|
39
|
+
const vendor = data.vendors[e.source];
|
|
40
|
+
if (vendor && vendor.license && RISKY_LICENSE.test(vendor.license)) findings.push({ scope: s.name, skill, level: 'warn', msg: `license: ${vendor.license}` });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const f of folders) {
|
|
44
|
+
if (!m.skills[f]) findings.push({ scope: s.name, skill: f, level: 'info', msg: 'folder not tracked by skills-atlas' });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [skill, scopes] of Object.entries(seen)) {
|
|
49
|
+
if (scopes.length > 1) findings.push({ scope: scopes.join('+'), skill, level: 'warn', msg: 'installed in both scopes (project shadows global)' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (values.json) { console.log(JSON.stringify(findings, null, 2)); return; }
|
|
53
|
+
if (!findings.length) { console.log(`${green('✓')} all good — no issues found.`); return; }
|
|
54
|
+
|
|
55
|
+
const order = { error: 0, warn: 1, info: 2 };
|
|
56
|
+
findings.sort((a, b) => order[a.level] - order[b.level]);
|
|
57
|
+
for (const f of findings) {
|
|
58
|
+
const tag = f.level === 'error' ? '✗' : f.level === 'warn' ? yellow('!') : dim('·');
|
|
59
|
+
console.log(` ${tag} ${f.skill} ${dim(`[${f.scope}]`)} ${f.msg}`);
|
|
60
|
+
}
|
|
61
|
+
const e = findings.filter(f => f.level === 'error').length;
|
|
62
|
+
const w = findings.filter(f => f.level === 'warn').length;
|
|
63
|
+
console.log(`\n${e} error(s), ${w} warning(s), ${findings.length - e - w} note(s).`);
|
|
64
|
+
};
|
package/src/commands/install.js
CHANGED
|
@@ -4,8 +4,9 @@ const path = require('path');
|
|
|
4
4
|
const { parse } = require('../args');
|
|
5
5
|
const { loadData } = require('../data');
|
|
6
6
|
const { buildIndices, vendorsFor, suggestSkills, skillDocPath } = require('../index-build');
|
|
7
|
-
const { listSkillFiles
|
|
7
|
+
const { listSkillFiles } = require('../github');
|
|
8
8
|
const fsu = require('../fsutil');
|
|
9
|
+
const { installFolder } = require('../installer');
|
|
9
10
|
const { confirm, choose } = require('../prompt');
|
|
10
11
|
const { buildInfo, infoForRow, renderInfo, bold, dim, cyan, green, stars, safeAlt } = require('../format');
|
|
11
12
|
|
|
@@ -17,6 +18,7 @@ options:
|
|
|
17
18
|
-s, --source <id> pick a source when a skill has several
|
|
18
19
|
-f, --force overwrite if already installed
|
|
19
20
|
-y, --yes non-interactive (auto-pick top source, assume yes)
|
|
21
|
+
--chain install the whole ⛓ workflow this skill belongs to
|
|
20
22
|
--dry-run show what would download, write nothing
|
|
21
23
|
--json machine-readable output
|
|
22
24
|
|
|
@@ -30,9 +32,56 @@ function resolveGlobal(values) {
|
|
|
30
32
|
return true; // default: global
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// Install every installable skill in a chain (workflow). One archive download
|
|
36
|
+
// serves them all (github archive cache).
|
|
37
|
+
async function installChain({ row, vendor, src, targetRoot, values }) {
|
|
38
|
+
const author = vendor.author || src.author;
|
|
39
|
+
const repo = vendor.repo || src.repo;
|
|
40
|
+
const branch = vendor.default_branch || src.default_branch || 'main';
|
|
41
|
+
|
|
42
|
+
const items = [], skipped = [];
|
|
43
|
+
for (const sk of row.skills || []) {
|
|
44
|
+
const dp = skillDocPath(vendor, sk);
|
|
45
|
+
if (dp) items.push({ name: sk, docPath: dp }); else skipped.push(sk);
|
|
46
|
+
}
|
|
47
|
+
if (!items.length) {
|
|
48
|
+
console.error(`no installable skills in this chain from ${src.name}.`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!values.json) console.log(`\ninstalling ${items.length}-skill chain from ${bold(src.name)}: ${dim(items.map(i => i.name).join(' → '))}`);
|
|
54
|
+
const installed = [], failed = [];
|
|
55
|
+
for (const it of items) {
|
|
56
|
+
const dest = path.join(targetRoot, it.name);
|
|
57
|
+
if (fsu.dirExists(dest) && !values.force) {
|
|
58
|
+
if (!values.json) console.log(dim(` • ${it.name} — already installed (use --force to overwrite)`));
|
|
59
|
+
installed.push(it.name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const r = await installFolder({ author, repo, branch, docPath: it.docPath, dest, targetRoot, skillName: it.name, source: src.name, group: row && row.group, category: row && row._cat });
|
|
64
|
+
if (!values.json) console.log(` ${green('✓')} ${it.name} ${dim(`(${r.fileCount} files)`)}`);
|
|
65
|
+
installed.push(it.name);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (!values.json) console.log(dim(` ✗ ${it.name} — ${e.message}`));
|
|
68
|
+
failed.push(it.name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (values.json) {
|
|
73
|
+
console.log(JSON.stringify({ mode: 'chain', group: row.group, dest: targetRoot, installed, failed, skipped }));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (skipped.length) console.log(dim(` (skipped ${skipped.length}: ${skipped.join(', ')} — no per-skill folder)`));
|
|
77
|
+
console.log(`\n${green('✓')} chain ready — ${installed.length} skill(s) in ${fsu.tildify(targetRoot)}`);
|
|
78
|
+
console.log(dim(` run in order: ${(row.skills || []).join(' → ')}`));
|
|
79
|
+
console.log(dim('\nStart a new Claude Code session to load them, then run the workflow in order.'));
|
|
80
|
+
}
|
|
81
|
+
|
|
33
82
|
module.exports = async function install(argv) {
|
|
34
83
|
const { values, positionals } = parse(argv,
|
|
35
|
-
['global', 'project', 'source', 'force', 'yes', 'dry-run', 'json']);
|
|
84
|
+
['global', 'project', 'source', 'force', 'yes', 'chain', 'dry-run', 'json']);
|
|
36
85
|
if (values.help) { console.log(HELP); return; }
|
|
37
86
|
|
|
38
87
|
const name = positionals[0];
|
|
@@ -125,6 +174,21 @@ module.exports = async function install(argv) {
|
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
const targetRoot = fsu.installTargetDir({ global: resolveGlobal(values) });
|
|
177
|
+
const isChain = Boolean(chosen.row && chosen.row.chain && (chosen.row.skills || []).length >= 2);
|
|
178
|
+
|
|
179
|
+
// --- chain: install the whole workflow ---
|
|
180
|
+
if (values.chain) {
|
|
181
|
+
if (!isChain) {
|
|
182
|
+
console.log(dim(`'${skill}' isn't part of a multi-skill chain; installing it alone.`));
|
|
183
|
+
} else if (values['dry-run']) {
|
|
184
|
+
console.log(`would install the ${chosen.row.skills.length}-skill chain: ${chosen.row.skills.join(' → ')}`);
|
|
185
|
+
return;
|
|
186
|
+
} else {
|
|
187
|
+
await installChain({ row: chosen.row, vendor: v, src, targetRoot, values });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
128
192
|
const dest = path.join(targetRoot, skill); // folder = skill name (strips repo nesting)
|
|
129
193
|
|
|
130
194
|
// --- already installed? ---
|
|
@@ -166,45 +230,11 @@ module.exports = async function install(argv) {
|
|
|
166
230
|
return;
|
|
167
231
|
}
|
|
168
232
|
|
|
169
|
-
// --- download
|
|
170
|
-
|
|
171
|
-
// extract only this skill's folder. Fallback: tree API + raw per file.
|
|
172
|
-
const tmp = fsu.mkdtemp();
|
|
173
|
-
let branchUsed, fileCount, note = null, scripts = [];
|
|
233
|
+
// --- single skill: download (archive → API fallback), record, report ---
|
|
234
|
+
let result;
|
|
174
235
|
try {
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
folder = await fetchSkillFolderTar({ author, repo, branch, docPath });
|
|
178
|
-
} catch (e) {
|
|
179
|
-
note = `archive download failed (${e.message}); fell back to the GitHub API`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
let rels;
|
|
183
|
-
if (folder && folder.files.length) {
|
|
184
|
-
branchUsed = folder.branchUsed;
|
|
185
|
-
for (const f of folder.files) fsu.writeFileMkdir(path.join(tmp, f.rel), f.data);
|
|
186
|
-
rels = folder.files.map(f => f.rel);
|
|
187
|
-
} else {
|
|
188
|
-
const listing = await listSkillFiles({ author, repo, branch, docPath });
|
|
189
|
-
branchUsed = listing.branchUsed;
|
|
190
|
-
if (listing.note) note = listing.note;
|
|
191
|
-
for (const f of listing.files) {
|
|
192
|
-
const buf = await fetchRaw(author, repo, listing.branchUsed, f.path);
|
|
193
|
-
fsu.writeFileMkdir(path.join(tmp, f.rel), buf);
|
|
194
|
-
}
|
|
195
|
-
rels = listing.files.map(f => f.rel);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const hasSkillMd = rels.some(rel => {
|
|
199
|
-
const r = rel.toLowerCase();
|
|
200
|
-
return r === 'skill.md' || r.endsWith('/skill.md');
|
|
201
|
-
});
|
|
202
|
-
if (!hasSkillMd) throw new Error('no SKILL.md found in downloaded folder');
|
|
203
|
-
fileCount = rels.length;
|
|
204
|
-
scripts = fsu.scriptFiles(rels);
|
|
205
|
-
fsu.swapDir(tmp, dest);
|
|
236
|
+
result = await installFolder({ author, repo, branch, docPath, dest, targetRoot, skillName: skill, source: src.name, group: chosen.row && chosen.row.group, category: chosen.row && chosen.row._cat });
|
|
206
237
|
} catch (e) {
|
|
207
|
-
fsu.rmrf(tmp);
|
|
208
238
|
console.error(`install failed: ${e.message}`);
|
|
209
239
|
process.exitCode = 1;
|
|
210
240
|
return;
|
|
@@ -212,18 +242,21 @@ module.exports = async function install(argv) {
|
|
|
212
242
|
|
|
213
243
|
if (values.json) {
|
|
214
244
|
console.log(JSON.stringify({
|
|
215
|
-
skill
|
|
216
|
-
dest, files: fileCount, scripts: scripts.length, branch: branchUsed,
|
|
245
|
+
skill, mode: 'folder', source: src.name,
|
|
246
|
+
dest: result.dest, files: result.fileCount, scripts: result.scripts.length, branch: result.branchUsed,
|
|
217
247
|
}));
|
|
218
248
|
return;
|
|
219
249
|
}
|
|
220
250
|
|
|
221
|
-
console.log(`\n${green('✓')} installed ${bold(skill)} → ${fsu.tildify(dest)} ${dim(`(${fileCount} file(s) from ${src.name}@${branchUsed})`)}`);
|
|
222
|
-
if (note) console.log(dim(' ' + note));
|
|
223
|
-
console.log(dim(` source: ${src.name}@${branchUsed} — branch HEAD, not a pinned commit; review before use`));
|
|
224
|
-
if (scripts.length) {
|
|
225
|
-
const show = scripts.slice(0, 6).join(', ') + (scripts.length > 6 ? ', …' : '');
|
|
226
|
-
console.log(dim(` ⚠ includes ${scripts.length} script file(s): ${show}`));
|
|
251
|
+
console.log(`\n${green('✓')} installed ${bold(skill)} → ${fsu.tildify(result.dest)} ${dim(`(${result.fileCount} file(s) from ${src.name}@${result.branchUsed})`)}`);
|
|
252
|
+
if (result.note) console.log(dim(' ' + result.note));
|
|
253
|
+
console.log(dim(` source: ${src.name}@${result.branchUsed} — branch HEAD, not a pinned commit; review before use`));
|
|
254
|
+
if (result.scripts.length) {
|
|
255
|
+
const show = result.scripts.slice(0, 6).join(', ') + (result.scripts.length > 6 ? ', …' : '');
|
|
256
|
+
console.log(dim(` ⚠ includes ${result.scripts.length} script file(s): ${show}`));
|
|
257
|
+
}
|
|
258
|
+
if (isChain) {
|
|
259
|
+
console.log(dim(` ⛓ part of a ${chosen.row.skills.length}-skill workflow — install all: skills-atlas install ${skill} --chain`));
|
|
227
260
|
}
|
|
228
261
|
|
|
229
262
|
// usage guidance — scoped by row identity to the exact group you installed from
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const fsu = require('../fsutil');
|
|
5
|
+
const manifest = require('../manifest');
|
|
6
|
+
const { bold, dim, cyan } = require('../format');
|
|
7
|
+
|
|
8
|
+
const HELP = `usage: skills-atlas installed [--global|--project] [--json]
|
|
9
|
+
|
|
10
|
+
List skills installed by skills-atlas (from the per-target manifest).
|
|
11
|
+
Default: both global (~/.claude/skills) and project (./.claude/skills).`;
|
|
12
|
+
|
|
13
|
+
module.exports = async function installed(argv) {
|
|
14
|
+
const { values } = parse(argv, ['global', 'project', 'json']);
|
|
15
|
+
if (values.help) { console.log(HELP); return; }
|
|
16
|
+
|
|
17
|
+
const scopes = [];
|
|
18
|
+
if (values.project && !values.global) {
|
|
19
|
+
scopes.push({ name: 'project', root: fsu.installTargetDir({ global: false }) });
|
|
20
|
+
} else if (values.global && !values.project) {
|
|
21
|
+
scopes.push({ name: 'global', root: fsu.installTargetDir({ global: true }) });
|
|
22
|
+
} else {
|
|
23
|
+
scopes.push({ name: 'global', root: fsu.installTargetDir({ global: true }) });
|
|
24
|
+
scopes.push({ name: 'project', root: fsu.installTargetDir({ global: false }) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const out = scopes.map(s => ({ scope: s.name, root: s.root, skills: manifest.list(s.root) }));
|
|
28
|
+
|
|
29
|
+
if (values.json) { console.log(JSON.stringify(out, null, 2)); return; }
|
|
30
|
+
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const s of out) {
|
|
33
|
+
if (!s.skills.length) continue;
|
|
34
|
+
console.log(`\n${bold(s.scope)} ${dim(fsu.tildify(s.root))}`);
|
|
35
|
+
for (const e of s.skills.slice().sort((a, b) => a.skill.localeCompare(b.skill))) {
|
|
36
|
+
total++;
|
|
37
|
+
const when = e.installedAt ? e.installedAt.slice(0, 10) : '';
|
|
38
|
+
const meta = [`${e.source || '?'}@${e.branch || '?'}`, e.group, when].filter(Boolean).join(' · ');
|
|
39
|
+
console.log(` ${cyan(e.skill)} ${dim(meta)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!total) {
|
|
43
|
+
console.log('no skills installed by skills-atlas yet.');
|
|
44
|
+
console.log(dim('find one: skills-atlas search <query> → skills-atlas install <skill>'));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const { loadData } = require('../data');
|
|
5
|
+
const fsu = require('../fsutil');
|
|
6
|
+
const manifest = require('../manifest');
|
|
7
|
+
const { cyan, dim, yellow } = require('../format');
|
|
8
|
+
|
|
9
|
+
const day = s => (s ? String(s).slice(0, 10) : null);
|
|
10
|
+
|
|
11
|
+
module.exports = async function outdated(argv) {
|
|
12
|
+
const { values } = parse(argv, ['global', 'project', 'json']);
|
|
13
|
+
if (values.help) { console.log('usage: skills-atlas outdated [--global|--project] [--json]'); return; }
|
|
14
|
+
|
|
15
|
+
const { data } = loadData({ quiet: values.json });
|
|
16
|
+
const report = [];
|
|
17
|
+
for (const s of fsu.scopesFor(values)) {
|
|
18
|
+
for (const e of manifest.list(s.root)) {
|
|
19
|
+
const vendor = data.vendors[e.source];
|
|
20
|
+
let status, latest = null;
|
|
21
|
+
if (!vendor) {
|
|
22
|
+
status = 'not-in-catalog';
|
|
23
|
+
} else {
|
|
24
|
+
latest = vendor.last_commit || null;
|
|
25
|
+
const inst = day(e.installedAt);
|
|
26
|
+
status = (latest && inst && day(latest) > inst) ? 'outdated' : 'up-to-date';
|
|
27
|
+
}
|
|
28
|
+
report.push({ scope: s.name, skill: e.skill, source: e.source, installed: day(e.installedAt), latest, status });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (values.json) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
33
|
+
if (!report.length) { console.log('no skills installed by skills-atlas.'); return; }
|
|
34
|
+
|
|
35
|
+
for (const r of report) {
|
|
36
|
+
const tag = r.status === 'outdated' ? yellow('outdated')
|
|
37
|
+
: r.status === 'not-in-catalog' ? dim('not in catalog') : dim('up to date');
|
|
38
|
+
console.log(` ${cyan(r.skill)} ${dim(`[${r.scope}]`)} inst ${r.installed || '?'} · ${r.source || '?'}@${r.latest || '?'} ${tag}`);
|
|
39
|
+
}
|
|
40
|
+
const n = report.filter(r => r.status === 'outdated').length;
|
|
41
|
+
const orphan = report.filter(r => r.status === 'not-in-catalog').length;
|
|
42
|
+
console.log(`\n${n} outdated${orphan ? `, ${orphan} not in catalog` : ''}.`);
|
|
43
|
+
if (n) console.log(dim('upgrade: skills-atlas upgrade --all (or upgrade <skill>)'));
|
|
44
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { parse } = require('../args');
|
|
5
|
+
const fsu = require('../fsutil');
|
|
6
|
+
const manifest = require('../manifest');
|
|
7
|
+
const { confirm } = require('../prompt');
|
|
8
|
+
const { green, dim } = require('../format');
|
|
9
|
+
|
|
10
|
+
module.exports = async function remove(argv) {
|
|
11
|
+
const { values, positionals } = parse(argv, ['global', 'project', 'yes', 'json']);
|
|
12
|
+
if (values.help) { console.log('usage: skills-atlas remove <skill> [--global|--project] [--yes]'); return; }
|
|
13
|
+
|
|
14
|
+
const name = positionals[0];
|
|
15
|
+
if (!name) { console.error('usage: skills-atlas remove <skill>'); process.exitCode = 1; return; }
|
|
16
|
+
|
|
17
|
+
const global = !values.project;
|
|
18
|
+
const root = fsu.installTargetDir({ global });
|
|
19
|
+
const dest = path.join(root, name);
|
|
20
|
+
const tracked = manifest.read(root).skills[name];
|
|
21
|
+
|
|
22
|
+
if (!fsu.dirExists(dest) && !tracked) {
|
|
23
|
+
console.error(`'${name}' is not installed (${global ? 'global' : 'project'}).`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!values.yes && !values.json) {
|
|
29
|
+
const ok = await confirm(`remove ${fsu.tildify(dest)}?`, false);
|
|
30
|
+
if (!ok) { console.log('aborted.'); return; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (fsu.dirExists(dest)) fsu.rmrf(dest);
|
|
34
|
+
manifest.remove(root, name);
|
|
35
|
+
|
|
36
|
+
if (values.json) { console.log(JSON.stringify({ removed: name, dest })); return; }
|
|
37
|
+
console.log(`${green('✓')} removed ${name} ${dim(fsu.tildify(dest))}`);
|
|
38
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { parse } = require('../args');
|
|
5
|
+
const { loadData } = require('../data');
|
|
6
|
+
const { buildIndices, vendorsFor, skillDocPath } = require('../index-build');
|
|
7
|
+
const { installFolder } = require('../installer');
|
|
8
|
+
const fsu = require('../fsutil');
|
|
9
|
+
const manifest = require('../manifest');
|
|
10
|
+
const { green, dim } = require('../format');
|
|
11
|
+
|
|
12
|
+
module.exports = async function upgrade(argv) {
|
|
13
|
+
const { values, positionals } = parse(argv, ['global', 'project', 'all', 'force', 'json']);
|
|
14
|
+
if (values.help) {
|
|
15
|
+
console.log('usage: skills-atlas upgrade [<skill> | --all] [--global|--project] [--force]\n\n' +
|
|
16
|
+
'Re-fetch installed skills to the latest. Refuses to overwrite a folder you\n' +
|
|
17
|
+
'have edited locally (or one installed before change-tracking) unless --force.');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!values.all && !positionals[0]) {
|
|
21
|
+
console.error('usage: skills-atlas upgrade <skill> | --all');
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Collect (scope, skill) targets across the selected scopes (default: both),
|
|
27
|
+
// so `outdated` (both scopes) → `upgrade --all` covers the same skills.
|
|
28
|
+
const targets = [];
|
|
29
|
+
for (const s of fsu.scopesFor(values)) {
|
|
30
|
+
const m = manifest.read(s.root);
|
|
31
|
+
const names = values.all ? Object.keys(m.skills) : (m.skills[positionals[0]] ? [positionals[0]] : []);
|
|
32
|
+
for (const n of names) targets.push({ scope: s, name: n, entry: m.skills[n] });
|
|
33
|
+
}
|
|
34
|
+
if (!targets.length) {
|
|
35
|
+
if (values.json) { console.log('[]'); return; }
|
|
36
|
+
console.log(positionals[0] ? `'${positionals[0]}' is not installed by skills-atlas.` : 'nothing installed to upgrade.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { data } = loadData({ quiet: values.json });
|
|
41
|
+
const idx = buildIndices(data);
|
|
42
|
+
const results = [];
|
|
43
|
+
|
|
44
|
+
for (const { scope, name, entry } of targets) {
|
|
45
|
+
const root = scope.root;
|
|
46
|
+
const rec = { skill: name, scope: scope.name };
|
|
47
|
+
|
|
48
|
+
const chosen = vendorsFor(idx.skillIndex, name).find(c => c.source.name === entry.source)
|
|
49
|
+
|| vendorsFor(idx.skillIndex, name)[0];
|
|
50
|
+
if (!chosen) { results.push({ ...rec, status: 'not-in-catalog' }); continue; }
|
|
51
|
+
|
|
52
|
+
const v = chosen.vendor, src = chosen.source;
|
|
53
|
+
const docPath = skillDocPath(v, name);
|
|
54
|
+
if (!docPath) { results.push({ ...rec, status: 'no-folder' }); continue; }
|
|
55
|
+
|
|
56
|
+
const dest = path.join(root, name);
|
|
57
|
+
// drift guard — never clobber edits, and never clobber a folder of unknown
|
|
58
|
+
// provenance (installed before hashes existed) without --force.
|
|
59
|
+
if (fsu.dirExists(dest) && !values.force) {
|
|
60
|
+
if (!entry.hash) { results.push({ ...rec, status: 'no-baseline' }); continue; }
|
|
61
|
+
let cur = null;
|
|
62
|
+
try { cur = fsu.hashDir(dest); } catch { /* unreadable → fall through */ }
|
|
63
|
+
if (cur && cur !== entry.hash) { results.push({ ...rec, status: 'local-changes' }); continue; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const r = await installFolder({
|
|
68
|
+
author: v.author || src.author, repo: v.repo || src.repo,
|
|
69
|
+
branch: v.default_branch || src.default_branch || 'main',
|
|
70
|
+
docPath, dest, targetRoot: root, skillName: name,
|
|
71
|
+
source: src.name, group: chosen.row && chosen.row.group, category: chosen.row && chosen.row._cat,
|
|
72
|
+
});
|
|
73
|
+
results.push({ ...rec, status: (entry.hash && r.hash === entry.hash) ? 'up-to-date' : 'upgraded', files: r.fileCount });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
results.push({ ...rec, status: 'failed', error: e.message });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (values.json) { console.log(JSON.stringify(results, null, 2)); return; }
|
|
80
|
+
for (const r of results) {
|
|
81
|
+
const sym = r.status === 'upgraded' ? green('✓')
|
|
82
|
+
: r.status === 'up-to-date' ? dim('=')
|
|
83
|
+
: (r.status === 'local-changes' || r.status === 'no-baseline') ? '✋' : '✗';
|
|
84
|
+
console.log(` ${sym} ${r.skill} ${dim(`[${r.scope}]`)} ${dim(r.status + (r.error ? ': ' + r.error : ''))}`);
|
|
85
|
+
}
|
|
86
|
+
const up = results.filter(r => r.status === 'upgraded').length;
|
|
87
|
+
console.log(`\n${up} upgraded.`);
|
|
88
|
+
if (results.some(r => r.status === 'local-changes' || r.status === 'no-baseline')) {
|
|
89
|
+
console.log(dim('skipped some (local edits, or installed before change-tracking) — re-run with --force.'));
|
|
90
|
+
}
|
|
91
|
+
};
|
package/src/fsutil.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
|
|
8
9
|
// Where skills get installed. Global => ~/.claude/skills, project => ./.claude/skills.
|
|
9
10
|
function installTargetDir({ global }) {
|
|
@@ -11,6 +12,15 @@ function installTargetDir({ global }) {
|
|
|
11
12
|
return path.join(root, '.claude', 'skills');
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
// Scopes to act on from --global / --project flags (default: both).
|
|
16
|
+
function scopesFor(values) {
|
|
17
|
+
const g = { name: 'global', root: installTargetDir({ global: true }) };
|
|
18
|
+
const p = { name: 'project', root: installTargetDir({ global: false }) };
|
|
19
|
+
if (values.project && !values.global) return [p];
|
|
20
|
+
if (values.global && !values.project) return [g];
|
|
21
|
+
return [g, p];
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
function dirExists(p) {
|
|
15
25
|
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
16
26
|
}
|
|
@@ -54,7 +64,31 @@ function scriptFiles(rels) {
|
|
|
54
64
|
return (rels || []).filter(r => SCRIPT_RE.test(r));
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
// Stable content hash of a folder, for drift detection on upgrade.
|
|
68
|
+
function hashFiles(files) {
|
|
69
|
+
const h = crypto.createHash('sha256');
|
|
70
|
+
for (const f of [...files].sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
|
|
71
|
+
h.update(f.rel); h.update('\0'); h.update(f.data); h.update('\0');
|
|
72
|
+
}
|
|
73
|
+
return h.digest('hex').slice(0, 16);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Same hash, read from an installed directory on disk.
|
|
77
|
+
function hashDir(dir) {
|
|
78
|
+
const files = [];
|
|
79
|
+
const walk = (d, base) => {
|
|
80
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
81
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
82
|
+
const p = path.join(d, e.name);
|
|
83
|
+
if (e.isDirectory()) walk(p, rel);
|
|
84
|
+
else files.push({ rel, data: fs.readFileSync(p) });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(dir, '');
|
|
88
|
+
return hashFiles(files);
|
|
89
|
+
}
|
|
90
|
+
|
|
57
91
|
module.exports = {
|
|
58
|
-
installTargetDir, dirExists, ensureDir, mkdtemp,
|
|
59
|
-
writeFileMkdir, rmrf, swapDir, tildify, scriptFiles,
|
|
92
|
+
installTargetDir, scopesFor, dirExists, ensureDir, mkdtemp,
|
|
93
|
+
writeFileMkdir, rmrf, swapDir, tildify, scriptFiles, hashFiles, hashDir,
|
|
60
94
|
};
|
package/src/github.js
CHANGED
|
@@ -180,12 +180,19 @@ function extractTarGz(gzBuf, folderPath) {
|
|
|
180
180
|
return out;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// One archive per repo@ref per process — so installing a whole chain (many
|
|
184
|
+
// skills from one repo) downloads the archive ONCE.
|
|
185
|
+
const _archiveCache = new Map();
|
|
183
186
|
async function fetchArchive(author, repo, ref) {
|
|
187
|
+
const key = `${author}/${repo}@${ref}`;
|
|
188
|
+
if (_archiveCache.has(key)) return _archiveCache.get(key);
|
|
184
189
|
const url = `https://codeload.github.com/${author}/${repo}/tar.gz/${ref}`;
|
|
185
190
|
const res = await fetchT(url, { headers: { 'User-Agent': UA } });
|
|
186
191
|
if (res.status === 404) { const e = new Error(`archive not found: ${ref}`); e.code = 'NOT_FOUND'; throw e; }
|
|
187
192
|
if (!res.ok) throw new Error(`codeload HTTP ${res.status}`);
|
|
188
|
-
|
|
193
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
194
|
+
_archiveCache.set(key, buf);
|
|
195
|
+
return buf;
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
// Resolve a skill's folder via the repo archive. Returns { files:[{rel,data}],
|
|
@@ -212,4 +219,27 @@ async function fetchSkillFolderTar({ author, repo, branch, docPath }) {
|
|
|
212
219
|
return { files, branchUsed, source: 'archive' };
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
|
|
222
|
+
// Resolve a skill's folder CONTENTS — archive first (no API budget), tree+raw
|
|
223
|
+
// fallback. Returns { files:[{rel,data}], branchUsed, source, note }.
|
|
224
|
+
async function getSkillFolder({ author, repo, branch, docPath }) {
|
|
225
|
+
let archiveErr = null;
|
|
226
|
+
try {
|
|
227
|
+
const folder = await fetchSkillFolderTar({ author, repo, branch, docPath });
|
|
228
|
+
if (folder && folder.files.length) {
|
|
229
|
+
return { files: folder.files, branchUsed: folder.branchUsed, source: 'archive', note: null };
|
|
230
|
+
}
|
|
231
|
+
} catch (e) {
|
|
232
|
+
archiveErr = e.message;
|
|
233
|
+
}
|
|
234
|
+
const listing = await listSkillFiles({ author, repo, branch, docPath });
|
|
235
|
+
const files = [];
|
|
236
|
+
for (const f of listing.files) {
|
|
237
|
+
files.push({ rel: f.rel, data: await fetchRaw(author, repo, listing.branchUsed, f.path) });
|
|
238
|
+
}
|
|
239
|
+
const note = archiveErr ? `archive unavailable (${archiveErr}); used the GitHub API` : listing.note;
|
|
240
|
+
return { files, branchUsed: listing.branchUsed, source: 'api', note };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
listSkillFiles, fetchRaw, fetchTree, fetchSkillFolderTar, extractTarGz, getSkillFolder,
|
|
245
|
+
};
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Shared install primitive used by both `install` (single + chain) and `upgrade`.
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getSkillFolder } = require('./github');
|
|
6
|
+
const fsu = require('./fsutil');
|
|
7
|
+
const manifest = require('./manifest');
|
|
8
|
+
|
|
9
|
+
const hasSkillMd = rels => rels.some(r => {
|
|
10
|
+
const x = r.toLowerCase();
|
|
11
|
+
return x === 'skill.md' || x.endsWith('/skill.md');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Download a skill's folder, atomically install into dest, and record the
|
|
15
|
+
// manifest (with a content hash for drift detection on upgrade).
|
|
16
|
+
// Returns { dest, fileCount, scripts, branchUsed, note, hash }. Throws on failure.
|
|
17
|
+
async function installFolder({ author, repo, branch, docPath, dest, targetRoot, skillName, source, group, category }) {
|
|
18
|
+
const folder = await getSkillFolder({ author, repo, branch, docPath });
|
|
19
|
+
const tmp = fsu.mkdtemp();
|
|
20
|
+
try {
|
|
21
|
+
for (const f of folder.files) fsu.writeFileMkdir(path.join(tmp, f.rel), f.data);
|
|
22
|
+
const rels = folder.files.map(f => f.rel);
|
|
23
|
+
if (!hasSkillMd(rels)) throw new Error('no SKILL.md found in downloaded folder');
|
|
24
|
+
fsu.swapDir(tmp, dest);
|
|
25
|
+
const scripts = fsu.scriptFiles(rels);
|
|
26
|
+
const hash = fsu.hashFiles(folder.files);
|
|
27
|
+
manifest.record(targetRoot, {
|
|
28
|
+
skill: skillName, source, repo, branch: folder.branchUsed,
|
|
29
|
+
group, category, files: rels.length, scripts: scripts.length, hash,
|
|
30
|
+
installedAt: new Date().toISOString(),
|
|
31
|
+
});
|
|
32
|
+
return { dest, fileCount: rels.length, scripts, branchUsed: folder.branchUsed, note: folder.note, hash };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
fsu.rmrf(tmp);
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { installFolder, hasSkillMd };
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Per-target install ledger: <skills-root>/.skills-atlas.json. Turns the opaque
|
|
2
|
+
// .claude/skills directory into a managed inventory — the keystone that lets
|
|
3
|
+
// `installed` / future `outdated` / `upgrade` / `remove` / `sync` work.
|
|
4
|
+
// (A dotfile, so Claude Code never loads it as a skill.)
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const FILE = '.skills-atlas.json';
|
|
11
|
+
|
|
12
|
+
const fileFor = root => path.join(root, FILE);
|
|
13
|
+
|
|
14
|
+
function read(root) {
|
|
15
|
+
try {
|
|
16
|
+
const m = JSON.parse(fs.readFileSync(fileFor(root), 'utf8'));
|
|
17
|
+
if (!m.skills) m.skills = {};
|
|
18
|
+
return m;
|
|
19
|
+
} catch {
|
|
20
|
+
return { version: 1, skills: {} };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function write(root, m) {
|
|
25
|
+
fs.mkdirSync(root, { recursive: true });
|
|
26
|
+
fs.writeFileSync(fileFor(root), JSON.stringify(m, null, 2) + '\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Upsert one installed skill.
|
|
30
|
+
function record(root, entry) {
|
|
31
|
+
const m = read(root);
|
|
32
|
+
m.skills[entry.skill] = {
|
|
33
|
+
source: entry.source || null,
|
|
34
|
+
repo: entry.repo || null,
|
|
35
|
+
branch: entry.branch || null,
|
|
36
|
+
group: entry.group || null,
|
|
37
|
+
category: entry.category || null,
|
|
38
|
+
files: entry.files ?? null,
|
|
39
|
+
scripts: entry.scripts ?? 0,
|
|
40
|
+
hash: entry.hash || null,
|
|
41
|
+
installedAt: entry.installedAt || null,
|
|
42
|
+
};
|
|
43
|
+
write(root, m);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function remove(root, skill) {
|
|
47
|
+
const m = read(root);
|
|
48
|
+
if (m.skills[skill]) { delete m.skills[skill]; write(root, m); return true; }
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function list(root) {
|
|
53
|
+
const m = read(root);
|
|
54
|
+
return Object.entries(m.skills).map(([skill, v]) => ({ skill, ...v }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { read, write, record, remove, list, fileFor, FILE };
|