skills-atlas-cli 0.8.2 → 0.8.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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",
@@ -1,9 +1,27 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const { parse } = require('../args');
4
6
  const { loadData } = require('../data');
5
7
  const { buildIndices, suggestSkills } = require('../index-build');
6
8
  const { buildInfo, renderInfo, dim } = require('../format');
9
+ const fsu = require('../fsutil');
10
+ const manifest = require('../manifest');
11
+
12
+ // The authoritative one-liner: a skill's own SKILL.md `description:` frontmatter,
13
+ // read locally when installed (offline). Catalog text is group-level; this is not.
14
+ function localSkillDesc(skill) {
15
+ for (const s of fsu.scopesFor({})) {
16
+ try {
17
+ const md = fs.readFileSync(path.join(s.root, skill, 'SKILL.md'), 'utf8');
18
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
19
+ const dm = fm && fm[1].match(/^description:\s*(.+)$/m);
20
+ if (dm) return dm[1].trim().replace(/^["']|["']$/g, '');
21
+ } catch { /* not installed in this scope */ }
22
+ }
23
+ return '';
24
+ }
7
25
 
8
26
  const HELP = `usage: skills-atlas info <skill> [--all] [--json] [--zh]
9
27
 
@@ -44,5 +62,8 @@ module.exports = async function info(argv) {
44
62
  console.log(JSON.stringify(infoObj, null, 2));
45
63
  return;
46
64
  }
47
- console.log(renderInfo(infoObj, { en: !values.zh, all: values.all }));
65
+ const installed = [];
66
+ for (const s of fsu.scopesFor({})) if (manifest.list(s.root).some(e => e.skill === infoObj.skill)) installed.push(s.name);
67
+ const skillDesc = installed.length ? localSkillDesc(infoObj.skill) : '';
68
+ console.log(renderInfo(infoObj, { en: !values.zh, all: values.all, installed, skillDesc }));
48
69
  };
@@ -5,6 +5,8 @@ const { loadData } = require('../data');
5
5
  const { buildIndices, suggestSkills } = require('../index-build');
6
6
  const { runSearch } = require('../search-core');
7
7
  const { renderRow, dim } = require('../format');
8
+ const fsu = require('../fsutil');
9
+ const manifest = require('../manifest');
8
10
 
9
11
  const HELP = `usage: skills-atlas search <query...> [filters]
10
12
 
@@ -86,7 +88,9 @@ module.exports = async function search(argv) {
86
88
  if (weak) {
87
89
  console.log(dim('\n⚠ partial match — results may cover only part of your query; the top ones can still help, or try different words.'));
88
90
  }
89
- shown.forEach(r => console.log(renderRow(r, { en })));
91
+ const installedSet = new Set();
92
+ for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installedSet.add(e.skill);
93
+ shown.forEach(r => console.log(renderRow(r, { en, installed: installedSet, vendors: data.vendors })));
90
94
  const truncated = rows.length > limit;
91
95
  console.log(`\n${rows.length} match(es)${truncated ? `, showing ${limit}` : ''}.`);
92
96
  if (truncated) console.log(dim(`see the rest with --limit ${rows.length}${values.category ? '' : ', or narrow with -c <category>'}.`));
package/src/format.js CHANGED
@@ -19,6 +19,17 @@ function stars(n) {
19
19
  return `★${n}`;
20
20
  }
21
21
 
22
+ // Repo freshness from a source's `last_commit` ("2026-05-28"). Returns null if
23
+ // absent/unparseable. `stale` flags a repo untouched for over a year — so a popular
24
+ // but abandoned source no longer looks identical to an actively maintained one.
25
+ function recency(lastCommit, now = Date.now()) {
26
+ if (!lastCommit) return null;
27
+ const t = Date.parse(lastCommit);
28
+ if (!Number.isFinite(t)) return null;
29
+ const days = Math.floor((now - t) / 86400000);
30
+ return { date: String(lastCommit).slice(0, 10), days, stale: days > 365 };
31
+ }
32
+
22
33
  // A `git clone <repo> ~/.claude/skills/skills` alt double-nests a multi-skill
23
34
  // monorepo (skills end up at .claude/skills/skills/<name>/) and won't load —
24
35
  // drop that foot-gun; the `npx skills add` command is the correct path.
@@ -34,8 +45,10 @@ function text(obj, key, en) {
34
45
  return en ? (obj[key + '_en'] || obj[key] || '') : (obj[key] || obj[key + '_en'] || '');
35
46
  }
36
47
 
37
- // One search/list result line block.
38
- function renderRow(r, { en = false } = {}) {
48
+ // One search/list result line block. `installed` (a Set of skill names) marks what
49
+ // you already have; `vendors` lets the source line say whether install is a clean
50
+ // single-skill folder or a whole-repo command.
51
+ function renderRow(r, { en = false, installed = null, vendors = null } = {}) {
39
52
  const chain = r.chain ? cyan('⛓ ') : '';
40
53
  const cat = en ? (r._catEn || r._cat) : r._cat;
41
54
  const lines = [`\n${chain}${bold(text(r, 'group', en))} ${dim('[' + cat + ']')}`];
@@ -43,14 +56,22 @@ function renderRow(r, { en = false } = {}) {
43
56
  if (uc) lines.push(` 💡 ${uc}`);
44
57
  const SK_MAX = 8;
45
58
  const sk = r.skills || [];
59
+ const tick = s => (installed && installed.has(s)) ? green(s + ' ✓') : green(s);
46
60
  const skStr = sk.length > SK_MAX
47
- ? green(sk.slice(0, SK_MAX).join(', ')) + dim(` +${sk.length - SK_MAX} more`)
48
- : green(sk.join(', '));
61
+ ? sk.slice(0, SK_MAX).map(tick).join(dim(', ')) + dim(` +${sk.length - SK_MAX} more`)
62
+ : sk.map(tick).join(dim(', '));
49
63
  lines.push(` ${dim('skills:')} ${skStr}`);
50
64
  const best = [...(r.sources || [])].sort((a, b) => (b.stars || 0) - (a.stars || 0))[0];
51
65
  if (best) {
66
+ const rec = recency(best.last_commit);
67
+ const recStr = rec ? ` updated ${rec.date}${rec.stale ? ' ⚠' : ''}` : '';
68
+ let kind = '';
69
+ if (vendors) {
70
+ const v = vendors[best.name];
71
+ kind = (v && skillDocPath(v, sk[0])) ? ' [single-skill]' : ' [whole-repo]';
72
+ }
52
73
  const inst = best.install && best.install.command ? ` — ${best.install.command}` : '';
53
- lines.push(` ${dim('via')} ${best.name} ${yellow(stars(best.stars))}${dim(inst)}`);
74
+ lines.push(` ${dim('via')} ${best.name} ${yellow(stars(best.stars))}${dim(recStr)}${dim(kind)}${dim(inst)}`);
54
75
  }
55
76
  return lines.join('\n');
56
77
  }
@@ -89,6 +110,7 @@ function groupOf(r, skillName, vendors) {
89
110
  id: s.name,
90
111
  url: s.url,
91
112
  stars: s.stars,
113
+ last_commit: s.last_commit || v.last_commit || null,
92
114
  license: (v.skill_licenses && v.skill_licenses[skillName]) || s.license || null,
93
115
  type: s.type,
94
116
  path: docPath || null,
@@ -128,15 +150,40 @@ function infoForRow(skillName, row, vendors) {
128
150
  return { skill: skillName, found: true, groups: [groupOf(row, skillName, vendors)] };
129
151
  }
130
152
 
131
- function renderInfo(info, { en = false, all = false } = {}) {
153
+ // Multi-skill groups share one description that often enumerates each skill as
154
+ // "<vendor> <skill> (<detail>) + …". Pull out the segment for THIS skill so info
155
+ // pinpoints what it does, not just the whole group. Conservative: only fire when
156
+ // the skill matches exactly one segment (otherwise we'd guess wrong).
157
+ function perSkillBlurb(skill, desc) {
158
+ if (!skill || !desc) return '';
159
+ const segs = String(desc).split(' + ');
160
+ if (segs.length < 2) return '';
161
+ const re = new RegExp('\\b' + String(skill).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
162
+ const hits = segs.filter(s => re.test(s));
163
+ if (hits.length !== 1) return '';
164
+ const seg = hits[0].trim();
165
+ return /[((]/.test(seg) ? seg : ''; // only worth showing if it carries a detail
166
+ }
167
+
168
+ // `skillDesc` is the authoritative one-liner from the skill's own SKILL.md (read
169
+ // locally when installed); it wins over the heuristic catalog-segment extraction.
170
+ function renderInfo(info, { en = false, all = false, installed = null, skillDesc = '' } = {}) {
132
171
  const pick = (zh, e) => (en ? (e || zh) : (zh || e)) || '';
133
172
  const out = [];
134
- out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('⛓') : ''}`);
173
+ const instTag = installed && installed.length
174
+ ? ` ${green('✓ installed')} ${dim('[' + installed.join('+') + ']')}` : '';
175
+ out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('⛓') : ''}${instTag}`);
135
176
  const shown = all ? info.groups : info.groups.slice(0, 1);
177
+ let firstGroup = true;
136
178
  for (const g of shown) {
137
179
  out.push(` ${dim('group:')} ${pick(g.group, g.group_en)} ${dim('[' + pick(g.category, g.category_en) + ']')}`);
138
180
  const desc = pick(g.description, g.description_en);
139
- if (desc) out.push(` ${desc}`);
181
+ const blurb = (firstGroup && skillDesc) ? skillDesc : perSkillBlurb(info.skill, desc);
182
+ firstGroup = false;
183
+ const distinguished = blurb && blurb !== desc;
184
+ if (distinguished) out.push(` ${dim('this skill:')} ${blurb}`);
185
+ // Only call it "group does:" when a per-skill line is shown above it to contrast.
186
+ if (desc) out.push(distinguished ? ` ${dim('group does:')} ${desc}` : ` ${desc}`);
140
187
  const uc = pick(g.use_case, g.use_case_en);
141
188
  if (uc) out.push(` ${dim('use case:')} ${uc}`);
142
189
  const wt = pick(g.when_to_use, g.when_to_use_en);
@@ -146,7 +193,9 @@ function renderInfo(info, { en = false, all = false } = {}) {
146
193
  }
147
194
  out.push(` ${dim('sources:')}`);
148
195
  for (const s of g.sources) {
149
- out.push(` • ${bold(s.id)} ${yellow(stars(s.stars))} ${dim(s.type || '')} ${s.license ? dim('(' + s.license + ')') : ''}`);
196
+ const rec = recency(s.last_commit);
197
+ const recStr = rec ? ` ${dim('updated ' + rec.date)}${rec.stale ? ' ' + yellow('⚠ stale') : ''}` : '';
198
+ out.push(` • ${bold(s.id)} ${yellow(stars(s.stars))} ${dim(s.type || '')} ${s.license ? dim('(' + s.license + ')') : ''}${recStr}`);
150
199
  out.push(` ${dim(s.url)}`);
151
200
  out.push(` ${dim('path:')} ${s.path || dim('(whole-repo install — no per-skill folder)')}`);
152
201
  if (s.install && s.install.command) {
@@ -166,6 +215,6 @@ function renderInfo(info, { en = false, all = false } = {}) {
166
215
  }
167
216
 
168
217
  module.exports = {
169
- bold, dim, green, cyan, yellow, stars, safeAlt, text, renderRow,
218
+ bold, dim, green, cyan, yellow, stars, recency, perSkillBlurb, safeAlt, text, renderRow,
170
219
  buildInfo, infoForRow, renderInfo, PERSONA_EN,
171
220
  };