skills-atlas-cli 0.9.1 → 0.10.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/bin/skills.js CHANGED
@@ -17,6 +17,7 @@ const hook = require('../src/commands/hook');
17
17
  const gaps = require('../src/commands/gaps');
18
18
  const gapAnalyze = require('../src/commands/gap-analyze');
19
19
  const prune = require('../src/commands/prune');
20
+ const feedback = require('../src/commands/feedback');
20
21
  const update = require('../src/commands/update');
21
22
  const mcp = require('../src/commands/mcp');
22
23
  const { categories, list } = require('../src/commands/categories');
@@ -24,7 +25,7 @@ const { categories, list } = require('../src/commands/categories');
24
25
  const VERSION = require('../package.json').version;
25
26
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
26
27
  const use = argv => install([...argv, '--inline']);
27
- const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, 'gap-analyze': gapAnalyze, prune, update, categories, list, registry, mcp };
28
+ const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, 'gap-analyze': gapAnalyze, prune, feedback, update, categories, list, registry, mcp };
28
29
 
29
30
  const HELP = `skills-atlas — search, install & manage AI agent skills
30
31
 
@@ -49,6 +50,7 @@ autopilot (opt-in):
49
50
  hook on|off|status proactively suggest a skill in Claude when your prompt fits one
50
51
  gaps kinds of work you keep doing without a skill (run: skills-atlas hook on)
51
52
  prune installed skills you no longer use — Claude suggests removing them
53
+ feedback what the autopilot learned from your installs/removes (sharpens it)
52
54
 
53
55
  catalog:
54
56
  update refresh the catalog from the public data feed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.9.1",
3
+ "version": "0.10.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,53 @@
1
+ // `skills-atlas feedback` — the autopilot's local suppression list. Two scopes:
2
+ // skills you've dismissed (never suggested, anywhere) and skills you've removed from
3
+ // THIS project (not suggested here). Show / add to / reset it. Nothing is sent.
4
+ 'use strict';
5
+
6
+ const { parse } = require('../args');
7
+ const feedback = require('../feedback');
8
+ const { green, dim } = require('../format');
9
+
10
+ const HELP = `usage: skills-atlas feedback [dismiss <skill> | reset]
11
+
12
+ Skills the autopilot won't suggest again. Two kinds, local only — nothing is sent:
13
+ • dismissed — you said "never suggest this"; applies in every project
14
+ • removed — you removed it from THIS project; suppressed here only
15
+ Installing a skill clears both. (Removing one from the global scope is just an
16
+ uninstall — it does not suppress; use 'dismiss' for a blanket no.)
17
+
18
+ feedback show the suppression list (this project + global)
19
+ feedback dismiss <skill> never suggest a skill again, in any project
20
+ feedback reset forget everything (global + this project)
21
+ --json`;
22
+
23
+ module.exports = async function feedbackCmd(argv) {
24
+ const { values, positionals } = parse(argv, ['json']);
25
+ if (values.help) { console.log(HELP); return; }
26
+ const sub = positionals[0];
27
+
28
+ if (sub === 'reset') {
29
+ feedback.clear();
30
+ console.log(values.json ? JSON.stringify({ reset: true }) : `${green('✓')} feedback reset.`);
31
+ return;
32
+ }
33
+ if (sub === 'dismiss') {
34
+ const x = positionals.slice(1).join(' ');
35
+ if (!x) { console.error('usage: skills-atlas feedback dismiss <skill>'); process.exitCode = 1; return; }
36
+ feedback.dismiss(x);
37
+ console.log(values.json ? JSON.stringify({ dismissed: x }) : `${green('✓')} won't suggest ${x} again (any project).`);
38
+ return;
39
+ }
40
+
41
+ const cur = feedback.current();
42
+ if (values.json) {
43
+ console.log(JSON.stringify({ dismissed: [...cur.global], removedHere: [...cur.project] }, null, 2));
44
+ return;
45
+ }
46
+ if (!cur.global.size && !cur.project.size) {
47
+ console.log(dim('nothing suppressed — run `skills-atlas feedback dismiss <skill>`, or remove a\nskill from this project, and the autopilot stops offering it.'));
48
+ return;
49
+ }
50
+ if (cur.global.size) console.log(`\n${green("won't suggest anywhere")} (${cur.global.size}): ${[...cur.global].join(', ')}`);
51
+ if (cur.project.size) console.log(`${green("won't suggest in this project")} (${cur.project.size}): ${[...cur.project].join(', ')}`);
52
+ console.log(dim('re-install one to clear it, or: skills-atlas feedback reset'));
53
+ };
@@ -253,6 +253,9 @@ module.exports = async function install(argv) {
253
253
  return;
254
254
  }
