skills-atlas-cli 0.3.1 → 0.4.0

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,25 @@ 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 **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:
116
+
117
+ - **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.
121
+ - **local & private** — your prompt is matched against the bundled catalog
122
+ on your machine; nothing is sent anywhere.
123
+ - **safe** — never auto-installs (always your call), and fails open (a hook
124
+ error never blocks your prompt).
125
+
107
126
  ## License
108
127
 
109
128
  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.0",
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,78 @@
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.
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { loadData } = require('../data');
13
+ const { buildIndices } = require('../index-build');
14
+ const { pickSuggestion } = require('../search-core');
15
+ const manifest = require('../manifest');
16
+ const fsu = require('../fsutil');
17
+
18
+ const COOLDOWN = 3; // min prompts between suggestions
19
+
20
+ function stateFile(sessionId) {
21
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
22
+ const id = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
23
+ return path.join(base, 'skills-atlas', 'suggest', `${id}.json`);
24
+ }
25
+ function readState(f) {
26
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
27
+ catch { return { count: 0, lastSuggestedCount: -COOLDOWN, suggested: [] }; }
28
+ }
29
+ function writeState(f, s) {
30
+ try { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(s)); } catch { /* ignore */ }
31
+ }
32
+
33
+ module.exports = async function suggest() {
34
+ try {
35
+ if (process.stdin.isTTY) {
36
+ console.error('skills-atlas suggest is a UserPromptSubmit hook (reads the event JSON from stdin).');
37
+ console.error('enable the autopilot with: skills-atlas hook on');
38
+ return;
39
+ }
40
+
41
+ let event = {};
42
+ try { event = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { /* not JSON → bail */ }
43
+ const prompt = event.prompt || '';
44
+ if (!prompt || prompt.length < 8) return;
45
+
46
+ const file = stateFile(event.session_id || event.sessionId);
47
+ const state = readState(file);
48
+ state.count = (state.count || 0) + 1;
49
+
50
+ // cooldown — don't suggest on every prompt
51
+ if (state.count - (state.lastSuggestedCount ?? -COOLDOWN) < COOLDOWN) { writeState(file, state); return; }
52
+
53
+ const { data } = loadData({ quiet: true });
54
+ const { flatRows } = buildIndices(data);
55
+
56
+ const installed = new Set();
57
+ for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
58
+ const suggested = new Set(state.suggested || []);
59
+
60
+ const pick = pickSuggestion(flatRows, prompt, { installed, suggested });
61
+ if (!pick) { writeState(file, state); return; }
62
+
63
+ const uc = pick.row.use_case_en || pick.row.use_case || '';
64
+ 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.`;
70
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } }));
71
+
72
+ state.lastSuggestedCount = state.count;
73
+ state.suggested = [...suggested, pick.skill];
74
+ writeState(file, state);
75
+ } catch {
76
+ // fail-open: never break the user's workflow
77
+ }
78
+ };
@@ -154,4 +154,43 @@ 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
+ // 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() } = {}) {
163
+ 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; };
166
+
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;
172
+ 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++;
176
+ }
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 };
182
+ }
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 };
192
+ }
193
+
194
+ module.exports = {
195
+ tokenize, searchRows, runSearch, scoreRow, buildFields, maxStars, pickSuggestion, PERSONA_ALIAS,
196
+ };