skills-atlas-cli 0.2.0 → 0.3.1

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 CHANGED
@@ -47,10 +47,17 @@ skills-atlas info brainstorming
47
47
  skills-atlas install brainstorming # → ~/.claude/skills/ (default, all projects)
48
48
  skills-atlas install brainstorming --project # → ./.claude/skills/ (this project only)
49
49
  skills-atlas install brainstorming --chain # install the whole ⛓ workflow it belongs to
50
+ skills-atlas use brainstorming # install AND activate now — prints SKILL.md, no restart
50
51
  skills-atlas install brainstorming --dry-run # preview the files, write nothing
51
52
 
52
- # 🗂️ Manage & browse
53
- skills-atlas installed # what you've installed (global + project)
53
+ # 🗂️ Manage what you've installed (like a package manager)
54
+ skills-atlas installed # list installed (global + project)
55
+ skills-atlas outdated # which have a newer upstream version
56
+ skills-atlas upgrade brainstorming # re-fetch to latest (--all; won't clobber local edits)
57
+ skills-atlas remove brainstorming # delete it
58
+ skills-atlas doctor # health check: orphans, drift, license/script risks
59
+
60
+ # 🌐 Catalog
54
61
  skills-atlas categories # the 20 top-level categories
55
62
  skills-atlas list marketing # skill groups within a category
56
63
  skills-atlas update # pull the latest catalog
package/bin/skills.js CHANGED
@@ -5,21 +5,36 @@ const search = require('../src/commands/search');
5
5
  const info = require('../src/commands/info');
6
6
  const install = require('../src/commands/install');
7
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');
8
12
  const update = require('../src/commands/update');
9
13
  const { categories, list } = require('../src/commands/categories');
10
14
 
11
15
  const VERSION = require('../package.json').version;
12
- const commands = { search, info, install, installed, update, categories, list };
16
+ // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
17
+ const use = argv => install([...argv, '--inline']);
18
+ const commands = { search, info, install, use, installed, upgrade, remove, outdated, doctor, update, categories, list };
13
19
 
14
- const HELP = `skills-atlas — search, install & learn AI agent skills
20
+ const HELP = `skills-atlas — search, install & manage AI agent skills
15
21
 
16
22
  usage: skills-atlas <command> [args]
17
23
 
18
- commands:
24
+ find & install:
19
25
  search <query> find skills (filters: -c category, -p persona, -t type, --chain)
20
26
  info <skill> show description, usage guidance, sources & install command
21
- install <skill> download the skill into .claude/skills/ (--chain for the whole workflow)
22
- installed list skills you've installed (global + project)
27
+ install <skill> download into .claude/skills/ (--chain for the whole workflow)
28
+ use <skill> install AND activate it for the current session now (inline)
29
+
30
+ manage what you've installed:
31
+ installed list installed skills (global + project)
32
+ outdated show which installed skills have a newer upstream version
33
+ upgrade [skill] re-fetch to the latest (--all; refuses to clobber local edits)
34
+ remove <skill> delete an installed skill
35
+ doctor health check: orphans, drift, missing SKILL.md, license/script risks
36
+
37
+ catalog:
23
38
  update refresh the catalog from the public data feed
24
39
  categories list the top-level categories
25
40
  list [category] list skill groups (optionally within one category)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Search, install and learn AI agent skills from the terminal — powered by the Skills Atlas catalog.",
5
5
  "bin": {
6
6
  "skills-atlas": "bin/skills.js",
package/src/args.js CHANGED
@@ -20,6 +20,7 @@ const ALL = {
20
20
  en: { type: 'boolean' },
21
21
  zh: { type: 'boolean' },
22
22
  all: { type: 'boolean' },
23
+ inline: { type: 'boolean' },
23
24
  help: { type: 'boolean', short: 'h' },
24
25
  };
25
26
 
@@ -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
+ };
@@ -1,12 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
  const { parse } = require('../args');
