skills-atlas-cli 0.8.5 → 0.8.8

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/bin/skills.js CHANGED
@@ -15,6 +15,7 @@ const registry = require('../src/commands/registry');
15
15
  const suggest = require('../src/commands/suggest');
16
16
  const hook = require('../src/commands/hook');
17
17
  const gaps = require('../src/commands/gaps');
18
+ const prune = require('../src/commands/prune');
18
19
  const update = require('../src/commands/update');
19
20
  const mcp = require('../src/commands/mcp');
20
21
  const { categories, list } = require('../src/commands/categories');
@@ -22,7 +23,7 @@ const { categories, list } = require('../src/commands/categories');
22
23
  const VERSION = require('../package.json').version;
23
24
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
24
25
  const use = argv => install([...argv, '--inline']);
25
- const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, update, categories, list, registry, mcp };
26
+ const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, prune, update, categories, list, registry, mcp };
26
27
 
27
28
  const HELP = `skills-atlas — search, install & manage AI agent skills
28
29
 
@@ -46,6 +47,7 @@ manage what you've installed:
46
47
  autopilot (opt-in):
47
48
  hook on|off|status proactively suggest a skill in Claude when your prompt fits one
48
49
  gaps kinds of work you keep doing without a skill (run: skills-atlas hook on)
50
+ prune installed skills you no longer use — Claude suggests removing them
49
51
 
50
52
  catalog:
51
53
  update refresh the catalog from the public data feed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.8",
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",
@@ -23,18 +23,18 @@ const isOurs = e => e && Array.isArray(e.hooks)
23
23
 
24
24
  module.exports = async function hook(argv) {
25
25
  const { values, positionals } = parse(argv, ['json']);
26
- if (values.help) { console.log('usage: skills-atlas hook <on|off|status|suggest on|off|gaps on|off>'); return; }
26
+ if (values.help) { console.log('usage: skills-atlas hook <on|off|status|suggest on|off|gaps on|off|prune on|off>'); return; }
27
27
  const sub = positionals[0] || 'status';
28
28
  const p = settingsPath();
29
29
 
30
- if (sub === 'suggest' || sub === 'gaps') {
30
+ if (sub === 'suggest' || sub === 'gaps' || sub === 'prune') {
31
31
  const onoff = positionals[1];
32
32
  if (onoff !== 'on' && onoff !== 'off') {
33
33
  console.error(`usage: skills-atlas hook ${sub} <on|off>`); process.exitCode = 1; return;
34
34
  }
35
- const key = sub === 'suggest' ? 'suggest' : 'gapAlerts';
35
+ const key = sub === 'suggest' ? 'suggest' : sub === 'gaps' ? 'gapAlerts' : 'prune';
36
36
  registry.setAutopilot({ [key]: onoff === 'on' });
37
- const label = sub === 'suggest' ? 'per-prompt autopilot' : 'gap alerts';
37
+ const label = sub === 'suggest' ? 'per-prompt autopilot' : sub === 'gaps' ? 'gap alerts' : 'prune suggestions';
38
38
  console.log(`${green('✓')} ${label}: ${onoff === 'on' ? green('on') : dim('off')}`);
39
39
  return;
40
40
  }
@@ -48,11 +48,12 @@ module.exports = async function hook(argv) {
48
48
  if (on) {
49
49
  console.log(` per-prompt suggest: ${ap.suggest ? green('on') : dim('off')} ${dim('(skills-atlas hook suggest on|off)')}`);
50
50
  console.log(` gap alerts: ${ap.gapAlerts ? green('on') : dim('off')} ${dim('(skills-atlas hook gaps on|off)')}`);
51
- console.log(dim(' review gaps: skills-atlas gaps'));
51
+ console.log(` prune suggestions: ${ap.prune ? green('on') : dim('off')} ${dim('(skills-atlas hook prune on|off)')}`);
52
+ console.log(dim(' review: skills-atlas gaps · skills-atlas prune'));
52
53
  } else {
53
54
  // Hook isn't registered, so these sub-toggles don't do anything yet — don't
54
55
  // imply the autopilot is running. Show them dimmed with the caveat.
55
- console.log(dim(` (suggest ${ap.suggest ? 'on' : 'off'}, gap alerts ${ap.gapAlerts ? 'on' : 'off'} — they take effect once you run 'skills-atlas hook on')`));
56
+ console.log(dim(` (suggest ${ap.suggest ? 'on' : 'off'}, gap alerts ${ap.gapAlerts ? 'on' : 'off'}, prune ${ap.prune ? 'on' : 'off'} — they take effect once you run 'skills-atlas hook on')`));
56
57
  console.log(dim('enable: skills-atlas hook on'));
57
58
  }
58
59
  return;
@@ -0,0 +1,107 @@
1
+ // `skills-atlas prune` — the inverse of `gaps`. Surface the user's INSTALLED skills
2
+ // (with install age + what each does) plus their recent activity, and a judging
3
+ // instruction, so CLAUDE spots skills that no longer fit the user's work and offers
4
+ // to remove them. We provide the data; Claude judges. NEVER deletes. No network.
5
+ 'use strict';
6
+
7
+ const { parse } = require('../args');
8
+ const { loadData } = require('../data');
9
+ const { buildIndices, rowsFor } = require('../index-build');
10
+ const fsu = require('../fsutil');
11
+ const manifest = require('../manifest');
12
+ const transcripts = require('../transcripts');
13
+ const prunestate = require('../prunestate');
14
+ const { dim, green } = require('../format');
15
+
16
+ const FRESH_DAYS = 14; // never suggest removing something installed this recently
17
+
18
+ const HELP = `usage: skills-atlas prune [dismiss <skill> | clear]
19
+
20
+ Surface your installed skills + recent Claude Code activity so Claude can spot ones
21
+ you no longer use and offer to remove them. Run it in Claude Code (or ask Claude
22
+ "any skills I can clean up?"). Never deletes anything — you confirm each removal.
23
+
24
+ prune review installed skills vs recent activity → Claude suggests
25
+ prune dismiss <skill> stop suggesting to remove one
26
+ prune clear reset dismissals
27
+ --json`;
28
+
29
+ // Installed skills (both scopes) worth reviewing: older than FRESH_DAYS and not
30
+ // dismissed, annotated with install age + what each does (from the catalog).
31
+ function reviewList(data, dismissed, now) {
32
+ const idx = buildIndices(data);
33
+ const out = [];
34
+ for (const s of fsu.scopesFor({})) {
35
+ for (const e of manifest.list(s.root)) {
36
+ if (dismissed.includes(e.skill)) continue;
37
+ const ageDays = e.installedAt ? Math.floor((now - Date.parse(e.installedAt)) / 86400000) : null;
38
+ if (ageDays !== null && ageDays >= 0 && ageDays < FRESH_DAYS) continue; // give new installs time
39
+ const row = rowsFor(idx.skillIndex, e.skill)[0];
40
+ out.push({
41
+ skill: e.skill,
42
+ scope: s.name,
43
+ ageDays,
44
+ use: row ? (row.use_case_en || row.use_case || '') : '',
45
+ });
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const instruction = dismissed =>
52
+ `Each skill above is installed but might no longer fit what the user is doing. From their recent ` +
53
+ `activity, flag any installed skill whose domain hasn't come up lately and they're unlikely to need ` +
54
+ `right now: name it, say briefly why it looks unused, and offer to remove it with ` +
55
+ `\`skills-atlas remove <skill>\` (add --project for a project-scoped one). NEVER remove anything ` +
56
+ `yourself — always let the user decide.` +
57
+ (dismissed.length ? ` Already dismissed (skip these): ${dismissed.join(', ')}.` : '') +
58
+ ` If every installed skill still fits their work, say nothing needs pruning.`;
59
+
60
+ // The full text block handed to Claude (shared by the command and the hook nudge).
61
+ function digestText(installed, recent, dismissed) {
62
+ const skills = installed.map(s =>
63
+ ` - ${s.skill} [${s.scope}]${s.ageDays != null ? ` · installed ${s.ageDays}d ago` : ''}` +
64
+ `${s.use ? ` · ${s.use.replace(/\s+/g, ' ').slice(0, 70)}` : ''}`).join('\n');
65
+ const activity = recent.length
66
+ ? recent.slice(0, 30).map(r => ` - ${r.text.replace(/\s+/g, ' ').slice(0, 100)}`).join('\n')
67
+ : ' (no recent activity found)';
68
+ return `[Skills Atlas — prune] Installed skills (older than ${FRESH_DAYS}d, newest activity below):\n${skills}\n\n` +
69
+ `The user's recent activity (newest first):\n${activity}\n\n${instruction(dismissed)}`;
70
+ }
71
+
72
+ module.exports = async function pruneCmd(argv) {
73
+ const { values, positionals } = parse(argv, ['json']);
74
+ if (values.help) { console.log(HELP); return; }
75
+ const sub = positionals[0];
76
+
77
+ if (sub === 'clear') {
78
+ prunestate.clear();
79
+ console.log(values.json ? JSON.stringify({ cleared: true }) : `${green('✓')} dismissals reset.`);
80
+ return;
81
+ }
82
+ if (sub === 'dismiss') {
83
+ const x = positionals.slice(1).join(' ');
84
+ if (!x) { console.error('usage: skills-atlas prune dismiss <skill>'); process.exitCode = 1; return; }
85
+ prunestate.dismiss(x);
86
+ console.log(values.json ? JSON.stringify({ dismissed: x }) : `${green('✓')} won't suggest removing: ${x}`);
87
+ return;
88
+ }
89
+
90
+ const { data } = loadData({ quiet: values.json });
91
+ const dismissed = prunestate.read().dismissed || [];
92
+ const installed = reviewList(data, dismissed, Date.now());
93
+ const recent = transcripts.recentPrompts({ max: 60 });
94
+
95
+ if (values.json) { console.log(JSON.stringify({ installed, recent, dismissed }, null, 2)); return; }
96
+
97
+ if (!installed.length) {
98
+ console.log(dim(`nothing to review — no skills installed longer than ${FRESH_DAYS} days.`));
99
+ console.log(dim('see what you have: skills-atlas installed'));
100
+ return;
101
+ }
102
+ console.log('\n' + digestText(installed, recent, dismissed));
103
+ };
104
+
105
+ module.exports.reviewList = reviewList;
106
+ module.exports.digestText = digestText;
107
+ module.exports.FRESH_DAYS = FRESH_DAYS;
@@ -18,6 +18,8 @@ const fsu = require('../fsutil');
18
18
  const registry = require('../registry');
