SLOPSHOPPER

mod-manager

Lists installed plugins and mods with enable/disable toggles (/mods)

newpanecommandprocesstimer
v0.1.0no licenseupdated 2026-10-16Ilja-Garbuz/claude-code-mods/mods/mod-manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-manager
│ ┃ mod-manager ✕ › fix the failing auth test and add an audit log call │ ┃ Reading installed plugins… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mods │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mod-manager
Reading installed plugins…
README

Claude Code mods

Three mods for the Claude Code terminal (CLI). Claude Desktop, VS Code and mobile keep their normal look.

ModWhat it does
session-panelA side panel docked on the right: live 5-hour and weekly limits, context use with a per-category breakdown, the model and effort with switchers, skills with their context cost, connector status, the working directory, the Claude Code version and toggles for your mods. /panel opens it; the ✕ collapses it to a « button.
message-styleYour prompts as opencode-style panels: full width, text on the right, a thin blue stripe on the right edge.
mod-manager/mods lists your installed plugins and mods with on/off toggles that apply in the open session, no restart. The side panel's Mods section uses it.

Install

You need Claude Code 2.1.300 or newer.

In a Claude Code terminal session, run each of these, answering y to add the marketplace and choosing the user scope:

/plugin install session-panel --marketplace Ilja-Garbuz/claude-code-mods
/plugin install message-style --marketplace Ilja-Garbuz/claude-code-mods
/plugin install mod-manager --marketplace Ilja-Garbuz/claude-code-mods

Or from any shell (PowerShell, bash, fish):

claude plugin marketplace add Ilja-Garbuz/claude-code-mods
claude plugin install session-panel@ilja-mods --scope user
claude plugin install message-style@ilja-mods --scope user
claude plugin install mod-manager@ilja-mods --scope user

session-panel brings mod-manager along, so installing it alone also works.

Start a new session (or run /reload-plugins) and the panel appears once the terminal is wide enough (144 columns in the fullscreen layout). Anywhere else, type /panel.

To update later: claude plugin marketplace update ilja-mods, then /reload-plugins.

Settings

  • message-style: /plugin configure message-style@ilja-mods sets the panel colour and the stripe colour.
  • Turn a mod off: use the toggles in the side panel's Mods section, /mods toggle <name>, or claude plugin disable <name>@ilja-mods.

Notes

  • Model switching: changing the model in the panel works like /model, so it also becomes your default for new sessions. Mid-session the panel warns first, because a switch drops the prompt cache.
  • Background checks: to stay current, the panel runs claude -p /usage --safe-mode every 3 minutes and claude mcp list every 15 minutes. Neither calls the model or uses your limits.
  • Windows and Linux: the mods call claude and git by name, so both need to be on your PATH. Nothing depends on the shell.

Developing

See DEVELOPMENT.md. Each mod checks with:

