skills-atlas-cli 0.4.2 → 0.6.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
@@ -32,6 +32,29 @@ npm install -g skills-atlas-cli # adds the `skills-atlas` command (alias: `
32
32
 
33
33
  Or run it without installing: `npx skills-atlas-cli search seo`
34
34
 
35
+ ## How you'll use it
36
+
37
+ Three ways to reach the same catalog — pick whichever fits the moment:
38
+
39
+ | Mode | Best when | Get going |
40
+ |---|---|---|
41
+ | **Manual** | you want to browse and grab skills yourself | `skills-atlas search <task>` → `skills-atlas use <skill>` |
42
+ | **In Claude Code** | you'd rather just ask Claude in-conversation | install the [plugin](#in-claude-code), then describe your task |
43
+ | **🤖 Autopilot** | you want the right skill to find *you* | `skills-atlas hook on` — Claude offers a fitting skill as you work |
44
+
45
+ **60-second quickstart:**
46
+
47
+ ```bash
48
+ npm install -g skills-atlas-cli
49
+ skills-atlas search "stress test my launch plan" # → pre-mortem tops the results
50
+ skills-atlas use pre-mortem # install + activate now (prints its SKILL.md)
51
+ skills-atlas hook on # optional — let the right skill find you from here on
52
+ ```
53
+
54
+ `use` drops the skill into `~/.claude/skills/` and prints it for the task at hand; it
55
+ auto-loads in new Claude Code sessions. With autopilot on, you don't even need to
56
+ `search` — describe your task and Claude surfaces the skill if one fits.
57
+
35
58
  ## Usage
36
59
 
37
60
  ```bash
@@ -57,6 +80,10 @@ skills-atlas upgrade brainstorming # re-fetch to latest (--all; won'
57
80
  skills-atlas remove brainstorming # delete it
58
81
  skills-atlas doctor # health check: orphans, drift, license/script risks
59
82
 
83
+ # 📦 Set up a whole project at once
84
+ skills-atlas kit # detect this project & install the right skills for it
85
+ skills-atlas sync # reproduce a project's kit (skills-atlas.kit.json)
86
+
60
87
  # 🌐 Catalog
61
88
  skills-atlas categories # the 20 top-level categories
62
89
  skills-atlas list marketing # skill groups within a category
@@ -67,6 +94,11 @@ skills-atlas update # pull the latest catalog
67
94
  `brainstorming → writing-plans → executing-plans → …`). `install <skill> --chain`
68
95
  installs the whole pipeline in one archive download, ready to run in order.
69
96
 
97
+ **📦 Project kits.** `skills-atlas kit` detects what this project is (frontend / backend /
98
+ data / infra) and installs a tailored set — a universal dev workflow plus archetype
99
+ add-ons — into `./.claude/skills/`, then writes a committable `skills-atlas.kit.json`.
100
+ A teammate runs `skills-atlas sync` to reproduce it exactly.
101
+
70
102
  Output is English by default; add `--zh` for Chinese, or `--json` to any command for machine-readable output.
71
103
  After installing a skill, start a new Claude Code session to load it.
72
104
 
@@ -93,6 +125,23 @@ On top of that, `install` can place a skill straight into `.claude/skills/`:
93
125
  The catalog ships inside the package and works offline. `skills-atlas update` pulls
94
126
  the latest from the public feed (cached under `~/.cache/skills-atlas/`).
95
127
 
128
+ ## Private / org catalog sources
129
+
130
+ Point the CLI at your organization's own catalog — a `data.json` in the same
131
+ schema — so internal skills show up in `search` / `info` / `install` / `kit`
132
+ alongside the public Atlas:
133
+
134
+ ```bash
135
+ skills-atlas registry add https://skills.acme.internal/data.json # or a local path
136
+ skills-atlas registry list
137
+ skills-atlas registry remove https://skills.acme.internal/data.json
138
+ ```
139
+
140
+ Private skills **merge** with the public catalog (a private source wins a same-name
141
+ clash). Sources are cached locally and merged offline. For a private URL behind
142
+ auth, set `SKILLS_ATLAS_TOKEN` (sent as a Bearer header); in CI,
143
+ `SKILLS_ATLAS_SOURCES=url1,url2` adds sources without touching config.
144
+
96
145
  ## In Claude Code
97
146
 
98
147
  A thin [Claude Code plugin](./plugin) lets Claude do all of this in-conversation —
package/bin/skills.js CHANGED
@@ -9,6 +9,9 @@ const upgrade = require('../src/commands/upgrade');
9
9
  const remove = require('../src/commands/remove');
10
10
  const outdated = require('../src/commands/outdated');
11
11
  const doctor = require('../src/commands/doctor');
12
+ const kit = require('../src/commands/kit');
13
+ const sync = require('../src/commands/sync');
14
+ const registry = require('../src/commands/registry');
12
15
  const suggest = require('../src/commands/suggest');
13
16
  const hook = require('../src/commands/hook');
14
17
  const update = require('../src/commands/update');
@@ -17,7 +20,7 @@ const { categories, list } = require('../src/commands/categories');
17
20
  const VERSION = require('../package.json').version;
18
21
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
19
22
  const use = argv => install([...argv, '--inline']);
20
- const commands = { search, info, install, use, installed, upgrade, remove, outdated, doctor, suggest, hook, update, categories, list };
23
+ const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, update, categories, list, registry };
21
24
 
22
25
  const HELP = `skills-atlas — search, install & manage AI agent skills