19
19
  const transcripts = require('../transcripts');
20
20
  const gapstate = require('../gapstate');
21
+ const prunestate = require('../prunestate');
22
+ const prune = require('./prune');
21
23
 
22
24
  const COOLDOWN = 3; // min prompts between suggestions
23
25
  const GAP_EVERY = 12; // a gap nudge is only considered at every Nth prompt (per session)
@@ -81,6 +83,26 @@ module.exports = async function suggest() {
81
83
  }
82
84
  }
83
85
 
86
+ // --- Proactive prune nudge: installed skills that may no longer fit the user's
87
+ // work. Offset from the gap check so the two never fire on the same prompt. ---
88
+ if (ap.prune && state.count % GAP_EVERY === Math.floor(GAP_EVERY / 2)) {
89
+ const recent = transcripts.recentPrompts({ max: 30 });
90
+ if (recent.length >= 8) {
91
+ const ps = prunestate.read();
92
+ const { fire, sig } = gapstate.shouldNudge(ps, recent, Date.now());
93
+ if (fire) {
94
+ const { data } = loadData({ quiet: true });
95
+ const installed = prune.reviewList(data, ps.dismissed || [], Date.now());
96
+ if (installed.length) {
97
+ emit(prune.digestText(installed, recent, ps.dismissed || []));
98
+ prunestate.touchNudge(sig);
99
+ writeState(file, state);
100
+ return;
101
+ }
102
+ }
103
+ }
104
+ }
105
+
84
106
  // --- Per-prompt autopilot (gated by the suggest toggle + cooldown) ---
