skills-atlas-cli 0.1.2 โ†’ 0.2.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 CHANGED
@@ -46,14 +46,20 @@ 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
- # ๐Ÿ—‚๏ธ Browse & refresh
52
+ # ๐Ÿ—‚๏ธ Manage & browse
53
+ skills-atlas installed # what you've installed (global + project)
52
54
  skills-atlas categories # the 20 top-level categories
53
55
  skills-atlas list marketing # skill groups within a category
54
56
  skills-atlas update # pull the latest catalog
55
57
  ```
56
58
 
59
+ **โ›“ Workflows, not just skills.** Many skills belong to a curated chain (e.g.
60
+ `brainstorming โ†’ writing-plans โ†’ executing-plans โ†’ โ€ฆ`). `install <skill> --chain`
61
+ installs the whole pipeline in one archive download, ready to run in order.
62
+
57
63
  Output is English by default; add `--zh` for Chinese, or `--json` to any command for machine-readable output.
58
64
  After installing a skill, start a new Claude Code session to load it.
59
65
 
package/bin/skills.js CHANGED
@@ -4,11 +4,12 @@
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');
7
8
  const update = require('../src/commands/update');
8
9
  const { categories, list } = require('../src/commands/categories');
9
10
 
10
11
  const VERSION = require('../package.json').version;
11
- const commands = { search, info, install, update, categories, list };
12
+ const commands = { search, info, install, installed, update, categories, list };
12
13
 
13
14
  const HELP = `skills-atlas โ€” search, install & learn AI agent skills
14
15
 
@@ -17,7 +18,8 @@ usage: skills-atlas <command> [args]
17
18
  commands:
18
19
  search <query> find skills (filters: -c category, -p persona, -t type, --chain)
19
20
  info <skill> show description, usage guidance, sources & install command
20
- install <skill> download the skill into .claude/skills/ (--global default, --project)
21
+ install <skill> download the skill into .claude/skills/ (--chain for the whole workflow)
22
+ installed list skills you've installed (global + project)
21
23
  update refresh the catalog from the public data feed
22
24
  categories list the top-level categories
23
25
  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.1.2",
3
+ "version": "0.2.0",
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",
@@ -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, fetchRaw, fetchSkillFolderTar } = require('../github');
7
+ const { listSkillFiles, getSkillFolder } = require('../github');
8
8
  const fsu = require('../fsutil');
9
+ const manifest = require('../manifest');
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,84 @@ function resolveGlobal(values) {
30
32
  return true; // default: global
31
33
  }
