skills-atlas-cli 0.4.0 → 0.4.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
@@ -110,14 +110,21 @@ just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
110
110
  skills-atlas hook on # enable (skills-atlas hook off / status)
111
111
  ```
112
112
 
113
- Registers a Claude Code `UserPromptSubmit` hook. When what you ask **strongly
114
- matches** a catalog skill you don't have, Claude gets a one-line note and can
115
- offer to install + activate it you don't have to know the skill exists. It's:
113
+ Registers a Claude Code `UserPromptSubmit` hook. When what you ask matches the
114
+ territory of a catalog skill you don't have, the hook hands Claude a short
115
+ shortlist of candidates and **Claude decides** whether any genuinely fits and
116
+ if so, explains **what it does and why it fits your task**, then offers a choice:
117
+ use it now, see what it covers first (`skills-atlas info`), or skip. You don't
118
+ have to know the skill exists. The split is deliberate: the hook does **recall**
119
+ (a distinctive-word match against the catalog, so the right skill is on the
120
+ table), Claude does **precision** (it understands your intent and stays silent
121
+ unless one truly fits, or searches further itself). It's:
116
122
 
117
123
  - **off by default** — you turn it on explicitly; `hook off` removes it cleanly.
118
- - **quiet** — only fires on a confident match, never for an already-installed
119
- skill, never the same skill twice, with a cooldown between suggestions, and
120
- **Claude still decides** whether it's relevant enough to mention.
124
+ - **quiet** — only fires on a distinctive match (greetings and generic actions
125
+ like "fix the typo" stay silent), never for an already-installed skill, never
126
+ the same skill twice, with a cooldown between suggestions — and Claude is the
127
+ final filter on relevance.
121
128
  - **local & private** — your prompt is matched against the bundled catalog
122
129
  on your machine; nothing is sent anywhere.
123
130
  - **safe** — never auto-installs (always your call), and fails open (a hook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -69,7 +69,13 @@ module.exports = async function hook(argv) {
69
69
  if (values.json) { console.log(JSON.stringify({ enabled: sub === 'on', settings: p })); return; }
70
70
  console.log(`${green('✓')} autopilot ${sub === 'on' ? 'enabled' : 'disabled'} ${dim(p)}`);
71
71
  if (sub === 'on') {
72
- console.log(dim('Claude now gets a one-line skill suggestion when your prompt strongly matches a catalog skill.'));
73
- console.log(dim('needs `skills-atlas` on PATH (npm i -g skills-atlas-cli). turn off: skills-atlas hook off'));
72
+ console.log('\nHow it works: when what you ask lines up with a skill you don\'t have yet, Claude');
73
+ console.log('quietly gets a shortlist and only if one truly fits explains it and offers a choice:');
74
+ console.log(dim(' you: "run a pre-mortem before we launch"'));
75
+ console.log(dim(' claude: "that\'s exactly what the pre-mortem skill does — it stress-tests your plan'));
76
+ console.log(dim(' before launch. use it now / see what it covers / skip?"'));
77
+ console.log(dim('\nIt stays silent on greetings and generic asks, never repeats a skill, and Claude makes'));
78
+ console.log(dim('the final call on relevance. Nothing leaves your machine.'));
79
+ console.log(dim('\nneeds `skills-atlas` on PATH (npm i -g skills-atlas-cli). turn off: skills-atlas hook off'));
74
80
  }
75
81
  };
@@ -275,12 +275,16 @@ module.exports = async function install(argv) {
275
275
  if (values.inline) {
276
276
  const body = readSkillMd(result.dest);
277
277
  if (body) {
278
- console.log('\n' + dim('─── SKILL.md (active for this task follow it now) ───'));
278
+ console.log('\n' + dim('─── SKILL.md the skill\'s own instructions; apply them to the task now ───'));
279
279
  console.log(body.trim());
280
280
  console.log(dim('─── end SKILL.md ───'));
281
281
  }
282
- console.log(dim('\n(the folder is also installed for future sessions)'));
282
+ console.log(`\n${green('✓')} ${bold(skill)} is now active — use the instructions above for the task at hand.`);
283
+ console.log(dim(` installed at ${fsu.tildify(result.dest)} (auto-loads in new sessions) · what it does: skills-atlas info ${skill} · remove: skills-atlas remove ${skill}`));
283
284
  } else {
284
- console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
285
+ console.log(`\n${bold(skill)} is installed but not loaded yet. To use it:`);
286
+ console.log(dim(` • now, in this session: skills-atlas use ${skill}`));
287
+ console.log(dim(' • or start a new Claude Code session — it auto-loads from ~/.claude/skills/'));
288
+ console.log(dim(` what it does: skills-atlas info ${skill} · remove: skills-atlas remove ${skill}`));
285
289
  }
286
290
  };
@@ -1,9 +1,10 @@
1
1
  // `skills-atlas suggest` — the autopilot. Runs as a Claude Code UserPromptSubmit
2
- // hook: reads the event JSON from stdin, matches the prompt against the catalog
3
- // locally, and (if a strong, fresh, not-installed match survives a cooldown)
4
- // emits a one-line additionalContext for Claude to weigh. ALWAYS exits 0 and
5
- // never blocks the user (fail-open). Matching is local-only the prompt is
6
- // never sent anywhere.
2
+ // hook: reads the event JSON from stdin, retrieves a SHORTLIST of catalog skills
3
+ // that may fit the prompt (recall), and injects them as additionalContext for
4
+ // Claude to judge (precision) Claude offers one only if it genuinely fits, and
5
+ // can run `skills-atlas search` itself to look further. ALWAYS exits 0 and never
6
+ // blocks the user (fail-open). Matching is local-only — the prompt is never sent
7
+ // anywhere.
7
8
  'use strict';
8
9
 
9
10
  const fs = require('fs');
@@ -11,7 +12,7 @@ const os = require('os');
11
12
  const path = require('path');
12
13
  const { loadData } = require('../data');
13
14
  const { buildIndices } = require('../index-build');
14
- const { pickSuggestion } = require('../search-core');
15
+ const { suggestCandidates } = require('../search-core');
15
16
  const manifest = require('../manifest');
16
17
  const fsu = require('../fsutil');
17
18
 
@@ -57,20 +58,28 @@ module.exports = async function suggest() {
57
58
  for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
58
59
  const suggested = new Set(state.suggested || []);
59
60
 
60
- const pick = pickSuggestion(flatRows, prompt, { installed, suggested });
61
- if (!pick) { writeState(file, state); return; }
61
+ const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
62
+ if (!fire) { writeState(file, state); return; }
62
63
 
63
- const uc = pick.row.use_case_en || pick.row.use_case || '';
64
+ const lines = candidates.map(c => {
65
+ const uc = (c.row.use_case_en || c.row.use_case || '').replace(/\s+/g, ' ').trim().slice(0, 80);
66
+ return `- ${c.skill}${uc ? ` — ${uc}` : ''} (details: \`skills-atlas info ${c.skill}\` · use now: \`skills-atlas use ${c.skill} --yes\`)`;
67
+ }).join('\n');
64
68
  const ctx =
