skills-atlas-cli 0.3.1 → 0.4.1

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
@@ -104,6 +104,31 @@ just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
104
104
  /plugin install skills-atlas@skills-atlas
105
105
  ```
106
106
 
107
+ ## Autopilot (opt-in) — the right skill finds you
108
+
109
+ ```bash
110
+ skills-atlas hook on # enable (skills-atlas hook off / status)
111
+ ```
112
+
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 offers to install + activate it. You don't have to know the skill exists.
117
+ The split is deliberate: the hook does **recall** (a distinctive-word match
118
+ against the catalog, so the right skill is on the table), Claude does
119
+ **precision** (it understands your intent and stays silent unless one truly
120
+ fits, or searches further itself). It's:
121
+
122
+ - **off by default** — you turn it on explicitly; `hook off` removes it cleanly.
123
+ - **quiet** — only fires on a distinctive match (greetings and generic actions
124
+ like "fix the typo" stay silent), never for an already-installed skill, never
125
+ the same skill twice, with a cooldown between suggestions — and Claude is the
126
+ final filter on relevance.
127
+ - **local & private** — your prompt is matched against the bundled catalog
128
+ on your machine; nothing is sent anywhere.
129
+ - **safe** — never auto-installs (always your call), and fails open (a hook
130
+ error never blocks your prompt).
131
+
107
132
  ## License
108
133
 
109
134
  MIT. Each installed skill keeps its own source repository's license.
package/bin/skills.js CHANGED
@@ -9,13 +9,15 @@ const upgrade = require('../src/commands/upgrade');
9
9
  const remove = require('../src/commands/remove');
10
10
  const outdated = require('../src/commands/outdated');
11
11
  const doctor = require('../src/commands/doctor');
12
+ const suggest = require('../src/commands/suggest');
13
+ const hook = require('../src/commands/hook');
12
14
  const update = require('../src/commands/update');
13
15
  const { categories, list } = require('../src/commands/categories');
14
16
 
15
17
  const VERSION = require('../package.json').version;
16
18
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
17
19
  const use = argv => install([...argv, '--inline']);
18
- const commands = { search, info, install, use, installed, upgrade, remove, outdated, doctor, update, categories, list };
20
+ const commands = { search, info, install, use, installed, upgrade, remove, outdated, doctor, suggest, hook, update, categories, list };
19
21
 
20
22
  const HELP = `skills-atlas — search, install & manage AI agent skills
21
23
 
@@ -34,6 +36,9 @@ manage what you've installed:
34
36
  remove <skill> delete an installed skill
35
37
  doctor health check: orphans, drift, missing SKILL.md, license/script risks
36
38
 
39
+ autopilot (opt-in):
40
+ hook on|off|status proactively suggest a skill in Claude when your prompt fits one
41
+
37
42
  catalog:
38
43
  update refresh the catalog from the public data feed
