skills-atlas-cli 0.1.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 +58 -0
- package/bin/skills.js +46 -0
- package/data.json +15568 -0
- package/package.json +41 -0
- package/src/args.js +33 -0
- package/src/commands/categories.js +63 -0
- package/src/commands/info.js +46 -0
- package/src/commands/install.js +179 -0
- package/src/commands/search.js +69 -0
- package/src/commands/update.js +27 -0
- package/src/data.js +150 -0
- package/src/format.js +104 -0
- package/src/fsutil.js +52 -0
- package/src/github.js +126 -0
- package/src/index-build.js +102 -0
- package/src/prompt.js +37 -0
- package/src/search-core.js +114 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skills-atlas-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Search, install and learn AI agent skills from the terminal — powered by the Skills Atlas catalog.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"skills-atlas": "bin/skills.js",
|
|
7
|
+
"sa": "bin/skills.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"data.json",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node build.js",
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"prepublishOnly": "node build.js"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"skills",
|
|
27
|
+
"agent-skills",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex",
|
|
30
|
+
"cli",
|
|
31
|
+
"install",
|
|
32
|
+
"skills-atlas"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"homepage": "https://zita-go.github.io/Skills-Atlas/",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/Zita-Go/Skills-Atlas.git",
|
|
39
|
+
"directory": "packages/skills-atlas-cli"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Thin wrapper over node:util.parseArgs with a shared option dictionary so each
|
|
2
|
+
// command declares only the flags it accepts (typos on others still error).
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { parseArgs } = require('node:util');
|
|
6
|
+
|
|
7
|
+
const ALL = {
|
|
8
|
+
category: { type: 'string', short: 'c' },
|
|
9
|
+
persona: { type: 'string', short: 'p' },
|
|
10
|
+
type: { type: 'string', short: 't' },
|
|
11
|
+
chain: { type: 'boolean' },
|
|
12
|
+
limit: { type: 'string' },
|
|
13
|
+
global: { type: 'boolean', short: 'g' },
|
|
14
|
+
project: { type: 'boolean' },
|
|
15
|
+
source: { type: 'string', short: 's' },
|
|
16
|
+
force: { type: 'boolean', short: 'f' },
|
|
17
|
+
yes: { type: 'boolean', short: 'y' },
|
|
18
|
+
'dry-run': { type: 'boolean' },
|
|
19
|
+
json: { type: 'boolean' },
|
|
20
|
+
en: { type: 'boolean' },
|
|
21
|
+
help: { type: 'boolean', short: 'h' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function parse(argv, allowed) {
|
|
25
|
+
const options = { help: ALL.help, en: ALL.en };
|
|
26
|
+
for (const k of allowed) {
|
|
27
|
+
if (!ALL[k]) throw new Error(`unknown option spec: ${k}`);
|
|
28
|
+
options[k] = ALL[k];
|
|
29
|
+
}
|
|
30
|
+
return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { parse };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const { loadData } = require('../data');
|
|
5
|
+
const { bold, dim, cyan } = require('../format');
|
|
6
|
+
|
|
7
|
+
const loose = (hay, needle) =>
|
|
8
|
+
String(hay || '').toLowerCase().includes(String(needle).toLowerCase());
|
|
9
|
+
|
|
10
|
+
async function categories(argv) {
|
|
11
|
+
const { values } = parse(argv, ['json']);
|
|
12
|
+
if (values.help) { console.log('usage: skills-atlas categories [--json] [--en]'); return; }
|
|
13
|
+
|
|
14
|
+
const en = Boolean(values.en);
|
|
15
|
+
const { data } = loadData({ quiet: values.json });
|
|
16
|
+
const cats = data.sections.map(s => ({
|
|
17
|
+
title: en ? (s.title_en || s.title) : s.title,
|
|
18
|
+
icon: s.icon || '',
|
|
19
|
+
subgroups: s.subsections.length,
|
|
20
|
+
groups: s.subsections.reduce((n, ss) => n + ss.rows.length, 0),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
if (values.json) { console.log(JSON.stringify(cats, null, 2)); return; }
|
|
24
|
+
cats.forEach(c =>
|
|
25
|
+
console.log(`${c.icon} ${bold(c.title)} ${dim(`(${c.subgroups} subgroups, ${c.groups} groups)`)}`));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function list(argv) {
|
|
29
|
+
const { values, positionals } = parse(argv, ['category', 'json']);
|
|
30
|
+
if (values.help) { console.log('usage: skills-atlas list [category] [--json] [--en]'); return; }
|
|
31
|
+
|
|
32
|
+
const en = Boolean(values.en);
|
|
33
|
+
const filter = (positionals.join(' ') || values.category || '').trim();
|
|
34
|
+
const { data } = loadData({ quiet: values.json });
|
|
35
|
+
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const s of data.sections) {
|
|
38
|
+
if (filter && !(loose(s.title, filter) || loose(s.title_en, filter))) continue;
|
|
39
|
+
out.push({
|
|
40
|
+
section: en ? (s.title_en || s.title) : s.title,
|
|
41
|
+
groups: s.subsections.flatMap(ss => ss.rows.map(r => ({
|
|
42
|
+
group: en ? (r.group_en || r.group) : r.group,
|
|
43
|
+
skills: r.skills,
|
|
44
|
+
chain: r.chain,
|
|
45
|
+
}))),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (values.json) { console.log(JSON.stringify(out, null, 2)); return; }
|
|
50
|
+
if (!out.length) {
|
|
51
|
+
console.error(`no category matching '${filter}'. run: skills-atlas categories`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const sec of out) {
|
|
56
|
+
console.log(`\n${bold(sec.section)}`);
|
|
57
|
+
for (const g of sec.groups) {
|
|
58
|
+
console.log(` ${g.chain ? cyan('⛓ ') : ''}${g.group} ${dim(g.skills.join(', '))}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { categories, list };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const { loadData } = require('../data');
|
|
5
|
+
const { buildIndices, suggestSkills } = require('../index-build');
|
|
6
|
+
const { buildInfo, renderInfo } = require('../format');
|
|
7
|
+
|
|
8
|
+
const HELP = `usage: skills-atlas info <skill> [--json] [--en]
|
|
9
|
+
|
|
10
|
+
Show a skill's description, use case, when-to-use, personas, source repo(s)
|
|
11
|
+
(stars / license / type), the in-repo SKILL.md path, and the install command.`;
|
|
12
|
+
|
|
13
|
+
module.exports = async function info(argv) {
|
|
14
|
+
const { values, positionals } = parse(argv, ['json']);
|
|
15
|
+
if (values.help) { console.log(HELP); return; }
|
|
16
|
+
|
|
17
|
+
const name = positionals[0];
|
|
18
|
+
if (!name) {
|
|
19
|
+
console.error('usage: skills-atlas info <skill>');
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { data } = loadData({ quiet: values.json });
|
|
25
|
+
const { skillIndex } = buildIndices(data);
|
|
26
|
+
const infoObj = buildInfo(name, { skillIndex, vendors: data.vendors });
|
|
27
|
+
|
|
28
|
+
if (!infoObj.found) {
|
|
29
|
+
if (values.json) {
|
|
30
|
+
console.log(JSON.stringify(infoObj, null, 2));
|
|
31
|
+
} else {
|
|
32
|
+
const sugg = suggestSkills(skillIndex, name);
|
|
33
|
+
console.error(`skill '${name}' not found.`);
|
|
34
|
+
if (sugg.length) console.error(`did you mean: ${sugg.join(', ')}`);
|
|
35
|
+
console.error(`try: skills-atlas search ${name}`);
|
|
36
|
+
}
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (values.json) {
|
|
42
|
+
console.log(JSON.stringify(infoObj, null, 2));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log(renderInfo(infoObj, { en: Boolean(values.en) }));
|
|
46
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { parse } = require('../args');
|
|
5
|
+
const { loadData } = require('../data');
|
|
6
|
+
const { buildIndices, vendorsFor, suggestSkills, skillDocPath } = require('../index-build');
|
|
7
|
+
const { listSkillFiles, fetchRaw } = require('../github');
|
|
8
|
+
const fsu = require('../fsutil');
|
|
9
|
+
const { confirm, choose } = require('../prompt');
|
|
10
|
+
const { buildInfo, renderInfo, bold, dim, cyan, green, stars } = require('../format');
|
|
11
|
+
|
|
12
|
+
const HELP = `usage: skills-atlas install <skill> [options]
|
|
13
|
+
|
|
14
|
+
options:
|
|
15
|
+
-g, --global install to ~/.claude/skills/ (default)
|
|
16
|
+
--project install to ./.claude/skills/
|
|
17
|
+
-s, --source <id> pick a source when a skill has several
|
|
18
|
+
-f, --force overwrite if already installed
|
|
19
|
+
-y, --yes non-interactive (auto-pick top source, assume yes)
|
|
20
|
+
--dry-run show what would download, write nothing
|
|
21
|
+
--json machine-readable output`;
|
|
22
|
+
|
|
23
|
+
function resolveGlobal(values) {
|
|
24
|
+
if (values.project) return false;
|
|
25
|
+
if (values.global) return true;
|
|
26
|
+
return true; // default: global
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = async function install(argv) {
|
|
30
|
+
const { values, positionals } = parse(argv,
|
|
31
|
+
['global', 'project', 'source', 'force', 'yes', 'dry-run', 'json']);
|
|
32
|
+
if (values.help) { console.log(HELP); return; }
|
|
33
|
+
|
|
34
|
+
const name = positionals[0];
|
|
35
|
+
if (!name) {
|
|
36
|
+
console.error('usage: skills-atlas install <skill> [--global|--project] [--source <id>] [--force] [--yes]');
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { data } = loadData({ quiet: values.json });
|
|
42
|
+
const idx = buildIndices(data);
|
|
43
|
+
const candidates = vendorsFor(idx.skillIndex, name); // distinct vendors, best first
|
|
44
|
+
|
|
45
|
+
if (candidates.length === 0) {
|
|
46
|
+
const sugg = suggestSkills(idx.skillIndex, name);
|
|
47
|
+
console.error(`skill '${name}' not found.`);
|
|
48
|
+
if (sugg.length) console.error(`did you mean: ${sugg.join(', ')}`);
|
|
49
|
+
console.error(`try: skills-atlas search ${name}`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- pick the source vendor ---
|
|
55
|
+
let chosen;
|
|
56
|
+
if (values.source) {
|
|
57
|
+
chosen = candidates.find(c => c.source.name.toLowerCase() === values.source.toLowerCase());
|
|
58
|
+
if (!chosen) {
|
|
59
|
+
console.error(`source '${values.source}' does not provide '${name}'.`);
|
|
60
|
+
console.error(`available: ${candidates.map(c => c.source.name).join(', ')}`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
} else if (candidates.length === 1 || values.yes) {
|
|
65
|
+
chosen = candidates[0];
|
|
66
|
+
} else {
|
|
67
|
+
const labels = candidates.map(c =>
|
|
68
|
+
`${c.source.name} ${stars(c.source.stars)} ${c.source.type || ''} ${(c.source.install && c.source.install.command) || ''}`);
|
|
69
|
+
const i = await choose(`'${name}' is available from ${candidates.length} sources:`, labels);
|
|
70
|
+
if (i < 0) {
|
|
71
|
+
console.error(`multiple sources for '${name}'. re-run with --source <id> or --yes.`);
|
|
72
|
+
console.error(`sources: ${candidates.map(c => c.source.name).join(', ')}`);
|
|
73
|
+
process.exitCode = 2;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
chosen = candidates[i];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const v = chosen.vendor || {};
|
|
80
|
+
const src = chosen.source;
|
|
81
|
+
const skill = chosen.skill || name; // canonical skill name from the index
|
|
82
|
+
const author = v.author || src.author;
|
|
83
|
+
const repo = v.repo || src.repo;
|
|
84
|
+
const branch = v.default_branch || src.default_branch || 'main';
|
|
85
|
+
const docPath = skillDocPath(v, skill);
|
|
86
|
+
const installCmd = src.install || v.install || null;
|
|
87
|
+
|
|
88
|
+
// --- fallback: no per-skill folder -> whole-repo installer (valid outcome) ---
|
|
89
|
+
if (!docPath) {
|
|
90
|
+
if (values.json) {
|
|
91
|
+
console.log(JSON.stringify({ skill: name, mode: 'whole-repo', source: src.name, install: installCmd }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
console.log(`\n'${name}' from ${bold(src.name)} (type=${src.type}) installs the whole repo, not a single folder.`);
|
|
95
|
+
if (installCmd && installCmd.command) {
|
|
96
|
+
console.log(`run:\n ${cyan(installCmd.command)}`);
|
|
97
|
+
if (installCmd.alt) console.log(`alt:\n ${installCmd.alt}`);
|
|
98
|
+
if (installCmd.note) console.log(dim(' ' + installCmd.note));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(`see ${src.url}`);
|
|
101
|
+
}
|
|
102
|
+
return; // exit 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!author || !repo) {
|
|
106
|
+
console.error(`missing author/repo for source '${src.name}'; cannot download.`);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const targetRoot = fsu.installTargetDir({ global: resolveGlobal(values) });
|
|
112
|
+
const dest = path.join(targetRoot, skill); // folder = skill name (strips repo nesting)
|
|
113
|
+
|
|
114
|
+
// --- already installed? ---
|
|
115
|
+
if (fsu.dirExists(dest) && !values['dry-run']) {
|
|
116
|
+
if (!values.force) {
|
|
117
|
+
if (values.yes) {
|
|
118
|
+
console.error(`'${name}' already installed at ${fsu.tildify(dest)} (use --force to overwrite).`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const ok = await confirm(`${fsu.tildify(dest)} exists. overwrite?`, false);
|
|
123
|
+
if (!ok) { console.log('aborted.'); return; }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- list the skill folder's files ---
|
|
128
|
+
let listing;
|
|
129
|
+
try {
|
|
130
|
+
listing = await listSkillFiles({ author, repo, branch, docPath });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`failed to read ${author}/${repo}: ${e.message}`);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (values['dry-run']) {
|
|
138
|
+
console.log(`would install ${listing.files.length} file(s) to ${fsu.tildify(dest)} (branch ${listing.branchUsed}):`);
|
|
139
|
+
listing.files.forEach(f => console.log(` ${f.rel}`));
|
|
140
|
+
if (listing.note) console.log(dim(' ' + listing.note));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- download into a tmp dir, then atomically swap into place ---
|
|
145
|
+
const tmp = fsu.mkdtemp();
|
|
146
|
+
try {
|
|
147
|
+
for (const f of listing.files) {
|
|
148
|
+
const buf = await fetchRaw(author, repo, listing.branchUsed, f.path);
|
|
149
|
+
fsu.writeFileMkdir(path.join(tmp, f.rel), buf);
|
|
150
|
+
}
|
|
151
|
+
const hasSkillMd = listing.files.some(f => {
|
|
152
|
+
const rel = f.rel.toLowerCase();
|
|
153
|
+
return rel === 'skill.md' || rel.endsWith('/skill.md');
|
|
154
|
+
});
|
|
155
|
+
if (!hasSkillMd) throw new Error('no SKILL.md found in downloaded folder');
|
|
156
|
+
fsu.swapDir(tmp, dest);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
fsu.rmrf(tmp);
|
|
159
|
+
console.error(`install failed: ${e.message}`);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (values.json) {
|
|
165
|
+
console.log(JSON.stringify({
|
|
166
|
+
skill: name, mode: 'folder', source: src.name,
|
|
167
|
+
dest, files: listing.files.length, branch: listing.branchUsed,
|
|
168
|
+
}));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(`\n${green('✓')} installed ${bold(skill)} → ${fsu.tildify(dest)} ${dim(`(${listing.files.length} file(s) from ${src.name}@${listing.branchUsed})`)}`);
|
|
173
|
+
if (listing.note) console.log(dim(' ' + listing.note));
|
|
174
|
+
|
|
175
|
+
// usage guidance
|
|
176
|
+
const infoObj = buildInfo(skill, { skillIndex: idx.skillIndex, vendors: data.vendors });
|
|
177
|
+
console.log(renderInfo(infoObj, { en: Boolean(values.en) }));
|
|
178
|
+
console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
|
|
179
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const { loadData } = require('../data');
|
|
5
|
+
const { buildIndices } = require('../index-build');
|
|
6
|
+
const { searchRows } = require('../search-core');
|
|
7
|
+
const { renderRow } = require('../format');
|
|
8
|
+
|
|
9
|
+
const HELP = `usage: skills-atlas search <query...> [filters]
|
|
10
|
+
|
|
11
|
+
Matches by words, not whole-string: multiple keywords and loose phrases work
|
|
12
|
+
(e.g. "pdf 翻译", "translate a whole pdf"). Ranked by how many terms hit.
|
|
13
|
+
|
|
14
|
+
filters:
|
|
15
|
+
-c, --category <s> match a top-level category (loose, zh or en)
|
|
16
|
+
-p, --persona <s> match a persona (工程/PM/设计/研究/运营/营销/...)
|
|
17
|
+
-t, --type <s> match a source type (skill/plugin/marketplace/...)
|
|
18
|
+
--chain only ⛓ strong-binding workflow chains
|
|
19
|
+
--limit <n> max results (default 15)
|
|
20
|
+
--json machine-readable output
|
|
21
|
+
--en English output`;
|
|
22
|
+
|
|
23
|
+
function jsonRow(r) {
|
|
24
|
+
return {
|
|
25
|
+
group: r.group, group_en: r.group_en, category: r._cat, chain: r.chain,
|
|
26
|
+
skills: r.skills, use_case: r.use_case, when_to_use: r.when_to_use,
|
|
27
|
+
personas: r.personas || [],
|
|
28
|
+
sources: (r.sources || []).map(s => ({
|
|
29
|
+
id: s.name, stars: s.stars, type: s.type,
|
|
30
|
+
install: s.install ? s.install.command : null,
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = async function search(argv) {
|
|
36
|
+
const { values, positionals } = parse(argv, ['category', 'persona', 'type', 'chain', 'limit', 'json']);
|
|
37
|
+
if (values.help) { console.log(HELP); return; }
|
|
38
|
+
|
|
39
|
+
const query = positionals.join(' ').trim();
|
|
40
|
+
const hasFilter = values.category || values.persona || values.type || values.chain;
|
|
41
|
+
if (!query && !hasFilter) {
|
|
42
|
+
console.error('usage: skills-atlas search <query...> [-c category] [-p persona] [-t type] [--chain]');
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const en = Boolean(values.en);
|
|
48
|
+
const { data } = loadData({ quiet: values.json });
|
|
49
|
+
const { flatRows } = buildIndices(data);
|
|
50
|
+
|
|
51
|
+
const rows = searchRows(flatRows, {
|
|
52
|
+
query,
|
|
53
|
+
category: values.category,
|
|
54
|
+
persona: values.persona,
|
|
55
|
+
type: values.type,
|
|
56
|
+
chain: values.chain,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const limit = Math.max(1, parseInt(values.limit || '15', 10) || 15);
|
|
60
|
+
const shown = rows.slice(0, limit);
|
|
61
|
+
|
|
62
|
+
if (values.json) {
|
|
63
|
+
console.log(JSON.stringify(shown.map(jsonRow), null, 2));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
shown.forEach(r => console.log(renderRow(r, { en })));
|
|
67
|
+
const more = rows.length > limit ? `, showing ${limit}` : '';
|
|
68
|
+
console.log(`\n${rows.length} match(es)${more}.`);
|
|
69
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parse } = require('../args');
|
|
4
|
+
const { refreshData } = require('../data');
|
|
5
|
+
|
|
6
|
+
module.exports = async function update(argv) {
|
|
7
|
+
const { values } = parse(argv, ['json']);
|
|
8
|
+
if (values.help) {
|
|
9
|
+
console.log('usage: skills-atlas update\n\nRefresh the local catalog cache from the public data feed.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const res = await refreshData();
|
|
15
|
+
if (values.json) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
16
|
+
if (!res.changed) {
|
|
17
|
+
console.log(`catalog already up to date (${res.counts.vendors} vendors, ${res.counts.groups} groups).`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
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
|
+
} catch (e) {
|
|
24
|
+
console.error(`update failed: ${e.message}`);
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
}
|
|
27
|
+
};
|
package/src/data.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Data loading + caching for the Skills Atlas catalog.
|
|
2
|
+
//
|
|
3
|
+
// Offline-first: normal commands (search/info/install) never hit the network —
|
|
4
|
+
// they read a refreshed cache if present, otherwise the bundled snapshot. Only
|
|
5
|
+
// `update` fetches from the public URL.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
const PUBLIC_URL = 'https://zita-go.github.io/Skills-Atlas/data.json';
|
|
13
|
+
const UA = 'skills-atlas-cli';
|
|
14
|
+
const STALE_DAYS = 30;
|
|
15
|
+
|
|
16
|
+
function cacheDir() {
|
|
17
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
18
|
+
return path.join(base, 'skills-atlas');
|
|
19
|
+
}
|
|
20
|
+
const cacheFile = () => path.join(cacheDir(), 'data.json');
|
|
21
|
+
const metaFile = () => path.join(cacheDir(), 'meta.json');
|
|
22
|
+
|
|
23
|
+
function tryReadJSON(p) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isValid(d) {
|
|
32
|
+
return d && Array.isArray(d.sections) && d.vendors && typeof d.vendors === 'object';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function counts(d) {
|
|
36
|
+
const groups = d.sections.reduce(
|
|
37
|
+
(n, s) => n + s.subsections.reduce((m, ss) => m + ss.rows.length, 0), 0);
|
|
38
|
+
return { sections: d.sections.length, groups, vendors: Object.keys(d.vendors).length };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve the bundled (offline) snapshot. Works when published (own ./data.json
|
|
42
|
+
// shipped in `files`) and in the monorepo during dev (sibling data package or
|
|
43
|
+
// the canonical docs/data.json), so it never hard-fails before `npm run build`.
|
|
44
|
+
function loadBundled() {
|
|
45
|
+
const candidates = [
|
|
46
|
+
path.join(__dirname, '..', 'data.json'), // copied by build.js
|
|
47
|
+
path.join(__dirname, '..', '..', 'skills-atlas-data', 'data.json'), // sibling package (dev)
|
|
48
|
+
path.join(__dirname, '..', '..', '..', 'docs', 'data.json'), // canonical (dev)
|
|
49
|
+
];
|
|
50
|
+
for (const p of candidates) {
|
|
51
|
+
const d = tryReadJSON(p);
|
|
52
|
+
if (isValid(d)) return { data: d, source: p };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const d = require('skills-atlas-data'); // npm dependency, if installed
|
|
56
|
+
if (isValid(d)) return { data: d, source: 'skills-atlas-data' };
|
|
57
|
+
} catch { /* not installed */ }
|
|
58
|
+
throw new Error(
|
|
59
|
+
'No Skills Atlas data found. Run `npm run build` in packages/skills-atlas-cli, ' +
|
|
60
|
+
'or run `skills-atlas update` to fetch the catalog.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One-line stderr nudge when the catalog is the bundled snapshot or a stale cache.
|
|
64
|
+
function maybeStaleNudge(fromCache) {
|
|
65
|
+
const meta = tryReadJSON(metaFile());
|
|
66
|
+
let stale = true;
|
|
67
|
+
if (fromCache && meta && meta.fetchedAt) {
|
|
68
|
+
const ageDays = (Date.now() - Date.parse(meta.fetchedAt)) / 86400000;
|
|
69
|
+
stale = !(ageDays >= 0 && ageDays < STALE_DAYS);
|
|
70
|
+
}
|
|
71
|
+
if (stale) {
|
|
72
|
+
process.stderr.write("tip: run 'skills-atlas update' to refresh the catalog\n");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadData({ quiet = false } = {}) {
|
|
77
|
+
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 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ensureDir(d) {
|
|
88
|
+
fs.mkdirSync(d, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fetch the public catalog and atomically replace the cache. Never corrupts the
|
|
92
|
+
// existing cache on failure (validate + tmp-then-rename).
|
|
93
|
+
async function refreshData() {
|
|
94
|
+
const meta = tryReadJSON(metaFile()) || {};
|
|
95
|
+
const headers = { 'User-Agent': UA, Accept: 'application/json' };
|
|
96
|
+
if (meta.etag) headers['If-None-Match'] = meta.etag;
|
|
97
|
+
|
|
98
|
+
const prev = tryReadJSON(cacheFile());
|
|
99
|
+
let res;
|
|
100
|
+
const ac = new AbortController();
|
|
101
|
+
const timeoutMs = Number(process.env.SKILLS_ATLAS_TIMEOUT_MS) || 25000;
|
|
102
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(PUBLIC_URL, { headers, signal: ac.signal });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
throw new Error(e.name === 'AbortError'
|
|
107
|
+
? `timed out after ${timeoutMs}ms fetching catalog`
|
|
108
|
+
: `network error fetching catalog: ${e.message}`);
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (res.status === 304 && isValid(prev)) {
|
|
114
|
+
return { changed: false, counts: counts(prev), url: PUBLIC_URL };
|
|
115
|
+
}
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new Error(`fetch failed: HTTP ${res.status} ${res.statusText} (${PUBLIC_URL})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
let data;
|
|
122
|
+
try {
|
|
123
|
+
data = JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error('downloaded catalog is not valid JSON; kept existing cache');
|
|
126
|
+
}
|
|
127
|
+
if (!isValid(data)) {
|
|
128
|
+
throw new Error('downloaded catalog missing sections/vendors; kept existing cache');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ensureDir(cacheDir());
|
|
132
|
+
const tmp = cacheFile() + '.tmp';
|
|
133
|
+
fs.writeFileSync(tmp, text);
|
|
134
|
+
fs.renameSync(tmp, cacheFile());
|
|
135
|
+
fs.writeFileSync(metaFile(), JSON.stringify({
|
|
136
|
+
fetchedAt: new Date().toISOString(),
|
|
137
|
+
etag: res.headers.get('etag') || null,
|
|
138
|
+
sourceUrl: PUBLIC_URL,
|
|
139
|
+
counts: counts(data),
|
|
140
|
+
}, null, 2));
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
changed: true,
|
|
144
|
+
counts: counts(data),
|
|
145
|
+
prevCounts: isValid(prev) ? counts(prev) : null,
|
|
146
|
+
url: PUBLIC_URL,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { loadData, refreshData, counts, cacheDir, PUBLIC_URL };
|