@cangurel81/skill-router 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Can Gurel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,254 @@
1
- # Temporary Holding Version
1
+ # skill-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![test](https://github.com/cangurel81/skill-router/actions/workflows/test.yml/badge.svg)](https://github.com/cangurel81/skill-router/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/@cangurel81/skill-router.svg)](https://www.npmjs.com/package/@cangurel81/skill-router)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
7
+
8
+ Index, classify and search a large agent-skill catalogue — so the agent stops guessing which of
9
+ your 900 skills applies, and so you notice when most of the catalogue is duplicated.
10
+
11
+ Zero dependencies. Node 18+. One file. No config required.
12
+
13
+ ```bash
14
+ npx @cangurel81/skill-router --search expo
15
+ ```
16
+
17
+ ---
18
+
19
+ ## The problem it solves
20
+
21
+ Once a machine has more than a few dozen installed skills, two failures appear and neither one is
22
+ visible:
23
+
24
+ **1. The agent guesses.** It names a skill that does not exist, or writes from scratch something
25
+ already installed forty times over, because it cannot tell which of a thousand descriptions
26
+ matches the task.
27
+
28
+ **2. The catalogue silently fills with copies.** A repository ships `skills/` plus
29
+ `docs/ja-JP/skills`, `docs/zh-CN/skills` and a mirrored `pi/core/skills` tree. Every one is
30
+ discovered as a separate capability. Your inventory says 966 skills; 651 of them are the same 315
31
+ translated or mirrored. Every search returns duplicates, and the counts you reason from are wrong.
32
+
33
+ `skill-router` measures both and answers the routing question with paths, not vibes.
34
+
35
+ ## What it does
36
+
37
+ | Step | Detail |
38
+ |---|---|
39
+ | Finds roots | `--root` flags, `SKILL_ROOTS`, or well-known install locations |
40
+ | Walks | up to 5 levels deep, skips `node_modules`, `.git`, `dist`, `build`, `vendor` |
41
+ | Parses | `SKILL.md` YAML frontmatter without a YAML dependency (block scalars included) |
42
+ | Classifies | 13 domain groups by weighted keyword match on name + description |
43
+ | De-duplicates | two detectors: `docs/<locale>/` trees, and byte-identical files via SHA-1 |
44
+ | Reports | inventory, per-domain counts, duplicate names, unusable descriptions |
45
+ | Searches | ranked matches, word-boundary aware, with full file paths |
46
+
47
+ It never edits, deletes or installs anything. It reads and reports.
48
+
49
+ ## Install
50
+
51
+ Pick one. They all run the same three files.
52
+
53
+ **npm — recommended**
54
+
55
+ ```bash
56
+ npx @cangurel81/skill-router --root ~/.claude/skills --search testing # no install
57
+ npm i -g @cangurel81/skill-router # global command
58
+ ```
59
+
60
+ The package installs the command `skill-router`, so after a global install you can drop the
61
+ `npx @cangurel81/` prefix:
62
+
63
+ ```bash
64
+ skill-router --search expo
65
+ ```
66
+
67
+ **Clone and run directly**
68
+
69
+ ```bash
70
+ git clone https://github.com/cangurel81/skill-router.git
71
+ cd skill-router
72
+ node bin/skill-router.mjs
73
+ ```
74
+
75
+ **Use as an agent skill**
76
+
77
+ Register the repo's `skills/` folder as a skill root, in whichever harness you use:
78
+
79
+ ```jsonc
80
+ // opencode.jsonc
81
+ { "skills": ["~/code/skill-router/skills"] }
82
+ ```
83
+
84
+ ```bash
85
+ # Claude Code
86
+ ln -s ~/code/skill-router/skills/skill-router ~/.claude/skills/skill-router
87
+ ```
88
+
89
+ The skill file at `skills/skill-router/SKILL.md` teaches the agent the routing procedure: name the
90
+ domain, search two or three words, read the path, then open only the winner.
91
+
92
+ **Copy the script**
93
+
94
+ ```bash
95
+ curl -O https://raw.githubusercontent.com/cangurel81/skill-router/main/bin/skill-router.mjs
96
+ node skill-router.mjs --root ~/.claude/skills --search testing
97
+ ```
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ node bin/skill-router.mjs # inventory of every root
103
+ node bin/skill-router.mjs --search expo # ranked matches
104
+ node bin/skill-router.mjs --search video --group video
105
+ node bin/skill-router.mjs --groups # domain keys and counts
106
+ node bin/skill-router.mjs --dupes # same name, different folders
107
+ node bin/skill-router.mjs --weak # descriptions too short to route on
108
+ node bin/skill-router.mjs --json # machine-readable, all records
109
+ node bin/skill-router.mjs --write ./index # write index/skills.json
110
+ node bin/skill-router.mjs --help
111
+ ```
112
+
113
+ Exit codes: `0` success, `1` no skill root found.
114
+
115
+ ### Choosing roots
116
+
117
+ ```bash
118
+ node bin/skill-router.mjs --root ./skills --root ~/.claude/skills
119
+ SKILL_ROOTS=~/.claude/skills:./skills node bin/skill-router.mjs # ';' on Windows
120
+ ```
121
+
122
+ `--root` wins over `SKILL_ROOTS`, which wins over the defaults:
123
+
124
+ ```
125
+ ./.agents/skills ./.opencode/skill ./.claude/skills ./skills
126
+ ~/.claude/skills ~/.codex/skills ~/.kimi-code/skills
127
+ ~/.config/opencode/skills ~/.config/opencode/local-skills
128
+ ```
129
+
130
+ ## Real output
131
+
132
+ Run against a real repository that registers its root instead of its `skills/` folder:
133
+
134
+ ```console
135
+ $ node bin/skill-router.mjs --root ~/.config/opencode/vendor/affaan-m__ecc
136
+
137
+ SKILL CATALOGUE
138
+ ================================================================
139
+ Roots with skills : 1
140
+ /home/me/.config/opencode/vendor/affaan-m__ecc
141
+ 966 files, 315 genuine
142
+
143
+ Total SKILL.md files : 966
144
+ genuine : 315
145
+ copies : 651 (67%) [locale 518, identical 133]
146
+ unique names (genuine) : 315
147
+ duplicate names : 0
148
+ weak descriptions : 0
149
+
150
+ By group (genuine only):
151
+ Agent / LLM / orchestration 105
152
+ Other 45
153
+ Code quality / testing / process 30
154
+ Backend / API / databases 28
155
+ Mobile / Expo / React Native 20
156
+ Frontend / UI / design systems 18
157
+ Infra / DevOps / network 16
158
+ Marketing / content / social 12
159
+ Video / motion / audio 12
160
+ Data / research / analytics 10
161
+ Security / compliance 9
162
+ Desktop / documents / office 7
163
+ SEO / GEO / search 3
164
+ ```
165
+
166
+ The honest reading: 315 real capabilities, 651 files that only exist because the same skills were
167
+ translated into six languages and mirrored once. Register `.../ECC-main/skills` instead of
168
+ `.../ECC-main` and 966 becomes 315.
169
+
170
+ Searching is where it earns its place:
171
+
172
+ ```console
173
+ $ node bin/skill-router.mjs --search caption
174
+
175
+ "caption" -> 1 match(es)
176
+
177
+ * remotion-video-creation [Video / motion / audio]
178
+ Best practices for Remotion - Video creation in React. 29 domain-specific rules covering 3D,
179
+ animations, audio, captions, charts, transitions, and more...
180
+ /home/me/.config/opencode/vendor/affaan-m__ecc/ECC-main/skills/remotion-video-creation/SKILL.md
181
+ ```
182
+
183
+ The path is not decoration. Two skills can share a name and differ completely; the path is the only
184
+ thing that says which repository you are actually loading.
185
+
186
+ ## Reading the report
187
+
188
+ Every number has an action attached:
189
+
190
+ | Signal | Means | Do this |
191
+ |---|---|---|
192
+ | `copies` above 10% | a root points at a repository, not at its `skills/` folder | point it at the real skill folder |
193
+ | `duplicate names` high | two repos ship the same skill; the agent may load either | drop one root, or accept the newer repo |
194
+ | `weak descriptions` | description under 25 chars, cannot be routed to | extend it, or treat the skill as absent |
195
+ | `Other` group large | descriptions too vague to classify | the same skills are also unroutable |
196
+ | a domain at `0` | genuine capability gap | this is where a new skill is justified |
197
+
198
+ `--dupes` prints both paths for every collision, so you can diff them before deciding.
199
+ `--weak` prints the character count, so "too short" is a number and not an opinion.
200
+
201
+ ## When a new skill is justified
202
+
203
+ All three must hold:
204
+
205
+ 1. `--search` over three plausible terms returns nothing relevant.
206
+ 2. The gap is knowledge no public repository could ship — local toolchain, local traps, private
207
+ systems.
208
+ 3. The knowledge repeats. It has already cost time twice.
209
+
210
+ Otherwise extend an existing skill or fix its description. A fourth near-duplicate makes the
211
+ catalogue worse, not more capable.
212
+
213
+ ## How duplicate detection works
214
+
215
+ Two independent detectors, because the two failure modes look different:
216
+
217
+ **Locale trees.** Any path matching `docs/<lang>/…` where `<lang>` is a locale slug (`tr`, `ja-JP`,
218
+ `zh-CN`, `pt_BR`) is a translated copy. This catches translations, which are *not* byte-identical
219
+ and would otherwise look like 183 new skills.
220
+
221
+ **Identical files.** Skills sharing a name whose `SKILL.md` has the same SHA-1 are the same file
222
+ copied. This catches mirrored trees (`pi/core/skills`, a vendored second copy) with no naming
223
+ convention to key on. The copy inside a real `skills/` directory wins as canonical, then the
224
+ shortest path, then lexicographic order — so the reported path never sends you to the mirror, and
225
+ the result is stable across runs and platforms.
226
+
227
+ Nothing is auto-deleted. Deleting skill folders is a decision with consequences; the tool hands you
228
+ the list and the path.
229
+
230
+ ## Development
231
+
232
+ ```bash
233
+ npm test # syntax check + smoke test
234
+ node test/smoke.mjs
235
+ ```
236
+
237
+ The smoke test builds a throwaway catalogue containing one genuine pair, one translated locale
238
+ copy and one byte-identical mirror, runs the real CLI, and asserts the counts, the canonical path,
239
+ the miss path and the exit codes. CI runs it on Linux, macOS and Windows across Node 18, 20 and 22 —
240
+ the script must behave identically on all three, since path separators are the main portability
241
+ risk here.
242
+
243
+ ## Limits
244
+
245
+ - It indexes discoverability, not quality. A short description is a routing failure, not a bad
246
+ skill.
247
+ - It reads frontmatter only. It does not open skill bodies — that is a separate review pass.
248
+ - Classification is keyword-based and will occasionally land a skill in the wrong group. Search
249
+ covers all groups by default, so this only costs you when you filter with `--group`.
250
+ - It does not fetch, install or update skills.
251
+
252
+ ## License
253
+
254
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * skill-router - index, classify and search a large agent-skill catalogue.
4
+ *
5
+ * Once a machine has more than a few dozen skills, two things break:
6
+ * the agent cannot tell which skill applies, and nobody notices when the
7
+ * catalogue is half full of duplicated or translated copies.
8
+ *
9
+ * This tool scans every skill root, reads each SKILL.md frontmatter, groups
10
+ * skills by domain, separates real capabilities from locale/duplicate trees,
11
+ * and answers "which skill fits this job?" with ranked matches and paths.
12
+ *
13
+ * Zero dependencies. Node 18+.
14
+ *
15
+ * node bin/skill-router.mjs summary of every root
16
+ * node bin/skill-router.mjs --search expo ranked matches
17
+ * node bin/skill-router.mjs --search video --group video
18
+ * node bin/skill-router.mjs --groups group keys and counts
19
+ * node bin/skill-router.mjs --dupes same name, different folders
20
+ * node bin/skill-router.mjs --weak unusable descriptions
21
+ * node bin/skill-router.mjs --json machine-readable output
22
+ *
23
+ * Roots come from, in order:
24
+ * 1. --root <path> (repeatable, wins over everything)
25
+ * 2. SKILL_ROOTS env var (paths separated by the OS path delimiter)
26
+ * 3. well-known locations (see DEFAULT_ROOTS)
27
+ */
28
+
29
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
30
+ import { createHash } from 'node:crypto';
31
+ import { join, dirname, basename, resolve, delimiter } from 'node:path';
32
+ import { homedir } from 'node:os';
33
+
34
+ const HOME = homedir();
35
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.cache', 'cache', 'dist', 'build', 'vendor']);
36
+ const MAX_DEPTH = 5;
37
+ const WEAK_DESCRIPTION = 25;
38
+
39
+ const DEFAULT_ROOTS = [
40
+ join(process.cwd(), '.agents', 'skills'),
41
+ join(process.cwd(), '.opencode', 'skill'),
42
+ join(process.cwd(), '.claude', 'skills'),
43
+ join(process.cwd(), 'skills'),
44
+ join(HOME, '.config', 'opencode', 'skills'),
45
+ join(HOME, '.config', 'opencode', 'local-skills'),
46
+ join(HOME, '.config', 'opencode', 'skill'),
47
+ join(HOME, '.claude', 'skills'),
48
+ join(HOME, '.codex', 'skills'),
49
+ join(HOME, '.kimi-code', 'skills'),
50
+ ];
51
+
52
+ /* ------------------------------------------------------------------ groups */
53
+
54
+ const GROUPS = [
55
+ { key: 'seo', label: 'SEO / GEO / search', words: ['seo', 'serp', 'backlink', 'hreflang', 'sitemap', 'schema markup', 'keyword research', 'ai overview', 'citability', 'llms.txt', 'local seo', 'search console', 'core web vitals'] },
56
+ { key: 'video', label: 'Video / motion / audio', words: ['video', 'remotion', 'animation', 'motion graphic', 'ffmpeg', 'caption', 'subtitle', 'lottie', 'audiogram', 'music', 'text-to-speech', 'tts', 'voiceover', 'shorts', 'reels', 'waveform', 'slideshow', 'lower third', 'countdown'] },
57
+ { key: 'wordpress', label: 'WordPress / WooCommerce', words: ['wordpress', 'woocommerce', 'wp-cli', 'elementor', 'gutenberg', 'custom post type', 'plugin header'] },
58
+ { key: 'mobile', label: 'Mobile / Expo / React Native', words: ['expo', 'react native', 'android', 'ios', 'swift', 'kotlin', 'flutter', 'dart', 'app store', 'play store', 'eas build'] },
59
+ { key: 'frontend', label: 'Frontend / UI / design systems', words: ['react', 'next.js', 'vue', 'nuxt', 'svelte', 'angular', 'css', 'tailwind', 'frontend', 'design system', 'accessibility', 'a11y', 'gsap', 'three.js', 'd3.js', 'component library'] },
60
+ { key: 'backend', label: 'Backend / API / databases', words: ['laravel', 'symfony', 'spring boot', 'quarkus', 'mybatis', 'jpa', 'hibernate', 'php', 'django', 'fastapi', 'nestjs', 'express', 'rails', 'api design', 'rest api', 'graphql', 'microservice', 'postgres', 'mysql', 'redis', 'database', 'orm', 'query optimization', 'schema design'] },
61
+ { key: 'marketing', label: 'Marketing / content / social', words: ['marketing', 'content strategy', 'social media', 'linkedin', 'twitter', 'newsletter', 'copywriting', 'brand voice', 'campaign', 'carousel', 'content brief', 'blog post', 'outreach', 'sales'] },
62
+ { key: 'agent', label: 'Agent / LLM / orchestration', words: ['agent', 'llm', 'prompt', 'mcp', 'subagent', 'orchestrat', 'harness', 'eval', 'rag', 'memory', 'autonomous', 'skill', 'claude code', 'context window'] },
63
+ { key: 'security', label: 'Security / compliance', words: ['security', 'vulnerab', 'pentest', 'hipaa', 'gdpr', 'compliance', 'phi', 'secret', 'threat model', 'access control'] },
64
+ { key: 'infra', label: 'Infra / DevOps / network', words: ['docker', 'kubernetes', 'deploy', 'ci/cd', 'github actions', 'nginx', 'network', 'vlan', 'dns', 'vpn', 'homelab', 'wireguard', 'cloud'] },
65
+ { key: 'data', label: 'Data / research / analytics', words: ['data pipeline', 'analytics', 'research', 'scrap', 'crawl', 'etl', 'sql', 'pandas', 'statistic', 'dashboard', 'benchmark'] },
66
+ { key: 'code', label: 'Code quality / testing / process', words: ['test-driven', 'unit test', 'tdd', 'code review', 'debug', 'refactor', 'coding standard', 'typescript', 'python', 'golang', 'rust', 'java', 'lint', 'codebase', 'architecture decision'] },
67
+ { key: 'desktop', label: 'Desktop / documents / office', words: ['windows', 'desktop', 'pywinauto', 'uiautomation', 'screenshot', 'excel', 'pdf', 'pandoc', 'document', 'slides', 'presentation'] },
68
+ ];
69
+
70
+ function classify(name, description) {
71
+ const text = `${name} ${description}`.toLowerCase();
72
+ let best = null;
73
+ let bestScore = 0;
74
+ for (const g of GROUPS) {
75
+ let score = 0;
76
+ for (const w of g.words) if (text.includes(w)) score += w.includes(' ') || w.includes('-') ? 3 : 1;
77
+ if (score > bestScore) {
78
+ bestScore = score;
79
+ best = g;
80
+ }
81
+ }
82
+ return best ?? { key: 'other', label: 'Other' };
83
+ }
84
+
85
+ /* ------------------------------------------------------------------- roots */
86
+
87
+ function rootsFromArgs(argv) {
88
+ const roots = [];
89
+ for (let i = 0; i < argv.length; i++) {
90
+ if (argv[i] === '--root' && argv[i + 1]) roots.push(resolve(argv[i + 1]));
91
+ }
92
+ if (roots.length) return roots;
93
+
94
+ const env = process.env.SKILL_ROOTS;
95
+ if (env) {
96
+ const fromEnv = env.split(delimiter).map((p) => p.trim()).filter(Boolean).map(resolve);
97
+ if (fromEnv.length) return fromEnv;
98
+ }
99
+ return DEFAULT_ROOTS;
100
+ }
101
+
102
+ function findSkillFiles(root) {
103
+ const found = [];
104
+ const stack = [{ path: root, depth: 0 }];
105
+ while (stack.length) {
106
+ const { path, depth } = stack.pop();
107
+ if (depth > MAX_DEPTH) continue;
108
+ let entries;
109
+ try {
110
+ entries = readdirSync(path, { withFileTypes: true });
111
+ } catch {
112
+ continue;
113
+ }
114
+ for (const e of entries) {
115
+ const full = join(path, e.name);
116
+ if (!e.isDirectory()) {
117
+ if (e.name === 'SKILL.md') found.push(full);
118
+ continue;
119
+ }
120
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
121
+ try {
122
+ if (statSync(full).isDirectory()) stack.push({ path: full, depth: depth + 1 });
123
+ } catch {
124
+ /* unreadable */
125
+ }
126
+ }
127
+ }
128
+ return found;
129
+ }
130
+
131
+ /* -------------------------------------------------------------- frontmatter */
132
+
133
+ function parseFrontmatter(text) {
134
+ const lines = text.split(/\r?\n/);
135
+ if (lines[0]?.trim() !== '---') return {};
136
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
137
+ if (end < 0) return {};
138
+
139
+ const fields = {};
140
+ for (let i = 1; i < end; i++) {
141
+ const m = lines[i].match(/^([A-Za-z_-]+):\s*(.*)$/);
142
+ if (!m) continue;
143
+ const [, key, raw] = m;
144
+ const value = raw.trim();
145
+ if (['', '|', '>', '|-', '>-', '|+', '>+'].includes(value)) {
146
+ const cont = [];
147
+ while (i + 1 < end && /^\s+\S/.test(lines[i + 1])) cont.push(lines[++i].trim());
148
+ fields[key] = cont.join(' ').trim();
149
+ } else {
150
+ fields[key] = value.replace(/^["']|["']$/g, '').trim();
151
+ }
152
+ }
153
+ return fields;
154
+ }
155
+
156
+ /* --------------------------------------------------------------- copy trees */
157
+
158
+ /**
159
+ * Translated documentation trees are the most common source of phantom
160
+ * skills: a repo ships docs/<locale>/skills alongside the real ones and every
161
+ * locale is discovered as a separate capability. They add no reach.
162
+ */
163
+ const LOCALE_SEGMENT = /^[a-z]{2}(?:[-_][A-Za-z]{2,4})?$/;
164
+
165
+ function isLocaleCopy(path) {
166
+ const parts = path.split(/[\\/]/);
167
+ for (let i = 0; i < parts.length - 1; i++) {
168
+ if ((parts[i] === 'docs' || parts[i] === 'doc') && LOCALE_SEGMENT.test(parts[i + 1])) return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ /* -------------------------------------------------------------------- scan */
174
+
175
+ function scan(roots) {
176
+ const records = [];
177
+ const seen = new Set();
178
+
179
+ for (const root of roots) {
180
+ if (!existsSync(root)) continue;
181
+ for (const file of findSkillFiles(root)) {
182
+ if (seen.has(file)) continue;
183
+ seen.add(file);
184
+ let fm = {};
185
+ let text = '';
186
+ try {
187
+ text = readFileSync(file, 'utf8');
188
+ fm = parseFrontmatter(text);
189
+ } catch {
190
+ /* unreadable */
191
+ }
192
+ const name = fm.name || basename(dirname(file));
193
+ const description = (fm.description || '').replace(/\s+/g, ' ').trim();
194
+ const group = classify(name, description);
195
+ const localeCopy = isLocaleCopy(file);
196
+ records.push({
197
+ name,
198
+ description,
199
+ group: group.key,
200
+ groupLabel: group.label,
201
+ path: file,
202
+ root,
203
+ copy: localeCopy,
204
+ origin: localeCopy ? 'locale' : null,
205
+ hash: createHash('sha1').update(text).digest('hex'),
206
+ disabled: String(fm['disable-model-invocation'] ?? '').toLowerCase() === 'true',
207
+ weak: description.length < WEAK_DESCRIPTION,
208
+ });
209
+ }
210
+ }
211
+
212
+ // Second detector: byte-identical files. Repos often ship a mirrored tree
213
+ // (a second copy of skills/ under another folder) that is not a locale copy
214
+ // and therefore escapes the path rule.
215
+ //
216
+ // Canonical selection matters: a file under a real `skills/` directory always
217
+ // beats a mirror, otherwise a folder named "mirror" can win on path length and
218
+ // the reported path sends the reader to the copy.
219
+ const canonicalScore = (r) => [/[\\/]skills[\\/]/i.test(r.path) ? 0 : 1, r.path.length];
220
+ const canonical = (a, b) => {
221
+ const [ax, ay] = canonicalScore(a);
222
+ const [bx, by] = canonicalScore(b);
223
+ return ax - bx || ay - by || a.path.localeCompare(b.path);
224
+ };
225
+
226
+ const byContent = new Map();
227
+ for (const r of records) {
228
+ if (r.copy) continue;
229
+ const key = `${r.name.toLowerCase()}|${r.hash}`;
230
+ const list = byContent.get(key) ?? [];
231
+ list.push(r);
232
+ byContent.set(key, list);
233
+ }
234
+ for (const list of byContent.values()) {
235
+ if (list.length < 2) continue;
236
+ list.sort(canonical);
237
+ for (const r of list.slice(1)) {
238
+ r.copy = true;
239
+ r.origin = 'duplicate';
240
+ }
241
+ }
242
+
243
+ for (const r of records) delete r.hash;
244
+ records.sort((a, b) => a.name.localeCompare(b.name));
245
+ return records;
246
+ }
247
+
248
+ /* ----------------------------------------------------------------- reports */
249
+
250
+ function stat(records) {
251
+ const genuine = records.filter((r) => !r.copy);
252
+ const copies = records.length - genuine.length;
253
+ const byGroup = new Map();
254
+ for (const r of genuine) byGroup.set(r.groupLabel, (byGroup.get(r.groupLabel) || 0) + 1);
255
+
256
+ const byName = new Map();
257
+ for (const r of genuine) {
258
+ const list = byName.get(r.name.toLowerCase()) || [];
259
+ list.push(r);
260
+ byName.set(r.name.toLowerCase(), list);
261
+ }
262
+ const dupes = [...byName.values()].filter((l) => l.length > 1);
263
+
264
+ return {
265
+ total: records.length,
266
+ genuine: genuine.length,
267
+ copies,
268
+ localeCopies: records.filter((r) => r.origin === 'locale').length,
269
+ duplicateCopies: records.filter((r) => r.origin === 'duplicate').length,
270
+ uniqueNames: new Set(genuine.map((r) => r.name.toLowerCase())).size,
271
+ byGroup,
272
+ dupes,
273
+ weak: genuine.filter((r) => r.weak),
274
+ disabled: genuine.filter((r) => r.disabled).length,
275
+ roots: [...new Set(records.map((r) => r.root))],
276
+ };
277
+ }
278
+
279
+ function summary(records) {
280
+ const s = stat(records);
281
+ const out = [];
282
+ out.push('SKILL CATALOGUE');
283
+ out.push('='.repeat(64));
284
+ out.push(`Roots with skills : ${s.roots.length}`);
285
+ for (const root of s.roots) {
286
+ const all = records.filter((r) => r.root === root).length;
287
+ const real = records.filter((r) => r.root === root && !r.copy).length;
288
+ out.push(` ${root}`);
289
+ out.push(` ${all} files, ${real} genuine`);
290
+ }
291
+ out.push('');
292
+ out.push(`Total SKILL.md files : ${s.total}`);
293
+ out.push(` genuine : ${s.genuine}`);
294
+ out.push(
295
+ ` copies : ${s.copies}${s.total ? ` (${Math.round((s.copies / s.total) * 100)}%)` : ''}` +
296
+ ` [locale ${s.localeCopies}, identical ${s.duplicateCopies}]`,
297
+ );
298
+ out.push(` unique names (genuine) : ${s.uniqueNames}`);
299
+ out.push(` duplicate names : ${s.dupes.length}`);
300
+ out.push(` weak descriptions : ${s.weak.length}`);
301
+ out.push('');
302
+ out.push('By group (genuine only):');
303
+ for (const [label, n] of [...s.byGroup.entries()].sort((a, b) => b[1] - a[1])) {
304
+ out.push(` ${label.padEnd(38)} ${String(n).padStart(4)}`);
305
+ }
306
+ return out.join('\n');
307
+ }
308
+
309
+ function search(records, query, groupFilter) {
310
+ const q = query.toLowerCase().trim();
311
+ if (!q) return 'Error: --search needs a query.';
312
+ const pool = records.filter(
313
+ (r) => !r.copy && (!groupFilter || r.group === groupFilter || r.groupLabel.toLowerCase().includes(groupFilter)),
314
+ );
315
+
316
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
317
+ // Plural tolerance so "captions" still finds "caption".
318
+ const forms = new Set([q, q.endsWith('s') ? q.slice(0, -1) : `${q}s`]);
319
+ const alts = [...forms].map(escape).join('|');
320
+ // A match must sit on real word boundaries: "expo" must not match "exposed".
321
+ const wordRe = new RegExp(`(^|[^a-z0-9])(${alts})(?![a-z0-9])`);
322
+
323
+ const scored = pool
324
+ .map((r) => {
325
+ const name = r.name.toLowerCase();
326
+ const segments = name.split(/[^a-z0-9+#.]+/).filter(Boolean);
327
+ let score = 0;
328
+ if (name === q) score += 100;
329
+ if (segments.some((s) => forms.has(s))) score += 60;
330
+ else if (name.includes(q)) score += 15; // weak: tie-breaker only
331
+ if (wordRe.test(name)) score += 20;
332
+ if (wordRe.test(r.description.toLowerCase())) score += 25;
333
+ return { r, score };
334
+ })
335
+ .filter((x) => x.score > 0)
336
+ .sort((a, b) => b.score - a.score || a.r.name.localeCompare(b.r.name));
337
+
338
+ if (!scored.length) return `No skill matches "${query}".`;
339
+ const out = [`"${query}" -> ${scored.length} match(es)`, ''];
340
+ for (const { r } of scored.slice(0, 25)) {
341
+ out.push(`* ${r.name} [${r.groupLabel}]`);
342
+ if (r.description) out.push(` ${r.description.slice(0, 220)}${r.description.length > 220 ? '...' : ''}`);
343
+ out.push(` ${r.path}`);
344
+ }
345
+ return out.join('\n');
346
+ }
347
+
348
+ function groupList(records) {
349
+ const s = stat(records);
350
+ const rows = [...s.byGroup.entries()].sort((a, b) => b[1] - a[1]);
351
+ const out = rows.map(([label, n]) => {
352
+ const key = GROUPS.find((g) => g.label === label)?.key ?? 'other';
353
+ return `${key.padEnd(12)} ${label.padEnd(38)} ${n}`;
354
+ });
355
+ if (rows.length) out.unshift('key label count');
356
+ return out.join('\n');
357
+ }
358
+
359
+ function dupes(records) {
360
+ const s = stat(records);
361
+ if (!s.dupes.length) return 'No duplicate names.';
362
+ const out = [`${s.dupes.length} duplicate name(s):`];
363
+ for (const list of s.dupes) {
364
+ out.push('');
365
+ out.push(`${list[0].name} (${list.length})`);
366
+ for (const r of list) out.push(` ${r.path}`);
367
+ }
368
+ return out.join('\n');
369
+ }
370
+
371
+ function weak(records) {
372
+ const s = stat(records);
373
+ if (!s.weak.length) return 'No weak descriptions.';
374
+ return s.weak.map((r) => `${r.name} (${r.description.length} chars) ${r.path}`).join('\n');
375
+ }
376
+
377
+ /* --------------------------------------------------------------------- cli */
378
+
379
+ const argv = process.argv.slice(2);
380
+ const valueOf = (flag) => {
381
+ const i = argv.indexOf(flag);
382
+ return i >= 0 ? argv[i + 1] : undefined;
383
+ };
384
+
385
+ if (argv.includes('--help') || argv.includes('-h')) {
386
+ console.log(readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].replace(/^#!.*\n/, ''));
387
+ process.exit(0);
388
+ }
389
+
390
+ const roots = rootsFromArgs(argv);
391
+ const records = scan(roots);
392
+
393
+ if (!roots.some((r) => existsSync(r))) {
394
+ console.error('No skill root found. Pass --root <path> or set SKILL_ROOTS.');
395
+ process.exit(1);
396
+ }
397
+
398
+ if (argv.includes('--json')) {
399
+ console.log(JSON.stringify(records, null, 2));
400
+ } else if (argv.includes('--search') || argv.includes('--grep')) {
401
+ console.log(search(records, valueOf('--search') ?? valueOf('--grep') ?? '', valueOf('--group')));
402
+ } else if (argv.includes('--groups')) {
403
+ console.log(groupList(records));
404
+ } else if (argv.includes('--dupes')) {
405
+ console.log(dupes(records));
406
+ } else if (argv.includes('--weak')) {
407
+ console.log(weak(records));
408
+ } else {
409
+ console.log(summary(records));
410
+ const outDir = valueOf('--write');
411
+ if (outDir) {
412
+ try {
413
+ mkdirSync(resolve(outDir), { recursive: true });
414
+ writeFileSync(join(resolve(outDir), 'skills.json'), JSON.stringify(records, null, 2), 'utf8');
415
+ console.log(`\nWrote ${join(resolve(outDir), 'skills.json')}`);
416
+ } catch (e) {
417
+ console.error(`Could not write index: ${e.message}`);
418
+ }
419
+ }
420
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@cangurel81/skill-router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Index, classify and search a large agent-skill catalogue. Answers \"which skill fits this job?\" and reports catalogue bloat caused by duplicated locale trees.",
5
+ "type": "module",
6
+ "bin": {
7
+ "skill-router": "bin/skill-router.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "skills/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "agent",
17
+ "skills",
18
+ "skill-router",
19
+ "claude-code",
20
+ "opencode",
21
+ "codex",
22
+ "ai-agents",
23
+ "tooling",
24
+ "cli"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "Can Gürel (https://webadhere.com)",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/cangurel81/skill-router.git"
31
+ },
32
+ "homepage": "https://github.com/cangurel81/skill-router#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/cangurel81/skill-router/issues"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "scripts": {
43
+ "start": "node bin/skill-router.mjs",
44
+ "test": "node --check bin/skill-router.mjs && node test/smoke.mjs"
45
+ }
46
+ }
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: skill-router
3
+ description: Use when a task may already be covered by one of the hundreds of installed agent skills and you need the right one instead of guessing — "which skill for this", "do we already have a skill for X", "is there a skill that does Y", "my skill folder is a mess", "too many skills", or when two installed skills overlap and one must be picked. Scans every configured skill root, classifies skills by domain, separates genuine skills from translated locale copies, and returns ranked matches with file paths plus a measured bloat report. Do not use for authoring a new skill (use writing-skills) or for installing a repository of skills.
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # Skill Router
8
+
9
+ A large skill catalogue fails in two directions. The agent picks a skill that does not exist,
10
+ or it writes from scratch something already installed forty times over. Both come from guessing
11
+ instead of looking.
12
+
13
+ **Rule: never claim "no skill covers this" and never name a skill without running the router.**
14
+
15
+ ## Run it
16
+
17
+ From the repository that contains this skill:
18
+
19
+ ```bash
20
+ node bin/skill-router.mjs # full catalogue inventory
21
+ node bin/skill-router.mjs --search expo # ranked matches with paths
22
+ node bin/skill-router.mjs --search video --group video
23
+ node bin/skill-router.mjs --groups # domain keys and counts
24
+ node bin/skill-router.mjs --dupes # same name, different folders
25
+ node bin/skill-router.mjs --weak # descriptions too short to route on
26
+ node bin/skill-router.mjs --json # machine-readable
27
+ ```
28
+
29
+ Point it somewhere explicit:
30
+
31
+ ```bash
32
+ node bin/skill-router.mjs --root ~/.claude/skills --search testing
33
+ node bin/skill-router.mjs --root ./vendor/skills --root ./skills
34
+ SKILL_ROOTS=~/.claude/skills:./skills node bin/skill-router.mjs
35
+ ```
36
+
37
+ `--root` (repeatable) beats `SKILL_ROOTS`, which beats the built-in defaults
38
+ (`./.claude/skills`, `./.agents/skills`, `./skills`, `~/.claude/skills`, `~/.config/opencode/skills`,
39
+ `~/.codex/skills`, `~/.kimi-code/skills`).
40
+
41
+ Read the **path** in every result before trusting the name. Two skills can share a name and differ
42
+ completely; the path is the only thing that says which repo you are actually loading.
43
+
44
+ ## Routing procedure
45
+
46
+ 1. **Name the domain first.** `--groups` gives the keys. Searching inside a domain beats searching
47
+ the whole catalogue, because generic words like "review", "audit" or "data" match hundreds.
48
+ 2. **Search two or three different words**, not one. A skill whose name says `tts` may only
49
+ mention "voiceover" in its description; the ranker searches both.
50
+ 3. **Read the description, then the path.** Description tells you if it fits. Path tells you which
51
+ repo and whether it is a locale copy.
52
+ 4. **Only then open the SKILL.md.** Opening every candidate is what the index exists to avoid.
53
+ 5. **If two match and overlap**, pick one, and check `--dupes` to see whether your catalogue carries
54
+ both permanently.
55
+
56
+ ## Domain keys
57
+
58
+ | Key | Domain | Example search terms |
59
+ |---|---|---|
60
+ | `seo` | SEO / GEO / search | `geo`, `citability`, `backlink`, `schema`, `llms.txt` |
61
+ | `video` | Video / motion / audio | `remotion`, `ffmpeg`, `caption`, `tts`, `shorts` |
62
+ | `frontend` | Frontend / UI / design system | `react`, `vue`, `tailwind`, `gsap`, `a11y` |
63
+ | `backend` | Backend / API / databases | `laravel`, `fastapi`, `postgres`, `graphql` |
64
+ | `mobile` | Mobile / native app work | `expo`, `flutter`, `swift`, `app store` |
65
+ | `marketing` | Marketing / content / social | `linkedin`, `carousel`, `hook`, `campaign` |
66
+ | `agent` | Agent / LLM / orchestration | `harness`, `subagent`, `eval`, `memory` |
67
+ | `security` | Security / compliance | `audit`, `hardening`, `threat model` |
68
+ | `infra` | Infra / DevOps / network | `docker`, `ci/cd`, `dns`, `vpn` |
69
+ | `data` | Data / research / analytics | `scrape`, `sql`, `dashboard`, `benchmark` |
70
+ | `code` | Code quality / testing | `tdd`, `review`, `refactor`, `lint` |
71
+ | `desktop` | Desktop / documents | `pdf`, `pandoc`, `screenshot` |
72
+ | `wordpress` | WordPress / WooCommerce | `plugin`, `woocommerce`, `wp-cli` |
73
+ | `other` | Unclassified | fix the description |
74
+
75
+ `other` being large is a finding, not a category. Skills land there because their description is
76
+ too vague to classify, which also means no agent can route to them.
77
+
78
+ ## Reading the inventory
79
+
80
+ The summary reports five numbers. Each one has an action attached:
81
+
82
+ | Number | Means | Action |
83
+ |---|---|---|
84
+ | `locale doc copies` above ~10% | repos are registered at their root, so `docs/<locale>/skills` are scanned as skills | register the real `skills/` folder instead |
85
+ | `duplicate names` high | two repos ship the same skill; the agent may load either | pick one and drop the other root, or accept the newer repo |
86
+ | `weak descriptions` | description under 25 chars, unusable for routing | extend the description or treat the skill as absent |
87
+ | `genuine` far below `total` | most of the catalogue is copies | fix roots before adding anything |
88
+ | one domain with 0 | real capability gap | this is where a new skill is justified |
89
+
90
+ The tool never deletes anything and never edits a skill. It measures and reports.
91
+
92
+ ## When a new skill is actually justified
93
+
94
+ All three must hold:
95
+
96
+ 1. `--search` over three plausible terms returns nothing relevant.
97
+ 2. The gap is knowledge no public repository could ship — local toolchain, local traps, local
98
+ decisions, private systems.
99
+ 3. The knowledge repeats. It has already cost time twice.
100
+
101
+ Otherwise extend an existing skill or fix its description. A fourth near-duplicate skill makes the
102
+ catalogue worse, not more capable.
103
+
104
+ ## What this does not do
105
+
106
+ - It does not install, fetch or update skills.
107
+ - It does not validate that a `SKILL.md` is correct — only that it is discoverable.
108
+ - It does not judge quality. A short description is a routing failure, not necessarily a bad skill.
109
+ - It does not read the skill body. Content review is a separate pass.