skills-atlas-cli 0.1.1 → 0.1.2

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
@@ -59,16 +59,21 @@ After installing a skill, start a new Claude Code session to load it.
59
59
 
60
60
  ## How install works
61
61
 
62
- Every skill records the **exact in-repo path** of its `SKILL.md`. `install` reads
63
- that, lists the skill's folder via one GitHub API call, then downloads each file
64
- (preserving subfolders) into `<target>/.claude/skills/<skill>/`.
62
+ The real value is the **catalog**: `search` / `info` / `categories` work fully
63
+ offline and map *which* skill fits function-organized, bilingual, tagged with
64
+ use-case / when-to-use / personas / ⛓ chains. That's what `npx skills add` and
65
+ GitHub search don't give you.
65
66
 
66
- - Unlike `git clone`, it fetches **only that skill's folder**, not the whole repo.
67
+ On top of that, `install` can place a skill straight into `.claude/skills/`:
68
+
69
+ - For a repo that exposes a **per-skill folder**, it downloads only that folder
70
+ (via the repo archive — **no GitHub API rate limit**) into
71
+ `<target>/.claude/skills/<skill>/`, not the whole repo.
67
72
  - Several sources? The best installable one is auto-picked — `--source <id>` to
68
73
  choose, `--yes` for non-interactive runs.
69
- - Whole-repo / marketplace sources (no per-skill folder) → `install` prints the
70
- exact command to run instead (e.g. `npx skills add owner/repo`).
71
- - Set `GITHUB_TOKEN` to raise the GitHub API rate limit (60/h → 5000/h).
74
+ - Other sources (whole-repo / marketplace) print their official command instead
75
+ (e.g. `npx skills add owner/repo`).
76
+ - `GITHUB_TOKEN` is only needed if you fall back to the API and hit its 60/h limit.
72
77
 
73
78
  ## Keeping the catalog fresh
74
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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
@@ -19,6 +19,7 @@ const ALL = {
19
19
  json: { type: 'boolean' },
20
20
  en: { type: 'boolean' },
21
21
  zh: { type: 'boolean' },
22
+ all: { type: 'boolean' },
22
23
  help: { type: 'boolean', short: 'h' },
23
24
  };
24
25
 
@@ -5,13 +5,14 @@ const { loadData } = require('../data');
5
5
  const { buildIndices, suggestSkills } = require('../index-build');
6
6
  const { buildInfo, renderInfo } = require('../format');
7
7
 
8
- const HELP = `usage: skills-atlas info <skill> [--json] [--zh]
8
+ const HELP = `usage: skills-atlas info <skill> [--all] [--json] [--zh]
9
9
 
10
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.`;
11
+ (stars / license / type), the in-repo SKILL.md path, and the install command.
12
+ Leads with the most relevant group; --all expands same-named skills in others.`;
12
13
 
13
14
  module.exports = async function info(argv) {
14
- const { values, positionals } = parse(argv, ['json']);
15
+ const { values, positionals } = parse(argv, ['json', 'all']);
15
16
  if (values.help) { console.log(HELP); return; }
16
17
 
17
18
  const name = positionals[0];
@@ -42,5 +43,5 @@ module.exports = async function info(argv) {
42
43
  console.log(JSON.stringify(infoObj, null, 2));
43
44
  return;
44
45
  }
45
- console.log(renderInfo(infoObj, { en: !values.zh }));
46
+ console.log(renderInfo(infoObj, { en: !values.zh, all: values.all }));
46
47
  };