32
34
 
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
+ // Install every installable skill in a chain (workflow). One archive download
64
+ // serves them all (github archive cache).
65
+ async function installChain({ row, vendor, src, targetRoot, values }) {
66
+ const author = vendor.author || src.author;
67
+ const repo = vendor.repo || src.repo;
68
+ const branch = vendor.default_branch || src.default_branch || 'main';
69
+
70
+ const items = [], skipped = [];
71
+ for (const sk of row.skills || []) {
72
+ const dp = skillDocPath(vendor, sk);
73
+ if (dp) items.push({ name: sk, docPath: dp }); else skipped.push(sk);
74
+ }
75
+ if (!items.length) {
76
+ console.error(`no installable skills in this chain from ${src.name}.`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ if (!values.json) console.log(`\ninstalling ${items.length}-skill chain from ${bold(src.name)}: ${dim(items.map(i => i.name).join(' โ†’ '))}`);
82
+ const installed = [], failed = [];
83
+ for (const it of items) {
84
+ const dest = path.join(targetRoot, it.name);
85
+ if (fsu.dirExists(dest) && !values.force) {
86
+ if (!values.json) console.log(dim(` โ€ข ${it.name} โ€” already installed (use --force to overwrite)`));
87
+ installed.push(it.name);
88
+ continue;
89
+ }
90
+ try {
91
+ const r = await installFolder({ author, repo, branch, docPath: it.docPath, dest, targetRoot, skillName: it.name, src, row });
92
+ if (!values.json) console.log(` ${green('โœ“')} ${it.name} ${dim(`(${r.fileCount} files)`)}`);
93
+ installed.push(it.name);
94
+ } catch (e) {
95
+ if (!values.json) console.log(dim(` โœ— ${it.name} โ€” ${e.message}`));
96
+ failed.push(it.name);
97
+ }
98
+ }
99
+
100
+ if (values.json) {
101
+ console.log(JSON.stringify({ mode: 'chain', group: row.group, dest: targetRoot, installed, failed, skipped }));
102
+ return;
103
+ }
104
+ if (skipped.length) console.log(dim(` (skipped ${skipped.length}: ${skipped.join(', ')} โ€” no per-skill folder)`));
105
+ console.log(`\n${green('โœ“')} chain ready โ€” ${installed.length} skill(s) in ${fsu.tildify(targetRoot)}`);
106
+ console.log(dim(` run in order: ${(row.skills || []).join(' โ†’ ')}`));
107
+ console.log(dim('\nStart a new Claude Code session to load them, then run the workflow in order.'));
108
+ }
109
+
33
110
  module.exports = async function install(argv) {
34
111
  const { values, positionals } = parse(argv,
35
- ['global', 'project', 'source', 'force', 'yes', 'dry-run', 'json']);
112
+ ['global', 'project', 'source', 'force', 'yes', 'chain', 'dry-run', 'json']);
36
113
  if (values.help) { console.log(HELP); return; }
37
114
 
38
115
  const name = positionals[0];
@@ -125,6 +202,21 @@ module.exports = async function install(argv) {
125
202
  }
126
203
 
127
204
  const targetRoot = fsu.installTargetDir({ global: resolveGlobal(values) });
205
+ const isChain = Boolean(chosen.row && chosen.row.chain && (chosen.row.skills || []).length >= 2);
206
+
207
+ // --- chain: install the whole workflow ---
208
+ if (values.chain) {
209
+ if (!isChain) {
210
+ console.log(dim(`'${skill}' isn't part of a multi-skill chain; installing it alone.`));
211
+ } else if (values['dry-run']) {
212
+ console.log(`would install the ${chosen.row.skills.length}-skill chain: ${chosen.row.skills.join(' โ†’ ')}`);
213
+ return;
214
+ } else {
215
+ await installChain({ row: chosen.row, vendor: v, src, targetRoot, values });
216
+ return;
217
+ }
218
+ }
219
+
128
220
  const dest = path.join(targetRoot, skill); // folder = skill name (strips repo nesting)
129
221
 
130
222
  // --- already installed? ---
@@ -166,45 +258,11 @@ module.exports = async function install(argv) {
166
258
  return;
167
259
  }
168
260
 
169
- // --- download into a tmp dir, then atomically swap into place ---
170
- // Primary: one repo-archive download from codeload (not GitHub-API rate-limited);
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 = [];
261
+ // --- single skill: download (archive โ†’ API fallback), record, report ---
262
+ let result;
174
263
  try {
175
- let folder = null;
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);
264
+ result = await installFolder({ author, repo, branch, docPath, dest, targetRoot, skillName: skill, src, row: chosen.row });
206
265
  } catch (e) {
207
- fsu.rmrf(tmp);
208
266
  console.error(`install failed: ${e.message}`);
209
267
  process.exitCode = 1;
210
268
  return;
@@ -212,18 +270,21 @@ module.exports = async function install(argv) {
212
270
 
213
271
  if (values.json) {
214
272
  console.log(JSON.stringify({
215
- skill: name, mode: 'folder', source: src.name,
216
- dest, files: fileCount, scripts: scripts.length, branch: branchUsed,
273
+ skill, mode: 'folder', source: src.name,
274
+ dest: result.dest, files: result.fileCount, scripts: result.scripts.length, branch: result.branchUsed,
217
275
  }));
218
276
  return;
219
277
  }
220
278
 
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}`));
279
+ console.log(`\n${green('โœ“')} installed ${bold(skill)} โ†’ ${fsu.tildify(result.dest)} ${dim(`(${result.fileCount} file(s) from ${src.name}@${result.branchUsed})`)}`);
280
+ if (result.note) console.log(dim(' ' + result.note));
281
+ console.log(dim(` source: ${src.name}@${result.branchUsed} โ€” branch HEAD, not a pinned commit; review before use`));
282
+ if (result.scripts.length) {
283
+ const show = result.scripts.slice(0, 6).join(', ') + (result.scripts.length > 6 ? ', โ€ฆ' : '');
284
+ console.log(dim(` โš  includes ${result.scripts.length} script file(s): ${show}`));
285
+ }
286
+ if (isChain) {
287
+ console.log(dim(` โ›“ part of a ${chosen.row.skills.length}-skill workflow โ€” install all: skills-atlas install ${skill} --chain`));
227
288
  }
228
289
 
229
290
  // 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
+ };
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
- return Buffer.from(await res.arrayBuffer());
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
- module.exports = { listSkillFiles, fetchRaw, fetchTree, fetchSkillFolderTar, extractTarGz };
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
+ };
@@ -0,0 +1,56 @@
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
+ installedAt: entry.installedAt || null,
41
+ };
42
+ write(root, m);
43
+ }
44
+
45
+ function remove(root, skill) {
46
+ const m = read(root);
47
+ if (m.skills[skill]) { delete m.skills[skill]; write(root, m); return true; }
48
+ return false;
49
+ }
50
+
51
+ function list(root) {
52
+ const m = read(root);
53
+ return Object.entries(m.skills).map(([skill, v]) => ({ skill, ...v }));
54
+ }
55
+
56
+ module.exports = { read, write, record, remove, list, fileFor, FILE };