skills-atlas-cli 0.5.0 → 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
@@ -125,6 +125,23 @@ On top of that, `install` can place a skill straight into `.claude/skills/`:
125
125
  The catalog ships inside the package and works offline. `skills-atlas update` pulls
126
126
  the latest from the public feed (cached under `~/.cache/skills-atlas/`).
127
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
+
128
145
  ## In Claude Code
129
146
 
130
147
  A thin [Claude Code plugin](./plugin) lets Claude do all of this in-conversation —
package/bin/skills.js CHANGED
@@ -11,6 +11,7 @@ const outdated = require('../src/commands/outdated');
11
11
  const doctor = require('../src/commands/doctor');
12
12
  const kit = require('../src/commands/kit');
13
13
  const sync = require('../src/commands/sync');
14
+ const registry = require('../src/commands/registry');
14
15
  const suggest = require('../src/commands/suggest');
15
16
  const hook = require('../src/commands/hook');
16
17
  const update = require('../src/commands/update');
@@ -19,7 +20,7 @@ const { categories, list } = require('../src/commands/categories');
19
20
  const VERSION = require('../package.json').version;
20
21
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
21
22
  const use = argv => install([...argv, '--inline']);
22
- const commands = { search, info, install, use, kit, sync, 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 };
23
24
 
24
25
  const HELP = `skills-atlas — search, install & manage AI agent skills
25
26
 
@@ -47,6 +48,7 @@ catalog:
47
48
  update refresh the catalog from the public data feed
48
49
  categories list the top-level categories
49
50
  list [category] list skill groups (optionally within one category)
51
+ registry add/list/remove a private catalog source (org-internal skills)
50
52
 
51
53
  global flags: --zh (中文 output; English by default), --json (machine output), -h/--help
52
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.5.0",
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
@@ -23,6 +23,7 @@ const ALL = {
23
23
  inline: { type: 'boolean' },
24
24
  archetype: { type: 'string' },
25
25
  update: { type: 'boolean' },
26
+ name: { type: 'string' },
26
27
  help: { type: 'boolean', short: 'h' },
27
28
  };
28
29
 
@@ -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
+ };
@@ -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 };
@@ -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;
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
+ };