@@ -7,7 +7,7 @@ const { buildIndices, vendorsFor, suggestSkills, skillDocPath } = require('../in
7
7
  const { listSkillFiles, fetchRaw, fetchSkillFolderTar } = require('../github');
8
8
  const fsu = require('../fsutil');
9
9
  const { confirm, choose } = require('../prompt');
10
- const { buildInfo, renderInfo, bold, dim, cyan, green, stars, safeAlt } = require('../format');
10
+ const { buildInfo, infoForRow, renderInfo, bold, dim, cyan, green, stars, safeAlt } = require('../format');
11
11
 
12
12
  const HELP = `usage: skills-atlas install <skill> [options]
13
13
 
@@ -20,8 +20,9 @@ options:
20
20
  --dry-run show what would download, write nothing
21
21
  --json machine-readable output
22
22
 
23
- Downloading uses the GitHub API (60 requests/hour unauthenticated). If you hit a
24
- rate limit, set GITHUB_TOKEN=<your token> to raise it to 5000/hour.`;
23
+ Downloads the repo archive (no GitHub API rate limit). Only if that fetch fails
24
+ does it fall back to the GitHub API (60/h unauthenticated) — set GITHUB_TOKEN to
25
+ raise that fallback to 5000/h.`;
25
26
 
26
27
  function resolveGlobal(values) {
27
28
  if (values.project) return false;
@@ -79,6 +80,17 @@ module.exports = async function install(argv) {
79
80
  chosen = candidates[i];
80
81
  }
81
82
 
83
+ // Heads-up when --yes auto-picked among semantically different groups.
84
+ if (values.yes && candidates.length > 1) {
85
+ const groups = [...new Set(candidates.map(c => c.row && c.row.group).filter(Boolean))];
86
+ if (groups.length > 1) {
87
+ const picked = chosen.row && chosen.row.group;
88
+ const otherGroups = groups.filter(g => g !== picked).join('; ');
89
+ const st = stars(chosen.source.stars);
90
+ console.error(dim(`note: '${name}' exists in ${groups.length} groups; auto-picked ${chosen.source.name}${st ? ' ' + st : ''} (${picked || '?'}). also in: ${otherGroups} — use --source to choose.`));
91
+ }
92
+ }
93
+
82
94
  const v = chosen.vendor || {};
83
95
  const src = chosen.source;
84
96
  const skill = chosen.skill || name; // canonical skill name from the index
@@ -138,6 +150,15 @@ module.exports = async function install(argv) {
138
150
  process.exitCode = 1;
139
151
  return;
140
152
  }
153
+ if (values.json) {
154
+ console.log(JSON.stringify({
155
+ skill: name, mode: 'folder', dest,
156
+ files: listing.files.length,
157
+ scripts: fsu.scriptFiles(listing.files.map(f => f.rel)).length,
158
+ branch: listing.branchUsed, dryRun: true,
159
+ }));
160
+ return;
161
+ }
141
162
  console.log(`would install ${listing.files.length} file(s) to ${fsu.tildify(dest)} (branch ${listing.branchUsed}):`);
142
163
  listing.files.forEach(f => console.log(` ${f.rel}`));
143
164
  if (listing.note) console.log(dim(' ' + listing.note));
@@ -149,7 +170,7 @@ module.exports = async function install(argv) {
149
170
  // Primary: one repo-archive download from codeload (not GitHub-API rate-limited);
150
171
  // extract only this skill's folder. Fallback: tree API + raw per file.
151
172
  const tmp = fsu.mkdtemp();
152
- let branchUsed, fileCount, note = null;
173
+ let branchUsed, fileCount, note = null, scripts = [];
153
174
  try {
154
175
  let folder = null;
155
176
  try {
@@ -180,6 +201,7 @@ module.exports = async function install(argv) {
180
201
  });
181
202
  if (!hasSkillMd) throw new Error('no SKILL.md found in downloaded folder');
182
203
  fileCount = rels.length;
204
+ scripts = fsu.scriptFiles(rels);
183
205
  fsu.swapDir(tmp, dest);
184
206
  } catch (e) {
185
207
  fsu.rmrf(tmp);
@@ -191,16 +213,23 @@ module.exports = async function install(argv) {
191
213
  if (values.json) {
192
214
  console.log(JSON.stringify({
193
215
  skill: name, mode: 'folder', source: src.name,
194
- dest, files: fileCount, branch: branchUsed,
216
+ dest, files: fileCount, scripts: scripts.length, branch: branchUsed,
195
217
  }));
196
218
  return;
197
219
  }
198
220
 
199
221
  console.log(`\n${green('✓')} installed ${bold(skill)} → ${fsu.tildify(dest)} ${dim(`(${fileCount} file(s) from ${src.name}@${branchUsed})`)}`);
200
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}`));
227
+ }
201
228
 
