skills-atlas-cli 0.8.2 → 0.8.5
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 +1 -1
- package/src/commands/info.js +22 -1
- package/src/commands/search.js +5 -1
- package/src/commands/suggest.js +9 -8
- package/src/format.js +59 -10
- package/src/gapstate.js +50 -4
package/package.json
CHANGED
package/src/commands/info.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
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/commands/suggest.js
CHANGED
|
@@ -20,8 +20,7 @@ const transcripts = require('../transcripts');
|
|
|
20
20
|
const gapstate = require('../gapstate');
|
|
21
21
|
|
|
22
22
|
const COOLDOWN = 3; // min prompts between suggestions
|
|
23
|
-
const GAP_EVERY = 12;
|
|
24
|
-
const NUDGE_COOLDOWN_MS = 24 * 3600000; // and at most one gap nudge per day (across sessions)
|
|
23
|
+
const GAP_EVERY = 12; // a gap nudge is only considered at every Nth prompt (per session)
|
|
25
24
|
|
|
26
25
|
function stateFile(sessionId) {
|
|
27
26
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
@@ -58,12 +57,14 @@ module.exports = async function suggest() {
|
|
|
58
57
|
state.count = (state.count || 0) + 1;
|
|
59
58
|
const ap = registry.getAutopilot();
|
|
60
59
|
|
|
61
|
-
// --- Proactive gap nudge: periodic
|
|
60
|
+
// --- Proactive gap nudge: periodic, and refired when the recurring work shifts
|
|
61
|
+
// to something new (anti-spam floor + activity fingerprint, not a daily clock) ---
|
|
62
62
|
if (ap.gapAlerts && state.count % GAP_EVERY === 0) {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
|
|
63
|
+
const recent = transcripts.recentPrompts({ max: 20 });
|
|
64
|
+
if (recent.length >= 8) {
|
|
65
|
+
const gs = gapstate.read();
|
|
66
|
+
const { fire, sig } = gapstate.shouldNudge(gs, recent, Date.now());
|
|
67
|
+
if (fire) {
|
|
67
68
|
const dismissed = gs.dismissed || [];
|
|
68
69
|
const lines = recent.map(r => `- ${r.text.replace(/\s+/g, ' ').slice(0, 100)}`).join('\n');
|
|
69
70
|
const days = Math.max(1, Math.round((Date.now() - recent[recent.length - 1].ts) / 86400000));
|
|
@@ -73,7 +74,7 @@ module.exports = async function suggest() {
|
|
|
73
74
|
`\`skills-atlas info <skill>\` and install with \`skills-atlas use <skill> --yes\`.` +
|
|
74
75
|
(dismissed.length ? ` Already dismissed (skip): ${dismissed.join(', ')}.` : '') +
|
|
75
76
|
` If nothing clearly recurs or it doesn't fit right now, stay silent.`);
|
|
76
|
-
gapstate.touchNudge();
|
|
77
|
+
gapstate.touchNudge(sig);
|
|
77
78
|
writeState(file, state);
|
|
78
79
|
return;
|
|
79
80
|
}
|
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
|
-
|
|
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
|
-
?
|
|
48
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/gapstate.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// The only thing capability-gaps persists: which suggestions the user dismissed,
|
|
2
|
-
//
|
|
2
|
+
// when we last proactively nudged, and a fingerprint of what the user was doing at
|
|
3
|
+
// that nudge. (Judgment is Claude's; no prompt text is stored — only token stems.)
|
|
3
4
|
'use strict';
|
|
4
5
|
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
9
|
+
const { tokenize } = require('./search-core');
|
|
10
|
+
|
|
11
|
+
// Gap nudges are gated by activity, not a wall clock: a short anti-spam floor, then
|
|
12
|
+
// a refire whenever the recurring work shifts to something new (so it can catch the
|
|
13
|
+
// user on their NEXT task), plus a long fallback so a persistent gap can resurface.
|
|
14
|
+
const MIN_INTERVAL_MS = 90 * 60 * 1000; // ~90 min: never two nudges in a burst
|
|
15
|
+
const REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000; // ~12 h: an unchanged gap may resurface
|
|
8
16
|
|
|
9
17
|
function file() {
|
|
10
18
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
@@ -19,7 +27,45 @@ function write(s) {
|
|
|
19
27
|
}
|
|
20
28
|
function dismiss(x) { const s = read(); if (x && !s.dismissed.includes(x)) s.dismissed.push(x); write(s); return s; }
|
|
21
29
|
function isDismissed(x) { return read().dismissed.includes(x); }
|
|
22
|
-
function touchNudge() { const s = read(); s.lastNudge = Date.now(); write(s); }
|
|
23
|
-
function clear() { write({ dismissed: [], lastNudge: 0 }); }
|
|
30
|
+
function touchNudge(sig) { const s = read(); s.lastNudge = Date.now(); if (sig) s.lastSig = sig; write(s); }
|
|
31
|
+
function clear() { write({ dismissed: [], lastNudge: 0, lastSig: [] }); }
|
|
32
|
+
|
|
33
|
+
// A coarse fingerprint of recent work: the most frequent contentful tokens across
|
|
34
|
+
// recent prompts. When this set shifts, the user has moved to a new kind of work.
|
|
35
|
+
function activitySignature(prompts, topN = 8) {
|
|
36
|
+
const freq = new Map();
|
|
37
|
+
for (const p of prompts || []) {
|
|
38
|
+
const text = typeof p === 'string' ? p : (p && p.text) || '';
|
|
39
|
+
for (const t of tokenize(String(text).toLowerCase())) freq.set(t, (freq.get(t) || 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
return [...freq.entries()]
|
|
42
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
43
|
+
.slice(0, topN).map(e => e[0]).sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Did the dominant activity shift by more than half (Jaccard < 0.5)? No prior → yes.
|
|
47
|
+
function signatureShifted(sig, prev) {
|
|
48
|
+
if (!prev || !prev.length) return true;
|
|
49
|
+
const a = new Set(sig);
|
|
50
|
+
if (!a.size) return false;
|
|
51
|
+
const b = new Set(prev);
|
|
52
|
+
let inter = 0; for (const x of a) if (b.has(x)) inter++;
|
|
53
|
+
const union = new Set([...a, ...b]).size || 1;
|
|
54
|
+
return inter / union < 0.5;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Smart-refire decision (pure). Fire when past the anti-spam floor AND either the
|
|
58
|
+
// activity shifted to something new, or the long fallback has elapsed.
|
|
59
|
+
function shouldNudge(state, recent, now) {
|
|
60
|
+
const sig = activitySignature(recent);
|
|
61
|
+
const since = now - ((state && state.lastNudge) || 0);
|
|
62
|
+
if (since < MIN_INTERVAL_MS) return { fire: false, sig };
|
|
63
|
+
const fire = signatureShifted(sig, state && state.lastSig) || since >= REFRESH_INTERVAL_MS;
|
|
64
|
+
return { fire, sig };
|
|
65
|
+
}
|
|
24
66
|
|
|
25
|
-
module.exports = {
|
|
67
|
+
module.exports = {
|
|
68
|
+
file, read, write, dismiss, isDismissed, touchNudge, clear,
|
|
69
|
+
activitySignature, signatureShifted, shouldNudge,
|
|
70
|
+
MIN_INTERVAL_MS, REFRESH_INTERVAL_MS,
|
|
71
|
+
};
|