6
+
7
+ const readSkillMd = dest => {
8
+ try { return fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8'); } catch { return null; }
9
+ };
5
10
  const { loadData } = require('../data');
6
11
  const { buildIndices, vendorsFor, suggestSkills, skillDocPath } = require('../index-build');
7
- const { listSkillFiles, getSkillFolder } = require('../github');
12
+ const { listSkillFiles } = require('../github');
8
13
  const fsu = require('../fsutil');
9
- const manifest = require('../manifest');
14
+ const { installFolder } = require('../installer');
10
15
  const { confirm, choose } = require('../prompt');
11
16
  const { buildInfo, infoForRow, renderInfo, bold, dim, cyan, green, stars, safeAlt } = require('../format');
12
17
 
@@ -32,34 +37,6 @@ function resolveGlobal(values) {
32
37
  return true; // default: global
33
38
  }
34
39
 
35
- const hasSkillMd = rels => rels.some(r => {
36
- const x = r.toLowerCase();
37
- return x === 'skill.md' || x.endsWith('/skill.md');
38
- });
39
-
40
- // Download one skill's folder and atomically install it; records the manifest.
41
- // Returns { dest, fileCount, scripts, branchUsed, note }. Throws on failure.
42
- async function installFolder({ author, repo, branch, docPath, dest, targetRoot, skillName, src, row }) {
43
- const folder = await getSkillFolder({ author, repo, branch, docPath });
44
- const tmp = fsu.mkdtemp();
45
- try {
46
- for (const f of folder.files) fsu.writeFileMkdir(path.join(tmp, f.rel), f.data);
47
- const rels = folder.files.map(f => f.rel);
48
- if (!hasSkillMd(rels)) throw new Error('no SKILL.md found in downloaded folder');
49
- fsu.swapDir(tmp, dest);
50
- const scripts = fsu.scriptFiles(rels);
51
- manifest.record(targetRoot, {
52
- skill: skillName, source: src.name, repo, branch: folder.branchUsed,
53
- group: row && row.group, category: row && row._cat,
54
- files: rels.length, scripts: scripts.length, installedAt: new Date().toISOString(),
55
- });
56
- return { dest, fileCount: rels.length, scripts, branchUsed: folder.branchUsed, note: folder.note };
57
- } catch (e) {
58
- fsu.rmrf(tmp);
59
- throw e;
60
- }
61
- }
62
-
63
40
  // Install every installable skill in a chain (workflow). One archive download
64
41
  // serves them all (github archive cache).
65
42
  async function installChain({ row, vendor, src, targetRoot, values }) {
@@ -88,7 +65,7 @@ async function installChain({ row, vendor, src, targetRoot, values }) {
88
65
  continue;
89
66
  }
90
67
  try {
91
- const r = await installFolder({ author, repo, branch, docPath: it.docPath, dest, targetRoot, skillName: it.name, src, row });
68
+ 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 });
92
69
  if (!values.json) console.log(` ${green('✓')} ${it.name} ${dim(`(${r.fileCount} files)`)}`);
93
70
  installed.push(it.name);
94
71
  } catch (e) {
@@ -109,7 +86,7 @@ async function installChain({ row, vendor, src, targetRoot, values }) {
109
86
 
110
87
  module.exports = async function install(argv) {
111
88
  const { values, positionals } = parse(argv,
112
- ['global', 'project', 'source', 'force', 'yes', 'chain', 'dry-run', 'json']);
89
+ ['global', 'project', 'source', 'force', 'yes', 'chain', 'inline', 'dry-run', 'json']);
113
90
  if (values.help) { console.log(HELP); return; }
114
91
 
115
92
  const name = positionals[0];
@@ -261,7 +238,7 @@ module.exports = async function install(argv) {
261
238
  // --- single skill: download (archive → API fallback), record, report ---
262
239
  let result;
263
240
  try {
264
- result = await installFolder({ author, repo, branch, docPath, dest, targetRoot, skillName: skill, src, row: chosen.row });
241
+ 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 });
265
242
  } catch (e) {
266
243
  console.error(`install failed: ${e.message}`);
267
244
  process.exitCode = 1;
@@ -269,10 +246,12 @@ module.exports = async function install(argv) {
269
246
  }
270
247
 
271
248
  if (values.json) {
272
- console.log(JSON.stringify({
249
+ const out = {
273
250
  skill, mode: 'folder', source: src.name,
274
251
  dest: result.dest, files: result.fileCount, scripts: result.scripts.length, branch: result.branchUsed,
275
- }));
252
+ };
253
+ if (values.inline) out.skillMd = readSkillMd(result.dest);
254
+ console.log(JSON.stringify(out));
276
255
  return;
277
256
  }
278
257
 
@@ -292,5 +271,16 @@ module.exports = async function install(argv) {
292
271
  ? infoForRow(skill, chosen.row, data.vendors)
293
272
  : buildInfo(skill, { skillIndex: idx.skillIndex, vendors: data.vendors });
294
273
  console.log(renderInfo(guide, { en: !values.zh, all: true }));
295
- console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
274
+
275
+ if (values.inline) {
276
+ const body = readSkillMd(result.dest);
277
+ if (body) {
278
+ console.log('\n' + dim('─── SKILL.md (active for this task — follow it now) ───'));
279
+ console.log(body.trim());
280
+ console.log(dim('─── end SKILL.md ───'));
281
+ }
282
+ console.log(dim('\n(the folder is also installed for future sessions)'));
283
+ } else {
284
+ console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
285
+ }
296
286
  };
@@ -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
  };
@@ -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 CHANGED
@@ -37,6 +37,7 @@ function record(root, entry) {
37
37
  category: entry.category || null,
38
38
  files: entry.files ?? null,
39
39
  scripts: entry.scripts ?? 0,
40
+ hash: entry.hash || null,
40
41
  installedAt: entry.installedAt || null,
41
42
  };
42
43
  write(root, m);