202
- // usage guidance
203
- const infoObj = buildInfo(skill, { skillIndex: idx.skillIndex, vendors: data.vendors });
204
- console.log(renderInfo(infoObj, { en: !values.zh }));
229
+ // usage guidance — scoped by row identity to the exact group you installed from
230
+ const guide = chosen.row
231
+ ? infoForRow(skill, chosen.row, data.vendors)
232
+ : buildInfo(skill, { skillIndex: idx.skillIndex, vendors: data.vendors });
233
+ console.log(renderInfo(guide, { en: !values.zh, all: true }));
205
234
  console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
206
235
  };
package/src/format.js CHANGED
@@ -55,47 +55,70 @@ const PERSONA_EN = {
55
55
  '研究': 'Research', '营销': 'Marketing', '设计': 'Design', '运营': 'Ops', '通用': 'General',
56
56
  };
57
57
 
58
- // Structured info for a skill (also used as the machine-readable --json shape).
58
+ // Rank a row best-first: top stars, then total stars, then source count — so a
59
+ // star tie (one popular source shared across namesake groups) breaks meaningfully.
60
+ function rowRank(r) {
61
+ const ss = (r.sources || []).map(s => s.stars || 0);
62
+ return { max: Math.max(0, ...ss), sum: ss.reduce((a, b) => a + b, 0), n: ss.length };
63
+ }
64
+
65
+ // The per-group info object for one row.
66
+ function groupOf(r, skillName, vendors) {
67
+ return {
68
+ group: r.group,
69
+ group_en: r.group_en,
70
+ category: r._cat,
71
+ category_en: r._catEn,
72
+ chain: r.chain,
73
+ description: r.description,
74
+ description_en: r.description_en,
75
+ use_case: r.use_case,
76
+ use_case_en: r.use_case_en,
77
+ when_to_use: r.when_to_use,
78
+ when_to_use_en: r.when_to_use_en,
79
+ personas: r.personas || [],
80
+ sources: (r.sources || []).map(s => {
81
+ const v = vendors[s.name] || {};
82
+ const docPath = skillDocPath(v, skillName);
83
+ return {
84
+ id: s.name,
85
+ url: s.url,
86
+ stars: s.stars,
87
+ license: (v.skill_licenses && v.skill_licenses[skillName]) || s.license || null,
88
+ type: s.type,
89
+ path: docPath || null,
90
+ install: s.install || v.install || null,
91
+ };
92
+ }),
93
+ };
94
+ }
95
+
96
+ // Structured info for a skill (also the machine-readable --json shape). Groups
97
+ // are ordered best-first so the most relevant one leads.
59
98
  function buildInfo(skillName, { skillIndex, vendors }) {
60
- const rows = rowsFor(skillIndex, skillName);
99
+ const rows = rowsFor(skillIndex, skillName).slice().sort((a, b) => {
100
+ const A = rowRank(a), B = rowRank(b);
101
+ return B.max - A.max || B.sum - A.sum || B.n - A.n;
102
+ });
61
103
  return {
62
104
  skill: skillName,
63
105
  found: rows.length > 0,
64
- groups: rows.map(r => ({
65
- group: r.group,
66
- group_en: r.group_en,
67
- category: r._cat,
68
- category_en: r._catEn,
69
- chain: r.chain,
70
- description: r.description,
71
- description_en: r.description_en,
72
- use_case: r.use_case,
73
- use_case_en: r.use_case_en,
74
- when_to_use: r.when_to_use,
75
- when_to_use_en: r.when_to_use_en,
76
- personas: r.personas || [],
77
- sources: (r.sources || []).map(s => {
78
- const v = vendors[s.name] || {};
79
- const docPath = skillDocPath(v, skillName);
80
- return {
81
- id: s.name,
82
- url: s.url,
83
- stars: s.stars,
84
- license: (v.skill_licenses && v.skill_licenses[skillName]) || s.license || null,
85
- type: s.type,
86
- path: docPath || null,
87
- install: s.install || v.install || null,
88
- };
89
- }),
90
- })),
106
+ groups: rows.map(r => groupOf(r, skillName, vendors)),
91
107
  };
92
108
  }
93
109
 