39
44
  categories list the top-level categories
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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",
@@ -0,0 +1,75 @@
1
+ // `skills-atlas hook on|off|status` — opt-in switch for the autopilot. Registers
2
+ // (or removes) a UserPromptSubmit hook in ~/.claude/settings.json that runs
3
+ // `skills-atlas suggest`. Merge-safe (never clobbers other settings), idempotent,
4
+ // backs up before writing, removes only our own entry.
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { parse } = require('../args');
11
+ const { green, dim } = require('../format');
12
+
13
+ const HOOK_CMD = 'skills-atlas suggest';
14
+ const settingsPath = () => path.join(os.homedir(), '.claude', 'settings.json');
15
+
16
+ function readSettings(p) {
17
+ if (!fs.existsSync(p)) return {};
18
+ return JSON.parse(fs.readFileSync(p, 'utf8')); // may throw → caller handles
19
+ }
20
+ const isOurs = e => e && Array.isArray(e.hooks)
21
+ && e.hooks.some(h => h && typeof h.command === 'string' && h.command.includes(HOOK_CMD));
22
+
23
+ module.exports = async function hook(argv) {
24
+ const { values, positionals } = parse(argv, ['json']);
25
+ if (values.help) { console.log('usage: skills-atlas hook <on|off|status>'); return; }
26
+ const sub = positionals[0] || 'status';
27
+ const p = settingsPath();
28
+
29
+ if (sub === 'status') {
30
+ let on = false;
31
+ try { on = ((readSettings(p).hooks || {}).UserPromptSubmit || []).some(isOurs); } catch { /* invalid → off */ }
32
+ if (values.json) { console.log(JSON.stringify({ enabled: on, settings: p })); return; }
33
+ console.log(`autopilot: ${on ? green('on') : dim('off')} ${dim(p)}`);
34
+ if (!on) console.log(dim('enable: skills-atlas hook on'));
35
+ return;
36
+ }
37
+
38
+ if (sub !== 'on' && sub !== 'off') {
39
+ console.error('usage: skills-atlas hook <on|off|status>');
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+
44
+ let settings;
45
+ try { settings = readSettings(p); }
46
+ catch (e) { console.error(`${p} is not valid JSON — fix it first (${e.message}).`); process.exitCode = 1; return; }
47
+
48
+ // Coerce defensively: a user's settings could have `hooks` as a non-object or
49
+ // `UserPromptSubmit` as a non-array. Don't crash on it — start from a clean shape.
50
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) settings.hooks = {};
51
+ const arr = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
52
+
53
+ if (sub === 'on') {
54
+ if (arr.some(isOurs)) { console.log(dim('autopilot already on.')); return; }
55
+ settings.hooks.UserPromptSubmit = [...arr, { matcher: '*', hooks: [{ type: 'command', command: HOOK_CMD, timeout: 5 }] }];
56
+ } else {
57
+ const kept = arr.filter(e => !isOurs(e));
58
+ if (kept.length) settings.hooks.UserPromptSubmit = kept;
59
+ else delete settings.hooks.UserPromptSubmit;
60
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
61
+ }
62
+
63
+ try {
64
+ fs.mkdirSync(path.dirname(p), { recursive: true });
65
+ if (fs.existsSync(p) && !fs.existsSync(`${p}.bak`)) fs.copyFileSync(p, `${p}.bak`); // keep the pristine original
66
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + '\n');
67
+ } catch (e) { console.error(`failed to write ${p}: ${e.message}`); process.exitCode = 1; return; }
68
+
69
+ if (values.json) { console.log(JSON.stringify({ enabled: sub === 'on', settings: p })); return; }
70
+ console.log(`${green('✓')} autopilot ${sub === 'on' ? 'enabled' : 'disabled'} ${dim(p)}`);
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'));
74
+ }
75
+ };
@@ -0,0 +1,84 @@
1
+ // `skills-atlas suggest` — the autopilot. Runs as a Claude Code UserPromptSubmit
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.
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { loadData } = require('../data');
14
+ const { buildIndices } = require('../index-build');
15
+ const { suggestCandidates } = require('../search-core');
16
+ const manifest = require('../manifest');
17
+ const fsu = require('../fsutil');
18
+
19
+ const COOLDOWN = 3; // min prompts between suggestions
20
+
21
+ function stateFile(sessionId) {
22
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
23
+ const id = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
24
+ return path.join(base, 'skills-atlas', 'suggest', `${id}.json`);
25
+ }
26
+ function readState(f) {
27
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
28
+ catch { return { count: 0, lastSuggestedCount: -COOLDOWN, suggested: [] }; }
29
+ }
30
+ function writeState(f, s) {
31
+ try { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(s)); } catch { /* ignore */ }
32
+ }
33
+
34
+ module.exports = async function suggest() {
35
+ try {
36
+ if (process.stdin.isTTY) {
37
+ console.error('skills-atlas suggest is a UserPromptSubmit hook (reads the event JSON from stdin).');
38
+ console.error('enable the autopilot with: skills-atlas hook on');
39
+ return;
40
+ }
41
+
42
+ let event = {};
43
+ try { event = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { /* not JSON → bail */ }
44
+ const prompt = event.prompt || '';
45
+ if (!prompt || prompt.length < 8) return;
46
+
47
+ const file = stateFile(event.session_id || event.sessionId);
48
+ const state = readState(file);
49
+ state.count = (state.count || 0) + 1;
50
+
51
+ // cooldown — don't suggest on every prompt
52
+ if (state.count - (state.lastSuggestedCount ?? -COOLDOWN) < COOLDOWN) { writeState(file, state); return; }
53
+
54
+ const { data } = loadData({ quiet: true });
55
+ const { flatRows } = buildIndices(data);
56
+
57
+ const installed = new Set();
58
+ for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
59
+ const suggested = new Set(state.suggested || []);
60
+
61
+ const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
62
+ if (!fire) { writeState(file, state); return; }
63
+
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}` : ''} (install+activate: \`skills-atlas use ${c.skill} --yes\`)`;
67
+ }).join('\n');
68
+ const ctx =
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 clearly fits, briefly offer it and (with the user's ok) install it via its command. ` +
73
+ `If none fit but the task plainly needs a specialized skill, you may run ` +
74
+ `\`skills-atlas search "<short intent>"\` to look further. If nothing fits, say nothing about ` +
75
+ `this at all — do not mention this hook, these skills, or that a suggestion was made.`;
76
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } }));
77
+
78
+ state.lastSuggestedCount = state.count;
79
+ state.suggested = [...suggested, ...candidates.map(c => c.skill)];
80
+ writeState(file, state);
81
+ } catch {
82
+ // fail-open: never break the user's workflow
83
+ }
84
+ };
@@ -154,4 +154,118 @@ function searchRows(rows, opts = {}) {
154
154
  return runSearch(rows, opts).rows;
155
155
  }
156
156
 
157
- module.exports = { tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars, PERSONA_ALIAS };
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 } = {}) {
221
+ const tokens = tokenize(lc(prompt));
222
+ if (tokens.length < 2) return { fire: false, candidates: [], weak: false };
223
+ const taken = new Set([...installed, ...suggested]);
224
+ const info = segmentDf(rows);
225
+
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();
235
+ for (const t of tokens) {
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);
242
+ }
243
+ if (strong) anchors.push({ skill: s, row: r, weight, strong });
244
+ }
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 };
266
+ }
267
+
268
+ module.exports = {
269
+ tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars,
270
+ nameWordHit, matchedSegment, suggestCandidates, PERSONA_ALIAS,
271
+ };