255
255
 
256
+ // installing clears any prior suppression (global dismiss + this project's removal).
257
+ try { require('../feedback').installed(skill); } catch { /* ignore */ }
258
+
256
259
  if (values.json) {
257
260
  const out = {
258
261
  skill, mode: 'folder', source: src.name,
@@ -40,6 +40,11 @@ module.exports = async function remove(argv) {
40
40
  if (fsu.dirExists(dest)) fsu.rmrf(dest);
41
41
  manifest.remove(root, name);
42
42
 
43
+ // Removing a PROJECT skill is a "not in this project" signal — suppress it here only,
44
+ // never across projects (it may be useful elsewhere). Removing a GLOBAL skill is just
45
+ // uninstalling; for a blanket "never suggest this", use `feedback dismiss`.
46
+ if (!global) { try { require('../feedback').removedInProject(name); } catch { /* ignore */ } }
47
+
43
48
  if (values.json) { console.log(JSON.stringify({ removed: name, dest })); return; }
44
49
  console.log(`${green('✓')} removed ${name} ${dim(fsu.tildify(dest))}`);
45
50
  if (otherHas) console.log(dim(`note: '${name}' is still installed in the ${global ? 'project' : 'global'} scope — remove that too: skills-atlas remove ${name} ${otherFlag}`));
@@ -131,7 +131,7 @@ module.exports = async function suggest() {
131
131
  for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
132
132
  const suggested = new Set(state.suggested || []);
133
133
 
134
- const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
134
+ const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested, feedback: require('../feedback').current() });
135
135
  if (!fire || !candidates.length) { writeState(file, state); return; }
136
136
 
137
137
  const lines = candidates.map(c => {
@@ -146,7 +146,8 @@ module.exports = async function suggest() {
146
146
  `fits THIS task (it's a curated skill from the Skills Atlas catalog, not something you made up), ` +
147
147
  `then let them choose — activate it now (\`skills-atlas use <skill> --yes --project\` installs + applies it ` +
148
148
  `immediately), see what it covers first (\`skills-atlas info <skill>\`), or skip and you'll just ` +
149
- `do the task yourself. If none fit but the task plainly needs a specialized skill, you may run ` +
149
+ `do the task yourself; if they ask to never suggest it again, run \`skills-atlas feedback dismiss <skill>\`. ` +
150
+ `If none fit but the task plainly needs a specialized skill, you may run ` +
150
151
  `\`skills-atlas search "<short intent>"\` to look further. If nothing fits, say nothing about this ` +
151
152
  `at all — don't mention this hook, these skills, or that a suggestion was made.` + langHint(ap.replyLang));
152
153
  state.lastSuggestedCount = state.count;
@@ -0,0 +1,86 @@
1
+ // Local suppression memory for the autopilot. Two scopes, because the two signals
2
+ // mean different things:
3
+ //
4
+ // • dismiss — you explicitly said "never suggest this skill" → GLOBAL table
5
+ // • removed — you removed a project-scoped skill you'd installed → PROJECT table
6
+ //
7
+ // A dismiss is a deliberate, blanket "no", so it applies everywhere. A removal is
8
+ // contextual ("done with it here") — it must NOT leak to other projects, so it lives
9
+ // in a per-project table keyed by the project root. The LATEST action per skill wins:
10
+ // installing a skill clears both its global dismiss and this project's removal.
11
+ //
12
+ // All local; project tables live under the cache (keyed by path hash), never in the
13
+ // repo, so personal suppression is never committed. Nothing is sent anywhere.
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ const MAX_EVENTS = 500;
22
+
23
+ function baseDir() {
24
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
25
+ return path.join(base, 'skills-atlas');
26
+ }
27
+ function globalFile() { return path.join(baseDir(), 'feedback.json'); }
28
+ function projectFile(root) {
29
+ const key = crypto.createHash('sha1').update(path.resolve(root || process.cwd())).digest('hex').slice(0, 16);
30
+ return path.join(baseDir(), 'projects', `${key}.json`);
31
+ }
32
+
33
+ function readStore(file) {
34
+ try { const s = JSON.parse(fs.readFileSync(file, 'utf8')); if (!Array.isArray(s.events)) s.events = []; return s; }
35
+ catch { return { events: [] }; }
36
+ }
37
+ function writeStore(file, s) {
38
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(s)); } catch { /* best-effort */ }
39
+ }
40
+ function append(file, skill, signal) {
41
+ if (!skill || !signal) return;
42
+ const s = readStore(file);
43
+ s.events.push({ skill, signal, at: new Date().toISOString() });
44
+ if (s.events.length > MAX_EVENTS) s.events = s.events.slice(-MAX_EVENTS);
45
+ writeStore(file, s);
46
+ }
47
+
48
+ // Pure: skills whose MOST RECENT action in this store is the suppressing signal.
49
+ // (An 'accepted' afterwards clears it — you installed it, so you changed your mind.)
50
+ function suppressedFrom(events, suppressSignal) {
51
+ const latest = new Map();
52
+ for (const e of events || []) {
53
+ if (!e || !e.skill) continue;
54
+ const prev = latest.get(e.skill);
55
+ if (!prev || Date.parse(e.at) >= Date.parse(prev.at)) latest.set(e.skill, e);
56
+ }
57
+ const out = new Set();
58
+ for (const [skill, e] of latest) if (e.signal === suppressSignal) out.add(skill);
59
+ return out;
60
+ }
61
+
62
+ // --- writes ---
63
+ function dismiss(skill) { append(globalFile(), skill, 'dismissed'); } // explicit "never, anywhere"
64
+ function removedInProject(skill, root) { append(projectFile(root), skill, 'removed'); } // "not in this project"
65
+ function installed(skill, root) { append(globalFile(), skill, 'accepted'); append(projectFile(root), skill, 'accepted'); }
66
+
67
+ // --- reads ---
68
+ function globalDismissed() { return suppressedFrom(readStore(globalFile()).events, 'dismissed'); }
69
+ function projectRemoved(root) { return suppressedFrom(readStore(projectFile(root)).events, 'removed'); }
70
+
71
+ // Merged view for the autopilot hook, which runs in a project cwd: a skill is hidden
72
+ // if it's globally dismissed OR removed in this project.
73
+ function current(root) {
74
+ const global = globalDismissed();
75
+ const project = projectRemoved(root);
76
+ const suppressed = new Set([...global, ...project]);
77
+ return { suppressed, isSuppressed: s => suppressed.has(s), global, project };
78
+ }
79
+
80
+ function clear(root) { writeStore(globalFile(), { events: [] }); writeStore(projectFile(root), { events: [] }); }
81
+
82
+ module.exports = {
83
+ globalFile, projectFile, readStore, writeStore,
84
+ dismiss, removedInProject, installed,
85
+ globalDismissed, projectRemoved, current, clear,
86
+ };
@@ -270,10 +270,11 @@ function contentDf(rows) {
270
270
  // `fire` is true only when there's a distinctive anchor (a rare name word, or ≥2
271
271
  // matched name words) or an otherwise strong (non-weak) match — so the hook stays
272
272
  // quiet on greetings and generic dev actions ("fix the typo", "build the app").
273
- function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), limit = 5 } = {}) {
273
+ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), feedback = null, limit = 5 } = {}) {
274
274
  const tokens = tokenize(lc(prompt));
275
275
  if (tokens.length < 2) return { fire: false, candidates: [], weak: false };
276
- const taken = new Set([...installed, ...suggested]);
276
+ // `taken` also drops what you've dismissed or installed-then-removed — never re-suggest those.
277
+ const taken = new Set([...installed, ...suggested, ...(feedback ? feedback.suppressed : [])]);
277
278
  const info = segmentDf(rows);
278
279
 
279
280
  // 1. name anchors — ONLY contentful (non-generic) name words count, weighted by
@@ -365,6 +366,9 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
365
366
  anchors.some(a => a.strong >= 2 || a.specific >= 1) ||
366
367
  contentAnchors.some(contentQualifies)
367
368
  );
369
+ // NB: feedback only SUPPRESSES (via `taken` above) — that changes which skills make
370
+ // the shortlist. We deliberately don't reorder the final ≤5: Claude reads all of them
371
+ // and picks by fit, so reordering them would do nothing.
368
372
  return { fire, candidates: out.slice(0, limit), weak };
369
373
  }
370
374