94
- function renderInfo(info, { en = false } = {}) {
110
+ // Single-group info for one specific row (scoped post-install guidance — scopes
111
+ // by row identity, so two namesake rows with the same group name don't both show).
112
+ function infoForRow(skillName, row, vendors) {
113
+ return { skill: skillName, found: true, groups: [groupOf(row, skillName, vendors)] };
114
+ }
115
+
116
+ function renderInfo(info, { en = false, all = false } = {}) {
95
117
  const pick = (zh, e) => (en ? (e || zh) : (zh || e)) || '';
96
118
  const out = [];
97
119
  out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('⛓') : ''}`);
98
- for (const g of info.groups) {
120
+ const shown = all ? info.groups : info.groups.slice(0, 1);
121
+ for (const g of shown) {
99
122
  out.push(` ${dim('group:')} ${pick(g.group, g.group_en)} ${dim('[' + pick(g.category, g.category_en) + ']')}`);
100
123
  const desc = pick(g.description, g.description_en);
101
124
  if (desc) out.push(` ${desc}`);
@@ -119,9 +142,15 @@ function renderInfo(info, { en = false } = {}) {
119
142
  }
120
143
  }
121
144
  }
145
+ if (!all && info.groups.length > 1) {
146
+ const others = info.groups.slice(1).map(g => pick(g.group, g.group_en));
147
+ out.push(dim(` + ${others.length} other group(s) with a same-named skill: ${others.join('; ')}`));
148
+ out.push(dim(` (run \`skills-atlas info ${info.skill} --all\` to expand)`));
149
+ }
122
150
  return out.join('\n');
123
151
  }
124
152
 
125
153
  module.exports = {
126
- bold, dim, green, cyan, yellow, stars, safeAlt, text, renderRow, buildInfo, renderInfo,
154
+ bold, dim, green, cyan, yellow, stars, safeAlt, text, renderRow,
155
+ buildInfo, infoForRow, renderInfo,
127
156
  };
package/src/fsutil.js CHANGED
@@ -46,7 +46,15 @@ function tildify(p) {
46
46
  return p.startsWith(home) ? '~' + p.slice(home.length) : p;
47
47
  }
48
48
 
49
+ // Executable/script files in a skill folder, by extension (for the install
50
+ // transparency heads-up). Conservative: extension-based, won't catch every
51
+ // extensionless executable, so the provenance note is always shown regardless.
52
+ const SCRIPT_RE = /\.(sh|bash|zsh|fish|command|js|cjs|mjs|ts|tsx|jsx|py|rb|pl|ps1|psm1|bat|cmd)$/i;
53
+ function scriptFiles(rels) {
54
+ return (rels || []).filter(r => SCRIPT_RE.test(r));
55
+ }
56
+
49
57
  module.exports = {
50
58
  installTargetDir, dirExists, ensureDir, mkdtemp,
51
- writeFileMkdir, rmrf, swapDir, tildify,
59
+ writeFileMkdir, rmrf, swapDir, tildify, scriptFiles,
52
60
  };
@@ -88,12 +88,27 @@ function levenshtein(a, b) {
88
88
 
89
89
  // Nearest skill names for a not-found query.
90
90
  function suggestSkills(skillIndex, query, max = 5) {
91
- const q = String(query).toLowerCase();
91
+ const q = String(query).toLowerCase().trim();
92
+ if (!q) return [];
92
93
  const names = [...skillIndex.keys()];
94
+
95
+ // 1) substring either way — high-signal.
93
96
  const sub = names.filter(n => n.includes(q) || q.includes(n));
94
97
  if (sub.length) return sub.slice(0, max);
98
+
99
+ // 2) share a meaningful word with the query (helps multi-word misses).
100
+ const qtokens = q.split(/[^a-z0-9一-鿿]+/)
101
+ .filter(t => (/[一-鿿]/.test(t) ? t.length >= 2 : t.length >= 3));
102
+ if (qtokens.length) {
103
+ const overlap = names.filter(n => qtokens.some(t => n.includes(t)));
104
+ if (overlap.length) return overlap.slice(0, max);
105
+ }
106
+
107
+ // 3) fuzzy, but GATED to close edits only — return nothing rather than noise.
108
+ const limit = Math.max(1, Math.floor(q.length * 0.34));
95
109
  return names
96
110
  .map(n => [n, levenshtein(q, n)])
111
+ .filter(([, d]) => d <= limit)
97
112
  .sort((a, b) => a[1] - b[1])
98
113
  .slice(0, max)
99
114
  .map(s => s[0]);