SLOPSHOPPER

mods-manager

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

newpanecommandprocess
v0.4.4MITupdated 2026-10-09ptpmediabr/mods-manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-manager
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ Carregando… [ Atualizar lista ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Filtrar por nome ⏎ filtrar [ Ligados ] [ De ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ PLUGINS INSTALADOS ⎿ Added 2 lines, removed 1 line │ ┃ ╭────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ Carregando… │ ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ PERFIS │ ┃ ╭────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ Um perfil guarda quais plugins estão │ ┃ │ ligados agora, para você voltar a esse › /mods │ ┃ │ conjunto com um clique. ⎿ mods-manager: Painel de mods aberto. │ ┃ │ Nome para salvar o conjunto atual ⏎ salva │ ┃ ╰────────────────────────────────────────── │ ┃ │ ┃ INSTALAR NOVOS │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ [ Buscar nos marketplaces ] │ │ ┃ ╰────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
Carregando… [ Atualizar lista ] Filtrar por nome ⏎ filtrar [ Ligados ] [ Desligados ] PLUGINS INSTALADOS ╭────────────────────────────────────────────────────────╮ │ Carregando… │ ╰────────────────────────────────────────────────────────╯ PERFIS ╭────────────────────────────────────────────────────────╮ │ Um perfil guarda quais plugins estão ligados agora, │ │ para você voltar a esse conjunto com um clique. │ │ Nome para salvar o conjunto atual ⏎ salvar │ ╰────────────────────────────────────────────────────────╯ INSTALAR NOVOS ╭────────────────────────────────────────────────────────╮ │ [ Buscar nos marketplaces ] │ ╰────────────────────────────────────────────────────────╯
README

mods-manager

Um painel para ver, ligar, desligar, instalar e agrupar em perfis os seus mods e plugins do Claude Code, sem decorar comandos.

Como usar

Digite /mods para abrir o painel Mods.

  • Plugins instalados: todos os seus plugins, cada um com um botão Ligado/Desligado. Em Detalhes você vê a origem, a versão, o escopo e o que o plugin traz (skills, comandos, agentes, hooks, servidores MCP), e pode Atualizar ou Remover (com confirmação).
  • Filtro: por nome, e pelos botões Ligados e Desligados.
  • Mods em desenvolvimento: os mods que você está escrevendo na sessão atual (~/.claude/dev-mods/<sessão>) também aparecem, e podem ser desligados.
  • Perfis: salve o conjunto de plugins ligados com um nome (por exemplo "Gravação" ou "Trabalho") e volte a ele com um clique.
  • Instalar novos: busque nos marketplaces que você já adicionou e instale com um clique, no escopo 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.

Instalação

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.

Privacidade e o que o mod faz

  • Ao abrir o painel, roda 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.
  • Lê o 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.
  • Para ligar ou desligar um mod em desenvolvimento, regrava o hooks/hooks.json dele sem mudar o conteúdo, só para forçar o recarregamento.
  • Guarda no armazenamento local do Claude Code (só deste mod) os seus perfis e a lista de mods em desenvolvimento que você desligou.
  • Não faz chamadas de rede por conta própria.

Desenvolvimento

claude plugin validate .
claude plugin test .

Testado com o Claude Code 2.1.296.

Licença

MIT. Feito por Chiara Costa.

Source 3 files
hooks/register.tsx 621 lines
1import { 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}
621
hooks/lib.ts 136 lines
1import 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))
136
types/index.d.ts 38 lines
1export 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