23
26
 
@@ -35,6 +38,8 @@ manage what you've installed:
35
38
  upgrade [skill] re-fetch to the latest (--all; refuses to clobber local edits)
36
39
  remove <skill> delete an installed skill
37
40
  doctor health check: orphans, drift, missing SKILL.md, license/script risks
41
+ kit set up the right skills for THIS project (detect + install)
42
+ sync reproduce a project's kit from skills-atlas.kit.json
38
43
 
39
44
  autopilot (opt-in):
40
45
  hook on|off|status proactively suggest a skill in Claude when your prompt fits one
@@ -43,6 +48,7 @@ catalog:
43
48
  update refresh the catalog from the public data feed
44
49
  categories list the top-level categories
45
50
  list [category] list skill groups (optionally within one category)
51
+ registry add/list/remove a private catalog source (org-internal skills)
46
52
 
47
53
  global flags: --zh (中文 output; English by default), --json (machine output), -h/--help
48
54
  docs: https://zita-go.github.io/Skills-Atlas/`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.4.2",
3
+ "version": "0.6.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",
package/src/args.js CHANGED
@@ -21,6 +21,9 @@ const ALL = {
21
21
  zh: { type: 'boolean' },
22
22
  all: { type: 'boolean' },
23
23
  inline: { type: 'boolean' },
24
+ archetype: { type: 'string' },
25
+ update: { type: 'boolean' },
26
+ name: { type: 'string' },
24
27
  help: { type: 'boolean', short: 'h' },
25
28
  };
26
29
 