65
- `[Skills Atlas autopilot] The user's task may fit the installable agent skill ` +
66
- `\`${pick.skill}\`${uc ? ` (catalog use-case: "${uc}")` : ''}. ONLY if it's clearly ` +
67
- `relevant to what they actually asked, briefly offer it — they can install and ` +
68
- `activate it now with \`skills-atlas use ${pick.skill} --yes\`. If it isn't a strong ` +
69
- `fit, don't mention it.`;
69
+ `[Skills Atlas autopilot] The user may be doing something one of these installable agent ` +
70
+ `skills is built for. Judge for yourself do NOT mention any of this unless one of them ` +
71
+ `genuinely fits what they actually asked:\n${lines}\n` +
72
+ `If one genuinely fits, DON'T just name it: in one line tell the user what it does and why it ` +
73
+ `fits THIS task (it's a curated skill from the Skills Atlas catalog, not something you made up), ` +
74
+ `then let them choose — activate it now (\`skills-atlas use <skill> --yes\` installs + applies it ` +
75
+ `immediately), see what it covers first (\`skills-atlas info <skill>\`), or skip and you'll just ` +
76
+ `do the task yourself. If none fit but the task plainly needs a specialized skill, you may run ` +
77
+ `\`skills-atlas search "<short intent>"\` to look further. If nothing fits, say nothing about this ` +
78
+ `at all — don't mention this hook, these skills, or that a suggestion was made.`;
70
79
  console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } }));
71
80
 
72
81
  state.lastSuggestedCount = state.count;
73
- state.suggested = [...suggested, pick.skill];
82
+ state.suggested = [...suggested, ...candidates.map(c => c.skill)];
74
83
  writeState(file, state);
