Picks the installed skill that fits each request, reading the recent conversation, and tells Claude to run it right away. /nav to inspect.

English · Русский
With dozens of plugins installed, Claude Code has hundreds of skills, and it often forgets to use the one that fits. Skill Navigator picks it for you. Before each request, a quick Haiku call reads your message, the last few lines of the conversation and the list of installed skills, then tells Claude to run the right skill first. When nothing fits, it stays silent.
The mod's own interface (toasts,
/navreplies) is in Russian. Requests in any language work.
TASK: …), then picks at most two skills, or NONE.🧭 Беру: /skill-name.If Haiku doesn't answer within 8 seconds, your message goes on without a pick.
Tested with live Haiku on 66 real requests: wrong launches went from 16 to 0 between v0.2 and v0.3. In 6 cases it stayed silent where a skill would have helped. That trade is deliberate: silence is cheap because Claude still sees every skill, while a wrong launch derails the work.
You need a Claude Code build with mods support (tested on 2.1.301).
claude plugin marketplace add danial112233/claude-skill-navigator
claude plugin install skill-navigator@claude-skill-navigator
Then restart Claude Code or run /reload-plugins. Inside a session you can also use:
/plugin install skill-navigator --marketplace danial112233/claude-skill-navigator
| Command | What it does |
|---|---|
/nav <task> | Shows what it would pick for this task, without running anything |
/nav list (/nav список) | Installed skills grouped by plugin |
/nav off / /nav on (выкл / вкл) | Turns the navigator off or back on |
claude plugin validate plugins/skill-navigator
claude plugin test plugins/skill-navigator
Built-in Claude Code skills (plugin-authoring, code-review, run…) arrive with source: 'builtin' together with interface commands such as /clear, and the two can't be told apart. That's why hooks/register.ts keeps an explicit BUILTIN_SKILLS list. When Claude Code gets a new built-in skill, add it there.
When you fix a wrong pick, add the request to the tests first, then measure before and after on live Haiku. Don't tune the instruction by eye.
hooks/register.ts 217 lines1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3// Навигатор: перед каждой задачей дешёвая Haiku смотрит список установленных скиллов
4// и команд и выбирает один (редко два), который нужен под задачу. Claude получает
5// указание сразу запустить его сам, а не предлагать пользователю. Чтобы короткое
6// «закрепи» или «открой вкладку» не цеплялось за слова, Haiku видит и конец разговора.
7
8type Entry = { name: string; description: string }
9
10const S = {
11 catalog: [] as Entry[],
12 builtAt: 0,
13 isOff: false,
14}
15
16const REFRESH_MS = 10 * 60 * 1000
17const OWN = new Set(['nav'])
18// Встроенные скиллы Claude Code приходят с пометкой builtin вместе с командами интерфейса
19// (/clear, /model, /usage…). Берём только те, что делают работу.
20const BUILTIN_SKILLS = new Set([
21 'plugin-authoring', 'run', 'verify', 'code-review', 'simplify', 'security-review', 'init',
22 'dataviz', 'artifact-design', 'artifact-diagramming', 'artifact-capabilities',
23 'claude-api', 'workflow-authoring', 'update-config', 'keybindings-help', 'fewer-permission-prompts',
24 'schedule', 'loop',
25])
26const RECENT_ROWS = 4
27const SKIP = /^(да|ок|окей|ага|давай|продолжай|дальше|делай|го|норм|хорошо|верно|нет|стоп|спасибо|yes|ok|go|continue|\+)[\s!.,]*$/iu
28
29const INSTRUCTIONS = `You decide which installed Claude Code skill, if any, Claude must run before it answers the user's latest message.
30Whatever you name is run at once, with no confirmation, so a wrong pick derails the work. Naming nothing is safe: Claude still sees every skill and can run one itself.
31
32CATALOG lists the entries one per line as "name — description".
33RECENT CONVERSATION is what was said just before the latest message. Use it to understand what the latest message asks for and what it refers to; never pick an entry only because of an earlier topic.
34
35Pick an entry only when its description covers the job the latest message asks Claude to do now.
36- A shared word, name or topic is not enough: the entry must be for the job itself, not for something the message merely mentions.
37- A short follow-up, a correction, a confirmation, an answer to Claude's question or the next step of work already under way gets NONE, unless that step is itself exactly the job of an entry.
38- Making or changing something takes the entry for that kind of thing, not one that only shares a word with it.
39- If several entries fit, take the most specific. Take a second only when the message clearly asks for two different jobs.
40- If you hesitate between an entry and NONE, answer NONE.
41
42Answer in this form and nothing else:
43TASK: <the job the latest message asks for, in at most ten words>
44then the entry names, one per line, exactly as written in the catalog, or the single line NONE.`
45
46// Список живой: берётся из того, что реально установлено, и обновляется раз в 10 минут.
47async function catalog($: EngineInterface): Promise<Entry[]> {
48 const now = await $.clock.now()
49 if (S.catalog.length > 0 && now - S.builtAt < REFRESH_MS) return S.catalog
50 const all = await $.command.list()
51 S.catalog = all
52 .filter(one => one.source === 'plugin' || one.source === 'user' || (one.source === 'builtin' && BUILTIN_SKILLS.has(one.name)))
53 .filter(one => !OWN.has(one.name))
54 .map(one => ({ name: one.name, description: one.description.replace(/\s+/g, ' ').slice(0, 160) }))
55 S.builtAt = now
56 return S.catalog
57}
58
59// Конец разговора перед запросом: последние реплики без служебных вставок и ответов инструментов.
60// У Claude важен конец ответа (там вопрос или следующий шаг), у пользователя начало.
61function recent(rows: readonly SessionMessage[], text: string): string {
62 const said = rows
63 .map(one => ({ role: one.role, text: one.text.replace(/\s+/g, ' ').trim() }))
64 .filter(one => one.text !== '' && !one.text.startsWith('<'))
65 const last = said[said.length - 1]
66 if (last?.role === 'user' && last.text === text.replace(/\s+/g, ' ').trim()) said.pop()
67 if (said.length === 0) return 'none: this is the first message of the conversation.'
68 return said
69 .slice(-RECENT_ROWS)
70 .map(one => `${one.role}: ${one.role === 'assistant' && one.text.length > 400 ? `…${one.text.slice(-400)}` : one.text.slice(0, 300)}`)
71 .join('\n')
72}
73
74// Скиллы, которые Claude уже запускал в этом разговоре: второй раз их навязывать незачем.
75// Claude зовёт скилл то с приставкой плагина (anthropic-skills:computer-use), то без неё.
76function alreadyRun(rows: readonly SessionMessage[]): string[] {
77 return rows.flatMap(one => one.toolUses)
78 .filter(use => use.tool === 'Skill' && typeof use.input.skill === 'string')
79 .map(use => String(use.input.skill).toLowerCase())
80}
81
82function sameSkill(a: string, b: string): boolean {
83 return a === b || (!a.includes(':') && b.endsWith(`:${a}`)) || (!b.includes(':') && a.endsWith(`:${b}`))
84}
85
86async function pick($: EngineInterface, text: string, entries: readonly Entry[], conversation = recent([], text)): Promise<Entry[]> {
87 if (entries.length === 0) return []
88 const list = entries.map(one => `${one.name} — ${one.description}`).join('\n')
89 const reply = await $.model.complete({
90 model: 'haiku',
91 system: INSTRUCTIONS,
92 // Каталог одинаковый от запроса к запросу: держим его в кэше, платим только за разговор и запрос.
93 prompt: [
94 { text: `CATALOG:\n${list}`, cache: true },
95 { text: `\n\nRECENT CONVERSATION:\n${conversation}\n\nLATEST MESSAGE:\n${text.slice(0, 2000)}` },
96 ],
97 // Строка TASK перед выбором заметно точнее на живой Haiku: сначала дело, потом скилл.
98 maxTokens: 120,
99 timeoutMs: 8000,
100 })
101 if (!reply.isAnswered) return []
102
103 const byName = new Map(entries.map(one => [one.name.toLowerCase(), one]))
104 const found: Entry[] = []
105 for (const line of reply.text.split('\n')) {
106 const name = line.trim().replace(/^[-*•\d.)\s/]+/, '').replace(/[`"']/g, '').toLowerCase()
107 const entry = byName.get(name)
108 if (entry && !found.includes(entry)) found.push(entry)
109 if (found.length === 2) break
110 }
111 return found
112}
113
114function hint(picks: readonly Entry[]): string {
115 const calls = picks.map(one => `Skill(skill: "${one.name}")`).join(', затем ')
116 return 'Навигатор плагинов выбрал под этот запрос:\n' +
117 picks.map(one => `- ${one.name}: ${one.description}`).join('\n') +
118 `\nПервым действием запусти сам: ${calls}, и работай по нему. ` +
119 'Не предлагай его пользователю и не спрашивай, запускать ли: просто запусти. ' +
120 'Пользователю одной короткой строкой скажи, какой скилл взял. ' +
121 'Если после запуска окажется, что скилл явно не про эту задачу, оставь его и делай без него.'
122}
123
124// Сводка установленного: плагин и сколько у него скиллов, с примерами.
125function grouped(entries: readonly Entry[]): string {
126 const groups = new Map<string, string[]>()
127 for (const one of entries) {
128 const [plugin, skill] = one.name.includes(':') ? one.name.split(':', 2) : ['свои', one.name]
129 const list = groups.get(plugin ?? 'свои') ?? []
130 list.push(skill ?? one.name)
131 groups.set(plugin ?? 'свои', list)
132 }
133 return [...groups.entries()]
134 .sort((a, b) => b[1].length - a[1].length)
135 .map(([plugin, skills]) => `• ${plugin} (${skills.length}): ${skills.slice(0, 5).join(', ')}${skills.length > 5 ? ', …' : ''}`)
136 .join('\n')
137}
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 S.isOff = (await $.store.get('off')) === true
142 await $.command.register({
143 name: 'nav',
144 description: 'Навигатор: /nav <задача> — какой скилл взять · /nav список · /nav выкл · /nav вкл',
145 argumentHint: '<задача>',
146 })
147 return next(e)
148 })
149
150 on('prompt.submit', async ($, e, next) => {
151 if (e.command !== undefined || e.isMeta || S.isOff) return next(e)
152 // Набрано, пока Claude работает: это поправка по ходу, а не новая задача.
153 if (e.turnId !== undefined && !e.wait) return next(e)
154 const text = e.text.trim()
155 if (text.length < 12 || text.startsWith('/') || SKIP.test(text)) return next(e)
156
157 let rows: SessionMessage[] = []
158 try {
159 rows = await $.session.messages()
160 } catch {
161 rows = []
162 }
163
164 let picks: Entry[]
165 try {
166 const done = alreadyRun(rows)
167 picks = (await pick($, text, await catalog($), recent(rows, text)))
168 .filter(one => !done.some(name => sameSkill(name, one.name.toLowerCase())))
169 } catch {
170 return next(e)
171 }
172 if (picks.length === 0) return next(e)
173
174 $.ui.toast(`🧭 Беру: ${picks.map(one => `/${one.name}`).join(', ')}`)
175 return next({ ...e, context: [...(e.context ?? []), hint(picks)] })
176 })
177
178 on('command.run', { command: 'nav' }, async ($, e) => {
179 const arg = e.args.trim()
180 const word = arg.toLowerCase()
181
182 if (word === 'выкл' || word === 'off') {
183 S.isOff = true
184 await $.store.set('off', true)
185 return { text: 'Навигатор выключен. Включить: /nav вкл' }
186 }
187 if (word === 'вкл' || word === 'on') {
188 S.isOff = false
189 await $.store.set('off', false)
190 return { text: 'Навигатор включён: перед каждой задачей подбирает скилл, и Claude сразу его запускает.' }
191 }
192 if (word === 'список' || word === 'list') {
193 S.builtAt = 0
194 const entries = await catalog($)
195 return { text: `Установлено скиллов и команд: ${entries.length}\n${grouped(entries)}` }
196 }
197 if (arg === '') {
198 return {
199 text: [
200 `Навигатор ${S.isOff ? 'выключен' : 'включён'}.`,
201 'Сам подбирает скилл под каждую задачу, и Claude сразу его запускает.',
202 '/nav <задача> — просто показать, какой скилл подойдёт, ничего не запуская',
203 '/nav список — что установлено · /nav выкл · /nav вкл',
204 ].join('\n'),
205 }
206 }
207
208 const picks = await pick($, arg, await catalog($))
209 if (picks.length === 0) return { text: `Для «${arg}» подходящего скилла не нашёл — Claude справится и без него.` }
210 return {
211 text: `Для «${arg}» навигатор возьмёт:\n` +
212 picks.map(one => `/${one.name} — ${one.description}`).join('\n') +
213 '\nПросто напиши задачу, и Claude сам запустит этот скилл.',
214 }
215 })
216}
217