Painel para ver, ligar, desligar, instalar e agrupar em perfis os seus mods e plugins.

Um painel para ver, ligar, desligar, instalar e agrupar em perfis os seus mods e plugins do Claude Code, sem decorar comandos.
Digite /mods para abrir o painel Mods.
~/.claude/dev-mods/<sessão>) também aparecem, e podem ser desligados.user.Ao ligar, desligar, instalar, atualizar ou remover, o painel recarrega os plugins (/reload-plugins) para a mudança valer na hora; se não conseguir, avisa para você rodar o comando. Desligar um plugin que tem servidor MCP pede um segundo clique, porque as ferramentas dele somem da sessão.
Os textos do painel estão em português.
Numa sessão do Claude Code no terminal (claude), digite:
/plugin install mods-manager --marketplace ptpmediabr/mods-manager
O Claude Code pergunta se pode adicionar o marketplace (responda y) e depois o escopo (escolha user para valer em todos os projetos). Quando aparecer Installed mods-manager. Plugin is now active., o mod já está rodando, sem reiniciar: use /mods.
O comando de instalação só funciona no terminal. Instalado no escopo user, o mod também carrega nas sessões do app desktop do Claude Code.
claude plugin list para listar o que está instalado. Ligar, desligar, instalar, atualizar e remover (claude plugin enable, disable, install, update, uninstall) só rodam quando você clica. Instalar usa o CLI do próprio Claude Code com os marketplaces que você já adicionou.plugin.json e lista as pastas de cada plugin instalado, para mostrar o que ele traz, e lê a pasta de mods em desenvolvimento da sessão.hooks/hooks.json dele sem mudar o conteúdo, só para forçar o recarregamento.claude plugin validate .
claude plugin test .
Testado com o Claude Code 2.1.296.
MIT. Feito por Chiara Costa.
hooks/register.tsx 621 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DevMod, Installed, Only, Profile } from '../types'
5import {
6 SHORT,
7 describe,
8 manifestOf,
9 matches,
10 offList,
11 passes,
12 parseAvailable,
13 parseInstalled,
14 profileChanges,
15 profileList,
16 providesLine,
17 shorten,
18} from './lib'
19import type { Available, SwitchLook } from './lib'
20
21const PANE = 'mods-manager'
22const STORE_OFF = 'off'
23const STORE_PROFILES = 'profiles'
24// The switch is a Button with its chrome (the terminal's `[ … ]`, a desktop's
25// native button), so it reads as something to press: a dot, green while on
26// and grey while off, then the state's name. A press flips it.
27const DOT = '●'
28const DOT_ON = '#30b566'
29const DOT_OFF = '#8a8f98'
30const SWITCH_ON = 'Ligado'
31const SWITCH_OFF = 'Desligado'
32const SWITCH_BUSY = '…'
33const MAX_OFFERS = 8
34
35const installed = atom({ plugin: 'mods-manager', key: 'installed' } as const, [])
36const devMods = atom({ plugin: 'mods-manager', key: 'devMods' } as const, [])
37const off = atom({ plugin: 'mods-manager', key: 'off' } as const, [])
38const expanded = atom({ plugin: 'mods-manager', key: 'expanded' } as const, [])
39const busy = atom({ plugin: 'mods-manager', key: 'busy' } as const, [])
40const pending = atom({ plugin: 'mods-manager', key: 'pending' } as const, [])
41const profiles = atom({ plugin: 'mods-manager', key: 'profiles' } as const, [])
42const confirm = atom({ plugin: 'mods-manager', key: 'confirm' } as const, '')
43const query = atom({ plugin: 'mods-manager', key: 'query' } as const, '')
44const only = atom({ plugin: 'mods-manager', key: 'only' } as const, 'all')
45const isCatalogLoaded = atom({ plugin: 'mods-manager', key: 'isCatalogLoaded' } as const, false)
46const note = atom({ plugin: 'mods-manager', key: 'note' } as const, '')
47const isLoading = atom({ plugin: 'mods-manager', key: 'isLoading' } as const, false)
48
49// What the marketplaces offer: thousands of rows, kept out of the state.
50let catalog: Available[] = []
51
52// The CLI of the build this session runs, not whichever `claude` is on PATH.
53async function claude($: EngineInterface, args: readonly string[], timeoutMs: number) {
54 const own = await $.env.get('CLAUDE_CODE_EXECPATH')
55
56 return $.process.run([own ?? 'claude', 'plugin', ...args], { timeoutMs }).catch(() => undefined)
57}
58
59async function sessionFolder($: EngineInterface) {
60 const home = await $.env.get('HOME')
61
62 return home === undefined ? undefined : `${home}/.claude/dev-mods/${await $.session.id()}`
63}
64
65// The mods being written in this session's mods folder, this one left out.
66async function listDevMods($: EngineInterface): Promise<DevMod[]> {
67 const folder = await sessionFolder($)
68 if (folder === undefined) {
69 return []
70 }
71 const entries = await $.fs.list(folder).catch(() => [])
72 const found: DevMod[] = []
73 for (const entry of entries) {
74 const root = `${folder}/${entry.name}`
75 if (entry.kind !== 'dir' || root === $.plugin.root) {
76 continue
77 }
78 const text = await $.fs.read(`${root}/.claude-plugin/plugin.json`).catch(() => undefined)
79 if (text === undefined) {
80 continue
81 }
82 const manifest = manifestOf(text)
83 found.push({ name: manifest.name ?? entry.name, root, description: manifest.description ?? '' })
84 }
85
86 return found.sort((a, b) => a.name.localeCompare(b.name))
87}
88
89async function countIn($: EngineInterface, folder: string) {
90 const entries = await $.fs.list(folder).catch(() => [])
91
92 return entries.filter(entry => !entry.name.startsWith('.')).length
93}
94
95async function withProvides($: EngineInterface, row: Installed): Promise<Installed> {
96 if (row.installPath === '') {
97 return { ...row, provides: providesLine({}, row.mcp) }
98 }
99 const [skills, comandos, agentes, hooks, manifest] = await Promise.all([
100 countIn($, `${row.installPath}/skills`),
101 countIn($, `${row.installPath}/commands`),
102 countIn($, `${row.installPath}/agents`),
103 countIn($, `${row.installPath}/hooks`),
104 $.fs.read(`${row.installPath}/.claude-plugin/plugin.json`).catch(() => undefined),
105 ])
106
107 return {
108 ...row,
109 provides: providesLine({ skills, comandos, agentes, hooks: hooks > 0 ? 1 : 0 }, row.mcp),
110 description: manifest === undefined ? '' : (manifestOf(manifest).description ?? ''),
111 }
112}
113
114async function refresh($: EngineInterface) {
115 await update($, isLoading, () => true)
116 const cwd = await $.session.cwd()
117 const listed = await claude($, ['list', '--json'], 30000)
118 if (listed === undefined || listed.exitCode !== 0) {
119 await update($, note, () => 'Erro: não consegui rodar `claude plugin list`.')
120 } else {
121 const rows: Installed[] = []
122 for (const row of parseInstalled(listed.stdout, cwd)) {
123 rows.push(await withProvides($, row))
124 }
125 await update($, installed, () => rows)
126 }
127 const mods = await listDevMods($)
128 await update($, devMods, () => mods)
129 const stored = offList(await $.store.get(STORE_OFF))
130 await update($, off, () => stored)
131 const saved = profileList(await $.store.get(STORE_PROFILES))
132 await update($, profiles, () => saved)
133 await update($, isLoading, () => false)
134}
135
136// Reloads the session's plugins so a switch takes effect now; what could not
137// be reloaded stays listed as pending.
138async function apply($: EngineInterface) {
139 const ran = await $.command.run({ command: 'reload-plugins' }).catch(() => undefined)
140 if (ran === undefined) {
141 await update($, note, () => 'Mudança salva, mas não consegui recarregar daqui: rode /reload-plugins.')
142
143 return
144 }
145 await update($, pending, () => [])
146}
147
148async function switchPlugin($: EngineInterface, row: Installed, isOn: boolean) {
149 const ran = await claude($, [isOn ? 'enable' : 'disable', row.id, '--scope', row.scope], 60000)
150 if (ran === undefined || ran.exitCode !== 0) {
151 const why = ran === undefined ? 'o comando não rodou' : (ran.stderr || ran.stdout).trim().slice(0, 200)
152 await update($, note, () => `Erro ao ${isOn ? 'ligar' : 'desligar'} ${row.id}: ${why}`)
153
154 return false
155 }
156 await update($, pending, list => (list.includes(row.id) ? list : [...list, row.id]))
157
158 return true
159}
160
161async function toggleInstalled($: EngineInterface, row: Installed) {
162 const key = `${row.id} ${row.scope}`
163 if ((await read($, busy)).includes(key)) {
164 return
165 }
166 // Switching off a plugin with MCP servers drops their tools: asked twice.
167 if (row.isEnabled && row.mcp.length > 0 && (await read($, confirm)) !== key) {
168 await update($, confirm, () => key)
169 await update($, note, () => `${describe(row).name} tem servidor MCP (${row.mcp.join(', ')}). Clique de novo para desligar.`)
170
171 return
172 }
173 await update($, confirm, () => '')
174 await update($, busy, list => [...list, key])
175 const isOn = !row.isEnabled
176 await update($, installed, list => list.map(one => (one.id === row.id && one.scope === row.scope ? { ...one, isEnabled: isOn } : one)))
177 const hasSwitched = await switchPlugin($, row, isOn)
178 await update($, busy, list => list.filter(one => one !== key))
179 if (!hasSwitched) {
180 await refresh($)
181
182 return
183 }
184 await update($, note, () => `${describe(row).name} ${isOn ? 'ligado' : 'desligado'}.`)
185 await apply($)
186}
187
188// A session mod is switched by this mod's `plugin.register` judge: the name
189// joins or leaves the stored list, and saving its hooks.json again reloads it.
190async function toggleDevMod($: EngineInterface, mod: DevMod) {
191 const before = offList(await $.store.get(STORE_OFF))
192 const isOff = before.includes(mod.name)
193 const after = isOff ? before.filter(name => name !== mod.name) : [...before, mod.name]
194 await $.store.set(STORE_OFF, after)
195 await update($, off, () => after)
196 const hooks = `${mod.root}/hooks/hooks.json`
197 const text = await $.fs.read(hooks).catch(() => undefined)
198 if (text !== undefined) {
199 await $.fs.write(hooks, text)
200 }
201 await update($, note, () => `${mod.name} ${isOff ? 'ligado' : 'desligado'}; recarrega em instantes.`)
202}
203
204async function saveProfile($: EngineInterface, raw: string) {
205 const name = raw.trim().slice(0, 40)
206 if (name === '') {
207 return
208 }
209 const ids = (await read($, installed)).filter(row => row.isEnabled && row.canToggle).map(row => row.id)
210 const next = [...(await read($, profiles)).filter(one => one.name !== name), { name, ids }]
211 await $.store.set(STORE_PROFILES, next)
212 await update($, profiles, () => next)
213 await update($, note, () => `Perfil "${name}" salvo com ${ids.length} plugins ligados.`)
214}
215
216async function deleteProfile($: EngineInterface, profile: Profile) {
217 const next = (await read($, profiles)).filter(one => one.name !== profile.name)
218 await $.store.set(STORE_PROFILES, next)
219 await update($, profiles, () => next)
220}
221
222async function applyProfile($: EngineInterface, profile: Profile) {
223 const changes = profileChanges(await read($, installed), profile)
224 if (changes.length === 0) {
225 await update($, note, () => `Perfil "${profile.name}" já está aplicado.`)
226
227 return
228 }
229 await update($, isLoading, () => true)
230 let done = 0
231 for (const row of changes) {
232 if (await switchPlugin($, row, !row.isEnabled)) {
233 done += 1
234 }
235 }
236 await refresh($)
237 await update($, note, () => `Perfil "${profile.name}" aplicado: ${done} de ${changes.length} plugins alterados.`)
238 await apply($)
239}
240
241async function loadCatalog($: EngineInterface) {
242 await update($, isLoading, () => true)
243 const listed = await claude($, ['list', '--available', '--json'], 60000)
244 catalog = listed === undefined || listed.exitCode !== 0 ? [] : parseAvailable(listed.stdout)
245 await update($, isCatalogLoaded, () => catalog.length > 0)
246 await update($, isLoading, () => false)
247 await update($, note, () =>
248 catalog.length > 0
249 ? `${catalog.length} plugins disponíveis: digite no filtro para buscar.`
250 : 'Erro: não consegui listar os marketplaces.',
251 )
252}
253
254// install, update and uninstall share a shape: run, say, re-read, reload.
255async function manage($: EngineInterface, verb: 'install' | 'update' | 'uninstall', id: string, scope: string) {
256 const said = { install: 'instalado', update: 'atualizado', uninstall: 'removido' }[verb]
257 await update($, confirm, () => '')
258 await update($, busy, list => [...list, id])
259 await update($, note, () => `${verb} ${id}…`)
260 const ran = await claude($, [verb, id, '--scope', scope], 300000)
261 await update($, busy, list => list.filter(one => one !== id))
262 if (ran === undefined || ran.exitCode !== 0) {
263 const why = ran === undefined ? 'o comando não rodou' : (ran.stderr || ran.stdout).trim().slice(0, 200)
264 await update($, note, () => `Erro (${verb} ${id}): ${why}`)
265
266 return
267 }
268 await update($, pending, list => (list.includes(id) ? list : [...list, id]))
269 await refresh($)
270 await update($, note, () => `${id} ${said}.`)
271 await apply($)
272}
273
274async function askRemove($: EngineInterface, row: Installed) {
275 const key = `rm ${row.id}`
276 if ((await read($, confirm)) !== key) {
277 await update($, confirm, () => key)
278
279 return
280 }
281 await manage($, 'uninstall', row.id, row.scope)
282}
283
284function flip(list: readonly string[], name: string) {
285 return list.includes(name) ? list.filter(one => one !== name) : [...list, name]
286}
287
288// The two filter buttons exclude each other; pressing the lit one clears it.
289function pick(now: Only, wanted: Only): Only {
290 return now === wanted ? 'all' : wanted
291}
292
293export const register: Register = on => {
294 on('session.start', async ($, e, next) => {
295 await $.command.register({ name: 'mods', description: 'Ver, ligar e desligar seus mods e plugins' })
296 // A reload starts the module over: rows of an older shape, work that was
297 // running and the catalog it held are gone, so the state says so.
298 await update($, installed, () => [])
299 await update($, busy, () => [])
300 await update($, confirm, () => '')
301 await update($, isCatalogLoaded, () => false)
302 await update($, isLoading, () => false)
303
304 return next(e)
305 })
306
307 on('plugin.register', async ($, e, next) => {
308 const folder = await sessionFolder($)
309 const isSessionMod = folder !== undefined && e.root.startsWith(`${folder}/`)
310 if (e.name !== $.plugin.name && isSessionMod && offList(await $.store.get(STORE_OFF)).includes(e.name)) {
311 return { refuse: 'desligado em /mods' }
312 }
313
314 return next(e)
315 }).catch(($, e, next) => next(e))
316
317 on('command.run', { command: 'mods' }, async $ => {
318 await $.ui.open({ id: PANE, title: 'Mods', focus: true, columns: 64 })
319 void refresh($)
320
321 return { text: 'Painel de mods aberto.' }
322 })
323
324 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
325 const ui = $.ui.resolve(e)
326 const { Box, Text, Button } = ui
327 const rows = await read($, installed)
328 const mods = await read($, devMods)
329 const offNames = await read($, off)
330 const open = await read($, expanded)
331 const working = await read($, busy)
332 const waiting = await read($, pending)
333 const saved = await read($, profiles)
334 const asked = await read($, confirm)
335 const wanted = await read($, query)
336 const kept = await read($, only)
337 const hasCatalog = await read($, isCatalogLoaded)
338 const message = await read($, note)
339 const loading = await read($, isLoading)
340
341 const onCount = rows.filter(row => row.isEnabled).length + mods.filter(mod => !offNames.includes(mod.name)).length
342 const shown = rows.filter(row => matches(wanted, row.id, row.description) && passes(kept, row.isEnabled))
343 const shownMods = mods.filter(mod => matches(wanted, mod.name, mod.description) && passes(kept, !offNames.includes(mod.name)))
344 const have = new Set(rows.map(row => row.id))
345 const offers =
346 hasCatalog && wanted.trim().length >= 2
347 ? catalog.filter(one => !have.has(one.id) && matches(wanted, one.name, one.description)).slice(0, MAX_OFFERS)
348 : []
349 const isError = message.startsWith('Erro')
350 const toggle = (key: string, look: SwitchLook, onPress: () => void) => {
351 const label = look === 'busy' ? SWITCH_BUSY : look === 'on' ? SWITCH_ON : SWITCH_OFF
352 if (look === 'busy') {
353 return <Button key={key} label={label} onPress={onPress} />
354 }
355
356 return (
357 <Button key={key} label={label} onPress={onPress}>
358 <Text color={look === 'on' ? DOT_ON : DOT_OFF}>{DOT}</Text>
359 {` ${label}`}
360 </Button>
361 )
362 }
363
364 return (
365 <Box flexDirection="column" gap={1} paddingX={1}>
366 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
367 <Text dimColor>{loading ? 'Carregando…' : `${onCount} de ${rows.length + mods.length} ligados`}</Text>
368 <Button key="refresh" label="Atualizar lista" dimColor onPress={() => void refresh($)} />
369 </Box>
370 {waiting.length > 0 && (
371 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
372 <Box flexShrink={1} minWidth={0}>
373 <Text color="warning" wrap="wrap">
374 {`Pendente de recarga: ${waiting.length}`}
375 </Text>
376 </Box>
377 <Box flexShrink={0}>
378 <Button key="reload" label="Aplicar agora" variant="primary" onPress={() => void apply($)} />
379 </Box>
380 </Box>
381 )}
382 {message !== '' && (
383 <Text color={isError ? 'error' : undefined} dimColor={!isError} wrap="wrap">
384 {message}
385 </Text>
386 )}
387 {'Input' in ui && (
388 <Box flexDirection="row" alignItems="center" gap={1}>
389 <Box flexGrow={1} minWidth={0}>
390 <ui.Input
391 key="q"
392 placeholder="Filtrar por nome"
393 value={wanted}
394 submitLabel="filtrar"
395 onInput={value => void update($, query, () => value)}
396 onSubmit={value => void update($, query, () => value)}
397 />
398 </Box>
399 <Box flexDirection="row" flexShrink={0} gap={1}>
400 <Button
401 key="only-on"
402 label="Ligados"
403 variant={kept === 'on' ? 'primary' : 'secondary'}
404 dimColor={kept !== 'on'}
405 onPress={() => void update($, only, value => pick(value, 'on'))}
406 />
407 <Button
408 key="only-off"
409 label="Desligados"
410 variant={kept === 'off' ? 'primary' : 'secondary'}
411 dimColor={kept !== 'off'}
412 onPress={() => void update($, only, value => pick(value, 'off'))}
413 />
414 </Box>
415 </Box>
416 )}
417 {shownMods.length > 0 && (
418 <Box flexDirection="column">
419 <Text dimColor bold>
420 MODS EM DESENVOLVIMENTO (ESTA SESSÃO)
421 </Text>
422 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
423 {shownMods.map(mod => {
424 const isOn = !offNames.includes(mod.name)
425 const isOpen = open.includes(mod.name)
426
427 return (
428 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
429 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
430 <Text bold={isOn} dimColor={!isOn} wrap="wrap">
431 {mod.name}
432 </Text>
433 {mod.description !== '' && (
434 <Text color="subtle" wrap="wrap">
435 {isOpen ? mod.description : shorten(mod.description)}
436 </Text>
437 )}
438 {mod.description.length > SHORT && (
439 <Box flexDirection="row">
440 <Button
441 key={`more:${mod.name}`}
442 label={isOpen ? 'Ler menos' : 'Ler mais'}
443 plain
444 dimColor
445 onPress={() => void update($, expanded, list => flip(list, mod.name))}
446 />
447 </Box>
448 )}
449 </Box>
450 <Box flexShrink={0}>
451 {toggle(`mod:${mod.name}`, isOn ? 'on' : 'off', () => void toggleDevMod($, mod))}
452 </Box>
453 </Box>
454 )
455 })}
456 </Box>
457 </Box>
458 )}
459 <Box flexDirection="column">
460 <Text dimColor bold>
461 PLUGINS INSTALADOS
462 </Text>
463 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
464 {shown.length === 0 && <Text dimColor>{loading ? 'Carregando…' : 'Nenhum plugin encontrado.'}</Text>}
465 {shown.map(row => {
466 const { name, meta, full } = describe(row)
467 const about = row.description === '' ? meta : shorten(row.description)
468 const key = `${row.id} ${row.scope}`
469 const isOpen = open.includes(key)
470 const isBusy = working.includes(key) || working.includes(row.id)
471 const isWaiting = waiting.includes(row.id)
472
473 return (
474 <Box flexDirection="column">
475 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
476 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
477 <Text bold={row.isEnabled} dimColor={!row.isEnabled} wrap="wrap">
478 {name}
479 </Text>
480 <Text color="subtle" wrap="wrap">
481 {isWaiting ? `${about} · pendente de recarga` : about}
482 </Text>
483 </Box>
484 <Box flexDirection="row" flexShrink={0} alignItems="center" gap={1}>
485 <Button
486 key={`more:${key}`}
487 label={isOpen ? 'Ocultar' : 'Detalhes'}
488 plain
489 dimColor
490 onPress={() => void update($, expanded, list => flip(list, key))}
491 />
492 {row.canToggle ? (
493 asked === key && !isBusy ? (
494 <Button
495 key={`plugin:${row.id}:${row.scope}`}
496 label="Desligar?"
497 variant="primary"
498 onPress={() => void toggleInstalled($, row)}
499 />
500 ) : (
501 toggle(`plugin:${row.id}:${row.scope}`, isBusy ? 'busy' : row.isEnabled ? 'on' : 'off', () => void toggleInstalled($, row))
502 )
503 ) : (
504 <Text dimColor>{row.isEnabled ? 'ligado · gerenciado' : 'desligado · gerenciado'}</Text>
505 )}
506 </Box>
507 </Box>
508 {isOpen && (
509 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
510 {row.description.length > SHORT && (
511 <Text dimColor wrap="wrap">
512 {row.description}
513 </Text>
514 )}
515 <Text dimColor wrap="wrap">
516 {full}
517 </Text>
518 <Text dimColor wrap="wrap">{`Traz: ${row.provides}`}</Text>
519 {row.installPath !== '' && (
520 <Text dimColor wrap="wrap">
521 {row.installPath}
522 </Text>
523 )}
524 {row.canToggle && (
525 <Box flexDirection="row" gap={1}>
526 <Button
527 key={`update:${key}`}
528 label="Atualizar"
529 dimColor
530 onPress={() => void manage($, 'update', row.id, row.scope)}
531 />
532 <Button
533 key={`remove:${key}`}
534 label={asked === `rm ${row.id}` ? 'Confirmar remoção' : 'Remover'}
535 dimColor
536 onPress={() => void askRemove($, row)}
537 />
538 </Box>
539 )}
540 </Box>
541 )}
542 </Box>
543 )
544 })}
545 </Box>
546 </Box>
547 <Box flexDirection="column">
548 <Text dimColor bold>
549 PERFIS
550 </Text>
551 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
552 {saved.length === 0 && (
553 <Text dimColor wrap="wrap">
554 Um perfil guarda quais plugins estão ligados agora, para você voltar a esse conjunto com um clique.
555 </Text>
556 )}
557 {saved.map(profile => (
558 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
559 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
560 <Text bold wrap="wrap">
561 {profile.name}
562 </Text>
563 <Text dimColor>{`${profile.ids.length} plugins ligados`}</Text>
564 </Box>
565 <Box flexDirection="row" flexShrink={0} gap={1}>
566 <Button key={`use:${profile.name}`} label="Aplicar" onPress={() => void applyProfile($, profile)} />
567 <Button key={`del:${profile.name}`} label="Excluir" dimColor onPress={() => void deleteProfile($, profile)} />
568 </Box>
569 </Box>
570 ))}
571 {'Input' in ui && (
572 <ui.Input
573 key={`profile-${saved.length}`}
574 placeholder="Nome para salvar o conjunto atual"
575 submitLabel="salvar"
576 onSubmit={value => void saveProfile($, value)}
577 />
578 )}
579 </Box>
580 </Box>
581 <Box flexDirection="column">
582 <Text dimColor bold>
583 INSTALAR NOVOS
584 </Text>
585 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
586 {!hasCatalog && (
587 <Box flexDirection="row">
588 <Button key="catalog" label="Buscar nos marketplaces" onPress={() => void loadCatalog($)} />
589 </Box>
590 )}
591 {hasCatalog && offers.length === 0 && (
592 <Text dimColor wrap="wrap">
593 {wanted.trim().length >= 2 ? 'Nada encontrado para esse filtro.' : 'Digite ao menos 2 letras no filtro acima.'}
594 </Text>
595 )}
596 {offers.map(one => (
597 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
598 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
599 <Text bold wrap="wrap">
600 {one.id}
601 </Text>
602 <Text color="subtle" wrap="wrap">
603 {shorten(one.description)}
604 </Text>
605 </Box>
606 <Box flexShrink={0}>
607 <Button
608 key={`install:${one.id}`}
609 label={working.includes(one.id) ? SWITCH_BUSY : 'Instalar'}
610 onPress={() => void manage($, 'install', one.id, 'user')}
611 />
612 </Box>
613 </Box>
614 ))}
615 </Box>
616 </Box>
617 </Box>
618 )
619 })
620}
621hooks/lib.ts 136 lines1import type { Installed, Only, Profile } from '../types'
2
3type Listed = {
4 id?: string
5 scope?: string
6 version?: string
7 enabled?: boolean
8 projectPath?: string
9 installPath?: string
10 mcpServers?: Record<string, unknown>
11}
12
13export type Available = { id: string; name: string; description: string }
14
15// `claude plugin list --json` as rows: the person's own installs and this
16// project's, each once; another project's install is left out, and a scope
17// the CLI cannot switch (a synced plugin) is shown without a toggle.
18export const parseInstalled = (stdout: string, cwd: string): Installed[] => {
19 let listed: unknown
20 try {
21 listed = JSON.parse(stdout)
22 } catch {
23 return []
24 }
25 const seen = new Set<string>()
26 const rows: Installed[] = []
27 for (const one of (Array.isArray(listed) ? listed : []) as Listed[]) {
28 const { id, scope = 'user' } = one
29 const isElsewhere = one.projectPath !== undefined && one.projectPath !== cwd
30 if (id === undefined || isElsewhere || seen.has(`${id} ${scope}`)) {
31 continue
32 }
33 seen.add(`${id} ${scope}`)
34 rows.push({
35 id,
36 scope,
37 version: one.version === undefined || one.version === 'unknown' ? '' : one.version,
38 isEnabled: one.enabled === true,
39 canToggle: scope === 'user' || scope === 'project' || scope === 'local',
40 installPath: one.installPath ?? '',
41 mcp: Object.keys(one.mcpServers ?? {}),
42 provides: '',
43 description: '',
44 })
45 }
46
47 return rows.sort((a, b) => a.id.localeCompare(b.id))
48}
49
50// `claude plugin list --available --json`: what the marketplaces offer.
51export const parseAvailable = (stdout: string): Available[] => {
52 let parsed: { available?: { pluginId?: string; name?: string; description?: string }[] }
53 try {
54 parsed = JSON.parse(stdout) as typeof parsed
55 } catch {
56 return []
57 }
58 const out: Available[] = []
59 for (const one of Array.isArray(parsed.available) ? parsed.available : []) {
60 if (one.pluginId !== undefined) {
61 out.push({ id: one.pluginId, name: one.name ?? one.pluginId, description: one.description ?? '' })
62 }
63 }
64
65 return out
66}
67
68export const matches = (query: string, ...texts: readonly string[]) => {
69 const wanted = query.trim().toLowerCase()
70
71 return wanted === '' || texts.some(text => text.toLowerCase().includes(wanted))
72}
73
74// The on/off filter: every row, or only the ones in that state.
75export const passes = (only: Only, isOn: boolean) => only === 'all' || (only === 'on') === isOn
76
77// What a plugin brings, in one line: "3 skills · 2 comandos · MCP: telegram".
78export const providesLine = (counts: Readonly<Record<string, number>>, mcp: readonly string[]) => {
79 const parts = Object.entries(counts)
80 .filter(([, n]) => n > 0)
81 .map(([what, n]) => `${n} ${what}`)
82 if (mcp.length > 0) {
83 parts.push(`MCP: ${mcp.join(', ')}`)
84 }
85
86 return parts.length === 0 ? 'nada detectado' : parts.join(' · ')
87}
88
89export type SwitchLook = 'on' | 'off' | 'busy'
90
91// A long description's opening, cut at a word; the text itself when it fits.
92export const SHORT = 44
93
94export const shorten = (text: string): string => {
95 if (text.length <= SHORT) {
96 return text
97 }
98 const head = text.slice(0, SHORT)
99 const space = head.lastIndexOf(' ')
100
101 return `${(space > SHORT / 2 ? head.slice(0, space) : head).trimEnd()}…`
102}
103
104export const manifestOf = (text: string): { name?: string; description?: string } => {
105 try {
106 return JSON.parse(text) as { name?: string; description?: string }
107 } catch {
108 return {}
109 }
110}
111
112export const offList = (stored: unknown): string[] =>
113 Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
114
115export const profileList = (stored: unknown): Profile[] =>
116 (Array.isArray(stored) ? stored : []).filter(
117 (x): x is Profile =>
118 typeof x === 'object' && x !== null && typeof (x as Profile).name === 'string' && Array.isArray((x as Profile).ids),
119 )
120
121// A row's words: the plugin's own name, where it comes from, and the same
122// with its version, which only the details show.
123export const describe = (row: Installed): { name: string; meta: string; full: string } => {
124 const at = row.id.lastIndexOf('@')
125 const name = at > 0 ? row.id.slice(0, at) : row.id
126 const source = at > 0 ? row.id.slice(at + 1) : ''
127 const version = row.version === '' ? '' : `v${row.version}`
128 const join = (parts: readonly string[]) => parts.filter(part => part !== '').join(' · ')
129
130 return { name, meta: join([source, row.scope]), full: join([source, version, row.scope]) }
131}
132
133// The rows a profile would switch: each one whose state differs from it.
134export const profileChanges = (rows: readonly Installed[], profile: Profile) =>
135 rows.filter(row => row.canToggle && row.isEnabled !== profile.ids.includes(row.id))
136types/index.d.ts 38 lines1export type Installed = {
2 id: string
3 scope: string
4 version: string
5 isEnabled: boolean
6 canToggle: boolean
7 installPath: string
8 mcp: string[]
9 provides: string
10 description: string
11}
12
13export type DevMod = { name: string; root: string; description: string }
14
15export type Profile = { name: string; ids: string[] }
16
17export type Only = 'all' | 'on' | 'off'
18
19declare module 'claude-code' {
20 interface PluginState {
21 'mods-manager': {
22 installed: Installed[]
23 devMods: DevMod[]
24 off: string[]
25 expanded: string[]
26 busy: string[]
27 pending: string[]
28 profiles: Profile[]
29 confirm: string
30 query: string
31 only: Only
32 isCatalogLoaded: boolean
33 note: string
34 isLoading: boolean
35 }
36 }
37}
38