85
107
  if (!ap.suggest) { writeState(file, state); return; }
86
108
  if (state.count - (state.lastSuggestedCount ?? -COOLDOWN) < COOLDOWN) { writeState(file, state); return; }
@@ -0,0 +1,26 @@
1
+ // Persistence for prune suggestions: which removals the user dismissed, when we
2
+ // last nudged, and the activity fingerprint at that nudge (so a nudge can refire
3
+ // when work shifts to a new task). The refire policy is shared with gaps.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ function file() {
11
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
12
+ return path.join(base, 'skills-atlas', 'prune.json');
13
+ }
14
+ function read() {
15
+ try { const s = JSON.parse(fs.readFileSync(file(), 'utf8')); if (!Array.isArray(s.dismissed)) s.dismissed = []; return s; }
16
+ catch { return { dismissed: [], lastNudge: 0 }; }
17
+ }
18
+ function write(s) {
19
+ try { fs.mkdirSync(path.dirname(file()), { recursive: true }); fs.writeFileSync(file(), JSON.stringify(s)); } catch { /* ignore */ }
20
+ }
21
+ function dismiss(x) { const s = read(); if (x && !s.dismissed.includes(x)) s.dismissed.push(x); write(s); return s; }
22
+ function isDismissed(x) { return read().dismissed.includes(x); }
23
+ function touchNudge(sig) { const s = read(); s.lastNudge = Date.now(); if (sig) s.lastSig = sig; write(s); }
24
+ function clear() { write({ dismissed: [], lastNudge: 0, lastSig: [] }); }
25
+
26
+ module.exports = { file, read, write, dismiss, isDismissed, touchNudge, clear };
package/src/registry.js CHANGED
@@ -76,7 +76,7 @@ function removeCachedSource(url) {
76
76
  // config.json; read-modify-write the full object so registry `sources` is preserved.
77
77
  function getAutopilot() {
78
78
  const c = readConfig();
79
- return { suggest: true, gapAlerts: true, ...(c.autopilot || {}) };
79
+ return { suggest: true, gapAlerts: true, prune: false, ...(c.autopilot || {}) };
80
80
  }
81
81
  function setAutopilot(patch) {
82
82
  const c = readConfig();
@@ -215,6 +215,43 @@ const idfOf = (info, seg) => Math.log(1 + info.n / ((info.df.get(seg) || 0) + 1)
215
215
 
216
216
  const FIRE_IDF = 4.2; // a single distinctive name word must clear this to fire alone
217
217
 
218
+ // --- Content anchors (match by FUNCTION, not just name) ----------------------
219
+ // ~a third of catalog skills have opaque names (sentry/grill-me/get-shit-done) that
220
+ // don't contain their function, and Chinese prompts never match an English name at
221
+ // all. So besides the skill NAME, anchor on the curated function text — use_case /
222
+ // group / when, in BOTH languages — with the same distinctiveness gate as names.
223
+ // A content match must include at least one DISTINCTIVE function word to fire, so
224
+ // generic prose overlap stays silent.
225
+ const CONTENT_FIRE_IDF = 4.6; // weight bar when only one distinctive word matched (+ a 2nd word)
226
+ const CONTENT_DISTINCT_IDF = 3.5; // a word this distinctive (~≤10 rows) counts toward "strong"
227
+
228
+ // Generic Chinese words that must not fire on their own — the CJK analog of
229
+ // ANCHOR_STOP (casual verbs + generic nouns that carry no domain intent).
230
+ const CJK_ANCHOR_STOP = new Set([
231
+ '看看', '看下', '看一', '帮忙', '处理', '解决', '完成', '搞定', '试试', '弄一', '做个', '做一',
232
+ '写个', '写一', '加个', '改改', '改一', '删掉', '运行', '创建', '生成', '修改', '优化', '检查',
233
+ '一下', '一个', '这个', '那个', '东西', '问题', '代码', '文件', '内容', '功能', '项目', '任务',
234
+ '系统', '方法', '工具', '数据', '需要', '想要', '怎么', '如何', '可以', '应该', '一些', '这些',
235
+ ]);
236
+
237
+ // Tokens of a row's curated short function text (NOT the long description — keep it
238
+ // distinctive), both languages, for the corpus DF and for matching a query.
239
+ const rowContent = r =>
240
+ tokenize(lc([r.use_case, r.use_case_en, r.group, r.group_en, r.when_to_use, r.when_to_use_en].filter(Boolean).join(' ')));
241
+ const contentHas = (set, t) => set.has(t) || (t.length > 3 && t.endsWith('s') && set.has(t.slice(0, -1)));
242
+
243
+ const _contentDfCache = new WeakMap();
244
+ function contentDf(rows) {
245
+ let info = _contentDfCache.get(rows);
246
+ if (info) return info;
247
+ const df = new Map();
248
+ let n = 0;
249
+ for (const r of rows) { n++; for (const t of new Set(rowContent(r))) df.set(t, (df.get(t) || 0) + 1); }
250
+ info = { df, n: n || 1 };
251
+ _contentDfCache.set(rows, info);
252
+ return info;
253
+ }
254
+
218
255
  // Autopilot recall: collect a SHORTLIST of catalog skills that may fit a free-text
219
256
  // prompt, for Claude to judge (we do recall; Claude does precision). Returns
220
257
  // { fire, candidates: [{skill, row}], weak }. Sources, in order:
@@ -252,10 +289,49 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
252
289
  }
253
290
  anchors.sort((a, b) => b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
254
291
 
292
+ // 1b. content anchors — match the curated FUNCTION text (use_case / group / when),
293
+ // so opaque-named skills are findable by what they do and Chinese prompts match at
294
+ // all. Generic words (ANCHOR_STOP / CJK_ANCHOR_STOP) are excluded up front.
295
+ const contentTokens = tokens.filter(t => !ANCHOR_STOP.has(t) && !CJK_ANCHOR_STOP.has(t));
296
+ const contentAnchors = [];
297
+ if (contentTokens.length) {
298
+ const cdf = contentDf(rows);
299
+ for (const r of rows) {
300
+ const content = new Set(rowContent(r));
301
+ let weight = 0, strong = 0, matched = 0;
302
+ for (const t of new Set(contentTokens)) {
303
+ if (!contentHas(content, t)) continue;
304
+ matched++;
305
+ const idf = idfOf(cdf, t);
306
+ weight += idf;
307
+ if (idf >= CONTENT_DISTINCT_IDF) strong++; // only distinctive words count as "strong"
308
+ }
309
+ if (strong) contentAnchors.push({ row: r, weight, strong, matched });
310
+ }
311
+ // Prefer rows matching MORE distinctive function words over an incidental hit.
312
+ contentAnchors.sort((a, b) => b.strong - a.strong || b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
313
+ }
314
+ // Fire/qualify only with a distinctive function match: two distinctive words, or
315
+ // one distinctive word backed by a second matched word and enough total weight.
316
+ // (A single distinctive word alone never fires — too easy to hit by coincidence.)
317
+ const contentQualifies = a => a.strong >= 2 || (a.strong >= 1 && a.matched >= 2 && a.weight >= CONTENT_FIRE_IDF);
318
+
319
+ // Merge name + qualifying content anchors into ONE shortlist ranked by strength,
320
+ // so a strong function match (grill-me: interrogate + stress-test) outranks a
321
+ // single-word name match (launch) when it's the better fit. runSearch backfills.
322
+ const ranked0 = [];
323
+ for (const a of anchors) ranked0.push({ skill: a.skill, row: a.row, strong: a.strong, weight: a.weight });
324
+ for (const a of contentAnchors) {
325
+ if (!contentQualifies(a)) continue;
326
+ const s = (a.row.skills || []).find(x => !taken.has(x));
327
+ if (s) ranked0.push({ skill: s, row: a.row, strong: a.strong, weight: a.weight });
328
+ }
329
+ ranked0.sort((a, b) => b.strong - a.strong || b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
330
+
255
331
  const out = [];
256
332
  const seen = new Set(taken);
257
333
  const push = (skill, row) => { if (!seen.has(skill)) { seen.add(skill); out.push({ skill, row }); } };
258
- for (const a of anchors) { if (out.length >= limit) break; push(a.skill, a.row); }
334
+ for (const a of ranked0) { if (out.length >= limit) break; push(a.skill, a.row); }
259
335
 
260
336
  // 2. fill remaining slots from the general ranked search — but only with rows
261
337
  // that are actually on-topic (a name/group hit, or strong coverage). A lone
@@ -276,7 +352,10 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
276
352
  // distinctive (high-IDF) one. A prompt with mere prose overlap and no name
277
353
  // signal stays silent (better a miss than noise on every generic prompt); the
278
354
  // ranked search still ENRICHES the shortlist once an anchor has fired.
279
- const fire = out.length > 0 && anchors.some(a => a.strong >= 2 || a.weight >= FIRE_IDF);
355
+ const fire = out.length > 0 && (
356
+ anchors.some(a => a.strong >= 2 || a.weight >= FIRE_IDF) ||
357
+ contentAnchors.some(contentQualifies)
358
+ );
280
359
  return { fire, candidates: out.slice(0, limit), weak };
281
360
  }
282
361