@@ -0,0 +1,136 @@
1
+ // `skills-atlas kit` — detect the project's archetype(s), propose a curated skill
2
+ // kit (core dev workflow + add-ons), install to project scope after confirmation,
3
+ // and write a committable skills-atlas.kit.json. Reuses installer/manifest/index.
4
+ 'use strict';
5
+
6
+ const path = require('path');
7
+ const { parse } = require('../args');
8
+ const { loadData } = require('../data');
9
+ const { buildIndices, vendorsFor, skillDocPath } = require('../index-build');
10
+ const { installFolder } = require('../installer');
11
+ const fsu = require('../fsutil');
12
+ const km = require('../kitmanifest');
13
+ const { detect } = require('../detect');
14
+ const { buildKitPlan, ARCHETYPE_NAMES } = require('../kits');
15
+ const { confirm } = require('../prompt');
16
+ const { bold, dim, green, cyan } = require('../format');
17
+
18
+ const HELP = `usage: skills-atlas kit [options]
19
+
20
+ Detect this project's type, propose a curated skill kit, install it to
21
+ ./.claude/skills/, and write a committable skills-atlas.kit.json.
22
+
23
+ options:
24
+ --archetype <name> override detection (${ARCHETYPE_NAMES.join(', ')})
25
+ --global install to ~/.claude/skills/ instead of the project
26
+ --yes install the proposed kit without prompting
27
+ --dry-run show the proposed kit; write nothing
28
+ --json machine-readable output`;
29
+
30
+ module.exports = async function kit(argv) {
31
+ const { values } = parse(argv, ['global', 'project', 'yes', 'dry-run', 'json', 'archetype']);
32
+ if (values.help) { console.log(HELP); return; }
33
+
34
+ const projectRoot = process.cwd();
35
+ const global = Boolean(values.global); // kit defaults to PROJECT scope
36
+ const targetRoot = fsu.installTargetDir({ global });
37
+
38
+ // 1. detect (or honor --archetype)
39
+ let archetypes, signals;
40
+ if (values.archetype) {
41
+ if (!ARCHETYPE_NAMES.includes(values.archetype)) {
42
+ console.error(`unknown archetype '${values.archetype}'. known: ${ARCHETYPE_NAMES.join(', ')}`);
43
+ process.exitCode = 1; return;
44
+ }
45
+ archetypes = [values.archetype]; signals = [`--archetype ${values.archetype}`];
46
+ } else {
47
+ ({ archetypes, signals } = detect(projectRoot));
48
+ }
49
+
50
+ // 2. resolve catalog + currently-installed set, build the plan
51
+ const { data } = loadData({ quiet: values.json });
52
+ const idx = buildIndices(data);
53
+ const installed = new Set(require('../manifest').list(targetRoot).map(e => e.skill));
54
+ const plan = buildKitPlan(archetypes, installed);
55
+
56
+ // resolve each skill to an installable source (skip ones with no folder)
57
+ const items = [];
58
+ for (const s of plan.skills) {
59
+ const chosen = vendorsFor(idx.skillIndex, s.name)[0];
60
+ const docPath = chosen && skillDocPath(chosen.vendor, s.name);
61
+ items.push({ ...s, chosen, docPath, installable: Boolean(docPath) });
62
+ }
63
+ const installable = items.filter(i => i.installable && !i.installed);
64
+
65
+ // human proposal (machine mode prints one JSON object at the end instead)
66
+ if (!values.json) {
67
+ console.log(`\nDetected: ${bold(archetypes.join(' + '))} ${dim(signals.join('; '))}`);
68
+ console.log(`Proposed kit — ${installable.length} new skill(s) → ${fsu.tildify(targetRoot)}\n`);
69
+ let group = null;
70
+ for (const i of items) {
71
+ if (i.group !== group) { group = i.group; console.log(` ${group === 'core' ? 'core dev workflow' : group}`); }
72
+ const mark = i.installed ? dim('✓ installed') : i.installable ? '' : dim('(whole-repo only — skipped)');
73
+ console.log(` • ${i.name.padEnd(26)} ${dim(i.reason)} ${mark}`);
74
+ }
75
+ }
76
+ const proposal = { archetypes, scope: global ? 'global' : 'project',
77
+ skills: items.map(i => ({ name: i.name, group: i.group, installed: i.installed, installable: i.installable })) };
78
+
79
+ if (values['dry-run']) {
80
+ if (values.json) console.log(JSON.stringify(proposal, null, 2));
81
+ else console.log(dim('\n(dry run — nothing written)'));
82
+ return;
83
+ }
84
+ if (!installable.length) {
85
+ if (values.json) console.log(JSON.stringify({ ...proposal, manifest: null, installedNow: [], failed: [] }, null, 2));
86
+ else console.log(green('\n✓ nothing to install — kit already satisfied.'));
87
+ return;
88
+ }
89
+
90
+ if (!values.yes && !values.json) {
91
+ const ok = await confirm(`\nInstall ${installable.length} skill(s) and write ${km.FILE}?`, true);
92
+ if (!ok) { console.log('aborted.'); return; }
93
+ }
94
+
95
+ // 3. install (best-effort, like the chain installer)
96
+ const recorded = [];
97
+ const installedNow = [], failed = [];
98
+ for (const i of installable) {
99
+ const v = i.chosen.vendor, src = i.chosen.source;
100
+ const dest = path.join(targetRoot, i.name);
101
+ try {
102
+ const r = await installFolder({
103
+ author: v.author || src.author, repo: v.repo || src.repo,
104
+ branch: v.default_branch || src.default_branch || 'main',
105
+ docPath: i.docPath, dest, targetRoot, skillName: i.name,
106
+ source: src.name, group: i.chosen.row && i.chosen.row.group, category: i.chosen.row && i.chosen.row._cat,
107
+ });
108
+ if (!values.json) console.log(` ${green('✓')} ${i.name} ${dim(`(${r.fileCount} files)`)}`);
109
+ recorded.push({ name: i.name, source: src.name, ref: r.branchUsed, hash: r.hash, group: i.group });
110
+ installedNow.push(i.name);
111
+ } catch (e) {
112
+ if (!values.json) console.log(` ${dim('✗ ' + i.name + ' — ' + e.message)}`);
113
+ failed.push({ name: i.name, error: e.message });
114
+ }
115
+ }
116
+
117
+ // include already-installed kit members in the manifest too (so it's complete)
118
+ for (const i of items.filter(x => x.installed)) {
119
+ const rec = require('../manifest').read(targetRoot).skills[i.name] || {};
120
+ recorded.push({ name: i.name, source: rec.source || null, ref: rec.branch || null, hash: rec.hash || null, group: i.group });
121
+ }
122
+
123
+ // 4. write the committable kit manifest
124
+ const manifest = { version: 1, archetypes, scope: global ? 'global' : 'project', skills: recorded };
125
+ km.write(projectRoot, manifest);
126
+ if (failed.length) process.exitCode = 1; // surface partial/total install failure to callers/CI
127
+
128
+ if (values.json) {
129
+ console.log(JSON.stringify({ ...proposal, manifest: km.FILE, installedNow, failed }, null, 2));
130
+ } else {
131
+ console.log(`\n${green('✓')} kit ready — ${recorded.length} skill(s); wrote ${cyan(km.FILE)}`);
132
+ if (failed.length) console.log(dim(` ${failed.length} failed to install — see above.`));
133
+ console.log(dim(` teammates can reproduce it with: skills-atlas sync`));
134
+ console.log(dim(' start a new Claude Code session to load the skills.'));
135
+ }
136
+ };
@@ -0,0 +1,66 @@
1
+ // `skills-atlas registry add|list|remove` — manage private catalog sources.
2
+ 'use strict';
3
+
4
+ const { parse } = require('../args');
5
+ const { fetchSource, counts, PUBLIC_URL } = require('../data');
6
+ const registry = require('../registry');
7
+ const { green, dim, cyan, bold } = require('../format');
8
+
9
+ const HELP = `usage: skills-atlas registry <add|list|remove> [url|path]
10
+
11
+ Manage private catalog sources (a data.json in the same schema). Their skills
12
+ merge into search / info / install / kit; a private source wins a name clash.
13
+
14
+ add <url|path> add a source (--name <label>); fetches + caches it now
15
+ list show the public default + your private sources
16
+ remove <url|path> drop a source
17
+
18
+ A source can be an https URL or a local file path. For a private URL behind auth,
19
+ set SKILLS_ATLAS_TOKEN (sent as a Bearer header). CI: SKILLS_ATLAS_SOURCES=url1,url2`;
20
+
21
+ module.exports = async function registryCmd(argv) {
22
+ const { values, positionals } = parse(argv, ['json', 'name']);
23
+ if (values.help) { console.log(HELP); return; }
24
+ const sub = positionals[0] || 'list';
25
+ const url = positionals[1];
26
+
27
+ if (sub === 'list') {
28
+ const srcs = registry.effectiveSources();
29
+ if (values.json) { console.log(JSON.stringify(srcs, null, 2)); return; }
30
+ console.log(`${bold('public')} ${PUBLIC_URL} ${dim('(default)')}`);
31
+ if (!srcs.length) { console.log(dim('no private sources. add one: skills-atlas registry add <url|path>')); return; }
32
+ for (const s of srcs) {
33
+ const cached = registry.readCachedSource(s.url);
34
+ const n = cached ? Object.keys(cached.vendors || {}).length : 0;
35
+ console.log(`${green('•')} ${s.name ? bold(s.name) + ' ' : ''}${s.url} ${dim(cached ? n + ' vendors' : 'not fetched — run skills-atlas update')}`);
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (sub === 'add') {
41
+ if (!url) { console.error('usage: skills-atlas registry add <url|path>'); process.exitCode = 1; return; }
42
+ try {
43
+ const d = await fetchSource(url);
44
+ registry.cacheSource(url, d);
45
+ registry.addSource(url, { name: values.name });
46
+ const c = counts(d);
47
+ if (values.json) { console.log(JSON.stringify({ added: url, counts: c })); return; }
48
+ console.log(`${green('✓')} added private source ${cyan(url)} ${dim(`(${c.vendors} vendors, ${c.groups} groups)`)}`);
49
+ console.log(dim('its skills now appear in search / info / install / kit.'));
50
+ } catch (e) { console.error(`failed to add ${url}: ${e.message}`); process.exitCode = 1; }
51
+ return;
52
+ }
53
+
54
+ if (sub === 'remove') {
55
+ if (!url) { console.error('usage: skills-atlas registry remove <url|path>'); process.exitCode = 1; return; }
56
+ const removed = registry.removeSource(url);
57
+ registry.removeCachedSource(url);
58
+ if (values.json) { console.log(JSON.stringify({ removed: removed ? url : null })); if (!removed) process.exitCode = 1; return; }
59
+ if (removed) console.log(`${green('✓')} removed ${url}`);
60
+ else { console.error(`not a configured source: ${url}`); process.exitCode = 1; }
61
+ return;
62
+ }
63
+
64
+ console.error(`unknown registry subcommand '${sub}'. use: add | list | remove`);
65
+ process.exitCode = 1;
66
+ };
@@ -0,0 +1,91 @@
1
+ // `skills-atlas sync` — read skills-atlas.kit.json and install/upgrade the project
2
+ // to match it. Idempotent; for teammates and CI. Reuses installer + drift guard.
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const { parse } = require('../args');
7
+ const { loadData } = require('../data');
8
+ const { buildIndices, vendorsFor, skillDocPath } = require('../index-build');
9
+ const { installFolder } = require('../installer');
10
+ const fsu = require('../fsutil');
11
+ const km = require('../kitmanifest');
12
+ const { green, dim, cyan } = require('../format');
13
+
14
+ const HELP = `usage: skills-atlas sync [options]
15
+
16
+ Read skills-atlas.kit.json and install/upgrade this project's skills to match it.
17
+
18
+ options:
19
+ --global sync to ~/.claude/skills/ instead of the project
20
+ --force overwrite skills with local edits
21
+ --update re-fetch every skill to latest and rewrite the manifest
22
+ --dry-run show what would change; write nothing
23
+ --json machine-readable output`;
24
+
25
+ module.exports = async function sync(argv) {
26
+ const { values } = parse(argv, ['global', 'project', 'force', 'update', 'dry-run', 'json']);
27
+ if (values.help) { console.log(HELP); return; }
28
+
29
+ const projectRoot = process.cwd();
30
+ const manifest = km.read(projectRoot);
31
+ if (!manifest) {
32
+ console.error(`no ${km.FILE} here. run \`skills-atlas kit\` first.`);
33
+ process.exitCode = 1; return;
34
+ }
35
+ const targetRoot = fsu.installTargetDir({ global: Boolean(values.global) });
36
+
37
+ const currentHash = name => {
38
+ const dest = path.join(targetRoot, name);
39
+ if (!fsu.dirExists(dest)) return null;
40
+ try { return fsu.hashDir(dest); } catch { return null; }
41
+ };
42
+ const actions = km.planSync(manifest, currentHash);
43
+
44
+ if (values['dry-run']) {
45
+ for (const a of actions) console.log(` ${a.action.padEnd(14)} ${a.name}`);
46
+ console.log(dim('\n(dry run — nothing written)'));
47
+ return;
48
+ }
49
+
50
+ const { data } = loadData({ quiet: values.json });
51
+ const idx = buildIndices(data);
52
+ const results = [];
53
+
54
+ for (const a of actions) {
55
+ const rec = { name: a.name, action: a.action };
56
+ if (a.action === 'up-to-date' && !values.update) { results.push({ ...rec, status: 'up-to-date' }); continue; }
57
+ if (a.action === 'local-changes' && !values.force) { results.push({ ...rec, status: 'local-changes' }); continue; }
58
+
59
+ const chosen = vendorsFor(idx.skillIndex, a.name).find(c => c.source.name === a.declared.source)
60
+ || vendorsFor(idx.skillIndex, a.name)[0];
61
+ const docPath = chosen && skillDocPath(chosen.vendor, a.name);
62
+ if (!docPath) { results.push({ ...rec, status: 'no-folder' }); continue; }
63
+
64
+ const v = chosen.vendor, src = chosen.source;
65
+ const dest = path.join(targetRoot, a.name);
66
+ try {
67
+ const r = await installFolder({
68
+ author: v.author || src.author, repo: v.repo || src.repo,
69
+ branch: a.declared.ref || v.default_branch || src.default_branch || 'main',
70
+ docPath, dest, targetRoot, skillName: a.name,
71
+ source: src.name, group: chosen.row && chosen.row.group, category: chosen.row && chosen.row._cat,
72
+ });
73
+ results.push({ ...rec, status: 'installed', hash: r.hash });
74
+ if (values.update) { a.declared.hash = r.hash; a.declared.ref = r.branchUsed; a.declared.source = src.name; }
75
+ } catch (e) {
76
+ results.push({ ...rec, status: 'failed', error: e.message });
77
+ }
78
+ }
79
+
80
+ if (values.update) km.write(projectRoot, manifest);
81
+ if (results.some(r => r.status === 'failed')) process.exitCode = 1; // CI-visible failure
82
+
83
+ if (values.json) { console.log(JSON.stringify(results, null, 2)); return; }
84
+ for (const r of results) {
85
+ const sym = r.status === 'installed' ? green('✓') : r.status === 'up-to-date' ? dim('=') : r.status === 'local-changes' ? '✋' : '✗';
86
+ console.log(` ${sym} ${r.name} ${dim(r.status + (r.error ? ': ' + r.error : ''))}`);
87
+ }
88
+ const n = results.filter(r => r.status === 'installed').length;
89
+ console.log(`\n${n} installed/updated from ${cyan(km.FILE)}.`);
90
+ if (results.some(r => r.status === 'local-changes')) console.log(dim('skipped local edits — re-run with --force.'));
91
+ };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { parse } = require('../args');
4
- const { refreshData } = require('../data');
4
+ const { refreshData, refreshSources } = require('../data');
5
5
 
6
6
  module.exports = async function update(argv) {
7
7
  const { values } = parse(argv, ['json']);
@@ -12,14 +12,20 @@ module.exports = async function update(argv) {
12
12
 
13
13
  try {
14
14
  const res = await refreshData();
15
- if (values.json) { console.log(JSON.stringify(res, null, 2)); return; }
15
+ const sources = await refreshSources();
16
+ if (values.json) { console.log(JSON.stringify({ ...res, sources }, null, 2)); return; }
16
17
  if (!res.changed) {
17
18
  console.log(`catalog already up to date (${res.counts.vendors} vendors, ${res.counts.groups} groups).`);
18
- return;
19
+ } else {
20
+ const p = res.prevCounts;
21
+ const was = p ? ` ${'(was ' + p.vendors + ' vendors / ' + p.groups + ' groups)'}` : '';
22
+ console.log(`updated: ${res.counts.sections} sections, ${res.counts.groups} groups, ${res.counts.vendors} vendors.${was}`);
23
+ }
24
+ if (sources.length) {
25
+ const ok = sources.filter(s => s.ok).length;
26
+ const failed = sources.filter(s => !s.ok).map(s => s.url);
27
+ console.log(`private sources: ${ok}/${sources.length} refreshed${failed.length ? ' (failed: ' + failed.join(', ') + ')' : ''}.`);
19
28
  }
20
- const p = res.prevCounts;
21
- const was = p ? ` ${'(was ' + p.vendors + ' vendors / ' + p.groups + ' groups)'}` : '';
22
- console.log(`updated: ${res.counts.sections} sections, ${res.counts.groups} groups, ${res.counts.vendors} vendors.${was}`);
23
29
  } catch (e) {
24
30
  console.error(`update failed: ${e.message}`);
25
31
  process.exitCode = 1;
package/src/data.js CHANGED
@@ -9,6 +9,9 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
11
 
12
+ const registry = require('./registry');
13
+ const { mergeCatalogs } = require('./merge');
14
+
12
15
  const PUBLIC_URL = 'https://zita-go.github.io/Skills-Atlas/data.json';
13
16
  const UA = 'skills-atlas-cli';
14
17
  const STALE_DAYS = 30;
@@ -75,13 +78,15 @@ function maybeStaleNudge(fromCache) {
75
78
 
76
79
  function loadData({ quiet = false } = {}) {
77
80
  const cached = tryReadJSON(cacheFile());
78
- if (isValid(cached)) {
79
- if (!quiet) maybeStaleNudge(true);
80
- return { data: cached, source: 'cache', fromCache: true };
81
- }
82
- const b = loadBundled();
83
- if (!quiet) maybeStaleNudge(false);
84
- return { ...b, fromCache: false };
81
+ let base, info;
82
+ if (isValid(cached)) { base = cached; info = { source: 'cache', fromCache: true }; if (!quiet) maybeStaleNudge(true); }
83
+ else { const b = loadBundled(); base = b.data; info = { source: b.source, fromCache: false }; if (!quiet) maybeStaleNudge(false); }
84
+
85
+ const overlays = registry.effectiveSources()
86
+ .map(s => registry.readCachedSource(s.url))
87
+ .filter(isValid);
88
+ if (!overlays.length) return { data: base, ...info };
89
+ return { data: mergeCatalogs(base, overlays), source: `${info.source}+${overlays.length}private`, fromCache: info.fromCache };
85
90
  }
86
91
 
87
92
  function ensureDir(d) {
@@ -147,4 +152,40 @@ async function refreshData() {
147
152
  };
148
153
  }
149
154
 
150
- module.exports = { loadData, refreshData, counts, cacheDir, PUBLIC_URL };
155
+ // Fetch (or read, for a local path / file:// URL) a private catalog source and
156
+ // validate it. Honors SKILLS_ATLAS_TOKEN (Bearer) and SKILLS_ATLAS_TIMEOUT_MS.
157
+ async function fetchSource(src) {
158
+ if (!/^https?:\/\//i.test(src)) { // local file path or file:// URL
159
+ const p = src.replace(/^file:\/\//, '');
160
+ let d;
161
+ try { d = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { throw new Error(`${src}: ${e.message}`); }
162
+ if (!isValid(d)) throw new Error(`${src}: not a valid catalog (sections/vendors)`);
163
+ return d;
164
+ }
165
+ const headers = { 'User-Agent': UA, Accept: 'application/json' };
166
+ if (process.env.SKILLS_ATLAS_TOKEN) headers.Authorization = `Bearer ${process.env.SKILLS_ATLAS_TOKEN}`;
167
+ const ac = new AbortController();
168
+ const timeoutMs = Number(process.env.SKILLS_ATLAS_TIMEOUT_MS) || 25000;
169
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
170
+ let res;
171
+ try { res = await fetch(src, { headers, signal: ac.signal }); }
172
+ catch (e) { throw new Error(e.name === 'AbortError' ? `timed out fetching ${src}` : `network error fetching ${src}: ${e.message}`); }
173
+ finally { clearTimeout(timer); }
174
+ if (!res.ok) throw new Error(`fetch failed: HTTP ${res.status} ${res.statusText} (${src})`);
175
+ let d;
176
+ try { d = JSON.parse(await res.text()); } catch { throw new Error(`${src}: not valid JSON`); }
177
+ if (!isValid(d)) throw new Error(`${src}: not a valid catalog (sections/vendors)`);
178
+ return d;
179
+ }
180
+
181
+ // Refresh every configured private source into its local cache (best-effort).
182
+ async function refreshSources() {
183
+ const out = [];
184
+ for (const s of registry.effectiveSources()) {
185
+ try { const d = await fetchSource(s.url); registry.cacheSource(s.url, d); out.push({ url: s.url, ok: true, counts: counts(d) }); }
186
+ catch (e) { out.push({ url: s.url, ok: false, error: e.message }); }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ module.exports = { loadData, refreshData, fetchSource, refreshSources, counts, cacheDir, PUBLIC_URL };
package/src/detect.js ADDED
@@ -0,0 +1,47 @@
1
+ // Detect a project's archetype(s) from on-disk signals. Best-effort and dependency
2
+ // free; always returns at least ['generic']. Pure w.r.t. its `dir` argument.
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
9
+ const readJson = p => { const t = readText(p); if (!t) return null; try { return JSON.parse(t); } catch { return null; } };
10
+ const listdir = d => { try { return fs.readdirSync(d); } catch { return []; } };
11
+
12
+ function detect(dir = process.cwd()) {
13
+ const archetypes = new Set();
14
+ const signals = [];
15
+ const add = (a, why) => { archetypes.add(a); signals.push(why); };
16
+
17
+ // --- Node / package.json ---
18
+ const pkg = readJson(path.join(dir, 'package.json'));
19
+ if (pkg) {
20
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
21
+ const names = Object.keys(deps);
22
+ const anyDep = res => names.some(d => res.some(re => re.test(d)));
23
+ if (anyDep([/^react(-dom)?$/, /^vue$/, /^svelte$/, /^next$/, /^nuxt$/, /^vite$/, /^@angular\//, /^solid-js$/]))
24
+ add('web-frontend', 'package.json: a frontend framework (react/vue/next/…)');
25
+ if (anyDep([/^express$/, /^fastify$/, /^@nestjs\//, /^koa$/, /^hapi$/]))
26
+ add('backend-service', 'package.json: a web-server framework');
27
+ if (pkg.bin) add('cli-library', 'package.json: bin field (CLI)');
28
+ }
29
+
30
+ // --- Python ---
31
+ const py = `${readText(path.join(dir, 'pyproject.toml')) || ''}\n${readText(path.join(dir, 'requirements.txt')) || ''}`.toLowerCase();
32
+ const hasNotebook = listdir(dir).some(f => f.endsWith('.ipynb'));
33
+ if (/pandas|numpy|scikit-learn|tensorflow|torch|jupyter/.test(py) || hasNotebook)
34
+ add('data-ml', 'python: data/ML libraries or notebooks');
35
+ if (/fastapi|flask|django/.test(py)) add('backend-service', 'python: a web framework');
36
+
37
+ // --- Infra ---
38
+ const entries = listdir(dir);
39
+ if (entries.some(f => f.endsWith('.tf')) ||
40
+ fs.existsSync(path.join(dir, 'Dockerfile')) ||
41
+ entries.some(f => /^docker-compose\.ya?ml$/.test(f)))
42
+ add('infra-devops', 'infra: terraform/docker files');
43
+
44
+ return { archetypes: archetypes.size ? [...archetypes] : ['generic'], signals };
45
+ }
46
+
47
+ module.exports = { detect };
@@ -53,6 +53,9 @@ function vendorsFor(skillIndex, skillName) {
53
53
  if (id && !seen.has(id)) seen.set(id, e);
54
54
  }
55
55
  return [...seen.values()].sort((a, b) => {
56
+ const pa = a.vendor && a.vendor._private ? 1 : 0;
57
+ const pb = b.vendor && b.vendor._private ? 1 : 0;
58
+ if (pa !== pb) return pb - pa; // private sources first (registry override)
56
59
  const da = skillDocPath(a.vendor, a.skill) ? 1 : 0;
57
60
  const db = skillDocPath(b.vendor, b.skill) ? 1 : 0;
58
61
  if (da !== db) return db - da;
@@ -0,0 +1,36 @@
1
+ // The committable, project-root kit declaration: skills-atlas.kit.json.
2
+ // Distinct from the per-dir install ledger (.skills-atlas.json): this is the
3
+ // *intent* ("the kit for this project is X"), committed and shared; `sync` applies it.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const FILE = 'skills-atlas.kit.json';
10
+ const fileFor = root => path.join(root, FILE);
11
+
12
+ function read(root) {
13
+ try { return JSON.parse(fs.readFileSync(fileFor(root), 'utf8')); } catch { return null; }
14
+ }
15
+
16
+ function write(root, m) {
17
+ fs.writeFileSync(fileFor(root), JSON.stringify(m, null, 2) + '\n');
18
+ }
19
+
20
+ // Decide what `sync` should do per declared skill. `currentHash(name)` returns the
21
+ // on-disk content hash, or null/undefined if the skill isn't installed.
22
+ // not installed -> 'install'
23
+ // installed, hash matches -> 'up-to-date'
24
+ // installed, hash differs -> 'local-changes' (don't clobber edits without --force)
25
+ function planSync(manifest, currentHash) {
26
+ return (manifest.skills || []).map(s => {
27
+ const cur = currentHash(s.name);
28
+ let action;
29
+ if (!cur) action = 'install';
30
+ else if (!s.hash || cur === s.hash) action = 'up-to-date';
31
+ else action = 'local-changes';
32
+ return { name: s.name, action, declared: s };
33
+ });
34
+ }
35
+
36
+ module.exports = { FILE, fileFor, read, write, planSync };
package/src/kits.js ADDED
@@ -0,0 +1,62 @@
1
+ // Curated project kits: a universal core dev workflow plus per-archetype add-ons.
2
+ // Each skill carries a one-line reason. EVERY name here must exist in the catalog
3
+ // (enforced by test/kits.test.js — it already caught a non-existent 'openapi').
4
+ 'use strict';
5
+
6
+ // [skillName, reason]
7
+ const CORE = [
8
+ ['brainstorming', 'turn an idea into a spec'],
9
+ ['writing-plans', 'break the work into a plan'],
10
+ ['executing-plans', 'run the plan task by task'],
11
+ ['systematic-debugging', 'root-cause a stubborn bug'],
12
+ ['test-driven-development', 'write tests first'],
13
+ ['requesting-code-review', 'get a structured review'],
14
+ ];
15
+
16
+ const ARCHETYPES = {
17
+ 'web-frontend': [
18
+ ['frontend-design', 'component/layout guidance'],
19
+ ['web-design-guidelines', 'accessibility + performance'],
20
+ ['webapp-testing', 'browser/E2E testing'],
21
+ ],
22
+ 'backend-service': [
23
+ ['sql-queries', 'natural-language → SQL'],
24
+ ['mcp-builder', 'build an MCP server'],
25
+ ],
26
+ 'data-ml': [
27
+ ['data-analysis', 'analyze datasets'],
28
+ ['exploratory-data-analysis', 'EDA workflow'],
29
+ ],
30
+ 'infra-devops': [
31
+ ['security-best-practices', 'secure coding + threat modeling'],
32
+ ['using-git-worktrees', 'isolated workspace workflow'],
33
+ ],
34
+ 'cli-library': [],
35
+ 'generic': [],
36
+ };
37
+
38
+ const ARCHETYPE_NAMES = Object.keys(ARCHETYPES);
39
+
40
+ // archetypes[] -> [{ name, reason, group }] (core first, then add-ons, deduped)
41
+ function kitFor(archetypes) {
42
+ const out = CORE.map(([name, reason]) => ({ name, reason, group: 'core' }));
43
+ const seen = new Set(out.map(s => s.name));
44
+ for (const a of archetypes || []) {
45
+ for (const [name, reason] of (ARCHETYPES[a] || [])) {
46
+ if (!seen.has(name)) { seen.add(name); out.push({ name, reason, group: a }); }
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Add an `installed` flag (from a set of already-present skill names).
53
+ function buildKitPlan(archetypes, installedNames = new Set()) {
54
+ const skills = kitFor(archetypes).map(s => ({ ...s, installed: installedNames.has(s.name) }));
55
+ return { archetypes: [...archetypes], skills };
56
+ }
57
+
58
+ const allKitSkills = () => [
59
+ ...new Set([...CORE.map(c => c[0]), ...Object.values(ARCHETYPES).flat().map(s => s[0])]),
60
+ ];
61
+
62
+ module.exports = { CORE, ARCHETYPES, ARCHETYPE_NAMES, kitFor, buildKitPlan, allKitSkills };
package/src/merge.js ADDED
@@ -0,0 +1,17 @@
1
+ // Merge a base catalog with private overlays. Private vendors are tagged `_private`
2
+ // so `vendorsFor` ranks them first on a same-name clash. Pure — returns a new object,
3
+ // never mutates inputs, and the result lives only in memory (never cached).
4
+ 'use strict';
5
+
6
+ function mergeCatalogs(base, overlays = []) {
7
+ const vendors = { ...(base.vendors || {}) };
8
+ const sections = [...(base.sections || [])];
9
+ for (const ov of overlays) {
10
+ if (!ov) continue;
11
+ for (const [id, v] of Object.entries(ov.vendors || {})) vendors[id] = { ...v, _private: true };
12
+ for (const s of (ov.sections || [])) sections.push(s);
13
+ }
14
+ return { sections, vendors };
15
+ }
16
+
17
+ module.exports = { mergeCatalogs };
@@ -0,0 +1,78 @@
1
+ // Private catalog sources ("registry"): user config + per-source local cache, so
2
+ // an org can point the CLI at its own data.json alongside the public Atlas.
3
+ // Self-contained (own XDG path helpers) to avoid a require cycle with data.js.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+
11
+ function configDir() {
12
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
13
+ return path.join(base, 'skills-atlas');
14
+ }
15
+ const configFile = () => path.join(configDir(), 'config.json');
16
+
17
+ function cacheDir() {
18
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
19
+ return path.join(base, 'skills-atlas');
20
+ }
21
+ const sourcesDir = () => path.join(cacheDir(), 'sources');
22
+
23
+ function readConfig() {
24
+ try {
25
+ const c = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
26
+ if (!Array.isArray(c.sources)) c.sources = [];
27
+ return c;
28
+ } catch { return { version: 1, sources: [] }; }
29
+ }
30
+ function writeConfig(c) {
31
+ fs.mkdirSync(configDir(), { recursive: true });
32
+ fs.writeFileSync(configFile(), JSON.stringify(c, null, 2) + '\n');
33
+ }
34
+ function addSource(url, opts = {}) {
35
+ const c = readConfig();
36
+ const existing = c.sources.find(s => s.url === url);
37
+ if (existing) { if (opts.name) existing.name = opts.name; }
38
+ else c.sources.push({ url, name: opts.name || null, addedAt: new Date().toISOString() });
39
+ writeConfig(c);
40
+ }
41
+ function removeSource(url) {
42
+ const c = readConfig();
43
+ const before = c.sources.length;
44
+ c.sources = c.sources.filter(s => s.url !== url);
45
+ writeConfig(c);
46
+ return c.sources.length < before;
47
+ }
48
+ const listSources = () => readConfig().sources;
49
+
50
+ const envSources = () => (process.env.SKILLS_ATLAS_SOURCES || '')
51
+ .split(',').map(s => s.trim()).filter(Boolean);
52
+
53
+ function effectiveSources() {
54
+ const out = [], seen = new Set();
55
+ for (const s of listSources()) if (s.url && !seen.has(s.url)) { seen.add(s.url); out.push({ url: s.url, name: s.name || null }); }
56
+ for (const u of envSources()) if (!seen.has(u)) { seen.add(u); out.push({ url: u, name: null }); }
57
+ return out;
58
+ }
59
+
60
+ function sourceCachePath(url) {
61
+ const h = crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
62
+ return path.join(sourcesDir(), `${h}.json`);
63
+ }
64
+ function cacheSource(url, dataObj) {
65
+ fs.mkdirSync(sourcesDir(), { recursive: true });
66
+ fs.writeFileSync(sourceCachePath(url), JSON.stringify(dataObj));
67
+ }
68
+ function readCachedSource(url) {
69
+ try { return JSON.parse(fs.readFileSync(sourceCachePath(url), 'utf8')); } catch { return null; }
70
+ }
71
+ function removeCachedSource(url) {
72
+ try { fs.rmSync(sourceCachePath(url), { force: true }); } catch { /* ignore */ }
73
+ }
74
+
75
+ module.exports = {
76
+ configDir, configFile, readConfig, writeConfig, addSource, removeSource, listSources,
77
+ effectiveSources, sourceCachePath, cacheSource, readCachedSource, removeCachedSource,
78
+ };