claude plugin validate mods/session-panel
claude plugin test mods/session-panel
Source 2 files
hooks/register.tsx 156 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModEntry } from '../types'
5
6const PANE = 'mod-manager'
7const mods = atom({ plugin: 'mod-manager', key: 'mods' } as const, [] as ModEntry[])
8const busy = atom({ plugin: 'mod-manager', key: 'busy' } as const, null as string | null)
9const note = atom({ plugin: 'mod-manager', key: 'note' } as const, null as string | null)
10
11type Listed = {
12  id: string
13  version?: string
14  scope: string
15  enabled: boolean
16  installPath?: string
17  readFromFolder?: string
18}
19
20// Scopes `claude plugin enable/disable --scope` accepts; synced and managed
21// plugins are toggled without one and the CLI decides.
22const SCOPES = ['user', 'project', 'local']
23
24function runClaude($: EngineInterface, args: string[]) {
25  return $.process.run(['claude', ...args], { timeoutMs: 60_000 })
26}
27
28async function hasModule($: EngineInterface, root: string | undefined) {
29  if (!root) return false
30  try {
31    return /"modules"/.test(await $.fs.read(`${root}/hooks/hooks.json`))
32  } catch {
33    return false
34  }
35}
36
37async function refresh($: EngineInterface) {
38  const ran = await runClaude($, ['plugin', 'list', '--json'])
39  if (ran.exitCode !== 0) {
40    await update($, note, () => `plugin list failed: ${ran.stderr.trim().slice(0, 160)}`)
41    return
42  }
43  const listed = JSON.parse(ran.stdout) as Listed[]
44  const entries = await Promise.all(
45    listed.map(async (p): Promise<ModEntry> => ({
46      id: p.id,
47      name: p.id.split('@')[0] ?? p.id,
48      version: p.version,
49      scope: p.scope,
50      enabled: p.enabled,
51      isMod: await hasModule($, p.readFromFolder ?? p.installPath),
52    })),
53  )
54  entries.sort((a, b) => Number(b.isMod) - Number(a.isMod) || a.name.localeCompare(b.name))
55  await update($, mods, () => entries)
56}
57
58async function toggle($: EngineInterface, id: string): Promise<string> {
59  let list = await read($, mods)
60  if (list.length === 0) {
61    await refresh($)
62    list = await read($, mods)
63  }
64  const entry = list.find(m => m.id === id || m.name === id)
65  if (!entry) return `No installed plugin named ${id}.`
66  if ((await read($, busy)) !== null) return 'Another toggle is still running.'
67
68  await update($, busy, () => entry.id)
69  try {
70    const verb = entry.enabled ? 'disable' : 'enable'
71    const args = ['plugin', verb, entry.id]
72    if (SCOPES.includes(entry.scope)) args.push('--scope', entry.scope)
73    const ran = await runClaude($, args)
74    if (ran.exitCode !== 0) {
75      const why = (ran.stderr || ran.stdout).trim().slice(0, 160)
76      await update($, note, () => `${entry.name}: ${why}`)
77      return `Could not ${verb} ${entry.name}: ${why}`
78    }
79    await refresh($)
80
81    // /reload-plugins applies the change to this session. It can't run inside
82    // the command that asked for it, so it runs from a timer right after; where
83    // it can't run at all (a -p run), the change lands on the next start.
84    $.clock.after(100, async () => {
85      try {
86        await $.command.run({ command: 'reload-plugins' })
87        await update($, note, () => `${entry.name} ${verb}d and plugins reloaded.`)
88      } catch (err) {
89        $.ui.log(`reload-plugins not run: ${String(err).slice(0, 200)}`)
90        await update($, note, () => `${entry.name} ${verb}d; restart Claude Code to apply it.`)
91      }
92    })
93    const said = `${entry.name} ${verb}d; reloading plugins…`
94    await update($, note, () => said)
95    return said
96  } finally {
97    await update($, busy, () => null)
98  }
99}
100
101export const register: Register = on => {
102  on('session.start', async ($, e, next) => {
103    await $.command.register({
104      name: 'mods',
105      description: 'Show installed mods and plugins with enable/disable toggles',
106      argumentHint: '[toggle <plugin> | refresh]',
107    })
108    void refresh($)
109    return next(e)
110  })
111
112  // /clear wipes the session's values; read the list again after it.
113  on('session.end', async ($, e, next) => {
114    const ended = await next(e)
115    if (e.reason === 'clear') $.clock.after(300, () => refresh($))
116    return ended
117  })
118
119  on('command.run', { command: 'mods' }, async ($, e) => {
120    const [verb, target] = (e.args ?? '').trim().split(/\s+/)
121    if (verb === 'toggle' && target) return { text: await toggle($, target) }
122    await refresh($)
123    if (verb === 'refresh') return { text: 'Plugin list refreshed.' }
124    await $.ui.open({ id: PANE, title: 'Mods' })
125    return { text: 'Mods pane opened.' }
126  })
127
128  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129    const { Box, Text, Button } = $.ui.resolve(e)
130    const list = await read($, mods)
131    const running = await read($, busy)
132    const said = await read($, note)
133
134    return (
135      <Box flexDirection="column">
136        {list.length === 0 && <Text dimColor>Reading installed plugins…</Text>}
137        {list.map(m => (
138          <Button
139            key={`toggle-${m.id}`}
140            label={m.id}
141            plain
142            onPress={() => void toggle($, m.id)}
143          >
144            <Text color={m.enabled ? 'success' : 'inactive'}>
145              {running === m.id ? '[ … ]' : m.enabled ? '[ on]' : '[off]'}
146            </Text>{' '}
147            {m.name}
148            <Text dimColor>{m.isMod ? ' mod' : ''} {m.scope}</Text>
149          </Button>
150        ))}
151        {said && <Text dimColor wrap="wrap">{said}</Text>}
152      </Box>
153    )
154  })
155}
156
types/index.d.ts 19 lines
1export type ModEntry = {
2  id: string
3  name: string
4  version?: string
5  scope: string
6  enabled: boolean
7  isMod: boolean
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'mod-manager': {
13      mods: ModEntry[]
14      busy: string | null
15      note: string | null
16    }
17  }
18}
19