skills-atlas-cli 0.3.0 → 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
@@ -47,6 +47,7 @@ skills-atlas info brainstorming
47
47
  skills-atlas install brainstorming # → ~/.claude/skills/ (default, all projects)
48
48
  skills-atlas install brainstorming --project # → ./.claude/skills/ (this project only)
49
49
  skills-atlas install brainstorming --chain # install the whole ⛓ workflow it belongs to
50
+ skills-atlas use brainstorming # install AND activate now — prints SKILL.md, no restart
50
51
  skills-atlas install brainstorming --dry-run # preview the files, write nothing
51
52
 
52
53
  # 🗂️ Manage what you've installed (like a package manager)
@@ -103,6 +104,25 @@ just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
103
104
  /plugin install skills-atlas@skills-atlas
104
105
  ```
105
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
+
106
126
  ## License
107
127
 
108
128
  MIT. Each installed skill keeps its own source repository's license.
package/bin/skills.js CHANGED
@@ -9,11 +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
- const commands = { search, info, install, installed, upgrade, remove, outdated, doctor, update, categories, list };
18
+ // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
19
+ const use = argv => install([...argv, '--inline']);
20
+ const commands = { search, info, install, use, installed, upgrade, remove, outdated, doctor, suggest, hook, update, categories, list };
17
21
 
18
22
  const HELP = `skills-atlas — search, install & manage AI agent skills
19
23
 
@@ -23,6 +27,7 @@ find & install:
23
27
  search <query> find skills (filters: -c category, -p persona, -t type, --chain)
24
28
  info <skill> show description, usage guidance, sources & install command
25
29
  install <skill> download into .claude/skills/ (--chain for the whole workflow)
30
+ use <skill> install AND activate it for the current session now (inline)
26
31
 
27
32
  manage what you've installed:
28
33
  installed list installed skills (global + project)
@@ -31,6 +36,9 @@ manage what you've installed:
31
36
  remove <skill> delete an installed skill
32
37
  doctor health check: orphans, drift, missing SKILL.md, license/script risks
33
38
 
39
+ autopilot (opt-in):
40
+ hook on|off|status proactively suggest a skill in Claude when your prompt fits one
41
+
34
42
  catalog:
35
43
  update refresh the catalog from the public data feed
36
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.0",
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",
package/src/args.js CHANGED
@@ -20,6 +20,7 @@ const ALL = {
20
20
  en: { type: 'boolean' },
21
21
  zh: { type: 'boolean' },
22
22
  all: { type: 'boolean' },
23
+ inline: { type: 'boolean' },
23
24
  help: { type: 'boolean', short: 'h' },
24
25
  };
25
26
 
@@ -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
+ };
@@ -1,7 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
  const { parse } = require('../args');
6
+
7
+ const readSkillMd = dest => {
8
+ try { return fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8'); } catch { return null; }
9
+ };
5
10
  const { loadData } = require('../data');
6
11
  const { buildIndices, vendorsFor, suggestSkills, skillDocPath } = require('../index-build');
7
12
  const { listSkillFiles } = require('../github');
@@ -81,7 +86,7 @@ async function installChain({ row, vendor, src, targetRoot, values }) {
81
86
 
82
87
  module.exports = async function install(argv) {
83
88
  const { values, positionals } = parse(argv,
84
- ['global', 'project', 'source', 'force', 'yes', 'chain', 'dry-run', 'json']);
89
+ ['global', 'project', 'source', 'force', 'yes', 'chain', 'inline', 'dry-run', 'json']);
85
90
  if (values.help) { console.log(HELP); return; }
86
91
 
87
92
  const name = positionals[0];
@@ -241,10 +246,12 @@ module.exports = async function install(argv) {
241
246
  }
242
247
 
243
248
  if (values.json) {
244
- console.log(JSON.stringify({
249
+ const out = {
245
250
  skill, mode: 'folder', source: src.name,
246
251
  dest: result.dest, files: result.fileCount, scripts: result.scripts.length, branch: result.branchUsed,
247
- }));
252
+ };
253
+ if (values.inline) out.skillMd = readSkillMd(result.dest);
254
+ console.log(JSON.stringify(out));
248
255
  return;
249
256
  }
250
257
 
@@ -264,5 +271,16 @@ module.exports = async function install(argv) {
264
271
  ? infoForRow(skill, chosen.row, data.vendors)
265
272
  : buildInfo(skill, { skillIndex: idx.skillIndex, vendors: data.vendors });
266
273
  console.log(renderInfo(guide, { en: !values.zh, all: true }));
267
- console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
274
+
275
+ if (values.inline) {
276
+ const body = readSkillMd(result.dest);
277
+ if (body) {
278
+ console.log('\n' + dim('─── SKILL.md (active for this task — follow it now) ───'));
279
+ console.log(body.trim());
280
+ console.log(dim('─── end SKILL.md ───'));
281
+ }
282
+ console.log(dim('\n(the folder is also installed for future sessions)'));
283
+ } else {
284
+ console.log(dim('\nStart a new Claude Code session to load the skill, then invoke it by name.'));
285
+ }
268
286
  };
@@ -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
+ };