skills-atlas-cli 0.8.0 โ†’ 0.8.4

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
@@ -3,57 +3,43 @@
3
3
  [![npm](https://img.shields.io/npm/v/skills-atlas-cli)](https://www.npmjs.com/package/skills-atlas-cli)
4
4
  [![license](https://img.shields.io/npm/l/skills-atlas-cli)](https://github.com/Zita-Go/Skills-Atlas/blob/main/LICENSE)
5
5
 
6
- **Stop guessing which agent skill to use.** Find, install, and learn the right
7
- specialized skill for the task โ€” in seconds, straight into Claude Code.
6
+ **Find, install, and use the right AI agent skill for any task, right inside Claude
7
+ Code.** Stop guessing which skill fits or copy-pasting from random repos. Search a
8
+ curated catalog of **800+ skills** and drop the right one into `.claude/skills/` in seconds.
8
9
 
9
- Powered by the [**Skills Atlas**](https://zita-go.github.io/Skills-Atlas/) catalog:
10
- hundreds of Claude Code / Codex skills across 100+ source repos, organized by what
11
- they actually do.
10
+ > **New to "skills"?** A skill is a reusable `SKILL.md` instruction pack that teaches
11
+ > Claude Code a specialized workflow: systematic debugging, pre-mortems, SEO audits,
12
+ > PDF translation, and hundreds more. This tool finds and installs them for you.
12
13
 
13
- <img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/cli-demo.png" alt="skills-atlas: search then install a skill" width="760">
14
-
15
- ## ๐ŸŒ Browse the whole catalog online
14
+ ### Two ways to get the right skill
16
15
 
17
- <table>
18
- <tr>
19
- <td><a href="https://zita-go.github.io/Skills-Atlas/"><img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/screenshot-light.png" alt="Skills Atlas โ€” light theme" width="400"></a></td>
20
- <td><a href="https://zita-go.github.io/Skills-Atlas/"><img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/screenshot-dark.png" alt="Skills Atlas โ€” dark theme" width="400"></a></td>
21
- </tr>
22
- </table>
16
+ 1. **๐Ÿ” Find and install it.** Search the catalog, then `use` it. It's live in Claude Code in seconds.
17
+ 2. **๐Ÿค– Let it find you.** Turn on autopilot and Claude offers the fitting skill as you work, no searching.
23
18
 
24
- **[โ†’ zita-go.github.io/Skills-Atlas](https://zita-go.github.io/Skills-Atlas/)** โ€”
25
- explore by category, then install what you find with the CLI.
19
+ <img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/cli-demo.png" alt="skills-atlas: search then install a skill" width="760">
26
20
 
27
- ## Install
21
+ ## Quickstart
28
22
 
29
23
  ```bash
30
- npm install -g skills-atlas-cli # adds the `skills-atlas` command (alias: `sa`)
31
- ```
32
-
33
- Or run it without installing: `npx skills-atlas-cli search seo`
34
-
35
- ## How you'll use it
24
+ npm install -g skills-atlas-cli # adds the `skills-atlas` command (alias sa). Or run any command with `npx`.
36
25
 
37
- Three ways to reach the same catalog โ€” pick whichever fits the moment:
26
+ skills-atlas search "stress test my launch plan" # pre-mortem tops the results
27
+ skills-atlas use pre-mortem # installs it, prints its SKILL.md so Claude applies it now
28
+ skills-atlas hook on # optional: turn on autopilot, skip the search next time
29
+ ```
38
30
 
39
- | Mode | Best when | Get going |
40
- |---|---|---|
41
- | **Manual** | you want to browse and grab skills yourself | `skills-atlas search <task>` โ†’ `skills-atlas use <skill>` |
42
- | **In Claude Code** | you'd rather just ask Claude in-conversation | install the [plugin](#in-claude-code), then describe your task |
43
- | **๐Ÿค– Autopilot** | you want the right skill to find *you* | `skills-atlas hook on` โ€” Claude offers a fitting skill as you work |
31
+ Now `pre-mortem` lives in `~/.claude/skills/` and auto-loads in new Claude Code sessions.
44
32
 
45
- **60-second quickstart:**
33
+ ## ๐ŸŒ Browse the catalog online
46
34
 
47
- ```bash
48
- npm install -g skills-atlas-cli
49
- skills-atlas search "stress test my launch plan" # โ†’ pre-mortem tops the results
50
- skills-atlas use pre-mortem # install + activate now (prints its SKILL.md)
51
- skills-atlas hook on # optional โ€” let the right skill find you from here on
52
- ```
35
+ <table>
36
+ <tr>
37
+ <td><a href="https://zita-go.github.io/Skills-Atlas/"><img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/screenshot-light.png" alt="Skills Atlas โ€” light theme" width="400"></a></td>
38
+ <td><a href="https://zita-go.github.io/Skills-Atlas/"><img src="https://raw.githubusercontent.com/Zita-Go/Skills-Atlas/main/docs/screenshot-dark.png" alt="Skills Atlas โ€” dark theme" width="400"></a></td>
39
+ </tr>
40
+ </table>
53
41
 
54
- `use` drops the skill into `~/.claude/skills/` and prints it for the task at hand; it
55
- auto-loads in new Claude Code sessions. With autopilot on, you don't even need to
56
- `search` โ€” describe your task and Claude surfaces the skill if one fits.
42
+ Explore the catalog visually at **[zita-go.github.io/Skills-Atlas](https://zita-go.github.io/Skills-Atlas/)**, then install what you find with the CLI.
57
43
 
58
44
  ## Usage
59
45
 
@@ -70,7 +56,7 @@ skills-atlas info brainstorming
70
56
  skills-atlas install brainstorming # โ†’ ~/.claude/skills/ (default, all projects)
71
57
  skills-atlas install brainstorming --project # โ†’ ./.claude/skills/ (this project only)
72
58
  skills-atlas install brainstorming --chain # install the whole โ›“ workflow it belongs to
73
- skills-atlas use brainstorming # install AND activate now โ€” prints SKILL.md, no restart
59
+ skills-atlas use brainstorming # install + activate now (prints SKILL.md, no restart)
74
60
  skills-atlas install brainstorming --dry-run # preview the files, write nothing
75
61
 
76
62
  # ๐Ÿ—‚๏ธ Manage what you've installed (like a package manager)
@@ -84,10 +70,18 @@ skills-atlas doctor # health check: orphans, drift, l
84
70
  skills-atlas kit # detect this project & install the right skills for it
85
71
  skills-atlas sync # reproduce a project's kit (skills-atlas.kit.json)
86
72
 
87
- # ๐ŸŒ Catalog
73
+ # ๐Ÿค– Autopilot & capability gaps (opt-in)
74
+ skills-atlas hook on # Claude proactively offers a fitting skill as you work
75
+ skills-atlas gaps # Claude spots kinds of work you keep doing without a skill
76
+
77
+ # ๐ŸŒ Catalog & sources
88
78
  skills-atlas categories # the 20 top-level categories
89
79
  skills-atlas list marketing # skill groups within a category
80
+ skills-atlas registry add <url|path> # add a private org catalog source (merges into search/install)
90
81
  skills-atlas update # pull the latest catalog
82
+
83
+ # ๐Ÿ”Œ Integrations
84
+ skills-atlas mcp # run as an MCP server (any MCP client: Claude Desktop, โ€ฆ)
91
85
  ```
92
86
 
93
87
  **โ›“ Workflows, not just skills.** Many skills belong to a curated chain (e.g.
@@ -95,8 +89,8 @@ skills-atlas update # pull the latest catalog
95
89
  installs the whole pipeline in one archive download, ready to run in order.
96
90
 
97
91
  **๐Ÿ“ฆ Project kits.** `skills-atlas kit` detects what this project is (frontend / backend /
98
- data / infra) and installs a tailored set โ€” a universal dev workflow plus archetype
99
- add-ons โ€” into `./.claude/skills/`, then writes a committable `skills-atlas.kit.json`.
92
+ data / infra) and installs a tailored set (a universal dev workflow plus archetype
93
+ add-ons) into `./.claude/skills/`, then writes a committable `skills-atlas.kit.json`.
100
94
  A teammate runs `skills-atlas sync` to reproduce it exactly.
101
95
 
102
96
  Output is English by default; add `--zh` for Chinese, or `--json` to any command for machine-readable output.
@@ -105,16 +99,16 @@ After installing a skill, start a new Claude Code session to load it.
105
99
  ## How install works
106
100
 
107
101
  The real value is the **catalog**: `search` / `info` / `categories` work fully
108
- offline and map *which* skill fits โ€” function-organized, bilingual, tagged with
102
+ offline and map *which* skill fits. It's function-organized, bilingual, tagged with
109
103
  use-case / when-to-use / personas / โ›“ chains. That's what `npx skills add` and
110
104
  GitHub search don't give you.
111
105
 
112
106
  On top of that, `install` can place a skill straight into `.claude/skills/`:
113
107
 
114
108
  - For a repo that exposes a **per-skill folder**, it downloads only that folder
115
- (via the repo archive โ€” **no GitHub API rate limit**) into
109
+ (via the repo archive, with **no GitHub API rate limit**) into
116
110
  `<target>/.claude/skills/<skill>/`, not the whole repo.
117
- - Several sources? The best installable one is auto-picked โ€” `--source <id>` to
111
+ - Several sources? The best installable one is auto-picked. Pass `--source <id>` to
118
112
  choose, `--yes` for non-interactive runs.
119
113
  - Other sources (whole-repo / marketplace) print their official command instead
120
114
  (e.g. `npx skills add owner/repo`).
@@ -127,8 +121,8 @@ the latest from the public feed (cached under `~/.cache/skills-atlas/`).
127
121
 
128
122
  ## Private / org catalog sources
129
123
 
130
- Point the CLI at your organization's own catalog โ€” a `data.json` in the same
131
- schema โ€” so internal skills show up in `search` / `info` / `install` / `kit`
124
+ Point the CLI at your organization's own catalog (a `data.json` in the same
125
+ schema) so internal skills show up in `search` / `info` / `install` / `kit`
132
126
  alongside the public Atlas:
133
127
 
134
128
  ```bash
@@ -144,8 +138,8 @@ auth, set `SKILLS_ATLAS_TOKEN` (sent as a Bearer header); in CI,
144
138
 
145
139
  ## In Claude Code
146
140
 
147
- A thin [Claude Code plugin](./plugin) lets Claude do all of this in-conversation โ€”
148
- just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
141
+ A thin [Claude Code plugin](./plugin) lets Claude do all of this in-conversation.
142
+ Just describe what you need, or use `/skills-atlas:skill-search`, `:skill-info`,
149
143
  `:skill-install`:
150
144
 
151
145
  ```text
@@ -164,10 +158,10 @@ the catalog. Add it to your client's config:
164
158
  ```
165
159
 
166
160
  It exposes four tools: **search_skills**, **skill_info**, **install_skill**, and
167
- **list_categories** โ€” discover, inspect, install, and browse the catalog from
161
+ **list_categories**. Discover, inspect, install, and browse the catalog from
168
162
  anywhere.
169
163
 
170
- ## Autopilot (opt-in) โ€” the right skill finds you
164
+ ## Autopilot (opt-in): the right skill finds you
171
165
 
172
166
  ```bash
173
167
  skills-atlas hook on # enable (skills-atlas hook off / status)
@@ -175,29 +169,29 @@ skills-atlas hook on # enable (skills-atlas hook off / status)
175
169
 
176
170
  Registers a Claude Code `UserPromptSubmit` hook. When what you ask matches the
177
171
  territory of a catalog skill you don't have, the hook hands Claude a short
178
- shortlist of candidates and **Claude decides** whether any genuinely fits โ€” and
179
- if so, explains **what it does and why it fits your task**, then offers a choice:
172
+ shortlist of candidates and **Claude decides** whether any genuinely fits. If it
173
+ does, Claude explains **what it does and why it fits your task**, then offers a choice:
180
174
  use it now, see what it covers first (`skills-atlas info`), or skip. You don't
181
175
  have to know the skill exists. The split is deliberate: the hook does **recall**
182
176
  (a distinctive-word match against the catalog, so the right skill is on the
183
177
  table), Claude does **precision** (it understands your intent and stays silent
184
178
  unless one truly fits, or searches further itself). It's:
185
179
 
186
- - **off by default** โ€” you turn it on explicitly; `hook off` removes it cleanly.
187
- - **quiet** โ€” only fires on a distinctive match (greetings and generic actions
180
+ - **Off by default.** You turn it on explicitly; `hook off` removes it cleanly.
181
+ - **Quiet.** Only fires on a distinctive match (greetings and generic actions
188
182
  like "fix the typo" stay silent), never for an already-installed skill, never
189
- the same skill twice, with a cooldown between suggestions โ€” and Claude is the
183
+ the same skill twice, with a cooldown between suggestions. Claude is the
190
184
  final filter on relevance.
191
- - **local & private** โ€” your prompt is matched against the bundled catalog
185
+ - **Local and private.** Your prompt is matched against the bundled catalog
192
186
  on your machine; nothing is sent anywhere.
193
- - **safe** โ€” never auto-installs (always your call), and fails open (a hook
187
+ - **Safe.** Never auto-installs (always your call), and fails open (a hook
194
188
  error never blocks your prompt).
195
189
 
196
190
  **๐Ÿ”ญ Capability gaps.** `skills-atlas gaps` shows Claude your *recent activity* and
197
191
  lets **Claude** spot the recurring kinds of work you keep doing that no installed
198
- skill covers yet โ€” then recommend one, with the pattern as evidence. We don't guess
192
+ skill covers yet, then recommend one with the pattern as evidence. We don't guess
199
193
  with heuristics; we just give Claude the memory it lacks (your recent prompts, read
200
- from Claude Code's own local transcripts โ€” **nothing is stored or sent**) plus the
194
+ from Claude Code's own local transcripts; **nothing is stored or sent**) plus the
201
195
  catalog. With the hook on, it also nudges in-conversation now and then. The two
202
196
  layers are independent: `skills-atlas hook suggest on|off` (per-prompt) and
203
197
  `skills-atlas hook gaps on|off` (the proactive nudge).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.4",
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
@@ -21,6 +21,7 @@ const ALL = {
21
21
  zh: { type: 'boolean' },
22
22
  all: { type: 'boolean' },
23
23
  inline: { type: 'boolean' },
24
+ verbose: { type: 'boolean' },
24
25
  archetype: { type: 'string' },
25
26
  update: { type: 'boolean' },
26
27
  name: { type: 'string' },
@@ -31,11 +31,17 @@ async function list(argv) {
31
31
 
32
32
  const en = !values.zh;
33
33
  const filter = (positionals.join(' ') || values.category || '').trim();
34
+ // A bare number means "that numbered category" โ€” match the "N." prefix exactly,
35
+ // so `list 4` doesn't also pull in "14." via loose substring.
36
+ const numFilter = /^\d+$/.test(filter) ? new RegExp(`^${filter}\\b`) : null;
34
37
  const { data } = loadData({ quiet: values.json });
35
38
 
36
39
  const out = [];
37
40
  for (const s of data.sections) {
38
- if (filter && !(loose(s.title, filter) || loose(s.title_en, filter))) continue;
41
+ const hit = !filter
42
+ || (numFilter ? (numFilter.test(s.title_en || '') || numFilter.test(s.title || ''))
43
+ : (loose(s.title, filter) || loose(s.title_en, filter)));
44
+ if (!hit) continue;
39
45
  out.push({
40
46
  section: en ? (s.title_en || s.title) : s.title,
41
47
  groups: s.subsections.flatMap(ss => ss.rows.map(r => ({
@@ -34,8 +34,8 @@ module.exports = async function doctor(argv) {
34
34
  const dir = path.join(root, skill);
35
35
  if (!fsu.dirExists(dir)) { findings.push({ scope: s.name, skill, level: 'warn', msg: 'recorded but folder missing on disk' }); continue; }
36
36
  if (!fs.existsSync(path.join(dir, 'SKILL.md'))) findings.push({ scope: s.name, skill, level: 'error', msg: 'no SKILL.md in folder' });
37
- if (!data.vendors[e.source] || !rowsFor(idx.skillIndex, skill).length) findings.push({ scope: s.name, skill, level: 'info', msg: 'no longer in the catalog' });
38
- if (e.scripts) findings.push({ scope: s.name, skill, level: 'info', msg: `ships ${e.scripts} script file(s) โ€” review` });
37
+ if (!data.vendors[e.source] || !rowsFor(idx.skillIndex, skill).length) findings.push({ scope: s.name, skill, level: 'info', msg: `no longer in the catalog (keep it, or: skills-atlas remove ${skill})` });
38
+ if (e.scripts) findings.push({ scope: s.name, skill, level: 'info', msg: `ships ${e.scripts} script file(s) โ€” review before trusting: ${fsu.tildify(dir)}` });
39
39
  const vendor = data.vendors[e.source];
40
40
  if (vendor && vendor.license && RISKY_LICENSE.test(vendor.license)) findings.push({ scope: s.name, skill, level: 'warn', msg: `license: ${vendor.license}` });
41
41
  }
@@ -45,10 +45,16 @@ module.exports = async function hook(argv) {
45
45
  const ap = registry.getAutopilot();
46
46
  if (values.json) { console.log(JSON.stringify({ enabled: on, suggest: ap.suggest, gapAlerts: ap.gapAlerts, settings: p })); return; }
47
47
  console.log(`autopilot hook: ${on ? green('on') : dim('off')} ${dim(p)}`);
48
- console.log(` per-prompt suggest: ${ap.suggest ? green('on') : dim('off')} ${dim('(skills-atlas hook suggest on|off)')}`);
49
- console.log(` gap alerts: ${ap.gapAlerts ? green('on') : dim('off')} ${dim('(skills-atlas hook gaps on|off)')}`);
50
- if (on) console.log(dim(' review gaps: skills-atlas gaps'));
51
- if (!on) console.log(dim('enable: skills-atlas hook on'));
48
+ if (on) {
49
+ console.log(` per-prompt suggest: ${ap.suggest ? green('on') : dim('off')} ${dim('(skills-atlas hook suggest on|off)')}`);
50
+ console.log(` gap alerts: ${ap.gapAlerts ? green('on') : dim('off')} ${dim('(skills-atlas hook gaps on|off)')}`);
51
+ console.log(dim(' review gaps: skills-atlas gaps'));
52
+ } else {
53
+ // Hook isn't registered, so these sub-toggles don't do anything yet โ€” don't
54
+ // imply the autopilot is running. Show them dimmed with the caveat.
55
+ console.log(dim(` (suggest ${ap.suggest ? 'on' : 'off'}, gap alerts ${ap.gapAlerts ? 'on' : 'off'} โ€” they take effect once you run 'skills-atlas hook on')`));
56
+ console.log(dim('enable: skills-atlas hook on'));
57
+ }
52
58
  return;
53
59
  }
54
60
 
@@ -71,6 +77,7 @@ module.exports = async function hook(argv) {
71
77
  if (arr.some(isOurs)) { console.log(dim('autopilot already on.')); return; }
72
78
  settings.hooks.UserPromptSubmit = [...arr, { matcher: '*', hooks: [{ type: 'command', command: HOOK_CMD, timeout: 5 }] }];
73
79
  } else {
80
+ if (!arr.some(isOurs)) { console.log(dim('autopilot already off.')); return; }
74
81
  const kept = arr.filter(e => !isOurs(e));
75
82
  if (kept.length) settings.hooks.UserPromptSubmit = kept;
76
83
  else delete settings.hooks.UserPromptSubmit;
@@ -80,7 +87,9 @@ module.exports = async function hook(argv) {
80
87
  try {
81
88
  fs.mkdirSync(path.dirname(p), { recursive: true });
82
89
  if (fs.existsSync(p) && !fs.existsSync(`${p}.bak`)) fs.copyFileSync(p, `${p}.bak`); // keep the pristine original
83
- fs.writeFileSync(p, JSON.stringify(settings, null, 2) + '\n');
90
+ // Removing our only setting? Don't leave a bare `{}` behind โ€” drop the file.
91
+ if (sub === 'off' && !Object.keys(settings).length && fs.existsSync(p)) fs.rmSync(p, { force: true });
92
+ else fs.writeFileSync(p, JSON.stringify(settings, null, 2) + '\n');
84
93
  } catch (e) { console.error(`failed to write ${p}: ${e.message}`); process.exitCode = 1; return; }
85
94
 
86
95
  if (values.json) { console.log(JSON.stringify({ enabled: sub === 'on', settings: p })); return; }
@@ -1,9 +1,27 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const { parse } = require('../args');
4
6
  const { loadData } = require('../data');
5
7
  const { buildIndices, suggestSkills } = require('../index-build');
6
- const { buildInfo, renderInfo } = require('../format');
8
+ const { buildInfo, renderInfo, dim } = require('../format');
9
+ const fsu = require('../fsutil');
10
+ const manifest = require('../manifest');
11
+
12
+ // The authoritative one-liner: a skill's own SKILL.md `description:` frontmatter,
13
+ // read locally when installed (offline). Catalog text is group-level; this is not.
14
+ function localSkillDesc(skill) {
15
+ for (const s of fsu.scopesFor({})) {
16
+ try {
17
+ const md = fs.readFileSync(path.join(s.root, skill, 'SKILL.md'), 'utf8');
18
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
19
+ const dm = fm && fm[1].match(/^description:\s*(.+)$/m);
20
+ if (dm) return dm[1].trim().replace(/^["']|["']$/g, '');
21
+ } catch { /* not installed in this scope */ }
22
+ }
23
+ return '';
24
+ }
7
25
 
8
26
  const HELP = `usage: skills-atlas info <skill> [--all] [--json] [--zh]
9
27
 
@@ -18,6 +36,7 @@ module.exports = async function info(argv) {
18
36
  const name = positionals[0];
19
37
  if (!name) {
20
38
  console.error('usage: skills-atlas info <skill>');
39
+ console.error(dim('find a skill name first: skills-atlas search <keyword>'));
21
40
  process.exitCode = 1;
22
41
  return;
23
42
  }
@@ -43,5 +62,8 @@ module.exports = async function info(argv) {
43
62
  console.log(JSON.stringify(infoObj, null, 2));
44
63
  return;
45
64
  }
46
- console.log(renderInfo(infoObj, { en: !values.zh, all: values.all }));
65
+ const installed = [];
66
+ for (const s of fsu.scopesFor({})) if (manifest.list(s.root).some(e => e.skill === infoObj.skill)) installed.push(s.name);
67
+ const skillDesc = installed.length ? localSkillDesc(infoObj.skill) : '';
68
+ console.log(renderInfo(infoObj, { en: !values.zh, all: values.all, installed, skillDesc }));
47
69
  };
@@ -25,6 +25,7 @@ options:
25
25
  -y, --yes non-interactive (auto-pick top source, assume yes)
26
26
  --chain install the whole โ›“ workflow this skill belongs to
27
27
  --dry-run show what would download, write nothing
28
+ --verbose with \`use\`: also print the full SKILL.md to the terminal
28
29
  --json machine-readable output
29
30
 
30
31
  Downloads the repo archive (no GitHub API rate limit). Only if that fetch fails
@@ -86,12 +87,13 @@ async function installChain({ row, vendor, src, targetRoot, values }) {
86
87
 
87
88
  module.exports = async function install(argv) {
88
89
  const { values, positionals } = parse(argv,
89
- ['global', 'project', 'source', 'force', 'yes', 'chain', 'inline', 'dry-run', 'json']);
90
+ ['global', 'project', 'source', 'force', 'yes', 'chain', 'inline', 'dry-run', 'verbose', 'json']);
90
91
  if (values.help) { console.log(HELP); return; }
91
92
 
92
93
  const name = positionals[0];
93
94
  if (!name) {
94
95
  console.error('usage: skills-atlas install <skill> [--global|--project] [--source <id>] [--force] [--yes]');
96
+ console.error(dim('find a skill first: skills-atlas search <keyword>'));
95
97
  process.exitCode = 1;
96
98
  return;
97
99
  }
@@ -126,23 +128,26 @@ module.exports = async function install(argv) {
126
128
  `${c.source.name} ${stars(c.source.stars)} ${c.source.type || ''} ${(c.source.install && c.source.install.command) || ''}`);
127
129
  const i = await choose(`'${name}' is available from ${candidates.length} sources:`, labels);
128
130
  if (i < 0) {
129
- console.error(`multiple sources for '${name}'. re-run with --source <id> or --yes.`);
130
- console.error(`sources: ${candidates.map(c => c.source.name).join(', ')}`);
131
+ // Non-interactive without a pick: show each source so the user can decide
132
+ // (stars / type / install command), instead of just listing bare names.
133
+ console.error(`'${name}' has ${candidates.length} sources โ€” pick one with --source <id>, or --yes to auto-pick the top:`);
134
+ candidates.forEach(c => console.error(dim(
135
+ ` ${c.source.name} ${stars(c.source.stars)} ${c.source.type || ''}` +
136
+ `${(c.source.install && c.source.install.command) ? ' โ†’ ' + c.source.install.command : ''}`)));
131
137
  process.exitCode = 2;
132
138
  return;
133
139
  }
134
140
  chosen = candidates[i];
135
141
  }
136
142
 
137
- // Heads-up when --yes auto-picked among semantically different groups.
138
- if (values.yes && candidates.length > 1) {
139
- const groups = [...new Set(candidates.map(c => c.row && c.row.group).filter(Boolean))];
140
- if (groups.length > 1) {
141
- const picked = chosen.row && chosen.row.group;
142
- const otherGroups = groups.filter(g => g !== picked).join('; ');
143
- const st = stars(chosen.source.stars);
144
- console.error(dim(`note: '${name}' exists in ${groups.length} groups; auto-picked ${chosen.source.name}${st ? ' ' + st : ''} (${picked || '?'}). also in: ${otherGroups} โ€” use --source to choose.`));
145
- }
143
+ // Heads-up whenever --yes auto-picked among several sources โ€” say which one and
144
+ // what else was available, so a silent pick of a lower-star (but installable)
145
+ // source over a higher-star whole-repo one is never a surprise.
146
+ if (values.yes && candidates.length > 1 && !values.json) {
147
+ const st = stars(chosen.source.stars);
148
+ const grp = chosen.row && chosen.row.group;
149
+ const others = candidates.filter(c => c !== chosen).map(c => c.source.name).join(', ');
150
+ console.error(dim(`note: '${name}' has ${candidates.length} sources; auto-picked ${chosen.source.name}${st ? ' ' + st : ''}${grp ? ` (${grp})` : ''}. also from: ${others} โ€” use --source <id> to choose.`));
146
151
  }
147
152
 
148
153
  const v = chosen.vendor || {};
@@ -179,6 +184,9 @@ module.exports = async function install(argv) {
179
184
  }
180
185
 
181
186
  const targetRoot = fsu.installTargetDir({ global: resolveGlobal(values) });
187
+ // Global installs read best as ~/โ€ฆ; a --project path is shorter and clearer as a
188
+ // relative ./.claude/skills/<skill> than a long absolute path.
189
+ const showPath = pth => resolveGlobal(values) ? fsu.tildify(pth) : './' + path.relative(process.cwd(), pth);
182
190
  const isChain = Boolean(chosen.row && chosen.row.chain && (chosen.row.skills || []).length >= 2);
183
191
 
184
192
  // --- chain: install the whole workflow ---
@@ -228,7 +236,7 @@ module.exports = async function install(argv) {
228
236
  }));
229
237
  return;
230
238
  }
231
- console.log(`would install ${listing.files.length} file(s) to ${fsu.tildify(dest)} (branch ${listing.branchUsed}):`);
239
+ console.log(`would install ${listing.files.length} file(s) to ${showPath(dest)} (branch ${listing.branchUsed}):`);
232
240
  listing.files.forEach(f => console.log(` ${f.rel}`));
233
241
  if (listing.note) console.log(dim(' ' + listing.note));
234
242
  console.log(dim(' (real install fetches the repo archive โ€” no GitHub API rate limit)'));
@@ -255,7 +263,7 @@ module.exports = async function install(argv) {
255
263
  return;
256
264
  }
257
265
 
258
- console.log(`\n${green('โœ“')} installed ${bold(skill)} โ†’ ${fsu.tildify(result.dest)} ${dim(`(${result.fileCount} file(s) from ${src.name}@${result.branchUsed})`)}`);
266
+ console.log(`\n${green('โœ“')} installed ${bold(skill)} โ†’ ${showPath(result.dest)} ${dim(`(${result.fileCount} file(s) from ${src.name}@${result.branchUsed})`)}`);
259
267
  if (result.note) console.log(dim(' ' + result.note));
260
268
  console.log(dim(` source: ${src.name}@${result.branchUsed} โ€” branch HEAD, not a pinned commit; review before use`));
261
269
  if (result.scripts.length) {
@@ -274,13 +282,19 @@ module.exports = async function install(argv) {
274
282
 
275
283
  if (values.inline) {
276
284
  const body = readSkillMd(result.dest);
277
- if (body) {
285
+ const mdPath = showPath(path.join(result.dest, 'SKILL.md'));
286
+ // Claude (and any piped caller) needs the full SKILL.md inline to apply the
287
+ // skill right now; a human at a terminal just needs the digest โ€” the file is
288
+ // on disk and auto-loads, so dumping ~200 lines would only bury the next step.
289
+ const full = body && (values.verbose || !process.stdout.isTTY);
290
+ if (full) {
278
291
  console.log('\n' + dim('โ”€โ”€โ”€ SKILL.md โ€” the skill\'s own instructions; apply them to the task now โ”€โ”€โ”€'));
279
292
  console.log(body.trim());
280
293
  console.log(dim('โ”€โ”€โ”€ end SKILL.md โ”€โ”€โ”€'));
281
294
  }
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}`));
295
+ console.log(`\n${green('โœ“')} ${bold(skill)} is now active โ€” ${full ? 'use the instructions above' : 'follow its SKILL.md'} for the task at hand.`);
296
+ console.log(dim(` installed at ${showPath(result.dest)} (auto-loads in new sessions) ยท what it does: skills-atlas info ${skill} ยท remove: skills-atlas remove ${skill}`));
297
+ if (body && !full) console.log(dim(` full instructions: ${mdPath} (or re-run with --verbose)`));
284
298
  } else {
285
299
  console.log(`\n${bold(skill)} is installed but not loaded yet. To use it:`);
286
300
  console.log(dim(` โ€ข now, in this session: skills-atlas use ${skill}`));
@@ -19,8 +19,15 @@ module.exports = async function remove(argv) {
19
19
  const dest = path.join(root, name);
20
20
  const tracked = manifest.read(root).skills[name];
21
21
 
22
+ // Is the skill also present in the OTHER scope? (remove only touches one scope.)
23
+ const otherRoot = fsu.installTargetDir({ global: !global });
24
+ const otherFlag = global ? '--project' : '--global';
25
+ const otherHas = otherRoot !== root
26
+ && (fsu.dirExists(path.join(otherRoot, name)) || !!manifest.read(otherRoot).skills[name]);
27
+
22
28
  if (!fsu.dirExists(dest) && !tracked) {
23
29
  console.error(`'${name}' is not installed (${global ? 'global' : 'project'}).`);
30
+ if (otherHas) console.error(dim(`it is installed in the ${global ? 'project' : 'global'} scope โ€” try: skills-atlas remove ${name} ${otherFlag}`));
24
31
  process.exitCode = 1;
25
32
  return;
26
33
  }
@@ -35,4 +42,5 @@ module.exports = async function remove(argv) {
35
42
 
36
43
  if (values.json) { console.log(JSON.stringify({ removed: name, dest })); return; }
37
44
  console.log(`${green('โœ“')} removed ${name} ${dim(fsu.tildify(dest))}`);
45
+ if (otherHas) console.log(dim(`note: '${name}' is still installed in the ${global ? 'project' : 'global'} scope โ€” remove that too: skills-atlas remove ${name} ${otherFlag}`));
38
46
  };
@@ -5,6 +5,8 @@ const { loadData } = require('../data');
5
5
  const { buildIndices, suggestSkills } = require('../index-build');
6
6
  const { runSearch } = require('../search-core');
7
7
  const { renderRow, dim } = require('../format');
8
+ const fsu = require('../fsutil');
9
+ const manifest = require('../manifest');
8
10
 
9
11
  const HELP = `usage: skills-atlas search <query...> [filters]
10
12
 
@@ -12,7 +14,8 @@ Matches by words, not whole-string: multiple keywords and loose phrases work
12
14
  (e.g. "pdf ็ฟป่ฏ‘", "translate a whole pdf"). Ranked by how much of the query hits.
13
15
 
14
16
  filters:
15
- -c, --category <s> match a top-level category (loose, zh or en)
17
+ -c, --category <s> match a top-level category (loose; zh or en; e.g. -c marketing).
18
+ run \`skills-atlas categories\` for the exact names
16
19
  -p, --persona <s> match a persona โ€” English or Chinese, e.g.
17
20
  engineering, pm, design, marketing, research, ops,
18
21
  founder, job-seeking, general (ๅทฅ็จ‹/PM/่ฎพ่ฎก/่ฅ้”€/...)
@@ -62,7 +65,10 @@ module.exports = async function search(argv) {
62
65
  const shown = rows.slice(0, limit);
63
66
 
64
67
  if (values.json) {
65
- console.log(JSON.stringify(shown.map(jsonRow), null, 2));
68
+ console.log(JSON.stringify({
69
+ query, total: rows.length, shown: shown.length, weak,
70
+ results: shown.map(jsonRow),
71
+ }, null, 2));
66
72
  return;
67
73
  }
68
74
 
@@ -73,15 +79,20 @@ module.exports = async function search(argv) {
73
79
  const { skillIndex } = buildIndices(data);
74
80
  const sugg = suggestSkills(skillIndex, query);
75
81
  if (sugg.length) console.log(dim(`did you mean: ${sugg.join(', ')}`));
76
- console.log(dim('try fewer / different words, or `skills-atlas categories` to browse.'));
77
82
  }
83
+ if (values.category) console.log(dim(`if -c "${values.category}" isn't matching, run \`skills-atlas categories\` for the exact names.`));
84
+ console.log(dim('try fewer / different words, or `skills-atlas categories` to browse.'));
78
85
  return;
79
86
  }
80
87
 
81
88
  if (weak) {
82
- console.log(dim('\nโš  weak matches โ€” none of the results cover your whole query; try different words.'));
89
+ console.log(dim('\nโš  partial match โ€” results may cover only part of your query; the top ones can still help, or try different words.'));
83
90
  }
84
- shown.forEach(r => console.log(renderRow(r, { en })));
85
- const more = rows.length > limit ? `, showing ${limit}` : '';
86
- console.log(`\n${rows.length} match(es)${more}.`);
91
+ const installedSet = new Set();
92
+ for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installedSet.add(e.skill);
93
+ shown.forEach(r => console.log(renderRow(r, { en, installed: installedSet, vendors: data.vendors })));
94
+ const truncated = rows.length > limit;
95
+ console.log(`\n${rows.length} match(es)${truncated ? `, showing ${limit}` : ''}.`);
96
+ if (truncated) console.log(dim(`see the rest with --limit ${rows.length}${values.category ? '' : ', or narrow with -c <category>'}.`));
97
+ console.log(dim('next: skills-atlas info <skill> to learn more, or skills-atlas use <skill> to install it.'));
87
98
  };
@@ -28,6 +28,7 @@ module.exports = async function update(argv) {
28
28
  }
29
29
  } catch (e) {
30
30
  console.error(`update failed: ${e.message}`);
31
+ console.error('your existing catalog is unchanged and still usable offline.');
31
32
  process.exitCode = 1;
32
33
  }
33
34
  };
@@ -32,8 +32,15 @@ module.exports = async function upgrade(argv) {
32
32
  for (const n of names) targets.push({ scope: s, name: n, entry: m.skills[n] });
33
33
  }
34
34
  if (!targets.length) {
35
- if (values.json) { console.log('[]'); return; }
36
- console.log(positionals[0] ? `'${positionals[0]}' is not installed by skills-atlas.` : 'nothing installed to upgrade.');
35
+ if (values.json) { console.log('[]'); if (positionals[0]) process.exitCode = 1; return; }
36
+ if (positionals[0]) {
37
+ // A named skill that isn't installed is a user error โ†’ non-zero, like `remove`.
38
+ console.log(`'${positionals[0]}' is not installed by skills-atlas.`);
39
+ console.log(dim('see what is: skills-atlas installed'));
40
+ process.exitCode = 1;
41
+ } else {
42
+ console.log('nothing installed to upgrade.');
43
+ }
37
44
  return;
38
45
  }
39
46
 
package/src/data.js CHANGED
@@ -31,8 +31,17 @@ function tryReadJSON(p) {
31
31
  }
32
32
  }
33
33
 
34
+ // Structural validation, not just "has the two top keys". The catalog is iterated
35
+ // as sections[].subsections[].rows[] everywhere (counts/merge/search), so a source
36
+ // missing that shape (e.g. an old `subgroups`/`groups` schema) must be rejected
37
+ // HERE โ€” both to fail `registry add` cleanly and to keep a stale bad cache from
38
+ // being merged as an overlay and crashing every downstream command.
34
39
  function isValid(d) {
35
- return d && Array.isArray(d.sections) && d.vendors && typeof d.vendors === 'object';
40
+ return !!d
41
+ && Array.isArray(d.sections)
42
+ && d.sections.every(s => s && Array.isArray(s.subsections)
43
+ && s.subsections.every(ss => ss && Array.isArray(ss.rows)))
44
+ && !!d.vendors && typeof d.vendors === 'object';
36
45
  }
37
46
 
38
47
  function counts(d) {
@@ -63,7 +72,10 @@ function loadBundled() {
63
72
  'or run `skills-atlas update` to fetch the catalog.');
64
73
  }
65
74
 
75
+ const nudgeFile = () => path.join(cacheDir(), 'nudge.json');
76
+
66
77
  // One-line stderr nudge when the catalog is the bundled snapshot or a stale cache.
78
+ // Throttled to once per day so it informs without nagging on every command.
67
79
  function maybeStaleNudge(fromCache) {
68
80
  const meta = tryReadJSON(metaFile());
69
81
  let stale = true;
@@ -71,9 +83,14 @@ function maybeStaleNudge(fromCache) {
71
83
  const ageDays = (Date.now() - Date.parse(meta.fetchedAt)) / 86400000;
72
84
  stale = !(ageDays >= 0 && ageDays < STALE_DAYS);
73
85
  }
74
- if (stale) {
75
- process.stderr.write("tip: run 'skills-atlas update' to refresh the catalog\n");
76
- }
86
+ if (!stale) return;
87
+ try {
88
+ const n = tryReadJSON(nudgeFile());
89
+ if (n && n.at && (Date.now() - Date.parse(n.at)) < 86400000) return; // shown within the day
90
+ fs.mkdirSync(cacheDir(), { recursive: true });
91
+ fs.writeFileSync(nudgeFile(), JSON.stringify({ at: new Date().toISOString() }));
92
+ } catch { /* throttle I/O is best-effort */ }
93
+ process.stderr.write("tip: run 'skills-atlas update' to refresh the catalog\n");
77
94
  }
78
95
 
79
96
  function loadData({ quiet = false } = {}) {
@@ -188,4 +205,4 @@ async function refreshSources() {
188
205
  return out;
189
206
  }
190
207
 
191
- module.exports = { loadData, refreshData, fetchSource, refreshSources, counts, cacheDir, PUBLIC_URL };
208
+ module.exports = { loadData, refreshData, fetchSource, refreshSources, counts, isValid, cacheDir, PUBLIC_URL };
package/src/format.js CHANGED
@@ -19,6 +19,17 @@ function stars(n) {
19
19
  return `โ˜…${n}`;
20
20
  }
21
21
 
22
+ // Repo freshness from a source's `last_commit` ("2026-05-28"). Returns null if
23
+ // absent/unparseable. `stale` flags a repo untouched for over a year โ€” so a popular
24
+ // but abandoned source no longer looks identical to an actively maintained one.
25
+ function recency(lastCommit, now = Date.now()) {
26
+ if (!lastCommit) return null;
27
+ const t = Date.parse(lastCommit);
28
+ if (!Number.isFinite(t)) return null;
29
+ const days = Math.floor((now - t) / 86400000);
30
+ return { date: String(lastCommit).slice(0, 10), days, stale: days > 365 };
31
+ }
32
+
22
33
  // A `git clone <repo> ~/.claude/skills/skills` alt double-nests a multi-skill
23
34
  // monorepo (skills end up at .claude/skills/skills/<name>/) and won't load โ€”
24
35
  // drop that foot-gun; the `npx skills add` command is the correct path.
@@ -34,18 +45,33 @@ function text(obj, key, en) {
34
45
  return en ? (obj[key + '_en'] || obj[key] || '') : (obj[key] || obj[key + '_en'] || '');
35
46
  }
36
47
 
37
- // One search/list result line block.
38
- function renderRow(r, { en = false } = {}) {
48
+ // One search/list result line block. `installed` (a Set of skill names) marks what
49
+ // you already have; `vendors` lets the source line say whether install is a clean
50
+ // single-skill folder or a whole-repo command.
51
+ function renderRow(r, { en = false, installed = null, vendors = null } = {}) {
39
52
  const chain = r.chain ? cyan('โ›“ ') : '';
40
53
  const cat = en ? (r._catEn || r._cat) : r._cat;
41
54
  const lines = [`\n${chain}${bold(text(r, 'group', en))} ${dim('[' + cat + ']')}`];
42
55
  const uc = text(r, 'use_case', en);
43
56
  if (uc) lines.push(` ๐Ÿ’ก ${uc}`);
44
- lines.push(` ${dim('skills:')} ${green(r.skills.join(', '))}`);
57
+ const SK_MAX = 8;
58
+ const sk = r.skills || [];
59
+ const tick = s => (installed && installed.has(s)) ? green(s + ' โœ“') : green(s);
60
+ const skStr = sk.length > SK_MAX
61
+ ? sk.slice(0, SK_MAX).map(tick).join(dim(', ')) + dim(` +${sk.length - SK_MAX} more`)
62
+ : sk.map(tick).join(dim(', '));
63
+ lines.push(` ${dim('skills:')} ${skStr}`);
45
64
  const best = [...(r.sources || [])].sort((a, b) => (b.stars || 0) - (a.stars || 0))[0];
46
65
  if (best) {
66
+ const rec = recency(best.last_commit);
67
+ const recStr = rec ? ` updated ${rec.date}${rec.stale ? ' โš ' : ''}` : '';
68
+ let kind = '';
69
+ if (vendors) {
70
+ const v = vendors[best.name];
71
+ kind = (v && skillDocPath(v, sk[0])) ? ' [single-skill]' : ' [whole-repo]';
72
+ }
47
73
  const inst = best.install && best.install.command ? ` โ€” ${best.install.command}` : '';
48
- lines.push(` ${dim('via')} ${best.name} ${yellow(stars(best.stars))}${dim(inst)}`);
74
+ lines.push(` ${dim('via')} ${best.name} ${yellow(stars(best.stars))}${dim(recStr)}${dim(kind)}${dim(inst)}`);
49
75
  }
50
76
  return lines.join('\n');
51
77
  }
@@ -84,6 +110,7 @@ function groupOf(r, skillName, vendors) {
84
110
  id: s.name,
85
111
  url: s.url,
86
112
  stars: s.stars,
113
+ last_commit: s.last_commit || v.last_commit || null,
87
114
  license: (v.skill_licenses && v.skill_licenses[skillName]) || s.license || null,
88
115
  type: s.type,
89
116
  path: docPath || null,
@@ -123,15 +150,40 @@ function infoForRow(skillName, row, vendors) {
123
150
  return { skill: skillName, found: true, groups: [groupOf(row, skillName, vendors)] };
124
151
  }
125
152
 
126
- function renderInfo(info, { en = false, all = false } = {}) {
153
+ // Multi-skill groups share one description that often enumerates each skill as
154
+ // "<vendor> <skill> (<detail>) + โ€ฆ". Pull out the segment for THIS skill so info
155
+ // pinpoints what it does, not just the whole group. Conservative: only fire when
156
+ // the skill matches exactly one segment (otherwise we'd guess wrong).
157
+ function perSkillBlurb(skill, desc) {
158
+ if (!skill || !desc) return '';
159
+ const segs = String(desc).split(' + ');
160
+ if (segs.length < 2) return '';
161
+ const re = new RegExp('\\b' + String(skill).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
162
+ const hits = segs.filter(s => re.test(s));
163
+ if (hits.length !== 1) return '';
164
+ const seg = hits[0].trim();
165
+ return /[(๏ผˆ]/.test(seg) ? seg : ''; // only worth showing if it carries a detail
166
+ }
167
+
168
+ // `skillDesc` is the authoritative one-liner from the skill's own SKILL.md (read
169
+ // locally when installed); it wins over the heuristic catalog-segment extraction.
170
+ function renderInfo(info, { en = false, all = false, installed = null, skillDesc = '' } = {}) {
127
171
  const pick = (zh, e) => (en ? (e || zh) : (zh || e)) || '';
128
172
  const out = [];
129
- out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('โ›“') : ''}`);
173
+ const instTag = installed && installed.length
174
+ ? ` ${green('โœ“ installed')} ${dim('[' + installed.join('+') + ']')}` : '';
175
+ out.push(`\n${bold(green(info.skill))}${info.groups.some(g => g.chain) ? ' ' + cyan('โ›“') : ''}${instTag}`);
130
176
  const shown = all ? info.groups : info.groups.slice(0, 1);
177
+ let firstGroup = true;
131
178
  for (const g of shown) {
132
179
  out.push(` ${dim('group:')} ${pick(g.group, g.group_en)} ${dim('[' + pick(g.category, g.category_en) + ']')}`);
133
180
  const desc = pick(g.description, g.description_en);
134
- if (desc) out.push(` ${desc}`);
181
+ const blurb = (firstGroup && skillDesc) ? skillDesc : perSkillBlurb(info.skill, desc);
182
+ firstGroup = false;
183
+ const distinguished = blurb && blurb !== desc;
184
+ if (distinguished) out.push(` ${dim('this skill:')} ${blurb}`);
185
+ // Only call it "group does:" when a per-skill line is shown above it to contrast.
186
+ if (desc) out.push(distinguished ? ` ${dim('group does:')} ${desc}` : ` ${desc}`);
135
187
  const uc = pick(g.use_case, g.use_case_en);
136
188
  if (uc) out.push(` ${dim('use case:')} ${uc}`);
137
189
  const wt = pick(g.when_to_use, g.when_to_use_en);
@@ -141,7 +193,9 @@ function renderInfo(info, { en = false, all = false } = {}) {
141
193
  }
142
194
  out.push(` ${dim('sources:')}`);
143
195
  for (const s of g.sources) {
144
- out.push(` โ€ข ${bold(s.id)} ${yellow(stars(s.stars))} ${dim(s.type || '')} ${s.license ? dim('(' + s.license + ')') : ''}`);
196
+ const rec = recency(s.last_commit);
197
+ const recStr = rec ? ` ${dim('updated ' + rec.date)}${rec.stale ? ' ' + yellow('โš  stale') : ''}` : '';
198
+ out.push(` โ€ข ${bold(s.id)} ${yellow(stars(s.stars))} ${dim(s.type || '')} ${s.license ? dim('(' + s.license + ')') : ''}${recStr}`);
145
199
  out.push(` ${dim(s.url)}`);
146
200
  out.push(` ${dim('path:')} ${s.path || dim('(whole-repo install โ€” no per-skill folder)')}`);
147
201
  if (s.install && s.install.command) {
@@ -161,6 +215,6 @@ function renderInfo(info, { en = false, all = false } = {}) {
161
215
  }
162
216
 
163
217
  module.exports = {
164
- bold, dim, green, cyan, yellow, stars, safeAlt, text, renderRow,
165
- buildInfo, infoForRow, renderInfo,
218
+ bold, dim, green, cyan, yellow, stars, recency, perSkillBlurb, safeAlt, text, renderRow,
219
+ buildInfo, infoForRow, renderInfo, PERSONA_EN,
166
220
  };
package/src/mcp.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const { loadData } = require('./data');
9
9
  const { buildIndices, vendorsFor, skillDocPath, suggestSkills } = require('./index-build');
10
10
  const { runSearch } = require('./search-core');
11
- const { buildInfo } = require('./format');
11
+ const { buildInfo, PERSONA_EN } = require('./format');
12
12
  const fsu = require('./fsutil');
13
13
  const { installFolder } = require('./installer');
14
14
 
@@ -29,7 +29,8 @@ function fmtSearch(data, { query, limit }) {
29
29
  (uc ? `\n ${uc}` : '') +
30
30
  (src.name ? `\n via ${src.name} โ˜…${src.stars || 0}${cmd ? ` โ€” ${cmd}` : ''}` : '');
31
31
  });
32
- return `${rows.length} result(s) for "${query}" (showing ${n}):\n\n${out.join('\n\n')}`;
32
+ const more = n < rows.length ? `\n\n(showing ${n} of ${rows.length}; pass limit:${rows.length} to see all)` : '';
33
+ return `${rows.length} result(s) for "${query}" (showing ${n}):\n\n${out.join('\n\n')}${more}`;
33
34
  }
34
35
 
35
36
  function fmtInfo(data, { skill }) {
@@ -44,7 +45,7 @@ function fmtInfo(data, { skill }) {
44
45
  const desc = g.description_en || g.description; if (desc) lines.push(desc);
45
46
  const uc = g.use_case_en || g.use_case; if (uc) lines.push(`use case: ${uc}`);
46
47
  const wt = g.when_to_use_en || g.when_to_use; if (wt) lines.push(`when: ${wt}`);
47
- if (g.personas && g.personas.length) lines.push(`personas: ${g.personas.join(', ')}`);
48
+ if (g.personas && g.personas.length) lines.push(`personas: ${g.personas.map(p => PERSONA_EN[p] || p).join(', ')}`);
48
49
  lines.push('sources:');
49
50
  for (const s of g.sources) {
50
51
  lines.push(` - ${s.id} โ˜…${s.stars || 0}${s.license ? ` (${s.license})` : ''} ${s.path || '(whole-repo install)'}`);
@@ -107,8 +108,8 @@ async function doInstall(data, { skill, scope, source, dry_run }) {
107
108
 
108
109
  function toolDefs() {
109
110
  return [
110
- { name: 'search_skills', description: 'Search the Skills Atlas catalog of AI agent skills by keyword or short task description.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'keywords or a short task description' }, limit: { type: 'integer', minimum: 1, description: 'max results (default 10)' } }, required: ['query'] } },
111
- { name: 'skill_info', description: 'Show what a catalog skill does, when to use it, its sources, and how to install it.', inputSchema: { type: 'object', properties: { skill: { type: 'string' } }, required: ['skill'] } },
111
+ { name: 'search_skills', description: 'Search the Skills Atlas catalog of AI agent skills by keyword or short task description. To install a result, call install_skill with the skill name โ€” do not run the npx/install command shown in the results (that is the source reference, often a whole-repo install).', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'keywords or a short task description' }, limit: { type: 'integer', minimum: 1, description: 'max results (default 10)' } }, required: ['query'] } },
112
+ { name: 'skill_info', description: 'Show what a catalog skill does, when to use it, its sources, and how to install it.', inputSchema: { type: 'object', properties: { skill: { type: 'string', description: 'exact skill name (e.g. brainstorming); fuzzy matching and typo correction are applied' } }, required: ['skill'] } },
112
113
  { name: 'install_skill', description: 'Install a skill from the catalog into .claude/skills/.', inputSchema: { type: 'object', properties: { skill: { type: 'string' }, scope: { type: 'string', enum: ['global', 'project'], description: 'default global (~/.claude/skills)' }, source: { type: 'string', description: 'pick a source when several provide the skill' }, dry_run: { type: 'boolean' } }, required: ['skill'] } },
113
114
  { name: 'list_categories', description: "List the catalog's top-level categories, or the skill groups within one.", inputSchema: { type: 'object', properties: { category: { type: 'string', description: 'optional โ€” drill into one category' } } } },
114
115
  ];
@@ -119,6 +120,8 @@ async function callTool(name, args, data) {
119
120
  switch (name) {
120
121
  case 'search_skills':
121
122
  if (!args.query) return { text: 'missing required "query".', isError: true };
123
+ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1))
124
+ return { text: `invalid "limit": ${JSON.stringify(args.limit)} (must be a positive integer).`, isError: true };
122
125
  return { text: fmtSearch(data, args) };
123
126
  case 'skill_info':
124
127
  if (!args.skill) return { text: 'missing required "skill".', isError: true };
@@ -138,21 +141,26 @@ async function handle(req, { data }) {
138
141
  const ok = result => ({ jsonrpc: '2.0', id, result });
139
142
  const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
140
143
  if (!req || req.jsonrpc !== '2.0') return id === undefined ? null : err(-32600, 'Invalid Request');
141
- switch (req.method) {
142
- case 'initialize':
143
- return ok({ protocolVersion: (req.params && req.params.protocolVersion) || PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'skills-atlas', version: VERSION } });
144
- case 'notifications/initialized':
145
- case 'initialized':
146
- return null;
147
- case 'tools/list':
148
- return ok({ tools: toolDefs() });
149
- case 'tools/call': {
150
- const p = req.params || {};
151
- const r = await callTool(p.name, p.arguments, data);
152
- return ok({ content: [{ type: 'text', text: r.text }], isError: Boolean(r.isError) });
144
+ try {
145
+ switch (req.method) {
146
+ case 'initialize':
147
+ return ok({ protocolVersion: (req.params && req.params.protocolVersion) || PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'skills-atlas', version: VERSION } });
148
+ case 'notifications/initialized':
149
+ case 'initialized':
150
+ return null;
151
+ case 'tools/list':
152
+ return ok({ tools: toolDefs() });
153
+ case 'tools/call': {
154
+ const p = req.params || {};
155
+ const r = await callTool(p.name, p.arguments, data);
156
+ return ok({ content: [{ type: 'text', text: r.text }], isError: Boolean(r.isError) });
157
+ }
158
+ default:
159
+ return id === undefined ? null : err(-32601, 'Method not found');
153
160
  }
154
- default:
155
- return id === undefined ? null : err(-32601, 'Method not found');
161
+ } catch (e) {
162
+ // A request with an id MUST get a response โ€” never leave the client hanging.
163
+ return id === undefined ? null : err(-32603, (e && e.message) || 'Internal error');
156
164
  }
157
165
  }
158
166
 
@@ -23,8 +23,16 @@ const STOP = new Set([
23
23
  'ๅœจ', 'ๆ˜ฏ', 'ๅฐฑ', 'ไนŸ', '้ƒฝ', '่ฟ˜', '่ฎฉ', '่ทŸ', 'ๅฏน', 'ๅ‘', 'ๆˆ‘ไปฌ', 'ๅธฎๆˆ‘', 'ๆˆ‘ๆƒณ',
24
24
  ]);
25
25
 
26
+ // Leading interrogative / filler phrases ("ๅฆ‚ไฝ•โ€ฆ", "how do iโ€ฆ", "i want toโ€ฆ") add
27
+ // noise tokens โ€” including a bridging CJK bigram ("ๅฆ‚ไฝ•้‡ๆž„" โ†’ keeps "ไฝ•้‡") โ€” that
28
+ // dilute coverage and wrongly flag a good query as weak. Strip them up front.
29
+ const LEAD_FILLER_EN = /^(?:\s*(?:how\s+(?:do|can|should)\s+(?:i|we|you)|how\s+to|i\s+(?:want|need|wanna)\s+to|i\s+would\s+like\s+to|can\s+you|could\s+you|please|help\s+me|let'?s)\s+)+/;
30
+ const LEAD_FILLER_ZH = /^(?:ๅฆ‚ไฝ•|ๆ€Žไนˆๆ ท|ๆ€Žๆ ท|ๆ€Žไนˆ|่ฏท้—ฎ|ๅธฎๆˆ‘็œ‹็œ‹|ๅธฎๆˆ‘|ๆˆ‘ๆƒณ่ฆ|ๆˆ‘ๆƒณ|ๆˆ‘่ฆ|ๆˆ‘้œ€่ฆ|้บป็ƒฆ)+/;
31
+ const stripLeadingFillers = q => q.replace(LEAD_FILLER_EN, '').replace(LEAD_FILLER_ZH, '');
32
+
26
33
  // Split a (lowercased) query into search terms.
27
34
  function tokenize(query) {
35
+ query = stripLeadingFillers(query);
28
36
  const out = new Set();
29
37
  const re = /[a-z0-9]+|[ไธ€-้ฟฟ]+/g;
30
38
  let m;
@@ -249,12 +257,19 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
249
257
  const push = (skill, row) => { if (!seen.has(skill)) { seen.add(skill); out.push({ skill, row }); } };
250
258
  for (const a of anchors) { if (out.length >= limit) break; push(a.skill, a.row); }
251
259
 
252
- // 2. fill from the general ranked search (one primary skill per row)
260
+ // 2. fill remaining slots from the general ranked search โ€” but only with rows
261
+ // that are actually on-topic (a name/group hit, or strong coverage). A lone
262
+ // description-word match must not pad the shortlist with off-topic skills
263
+ // (e.g. "product-marketing" riding into a "debug this crash" prompt).
253
264
  const { rows: ranked, weak } = runSearch(rows, { query: prompt });
254
265
  for (const r of ranked) {
255
266
  if (out.length >= limit) break;
256
267
  const s = (r.skills || []).find(x => !seen.has(x));
257
- if (s) push(s, r);
268
+ if (!s) continue;
269
+ const f = buildFields(r);
270
+ const nameOrGroup = tokens.some(t => fieldHas(f.name, t) || fieldHas(f.group, t));
271
+ if (!nameOrGroup && scoreRow(r, tokens, lc(prompt)).coverage < 0.6) continue;
272
+ push(s, r);
258
273
  }
259
274
 
260
275
  // fire decision: only on a real name anchor โ€” two contentful name words, OR one