75
84
  } catch {
76
85
  // fail-open: never break the user's workflow
@@ -154,43 +154,118 @@ function searchRows(rows, opts = {}) {
154
154
  return runSearch(rows, opts).rows;
155
155
  }
156
156
 
157
- // Autopilot: pick the single best skill to proactively suggest for a free-text
158
- // prompt, or null. Conservative on purpose requires a STRONG, multi-signal
159
- // match (an exact skill-name token, OR ≥2 distinct query tokens hitting the
160
- // high-signal fields name/group/use_case), so a single common word can't
161
- // trigger it. Skips already-installed and already-suggested skills.
162
- function pickSuggestion(rows, prompt, { installed = new Set(), suggested = new Set() } = {}) {
157
+ // Generic words that, on their OWN, must not make the autopilot fire: common
158
+ // English verbs/nouns that often appear inside skill names but carry no domain
159
+ // intent ("fix the typo", "build the app", "design a system" stay quiet). They
160
+ // can still ride along in a multi-word match; they just can't trigger alone.
161
+ const ANCHOR_STOP = new Set([
162
+ 'fix', 'make', 'build', 'create', 'add', 'remove', 'delete', 'update', 'change',
163
+ 'changes', 'set', 'run', 'clean', 'rename', 'move', 'copy', 'write', 'review',
164
+ 'improve', 'optimize', 'check', 'format', 'handle', 'manage', 'generate', 'edit',
165
+ 'open', 'start', 'stop', 'ship', 'render', 'page', 'file', 'files', 'line', 'load',
166
+ 'code', 'app', 'apps', 'work', 'thing', 'things', 'stuff', 'data', 'system',
167
+ 'design', 'document', 'component', 'components', 'feature', 'features', 'variable',
168
+ 'function', 'project', 'task', 'tool', 'name', 'names',
169
+ ]);
170
+
171
+ // Word-aware skill-NAME match: return the matched name segment (or null). Stricter
172
+ // than substring — generic fragments must NOT match inside a name ("line" ≠
173
+ // "guide-LINE-s"). Stem matching only for tokens/segments ≥5 chars so
174
+ // "debug"~"debugging" matches but "line"~"linear" does not. Plural-tolerant.
175
+ function matchedSegment(skillName, token) {
176
+ const segs = lc(skillName).split(/[^a-z0-9]+/).filter(Boolean);
177
+ let hit = null;
178
+ for (const seg of segs) {
179
+ if (seg === token) return seg; // exact wins
180
+ if ((token.length >= 5 && seg.startsWith(token)) || // debug → debugging
181
+ (seg.length >= 5 && token.startsWith(seg)) || // requesting ← request
182
+ (token.length > 3 && token.endsWith('s') && seg === token.slice(0, -1)) || // tests → test
183
+ (seg.length > 3 && seg.endsWith('s') && token === seg.slice(0, -1))) hit = seg; // test → tests
184
+ }
185
+ return hit;
186
+ }
187
+ const nameWordHit = (skillName, token) => matchedSegment(skillName, token) !== null;
188
+
189
+ // Document frequency of each name segment across all skills (memoized per rows
190
+ // array). Distinctive segments ("brainstorm", "mortem", "figma") appear in few
191
+ // skills → high IDF; generic ones ("user", "test") in many → low IDF.
192
+ const _dfCache = new WeakMap();
193
+ function segmentDf(rows) {
194
+ let info = _dfCache.get(rows);
195
+ if (info) return info;
196
+ const df = new Map();
197
+ let n = 0;
198
+ for (const r of rows) for (const s of r.skills || []) {
199
+ n++;
200
+ for (const seg of new Set(lc(s).split(/[^a-z0-9]+/).filter(Boolean))) df.set(seg, (df.get(seg) || 0) + 1);
201
+ }
202
+ info = { df, n: n || 1 };
203
+ _dfCache.set(rows, info);
204
+ return info;
205
+ }
206
+ const idfOf = (info, seg) => Math.log(1 + info.n / ((info.df.get(seg) || 0) + 1));
207
+
208
+ const FIRE_IDF = 4.2; // a single distinctive name word must clear this to fire alone
209
+
210
+ // Autopilot recall: collect a SHORTLIST of catalog skills that may fit a free-text
211
+ // prompt, for Claude to judge (we do recall; Claude does precision). Returns
212
+ // { fire, candidates: [{skill, row}], weak }. Sources, in order:
213
+ // 1. skill-NAME matches, scored by summed IDF of the matched words and sorted
214
+ // so the most distinctive multi-word match leads (paper-slide-deck beats a
215
+ // one-word "research" match), then
216
+ // 2. the general ranked search (runSearch) fills remaining slots.
217
+ // `fire` is true only when there's a distinctive anchor (a rare name word, or ≥2
218
+ // matched name words) or an otherwise strong (non-weak) match — so the hook stays
219
+ // quiet on greetings and generic dev actions ("fix the typo", "build the app").
220
+ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), limit = 5 } = {}) {
163
221
  const tokens = tokenize(lc(prompt));
164
- if (tokens.length < 2) return null; // too vague to suggest confidently
165
- const inName = (name, ts) => { const n = lc(name); return ts.filter(t => n.includes(t)).length; };
222
+ if (tokens.length < 2) return { fire: false, candidates: [], weak: false };
223
+ const taken = new Set([...installed, ...suggested]);
224
+ const info = segmentDf(rows);
166
225
 
167
- let best = null;
168
- for (const r of rows) {
169
- const f = buildFields(r);
170
- const skillSet = new Set((r.skills || []).map(lc));
171
- let nameHits = 0, otherHits = 0, exact = false;
226
+ // 1. name anchors — ONLY contentful (non-generic) name words count, weighted by
227
+ // distinctiveness. A skill matched purely by a generic word ("optimize",
228
+ // "design", "system") is not an anchor at all, so generic words can neither
229
+ // fire the hook nor crowd the shortlist.
230
+ const anchors = [];
231
+ for (const r of rows) for (const s of r.skills || []) {
232
+ if (taken.has(s)) continue;
233
+ let weight = 0, strong = 0;
234
+ const usedSeg = new Set();
172
235
  for (const t of tokens) {
173
- if (skillSet.has(t)) exact = true;
174
- if (fieldHas(f.name, t)) nameHits++; // skill-name match = strongest signal
175
- else if (fieldHas(f.group, t) || fieldHas(f.use, t)) otherHits++;
236
+ const seg = matchedSegment(s, t);
237
+ if (!seg || usedSeg.has(seg)) continue;
238
+ usedSeg.add(seg);
239
+ if (ANCHOR_STOP.has(t) || ANCHOR_STOP.has(seg)) continue; // generic word — ignore
240
+ strong++;
241
+ weight += idfOf(info, seg);
176
242
  }
177
- // Require a skill-NAME signal (or an exact name token). Two ordinary words
178
- // co-occurring in some row's verbose prose is NOT enough — that's the noise.
179
- if (!exact && !(nameHits >= 1 && nameHits + otherHits >= 2)) continue;
180
- const score = nameHits * 15 + otherHits * 6 + (exact ? 50 : 0) + maxStars(r) / 1e7;
181
- if (!best || score > best.score) best = { row: r, score };
243
+ if (strong) anchors.push({ skill: s, row: r, weight, strong });
182
244
  }
183
- if (!best) return null;
184
-
185
- // pick the most on-point skill in the winning row that isn't already in play
186
- const cand = (best.row.skills || []).filter(s => !installed.has(s) && !suggested.has(s));
187
- if (!cand.length) return null;
188
- cand.sort((a, b) => inName(b, tokens) - inName(a, tokens));
189
- // the suggested skill's own name must overlap the prompt, else it's a mispick
190
- if (inName(cand[0], tokens) === 0) return null;
191
- return { skill: cand[0], row: best.row };
245
+ anchors.sort((a, b) => b.weight - a.weight || maxStars(b.row) - maxStars(a.row));
246
+
247
+ const out = [];
248
+ const seen = new Set(taken);
249
+ const push = (skill, row) => { if (!seen.has(skill)) { seen.add(skill); out.push({ skill, row }); } };
250
+ for (const a of anchors) { if (out.length >= limit) break; push(a.skill, a.row); }
251
+
252
+ // 2. fill from the general ranked search (one primary skill per row)
253
+ const { rows: ranked, weak } = runSearch(rows, { query: prompt });
254
+ for (const r of ranked) {
255
+ if (out.length >= limit) break;
256
+ const s = (r.skills || []).find(x => !seen.has(x));
257
+ if (s) push(s, r);
258
+ }
259
+
260
+ // fire decision: only on a real name anchor — two contentful name words, OR one
261
+ // distinctive (high-IDF) one. A prompt with mere prose overlap and no name
262
+ // signal stays silent (better a miss than noise on every generic prompt); the
263
+ // ranked search still ENRICHES the shortlist once an anchor has fired.
264
+ const fire = out.length > 0 && anchors.some(a => a.strong >= 2 || a.weight >= FIRE_IDF);
265
+ return { fire, candidates: out.slice(0, limit), weak };
192
266
  }
193
267
 
194
268
  module.exports = {
195
- tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars, pickSuggestion, PERSONA_ALIAS,
269
+ tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars,
270
+ nameWordHit, matchedSegment, suggestCandidates, PERSONA_ALIAS,
196
271
  };