skills-atlas-cli 0.8.7 → 0.8.9
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 +6 -1
- package/package.json +1 -1
- package/src/commands/hook.js +7 -6
- package/src/commands/prune.js +107 -0
- package/src/commands/suggest.js +22 -0
- package/src/commands/update.js +4 -1
- package/src/data.js +32 -1
- package/src/prunestate.js +26 -0
- package/src/registry.js +1 -1
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
|
|
@@ -71,6 +73,9 @@ async function main() {
|
|
|
71
73
|
process.exitCode = 1;
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
76
|
+
// Opportunistic, non-blocking background catalog refresh so new skills appear over
|
|
77
|
+
// time without a manual `update`. Skipped for `update` itself; fully fail-silent.
|
|
78
|
+
if (sub !== 'update') { try { require('../src/data').maybeBackgroundRefresh(); } catch { /* ignore */ } }
|
|
74
79
|
await cmd(rest);
|
|
75
80
|
}
|
|
76
81
|
|
package/package.json
CHANGED
package/src/commands/hook.js
CHANGED
|
@@ -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(
|
|
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;
|
package/src/commands/suggest.js
CHANGED
|
@@ -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; }
|
package/src/commands/update.js
CHANGED
|
@@ -6,7 +6,10 @@ const { refreshData, refreshSources } = require('../data');
|
|
|
6
6
|
module.exports = async function update(argv) {
|
|
7
7
|
const { values } = parse(argv, ['json']);
|
|
8
8
|
if (values.help) {
|
|
9
|
-
console.log('usage: skills-atlas update\n\
|
|
9
|
+
console.log('usage: skills-atlas update\n\n' +
|
|
10
|
+
'Refresh the local catalog cache from the public data feed.\n' +
|
|
11
|
+
'The catalog also auto-refreshes in the background (~daily) so new skills appear\n' +
|
|
12
|
+
'on their own; set SKILLS_ATLAS_NO_REFRESH=1 (or SKILLS_ATLAS_OFFLINE=1) to disable.');
|
|
10
13
|
return;
|
|
11
14
|
}
|
|
12
15
|
|
package/src/data.js
CHANGED
|
@@ -93,6 +93,37 @@ function maybeStaleNudge(fromCache) {
|
|
|
93
93
|
process.stderr.write("tip: run 'skills-atlas update' to refresh the catalog\n");
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const REFRESH_AFTER_MS = 24 * 3600000; // auto-refresh once the cache is a day old
|
|
97
|
+
const refreshStampFile = () => path.join(cacheDir(), 'autorefresh.json');
|
|
98
|
+
|
|
99
|
+
// Pure decision: should we kick off a background refresh now? (cache stale/absent,
|
|
100
|
+
// not throttled, not opted out). Injectable for tests.
|
|
101
|
+
function shouldAutoRefresh({ meta, stamp, env, now }) {
|
|
102
|
+
if (env.SKILLS_ATLAS_NO_REFRESH || env.SKILLS_ATLAS_OFFLINE || env.SKILLS_ATLAS_BG_REFRESH) return false;
|
|
103
|
+
const t = meta && meta.fetchedAt ? Date.parse(meta.fetchedAt) : NaN;
|
|
104
|
+
const ageMs = Number.isFinite(t) ? now - t : Infinity; // absent/bad meta → treat as stale
|
|
105
|
+
if (ageMs < REFRESH_AFTER_MS) return false;
|
|
106
|
+
const st = stamp && stamp.at ? Date.parse(stamp.at) : NaN;
|
|
107
|
+
if (Number.isFinite(st) && (now - st) < REFRESH_AFTER_MS) return false; // already tried today
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fire-and-forget: if the catalog is old (or absent) and we haven't tried today,
|
|
112
|
+
// spawn a DETACHED background `update` so new skills appear next time. Never blocks,
|
|
113
|
+
// never errors out. Opt out with SKILLS_ATLAS_NO_REFRESH / SKILLS_ATLAS_OFFLINE.
|
|
114
|
+
function maybeBackgroundRefresh() {
|
|
115
|
+
try {
|
|
116
|
+
if (!shouldAutoRefresh({ meta: tryReadJSON(metaFile()), stamp: tryReadJSON(refreshStampFile()), env: process.env, now: Date.now() })) return;
|
|
117
|
+
ensureDir(cacheDir());
|
|
118
|
+
fs.writeFileSync(refreshStampFile(), JSON.stringify({ at: new Date().toISOString() })); // stamp first → no double-spawn
|
|
119
|
+
const { spawn } = require('child_process');
|
|
120
|
+
const child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'skills.js'), 'update'], {
|
|
121
|
+
detached: true, stdio: 'ignore', env: { ...process.env, SKILLS_ATLAS_BG_REFRESH: '1' },
|
|
122
|
+
});
|
|
123
|
+
child.unref();
|
|
124
|
+
} catch { /* fully fail-silent — never affect the foreground command */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
96
127
|
function loadData({ quiet = false } = {}) {
|
|
97
128
|
const cached = tryReadJSON(cacheFile());
|
|
98
129
|
let base, info;
|
|
@@ -205,4 +236,4 @@ async function refreshSources() {
|
|
|
205
236
|
return out;
|
|
206
237
|
}
|
|
207
238
|
|
|
208
|
-
module.exports = { loadData, refreshData, fetchSource, refreshSources, counts, isValid, cacheDir, PUBLIC_URL };
|
|
239
|
+
module.exports = { loadData, refreshData, fetchSource, refreshSources, counts, isValid, shouldAutoRefresh, maybeBackgroundRefresh, cacheDir, PUBLIC_URL };
|
|
@@ -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();
|