SLOPSHOPPER

Mod Manager

A Dracula-themed pane to install the Dracula mods and turn them on and off. Every other mod ships off; this one lists them all and remembers your choice across…

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-manager
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⚙ Mods 0 of 12 on · v2.6.2 [ Install all ( ⏺ Read(src/auth.ts) │ ┃ Turning a mod on or off reloads it right ⎿ Read 6 lines │ ┃ away. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ◌ Live Diff not installed [ Install ] ⏺ Bash(bun test) │ ┃ Diff of every edit Claude makes, with… ⎿ 3 pass, 1 fail │ ┃ │ ┃ ◌ Kube Pane not installed [ Install ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Kubernetes context chip, pods pane an… │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ◌ PR Pane not installed [ Install ] │ ┃ Your open PRs with CI, Codex review a… › /mods │ ┃ ⎿ mod-manager: Mods pane opened. │ ┃ ◌ Guardrails not installed [ Install ] │ ┃ Blocks risky shell commands, confirms… │ ┃ │ ┃ ◌ Promote Branch not installed [ Install │ ┃ /promote merges your branch into the … │ ┃ │ ┃ ◌ Codex Loop not installed [ Install ] │ ┃ /codex-loop runs the Codex review loo… │ ┃ │ ┃ ◌ Turn Done not installed [ Install ] │ ┃ Toast and chime when a long turn fini… ⚙ mods [ 0/12 ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚙ mods [ 0/12 ] ⟨Claude Code's own drawing⟩
Pane · Mods
⚙ Mods 0 of 12 on · v2.6.2 [ Install all (12) ] [ ↻ Reload Turning a mod on or off reloads it right away. ◌ Live Diff not installed [ Install ] Diff of every edit Claude makes, with a file navigator ◌ Kube Pane not installed [ Install ] Kubernetes context chip, pods pane and a shell button ◌ PR Pane not installed [ Install ] Your open PRs with CI, Codex review and integration b… ◌ Guardrails not installed [ Install ] Blocks risky shell commands, confirms production writ… ◌ Promote Branch not installed [ Install ] /promote merges your branch into the integration bran… ◌ Codex Loop not installed [ Install ] /codex-loop runs the Codex review loop on a PR ◌ Turn Done not installed [ Install ] Toast and chime when a long turn finishes ◌ AWS Profile not installed [ Install ] Each AWS profile with its login state and a one-click… ◌ Docker Pane not installed [ Install ] Containers by compose project, restart by name, stale… ◌ Deploy Watch not installed [ Install ] CodeBuild builds, logs and a warning when a push buil… ◌ Worktrees not installed [ Install ] Your git worktrees with changes and PRs; remove merge… ◌ Dot not installed [ Install ] An always-on assistant that works on your goals in th…
README

claude-mods

Dracula-themed mods for Claude Code: a live diff pane, Kubernetes, Docker, CodeBuild and worktree panes, a pull request dashboard, AWS SSO status, guardrails for risky shell commands and a few workflow helpers. Each mod is its own plugin, so install only the ones you want.

Mods use Claude Code's function hooks API, which is in early access and may change between releases. Mods run on your machine with your permissions: read the code before installing, like any plugin.

Install

In Claude Code (terminal or the desktop app's Code tab):

/plugin marketplace add lucasleandro08/claude-mods
/plugin install mod-manager@dracula-mods

The marketplace is named dracula-mods. Open /mods (or click the ⚙ Mods chip above the prompt; the pane also opens by itself while no mod is on): install the mods you want from there (Install or Install all) and turn them on. Every mod ships turned off. Your choices are saved in ~/.claude/dracula-mods.json, so they hold in every session; each mod's other options are in /config.

Mods

ModWhat it doesCommands
mod-managerThe only mod on by default: a pane that lists every mod with an on/off switch and an Install button for the ones not installed yet (it runs claude plugin install, or puts the /plugin install command in your prompt when the CLI is not found), plus a ⚙ Mods · n/12 chip above the prompt. After an install it runs /reload-plugins, so the new mod shows up in the session you are in; ↻ Reload does the same by hand./mods, /mods reload
live-diffA pane with the diff of every Edit and Write Claude makes: file navigator with M/A status, +/- counts, line numbers and red/green rows. A Δ Diff chip above the prompt reopens it./diff, /diff-clear
kube-paneA ⎈ context chip above the prompt (red on production) and a pods pane: context and namespace pickers, filter, pages, unhealthy pods first, and a ⌘ Shell button that opens kubectl exec in the app's Terminal, with a container picker for multi-container pods. Switching context only affects Claude's session: your kubeconfig is never written./pods
pr-paneYour open pull requests grouped by repo, with CI status, the Codex review state and whether each PR already reached your integration branch. A ⑂ PRs chip flags failing CI and Codex findings. Needs the GitHub CLI./prs
guardrailsBlocks risky commands before they run: killing a port owned by a Docker container, pkill docker, printing secrets (env, printenv, echo $API_TOKEN), rewriting the kubeconfig. Asks before kubectl writes on production contexts. Each rule can be switched off.—
promote-branch/promote previews merging the current branch into your integration branch (commits, files, test files touched). /promote go does a full merge in a temporary worktree and pushes it, refusing dirty trees and protected branches./promote, /promote go
codex-loopHands Claude the review loop with the Codex GitHub bot: request a review, weigh each finding, fix or 👎 it, reply, resolve, repeat until 👍, for at most 5 rounds (and 15 minutes of waiting per round) before it stops and reports what is still open./codex-loop <PR> [--max <rounds>]
turn-doneA toast and a chime when a long turn finishes.—
aws-profileEach AWS profile above the prompt with whether its login still works (aws sts get-caller-identity, only the exit code is kept). Expired ones get a login button that runs aws sso login --profile … in the app's Terminal and turns green once you finish. The AWS_PROFILE Claude Code runs with is marked ●; production profiles are red.—
docker-paneA 🐳 Docker · n up chip (unhealthy and stale counts) and a pane grouped by compose project: ports, health, Start/Stop/Restart by container name (never by port), Logs and compose Recreate in the app's Terminal. Flags containers created before your last git checkout in the session's repo, the usual cause of a test failing for no reason./docker
deploy-watchWatches AWS CodeBuild projects: a 🚀 chip with the last build, a warning (chip and toast) when a git push to a project's branch started no build after a few minutes, and a pane with recent builds, their logs in the Terminal and a Start build button that asks first./deploys
worktreesA 🌳 n worktrees chip and a pane with every worktree of the session's repo and of the repos under your roots: branch, uncommitted changes, PR state, a Terminal button, and Remove (asks first, never --force) for clean worktrees whose branch is merged./worktrees
dotAn always-on assistant with a name and a face: Vlad, a chibi vampire drawn in SVG that lives in its own pane and animates by mood (working, waiting on you, idle, paused), with what it is doing right under its name; the mods bar keeps a one-word tile that opens it. Rename it with /dot name. Give it goals in its pane or with /dot <goal>; background rounds (the desktop app's scheduled tasks, every 30 min) pick one goal at a time and work on it, following ~/.claude/dot/rules.md: act alone, ask first, or hand off to you. When it needs a decision it notifies you and the goal shows under Waiting on you, where you answer it. A weekday morning briefing sums up what needs you. Its memory is a file you can read and edit. Rounds run while the desktop app is open./dot, /dot <goal>, `/dot on \pause \resume \run`

Options

ModOptionDefault
mod-managerShow the Mods chip · open on start until a mod is on · claude CLI pathon · on · PATH
kube-paneProduction context pattern · default namespace · refresh interval · pods per page · kubectl pathprod · default · 15 s · 25 · PATH
pr-paneIntegration branch · Codex bot login · refresh interval · PRs to load · gh pathstaging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH
guardrailsProduction pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker portsprod · on · on · on · on
promote-branchTarget branch · protected branchesstaging · main,master
live-diffContext lines · edits kept2 · 100
turn-doneLong turn threshold · play sound60 s · on
codex-loopCodex bot login · maximum review rounds · wait for Codexchatgpt-codex-connector[bot] · 5 · 15 min
aws-profileProduction pattern · profiles · check interval · aws pathprod · all · 5 min · PATH
docker-paneRefresh interval · docker path60 s (10 s while open) · PATH
deploy-watchProjects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws pathnone · CLI default · profile's · 60 s · 3 min · prod · PATH
worktreesRoots (folders holding your repos) · refresh interval · gh pathsession repo only · 5 min · PATH
dotWork round interval · morning briefing (weekdays) · open its pane on start30 min · 09:00 · on

With the mod-manager on, every mod's chip is drawn in one bar above the prompt: a tile per mod, its name dimmed over its value (like a usage band), wrapping between tiles with a blank row between lines; each mod publishes its chip as state and the bar sends presses back to it. Without the manager, or if it stops, each mod draws its own row again. Either way the band keeps whatever other mods draw beneath it.

Rolling out to a whole organization

Admins can register the marketplace and install mods for everyone through managed settings (extraKnownMarketplaces and enabledPlugins); people then turn on what they use in /mods. See Plugins for organizations and Mods for admins.

Development

git clone https://github.com/lucasleandro08/claude-mods
cd claude-mods
scripts/check.sh            # validate + test every plugin (CLAUDE_BIN overrides the CLI path)
scripts/check.sh kube-pane  # only one

Load your checkout instead of the published version by adding it as a local marketplace and installing from it:

claude plugin marketplace add ~/claude-mods
claude plugin install mod-manager@dracula-mods --scope user

Plugins of a marketplace added from a local folder load in place, so edits and newly installed mods reach open sessions with /reload-plugins (or ↻ Reload in /mods); see Plugin loading. Avoid CLAUDE_CODE_PLUGIN_DIRS for this: it is read only when a session starts, so a mod added to it shows up only in new sessions.

Layout

.claude-plugin/marketplace.json
plugins/<mod>/
  .claude-plugin/plugin.json   manifest and /config options
  hooks/register.tsx           event wiring, state atoms, everything that touches $
  src/                         pure logic (diffing, parsing kubectl and gh output, rules)
  src/shared/                  vendored copies of scripts/shared (theme, binary lookup)
  ui/                          components the panes and chips draw
  types/index.d.ts             state contract
  tests/                       claude plugin test suites
scripts/shared/                source of the shared files; scripts/sync-shared.sh copies them

Two rules of the hooks runtime shape this layout: $ can only be passed to functions declared in register.tsx itself, and state atoms must be declared there too. Plugins cannot import files from each other, which is why small shared helpers are vendored.

License

MIT


🇧🇷 Em português: mods para o Claude Code com tema Dracula. Instale com /plugin marketplace add lucasleandro08/claude-mods e /plugin install mod-manager@dracula-mods, instale os mods que quiser e ligue cada um em /mods (todos vêm desligados); as demais opções ficam em /config.

Source 9 files
hooks/register.tsx 214 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModEntry, Notice } from '../types'
5import { entries, installCommand, readInstalled } from '../src/catalog'
6import { claudeCandidates, installArgs, readInstallResult } from '../src/install'
7import { MARKETPLACE, parseState, serializeState, statePath, withToggle } from '../src/shared/toggle'
8import { ModsBar, modsChip, type BarEntry } from '../ui/chip'
9import type { Chip } from '../src/shared/chip'
10import { ModsPane } from '../ui/pane'
11
12const PANE_ID = 'mod-manager'
13const VERSION = '2.6.2'
14const REFRESH_MS = 10_000
15const BAR_BEAT_MS = 10_000
16
17const mods = atom({ plugin: 'mod-manager', key: 'mods' } as const, [] as ModEntry[])
18const lastError = atom({ plugin: 'mod-manager', key: 'lastError' } as const, '')
19const installing = atom({ plugin: 'mod-manager', key: 'installing' } as const, [])
20const notices = atom({ plugin: 'mod-manager', key: 'notices' } as const, {})
21const marketplace = atom({ plugin: 'mod-manager', key: 'marketplace' } as const, MARKETPLACE)
22// A heartbeat, not a flag: if the manager goes away, the other mods see it stale and draw their own chips again
23const bar = atom({ plugin: 'mod-manager', key: 'bar' } as const, 0)
24const press = atom({ plugin: 'mod-manager', key: 'press' } as const, null)
25
26// Refs must be literals, so each mod's chip is read by name; a mod that is not loaded reads undefined
27async function readChips($: EngineInterface): Promise<BarEntry[]> {
28  const found: [string, Chip | null | undefined][] = [
29    ['live-diff', (await $.state.get({ plugin: 'live-diff', key: 'chip' })).value],
30    ['kube-pane', (await $.state.get({ plugin: 'kube-pane', key: 'chip' })).value],
31    ['pr-pane', (await $.state.get({ plugin: 'pr-pane', key: 'chip' })).value],
32    ['aws-profile', (await $.state.get({ plugin: 'aws-profile', key: 'chip' })).value],
33    ['docker-pane', (await $.state.get({ plugin: 'docker-pane', key: 'chip' })).value],
34    ['deploy-watch', (await $.state.get({ plugin: 'deploy-watch', key: 'chip' })).value],
35    ['worktrees', (await $.state.get({ plugin: 'worktrees', key: 'chip' })).value],
36    ['dot', (await $.state.get({ plugin: 'dot', key: 'chip' })).value],
37  ]
38  return found.flatMap(([plugin, chip]) => (chip ? [{ plugin, chip }] : []))
39}
40
41async function pressChip($: EngineInterface, plugin: string, action: string) {
42  if (plugin === 'mod-manager') return openPane($)
43  await update($, press, () => ({ plugin, action, at: Date.now() }))
44}
45
46async function stateFile($: EngineInterface) {
47  return statePath(await $.env.get('HOME').catch(() => undefined))
48}
49
50async function readState($: EngineInterface) {
51  return parseState(await $.fs.read(await stateFile($)).catch(() => ''))
52}
53
54async function refreshMods($: EngineInterface) {
55  const settings = await $.settings.read().catch(() => ({}))
56  const envDirs = await $.env.get('CLAUDE_CODE_PLUGIN_DIRS').catch(() => undefined)
57  const home = await $.env.get('HOME').catch(() => undefined)
58  const known = await $.fs.read(`${home ?? ''}/.claude/plugins/known_marketplaces.json`).catch(() => '')
59  const installedJson = await $.fs.read(`${home ?? ''}/.claude/plugins/installed_plugins.json`).catch(() => '')
60  const installed = readInstalled(settings, envDirs, known, installedJson)
61  await update($, marketplace, prev => (prev === installed.marketplaces[0] ? prev : (installed.marketplaces[0] ?? MARKETPLACE)))
62  const next = entries(installed, await readState($))
63  await update($, mods, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
64  return next
65}
66
67async function setNotice($: EngineInterface, name: string, notice: Notice) {
68  await update($, notices, prev => ({ ...prev, [name]: notice }))
69}
70
71async function toggle($: EngineInterface, mod: ModEntry) {
72  try {
73    const next = withToggle(await readState($), mod.name, mod.state !== 'on')
74    await $.fs.write(await stateFile($), serializeState(next))
75    await update($, lastError, () => '')
76  } catch (err) {
77    await update($, lastError, () => `${mod.title}: ${err instanceof Error ? err.message : String(err)}`)
78  }
79  await refreshMods($)
80}
81
82async function runInstall($: EngineInterface, claudePath: string, name: string) {
83  for (const bin of claudeCandidates(await $.env.get('HOME').catch(() => undefined), claudePath)) {
84    try {
85      const ran = await $.process.run([bin, ...installArgs(name, await read($, marketplace))], { timeoutMs: 120_000 })
86      return readInstallResult(ran.exitCode, ran.stdout, ran.stderr)
87    } catch {
88      continue
89    }
90  }
91  return undefined
92}
93
94// /reload-plugins picks up new installs and code edits in this session; it cannot run inside the hook a turn waits on
95function reloadPlugins($: EngineInterface) {
96  const byHand = async () => {
97    const filled = await $.prompt.fill({ text: '/reload-plugins', mode: 'replace' }).catch(() => ({ isFilled: false }))
98    if (!filled.isFilled) $.ui.toast('Run /reload-plugins to load the change.')
99  }
100  $.clock.after(0, () => {
101    void $.command.run({ command: 'reload-plugins', args: '' } as never)
102      .then(ran => (/(isn['’]t|not) available|unknown/i.test(String((ran as { text?: string }).text ?? '')) ? byHand() : undefined))
103      .catch(byHand)
104  })
105}
106
107async function install($: EngineInterface, claudePath: string, name: string, reload = true) {
108  await update($, installing, list => [...list, name])
109  await setNotice($, name, { tone: 'info', text: 'Installing…' })
110  try {
111    const result = await runInstall($, claudePath, name)
112    if (result) {
113      await setNotice($, name, result)
114      if (result.tone === 'ok' && reload) reloadPlugins($)
115      return result.tone === 'ok'
116    }
117    const command = installCommand(name, await read($, marketplace))
118    const filled = await $.prompt.fill({ text: command, mode: 'replace' })
119    await setNotice($, name, {
120      tone: 'info',
121      text: filled.isFilled
122        ? 'claude CLI not found: the install command is in your prompt, press Enter.'
123        : `claude CLI not found: run ${command} or set its path in /config.`,
124    })
125    return false
126  } finally {
127    await update($, installing, list => list.filter(n => n !== name))
128    await refreshMods($)
129  }
130}
131
132async function installAll($: EngineInterface, claudePath: string, names: readonly string[]) {
133  let installed = 0
134  for (const name of names) {
135    if (!(await install($, claudePath, name, false))) break
136    installed++
137  }
138  if (installed > 0) reloadPlugins($)
139}
140
141async function openPane($: EngineInterface) {
142  await refreshMods($)
143  await $.ui.open({ id: PANE_ID, title: 'Mods' })
144}
145
146async function start($: EngineInterface, openOnStart: boolean) {
147  const current = await refreshMods($)
148  if (openOnStart && !current.some(m => m.state === 'on')) await $.ui.open({ id: PANE_ID, title: 'Mods' })
149}
150
151export const register: Register = (on, options) => {
152  const showChip = options.showChip !== false
153  const claudePath = String(options.claudePath ?? '')
154  const openOnStart = options.openOnStart !== false
155
156  on('session.start', async ($, e, next) => {
157    const beat = () => (showChip ? update($, bar, () => Date.now()) : undefined)
158    await beat()
159    $.clock.every(BAR_BEAT_MS, () => void beat())
160    await $.command.register({ name: 'mods', description: 'Install the Dracula mods and turn them on and off' })
161    void start($, openOnStart).catch(() => undefined)
162    $.clock.every(REFRESH_MS, () => void refreshMods($).catch(() => undefined))
163    return next(e)
164  })
165
166  on('command.run', { command: 'mods' }, async ($, e) => {
167    if (e.args.trim() === 'status') {
168      const list = await refreshMods($)
169      return {
170        text: [
171          `mod-manager ${VERSION}, marketplace "${await read($, marketplace)}"`,
172          ...list.map(m => `${m.state.padEnd(8)} ${m.name}`),
173        ].join('\n'),
174      }
175    }
176    if (e.args.trim() === 'reload') {
177      reloadPlugins($)
178      return { text: 'Reloading plugins…' }
179    }
180    await openPane($)
181    return { text: 'Mods pane opened.' }
182  })
183
184  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
185    const rest = await next(e)
186    if (!showChip || e.props.hasSurvey) return rest
187
188    const list = await read($, mods)
189    const own = { plugin: 'mod-manager', chip: modsChip(list.filter(m => m.state === 'on').length, list.length) }
190    const ui = $.ui.resolve(e)
191    return (
192      <ui.Box flexDirection="column">
193        <ModsBar ui={ui} entries={[own, ...(await readChips($))]} onPress={(plugin, action) => pressChip($, plugin, action)} />
194        {rest}
195      </ui.Box>
196    )
197  })
198
199  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => (
200    <ModsPane
201      ui={$.ui.resolve(e)}
202      mods={await read($, mods)}
203      version={VERSION}
204      error={await read($, lastError)}
205      installing={await read($, installing)}
206      notices={await read($, notices)}
207      onToggle={mod => toggle($, mod)}
208      onInstall={mod => install($, claudePath, mod.name)}
209      onInstallAll={names => installAll($, claudePath, names)}
210      onReload={() => reloadPlugins($)}
211    />
212  ))
213}
214
src/catalog.ts 81 lines
1import type { ModEntry, ModState } from '../types'
2import { isOn, MARKETPLACE, type ModsState } from './shared/toggle'
3
4export const CATALOG = [
5  { name: 'live-diff', title: 'Live Diff', summary: 'Diff of every edit Claude makes, with a file navigator' },
6  { name: 'kube-pane', title: 'Kube Pane', summary: 'Kubernetes context chip, pods pane and a shell button' },
7  { name: 'pr-pane', title: 'PR Pane', summary: 'Your open PRs with CI, Codex review and integration branch' },
8  { name: 'guardrails', title: 'Guardrails', summary: 'Blocks risky shell commands, confirms production writes' },
9  { name: 'promote-branch', title: 'Promote Branch', summary: '/promote merges your branch into the integration branch' },
10  { name: 'codex-loop', title: 'Codex Loop', summary: '/codex-loop runs the Codex review loop on a PR' },
11  { name: 'turn-done', title: 'Turn Done', summary: 'Toast and chime when a long turn finishes' },
12  { name: 'aws-profile', title: 'AWS Profile', summary: 'Each AWS profile with its login state and a one-click SSO login' },
13  { name: 'docker-pane', title: 'Docker Pane', summary: 'Containers by compose project, restart by name, stale warning' },
14  { name: 'deploy-watch', title: 'Deploy Watch', summary: 'CodeBuild builds, logs and a warning when a push builds nothing' },
15  { name: 'worktrees', title: 'Worktrees', summary: 'Your git worktrees with changes and PRs; remove merged ones' },
16  { name: 'dot', title: 'Dot', summary: 'An always-on assistant that works on your goals in the background' },
17] as const
18
19export const REPO = 'lucasleandro08/claude-mods'
20
21export type Installed = { enabledPlugins: Record<string, unknown>; pluginDirs: string[]; installedIds: string[]; marketplaces: string[] }
22
23type KnownMarketplaces = Record<string, { source?: { repo?: string; url?: string } }>
24
25// The marketplace keeps the name it had when it was added, so an older install may still call it claude-mods
26export function ourMarketplaces(knownJson: string): string[] {
27  let known: KnownMarketplaces = {}
28  try {
29    known = JSON.parse(knownJson) as KnownMarketplaces
30  } catch {
31    known = {}
32  }
33  const ours = Object.entries(known)
34    .filter(([, entry]) => `${entry.source?.repo ?? ''} ${entry.source?.url ?? ''}`.toLowerCase().includes(REPO))
35    .map(([name]) => name)
36  return ours.length > 0 ? ours : [MARKETPLACE]
37}
38
39export function installedIds(installedJson: string): string[] {
40  try {
41    const parsed = JSON.parse(installedJson) as { plugins?: Record<string, unknown> }
42    return Object.keys(parsed.plugins ?? {})
43  } catch {
44    return []
45  }
46}
47
48export function readInstalled(
49  settings: Readonly<Record<string, unknown>>,
50  envDirs: string | undefined,
51  knownJson = '',
52  installedJson = '',
53): Installed {
54  const enabledPlugins = (settings.enabledPlugins ?? {}) as Record<string, unknown>
55  const env = (settings.env ?? {}) as Record<string, unknown>
56  const dirs = [envDirs, typeof env.CLAUDE_CODE_PLUGIN_DIRS === 'string' ? env.CLAUDE_CODE_PLUGIN_DIRS : undefined]
57    .filter((d): d is string => typeof d === 'string' && d !== '')
58    .flatMap(d => d.split(':'))
59    .map(d => d.trim().replace(/\/+$/, ''))
60    .filter(Boolean)
61  return { enabledPlugins, pluginDirs: [...new Set(dirs)], installedIds: installedIds(installedJson), marketplaces: ourMarketplaces(knownJson) }
62}
63
64export function isInstalled(installed: Installed, name: string) {
65  const ids = installed.marketplaces.map(m => `${name}@${m}`)
66  const fromMarketplace = ids.some(id => installed.enabledPlugins[id] === true || installed.installedIds.includes(id))
67  const fromFolder = installed.pluginDirs.some(dir => dir.split('/').pop() === name)
68  return fromMarketplace || fromFolder
69}
70
71export function entries(installed: Installed, state: ModsState): ModEntry[] {
72  return CATALOG.map(mod => {
73    const status: ModState = !isInstalled(installed, mod.name) ? 'missing' : isOn(state, mod.name) ? 'on' : 'off'
74    return { ...mod, state: status }
75  })
76}
77
78export function installCommand(name: string, marketplace = MARKETPLACE) {
79  return `/plugin install ${name}@${marketplace}`
80}
81
src/install.ts 36 lines
1import type { Notice } from '../types'
2import { MARKETPLACE } from './shared/toggle'
3
4const CLAUDE_DIRS = ['.local/bin', '.claude/local', 'bin']
5
6export function pluginId(name: string, marketplace = MARKETPLACE) {
7  return `${name}@${marketplace}`
8}
9
10export function claudeCandidates(home: string | undefined, override: string) {
11  const homeBins = home ? CLAUDE_DIRS.map(dir => `${home}/${dir}/claude`) : []
12  return [override, 'claude', ...homeBins, '/opt/homebrew/bin/claude', '/usr/local/bin/claude'].filter(bin => bin !== '')
13}
14
15export function installArgs(name: string, marketplace = MARKETPLACE) {
16  return ['plugin', 'install', pluginId(name, marketplace), '--scope', 'user', '--json']
17}
18
19type InstallJson = { outcome?: string; message?: string }
20
21export function readInstallResult(exitCode: number, stdout: string, stderr: string): Notice {
22  const line = stdout.split('\n').find(l => l.trim().startsWith('{'))
23  let parsed: InstallJson = {}
24  try {
25    parsed = line ? (JSON.parse(line) as InstallJson) : {}
26  } catch {
27    parsed = {}
28  }
29
30  if (exitCode === 0 && parsed.outcome !== 'failed') {
31    return { tone: 'ok', text: 'Installed and reloaded. Turn it on.' }
32  }
33  const reason = parsed.message ?? stderr.trim().split('\n').pop() ?? `claude exited with ${exitCode}`
34  return { tone: 'error', text: `Install failed: ${reason}` }
35}
36
src/shared/toggle.ts 31 lines
1export const MARKETPLACE = 'dracula-mods'
2export const STATE_FILE = '.claude/dracula-mods.json'
3export const OFF_TEXT = 'This mod is off. Turn it on in /mods.'
4
5export type ModsState = { enabled: Record<string, boolean> }
6
7export function statePath(home: string | undefined) {
8  return `${home ?? ''}/${STATE_FILE}`
9}
10
11export function parseState(text: string): ModsState {
12  try {
13    const parsed = JSON.parse(text) as Partial<ModsState>
14    return { enabled: typeof parsed.enabled === 'object' && parsed.enabled !== null ? parsed.enabled : {} }
15  } catch {
16    return { enabled: {} }
17  }
18}
19
20export function isOn(state: ModsState, name: string) {
21  return state.enabled[name] === true
22}
23
24export function withToggle(state: ModsState, name: string, on: boolean): ModsState {
25  return { enabled: { ...state.enabled, [name]: on } }
26}
27
28export function serializeState(state: ModsState) {
29  return `${JSON.stringify(state, null, 2)}\n`
30}
31
ui/chip.tsx 47 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import { TONE_COLOR, type Chip } from '../src/shared/chip'
4import { DRACULA } from '../src/shared/theme'
5
6type Ui = Elements[RenderSurface]
7
8export type BarEntry = { plugin: string; chip: Chip }
9
10const TILE_MIN_WIDTH = 10
11
12export function modsChip(on: number, total: number): Chip {
13  return { icon: '⚙', label: 'mods', tone: 'accent', parts: [{ text: `${on}/${total}`, tone: 'text', action: 'open' }] }
14}
15
16// Tiles like the usage band: a dim label over the value, wrapping between tiles with a blank row between lines
17export function ModsBar({ ui, entries, onPress }: { ui: Ui; entries: BarEntry[]; onPress: (plugin: string, action: string) => unknown }) {
18  const { Box } = ui
19
20  return (
21    <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1} paddingX={1} marginBottom={1}>
22      {entries.map(entry => <Tile ui={ui} entry={entry} onPress={onPress} />)}
23    </Box>
24  )
25}
26
27function Tile({ ui, entry, onPress }: { ui: Ui; entry: BarEntry; onPress: (plugin: string, action: string) => unknown }) {
28  const { Box, Text, Button } = ui
29  const { plugin, chip } = entry
30
31  return (
32    <Box key={`chip-${plugin}`} flexDirection="column" minWidth={TILE_MIN_WIDTH}>
33      <Text>
34        <Text color={TONE_COLOR[chip.tone]}>{chip.icon} </Text>
35        <Text color={DRACULA.comment}>{chip.label}</Text>
36      </Text>
37      <Box flexDirection="row" gap={1} alignItems="center">
38        {chip.parts.map((part, index) =>
39          part.action !== undefined
40            ? <Button key={`${plugin}-${part.action}`} label={part.text} dimColor onPress={() => onPress(plugin, part.action ?? '')} />
41            : <Text key={`${plugin}-text-${index}`} color={TONE_COLOR[part.tone]} bold={part.bold}>{part.text}</Text>,
42        )}
43      </Box>
44    </Box>
45  )
46}
47
src/shared/chip.ts 28 lines
1import { DRACULA } from './theme'
2
3// The mod-manager draws every mod's chip in one bar; each mod publishes its chip as data
4export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
5export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
6export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
7export type ChipPress = { plugin: string; action: string; at: number }
8
9export const TONE_COLOR: Record<ChipTone, string> = {
10  text: DRACULA.foreground,
11  muted: DRACULA.comment,
12  ok: DRACULA.green,
13  warn: DRACULA.orange,
14  bad: DRACULA.red,
15  info: DRACULA.cyan,
16  accent: DRACULA.purple,
17}
18
19export function sameChip(a: Chip | null, b: Chip | null) {
20  return JSON.stringify(a) === JSON.stringify(b)
21}
22
23export const BAR_STALE_MS = 30_000
24
25export function barIsLive(beat: number | undefined, now: number) {
26  return typeof beat === 'number' && now - beat < BAR_STALE_MS
27}
28
ui/pane.tsx 86 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { ModEntry, Notice, Notices } from '../types'
4import { DRACULA } from '../src/shared/theme'
5
6type Ui = Elements[RenderSurface]
7
8export type ModsPaneProps = {
9  ui: Ui
10  mods: ModEntry[]
11  version: string
12  error: string
13  installing: string[]
14  notices: Notices
15  onToggle: (mod: ModEntry) => unknown
16  onInstall: (mod: ModEntry) => unknown
17  onInstallAll: (names: string[]) => unknown
18  onReload: () => unknown
19}
20
21export function ModsPane({ ui, mods, version, error, installing, notices, onToggle, onInstall, onInstallAll, onReload }: ModsPaneProps) {
22  const { Box, Text, Button } = ui
23  const on = mods.filter(m => m.state === 'on').length
24  const missing = mods.filter(m => m.state === 'missing' && !installing.includes(m.name)).map(m => m.name)
25
26  return (
27    <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
28      <Box flexDirection="row" gap={1} alignItems="center">
29        <Text color={DRACULA.purple} bold>⚙ Mods</Text>
30        <Text color={DRACULA.comment}>{on} of {mods.length} on · v{version}</Text>
31        <Box flexGrow={1} />
32        {missing.length > 0 && installing.length === 0 && (
33          <Button key="install-all" label={`Install all (${missing.length})`} dimColor onPress={() => onInstallAll(missing)} />
34        )}
35        <Button key="reload" label="↻ Reload" dimColor onPress={onReload} />
36      </Box>
37      <Text color={DRACULA.comment} italic>Turning a mod on or off reloads it right away.</Text>
38      {error !== '' && <Text color={DRACULA.red}>{error}</Text>}
39      {mods.map(mod => (
40        <ModRow ui={ui} mod={mod} isInstalling={installing.includes(mod.name)} notice={notices[mod.name]}
41          onToggle={() => onToggle(mod)} onInstall={() => onInstall(mod)} />
42      ))}
43    </Box>
44  )
45}
46
47type ModRowProps = { ui: Ui; mod: ModEntry; isInstalling: boolean; notice?: Notice; onToggle: () => unknown; onInstall: () => unknown }
48
49function ModRow({ ui, mod, isInstalling, notice, onToggle, onInstall }: ModRowProps) {
50  const { Box, Text } = ui
51  const isOn = mod.state === 'on'
52  const toneColor = { ok: DRACULA.green, error: DRACULA.red, info: DRACULA.yellow } as const
53
54  return (
55    <Box key={`mod-${mod.name}`} flexDirection="column" marginTop={1} paddingX={1}
56      backgroundColor={isOn ? DRACULA.currentLine : undefined}>
57      <Box flexDirection="row" gap={1} alignItems="center">
58        <Text color={isOn ? DRACULA.green : mod.state === 'missing' ? DRACULA.orange : DRACULA.comment}>
59          {isOn ? '●' : mod.state === 'missing' ? '◌' : '○'}
60        </Text>
61        <Text color={isOn ? DRACULA.pink : DRACULA.foreground} bold={isOn}>{mod.title}</Text>
62        {mod.state === 'missing' && <Text color={DRACULA.orange}>not installed</Text>}
63        <Box flexGrow={1} />
64        <Action ui={ui} mod={mod} isInstalling={isInstalling} onToggle={onToggle} onInstall={onInstall} />
65      </Box>
66      <Box paddingLeft={2}>
67        <Text color={DRACULA.comment} wrap="truncate-end">{mod.summary}</Text>
68      </Box>
69      {notice && (
70        <Box paddingLeft={2}>
71          <Text color={toneColor[notice.tone]}>{notice.text}</Text>
72        </Box>
73      )}
74    </Box>
75  )
76}
77
78type ActionProps = { ui: Ui; mod: ModEntry; isInstalling: boolean; onToggle: () => unknown; onInstall: () => unknown }
79
80function Action({ ui, mod, isInstalling, onToggle, onInstall }: ActionProps) {
81  const { Text, Button } = ui
82  if (isInstalling) return <Text color={DRACULA.yellow}>installing…</Text>
83  if (mod.state === 'missing') return <Button key={`install-${mod.name}`} label="Install" dimColor onPress={onInstall} />
84  return <Button key={`toggle-${mod.name}`} label={mod.state === 'on' ? 'Turn off' : 'Turn on'} dimColor onPress={onToggle} />
85}
86
src/shared/theme.ts 15 lines
1// Dracula palette: https://draculatheme.com/contribute#color-palette
2export const DRACULA = {
3  background: '#282a36',
4  currentLine: '#44475a',
5  foreground: '#f8f8f2',
6  comment: '#6272a4',
7  cyan: '#8be9fd',
8  green: '#50fa7b',
9  orange: '#ffb86c',
10  pink: '#ff79c6',
11  purple: '#bd93f9',
12  red: '#ff5555',
13  yellow: '#f1fa8c',
14} as const
15
types/index.d.ts 32 lines
1export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
2export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
3export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
4export type ChipPress = { plugin: string; action: string; at: number }
5
6export type ModState = 'on' | 'off' | 'missing'
7export type ModEntry = { name: string; title: string; summary: string; state: ModState }
8export type Notice = { tone: 'ok' | 'error' | 'info'; text: string }
9export type Notices = Record<string, Notice>
10
11declare module 'claude-code' {
12  interface PluginState {
13    'mod-manager': {
14      mods: ModEntry[]
15      lastError: string
16      installing: string[]
17      notices: Notices
18      marketplace: string
19      bar: number
20      press: ChipPress | null
21    }
22    'live-diff': { chip: Chip | null }
23    'kube-pane': { chip: Chip | null }
24    'pr-pane': { chip: Chip | null }
25    'aws-profile': { chip: Chip | null }
26    'docker-pane': { chip: Chip | null }
27    'deploy-watch': { chip: Chip | null }
28    worktrees: { chip: Chip | null }
29    dot: { chip: Chip | null }
30  }
31}
32