A Docker chip above the prompt and a Dracula-themed containers pane grouped by compose project: ports, health, Start/Stop/Restart by container name, logs and…

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.
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.
| Mod | What it does | Commands | |||
|---|---|---|---|---|---|
| mod-manager | The 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-diff | A 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-pane | A ⎈ 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-pane | Your 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 | |||
| guardrails | Blocks 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-loop | Hands 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-done | A toast and a chime when a long turn finishes. | — | |||
| aws-profile | Each 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-pane | A 🐳 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-watch | Watches 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 | |||
| worktrees | A 🌳 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 | |||
| dot | An 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` |
| Mod | Option | Default |
|---|---|---|
| mod-manager | Show the Mods chip · open on start until a mod is on · claude CLI path | on · on · PATH |
| kube-pane | Production context pattern · default namespace · refresh interval · pods per page · kubectl path | prod · default · 15 s · 25 · PATH |
| pr-pane | Integration branch · Codex bot login · refresh interval · PRs to load · gh path | staging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH |
| guardrails | Production pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker ports | prod · on · on · on · on |
| promote-branch | Target branch · protected branches | staging · main,master |
| live-diff | Context lines · edits kept | 2 · 100 |
| turn-done | Long turn threshold · play sound | 60 s · on |
| codex-loop | Codex bot login · maximum review rounds · wait for Codex | chatgpt-codex-connector[bot] · 5 · 15 min |
| aws-profile | Production pattern · profiles · check interval · aws path | prod · all · 5 min · PATH |
| docker-pane | Refresh interval · docker path | 60 s (10 s while open) · PATH |
| deploy-watch | Projects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws path | none · CLI default · profile's · 60 s · 3 min · prod · PATH |
| worktrees | Roots (folders holding your repos) · refresh interval · gh path | session repo only · 5 min · PATH |
| dot | Work round interval · morning briefing (weekdays) · open its pane on start | 30 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.
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.
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.
.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.
🇧🇷 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.
hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import { isHealthy, isStale, lastHeadMove, logsCommand, parseDockerPs, recreateCommand, type ContainerRow } from '../src/containers'
5import { binaryCandidates } from '../src/shared/bin'
6import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
7import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
8import { DockerChip, dockerChip } from '../ui/chip'
9import { DockerPane, PaneError, type Action } from '../ui/pane'
10
11const MOD = 'docker-pane'
12const CHECK_MS = 5000
13const active = atom({ plugin: 'docker-pane', key: 'active' } as const, false)
14let checkedAt = 0
15
16async function isActive($: EngineInterface) {
17 if (Date.now() - checkedAt > CHECK_MS) {
18 checkedAt = Date.now()
19 const home = await $.env.get('HOME').catch(() => undefined)
20 const text = await $.fs.read(statePath(home)).catch(() => '')
21 const on = isOn(parseState(text), MOD)
22 await update($, active, prev => (prev === on ? prev : on))
23 }
24 return read($, active)
25}
26
27const chip = atom({ plugin: 'docker-pane', key: 'chip' } as const, null)
28
29async function barOwnsChips($: EngineInterface) {
30 const { value } = await $.state.get({ plugin: 'mod-manager', key: 'bar' })
31 return barIsLive(value, Date.now())
32}
33
34async function setChip($: EngineInterface, next: Chip | null) {
35 await update($, chip, prev => (sameChip(prev, next) ? prev : next))
36}
37
38const PANE_ID = 'docker-pane'
39const OPEN_POLL_MS = 10_000
40
41const containers = atom({ plugin: 'docker-pane', key: 'containers' } as const, [])
42const repoRoot = atom({ plugin: 'docker-pane', key: 'repoRoot' } as const, '')
43const headMovedAt = atom({ plugin: 'docker-pane', key: 'headMovedAt' } as const, 0)
44const isOpen = atom({ plugin: 'docker-pane', key: 'isOpen' } as const, false)
45const isLoading = atom({ plugin: 'docker-pane', key: 'isLoading' } as const, false)
46const lastError = atom({ plugin: 'docker-pane', key: 'error' } as const, '')
47const updatedAt = atom({ plugin: 'docker-pane', key: 'updatedAt' } as const, 0)
48const busy = atom({ plugin: 'docker-pane', key: 'busy' } as const, '')
49const hideStopped = atom({ plugin: 'docker-pane', key: 'hideStopped' } as const, false)
50let polledAt = 0
51
52type Settings = { dockerPath: string; pollMs: number }
53
54function readSettings(options: PluginOptions): Settings {
55 return {
56 dockerPath: String(options.dockerPath ?? ''),
57 pollMs: Math.max(10, Number(options.pollSeconds ?? 60)) * 1000,
58 }
59}
60
61async function docker($: EngineInterface, settings: Settings, args: readonly string[], timeoutMs = 15_000) {
62 for (const bin of binaryCandidates('docker', await $.env.get('HOME'), settings.dockerPath)) {
63 try {
64 return await $.process.run([bin, ...args], { timeoutMs })
65 } catch {
66 continue
67 }
68 }
69 throw new Error('docker not found; set its path in /config')
70}
71
72async function loadRepo($: EngineInterface) {
73 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => undefined)
74 const root = top?.exitCode === 0 ? top.stdout.trim() : ''
75 await update($, repoRoot, prev => (prev === root ? prev : root))
76 if (root === '') return
77 const reflog = await $.process.run(['git', 'reflog', '-n', '50', '--format=%ct %gs'], { timeoutMs: 5000 }).catch(() => undefined)
78 const moved = reflog?.exitCode === 0 ? lastHeadMove(reflog.stdout) : 0
79 await update($, headMovedAt, prev => (prev === moved ? prev : moved))
80}
81
82async function load($: EngineInterface, settings: Settings) {
83 if (await read($, isLoading)) return
84 await update($, isLoading, () => true)
85 polledAt = Date.now()
86 try {
87 await loadRepo($)
88 const ran = await docker($, settings, ['ps', '-a', '--no-trunc', '--format', '{{json .}}'])
89 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim().split('\n')[0] || 'docker ps failed')
90 await update($, containers, () => parseDockerPs(ran.stdout))
91 await update($, lastError, () => '')
92 await update($, updatedAt, () => Date.now())
93 } catch (err) {
94 await update($, containers, () => [])
95 await update($, lastError, () => (err instanceof Error ? err.message : String(err)))
96 } finally {
97 await update($, isLoading, () => false)
98 }
99}
100
101async function tick($: EngineInterface, settings: Settings) {
102 if (!(await isActive($))) return
103 const every = (await read($, isOpen)) ? OPEN_POLL_MS : settings.pollMs
104 if (Date.now() - polledAt >= every) await load($, settings)
105}
106
107async function openPane($: EngineInterface, settings: Settings) {
108 const opened = await $.ui.open({ id: PANE_ID, title: 'Docker' })
109 if (opened.isPlaced) await update($, isOpen, () => true)
110 await load($, settings)
111}
112
113async function inTerminal($: EngineInterface, command: string, title: string) {
114 try {
115 await $.tool.call({ tool: 'mcp__terminal__run_in_terminal', command, title: title.slice(0, 40) })
116 } catch (err) {
117 $.ui.toast(`Could not open the terminal: ${err instanceof Error ? err.message : String(err)}`)
118 }
119}
120
121async function confirmRecreate($: EngineInterface, row: ContainerRow) {
122 try {
123 const answer = await $.ui.ask(`Recreate ${row.service} of ${row.project}? The container is replaced; named volumes stay.`, {
124 header: 'Recreate',
125 options: ['Recreate', 'Cancel'],
126 })
127 return answer === 'Recreate'
128 } catch {
129 return false
130 }
131}
132
133async function act($: EngineInterface, settings: Settings, row: ContainerRow, action: Action) {
134 if (action === 'logs') return inTerminal($, logsCommand(row), `logs ${row.name}`)
135 if (action === 'recreate') {
136 const command = recreateCommand(row)
137 if (command !== undefined && (await confirmRecreate($, row))) await inTerminal($, command, `recreate ${row.service}`)
138 return
139 }
140 await update($, busy, () => row.name)
141 try {
142 const ran = await docker($, settings, [action, row.name], 90_000)
143 if (ran.exitCode !== 0) $.ui.toast(`docker ${action} ${row.name}: ${ran.stderr.trim().split('\n')[0] || 'failed'}`)
144 } catch (err) {
145 $.ui.toast(err instanceof Error ? err.message : String(err))
146 } finally {
147 await update($, busy, () => '')
148 }
149 await load($, settings)
150}
151
152async function publishChip($: EngineInterface) {
153 if (!(await read($, active))) return setChip($, null)
154 const rows = await read($, containers)
155 const root = await read($, repoRoot)
156 const moved = await read($, headMovedAt)
157 const up = rows.filter(r => r.state === 'running')
158 const stale = up.filter(r => isStale(r, root, moved)).length
159 await setChip($, dockerChip(up.length, stale, up.filter(r => !isHealthy(r)).length, (await read($, lastError)) !== ''))
160}
161
162export const register: Register = (on, options) => {
163 const settings = readSettings(options)
164
165 on('state.set', async ($, e, next) => {
166 const ran = await next(e)
167 if (e.plugin === 'mod-manager' && e.key === 'press') {
168 const pressed = e.value as ChipPress | null
169 if (pressed?.plugin === MOD && (await isActive($))) await openPane($, settings)
170 }
171 return ran
172 })
173
174 on('session.start', async ($, e, next) => {
175 polledAt = 0
176 await $.command.register({ name: 'docker', description: 'Open the Docker containers pane' })
177 $.clock.every(CHECK_MS, () => void tick($, settings).catch(() => undefined))
178 $.clock.every(CHECK_MS, () => void publishChip($).catch(() => undefined))
179 return next(e)
180 })
181
182 on('command.run', { command: 'docker' }, async $ => {
183 if (!(await isActive($))) return { text: OFF_TEXT }
184 await openPane($, settings)
185 return { text: 'Docker pane opened.' }
186 })
187
188 on('ui.close', async ($, e, next) => {
189 if (!(await isActive($))) return next(e)
190 if (e.id === PANE_ID) await update($, isOpen, () => false)
191 return next(e)
192 })
193
194 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
195 if (!(await isActive($))) return next(e)
196 const ran = await next(e)
197 if (/\bdocker\b|\bsail\b|\bgit\s+(checkout|switch|pull|merge|rebase|reset)\b/.test(e.command)) polledAt = 0
198 return ran
199 })
200
201 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202 if (!(await read($, active)) || (await barOwnsChips($))) return next(e)
203 const rest = await next(e)
204 if (e.props.hasSurvey) return rest
205
206 const rows = await read($, containers)
207 const root = await read($, repoRoot)
208 const moved = await read($, headMovedAt)
209 const up = rows.filter(r => r.state === 'running')
210 const ui = $.ui.resolve(e)
211 return (
212 <ui.Box flexDirection="column">
213 <DockerChip
214 ui={ui}
215 running={up.length}
216 unhealthy={up.filter(r => !isHealthy(r)).length}
217 stale={up.filter(r => isStale(r, root, moved)).length}
218 isDown={(await read($, lastError)) !== ''}
219 onOpen={() => openPane($, settings)}
220 />
221 {rest}
222 </ui.Box>
223 )
224 })
225
226 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
227 const ui = $.ui.resolve(e)
228 if (!(await read($, active))) {
229 return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
230 }
231 try {
232 return (
233 <DockerPane
234 ui={ui}
235 width={e.props.bodyColumns}
236 containers={await read($, containers)}
237 repoRoot={await read($, repoRoot)}
238 headMovedAt={await read($, headMovedAt)}
239 isLoading={await read($, isLoading)}
240 error={await read($, lastError)}
241 updatedAt={await read($, updatedAt)}
242 busy={await read($, busy)}
243 hideStopped={await read($, hideStopped)}
244 now={Date.now()}
245 onRefresh={() => load($, settings)}
246 onToggleStopped={() => update($, hideStopped, prev => !prev)}
247 onAction={(row, action) => act($, settings, row, action)}
248 />
249 )
250 } catch (err) {
251 const message = err instanceof Error ? err.message : String(err)
252 $.ui.log(`docker-pane render failed: ${message}`)
253 return <PaneError ui={ui} message={message} onRefresh={() => load($, settings)} />
254 }
255 })
256}
257src/containers.ts 151 lines1import type { ContainerRow } from '../types'
2
3export type { ContainerRow }
4export type ContainerGroup = { project: string; rows: ContainerRow[] }
5
6type DockerContainer = {
7 ID?: string
8 Names?: string
9 Image?: string
10 State?: string
11 Status?: string
12 Ports?: string
13 CreatedAt?: string
14 Labels?: string
15}
16
17export function parseLabels(text: string): Record<string, string> {
18 const labels: Record<string, string> = {}
19 let previous = ''
20 for (const piece of text.split(',')) {
21 const equals = piece.indexOf('=')
22 if (equals < 0) {
23 if (previous !== '') labels[previous] += `,${piece}`
24 continue
25 }
26 const key = piece.slice(0, equals)
27 labels[key] = piece.slice(equals + 1)
28 previous = key
29 }
30 return labels
31}
32
33export function parseCreatedAt(text: string): number {
34 const match = text.match(/^(\d{4}-\d\d-\d\d)\s+(\d\d:\d\d:\d\d)\s+([+-]\d{2})(\d\d)(?:\s+.*)?$/)
35 if (!match) return 0
36 const value = Date.parse(`${match[1]}T${match[2]}${match[3]}:${match[4]}`)
37 return Number.isNaN(value) ? 0 : value
38}
39
40export function publishedPorts(ports: string): string[] {
41 const published = new Set<string>()
42 for (const entry of ports.split(',')) {
43 const arrow = entry.indexOf('->')
44 if (arrow < 0) continue
45 const host = entry.slice(0, arrow).trim()
46 const container = entry.slice(arrow + 2).trim().replace(/\/[^/]+$/, '')
47 const colon = host.lastIndexOf(':')
48 if (colon < 0 || container === '') continue
49 published.add(`${host.slice(colon + 1)}→${container}`)
50 }
51 return [...published]
52}
53
54export function parseDockerPs(stdout: string): ContainerRow[] {
55 const rows: ContainerRow[] = []
56 for (const line of stdout.split('\n')) {
57 if (line.trim() === '') continue
58 let container: DockerContainer
59 try {
60 container = JSON.parse(line) as DockerContainer
61 } catch {
62 continue
63 }
64 const labels = parseLabels(container.Labels ?? '')
65 rows.push({
66 id: container.ID ?? '',
67 name: container.Names ?? '',
68 image: container.Image ?? '',
69 state: container.State ?? '',
70 status: container.Status ?? '',
71 ports: publishedPorts(container.Ports ?? ''),
72 createdAt: parseCreatedAt(container.CreatedAt ?? ''),
73 project: labels['com.docker.compose.project'] ?? '',
74 service: labels['com.docker.compose.service'] ?? '',
75 workingDir: labels['com.docker.compose.project.working_dir'] ?? '',
76 configFiles: (labels['com.docker.compose.project.config_files'] ?? '').split(',').filter(Boolean),
77 })
78 }
79 return rows
80}
81
82export function isHealthy(row: ContainerRow): boolean {
83 return row.state === 'running' && !row.status.toLowerCase().includes('unhealthy')
84}
85
86export function groupByProject(rows: ContainerRow[]): ContainerGroup[] {
87 const groups = new Map<string, ContainerRow[]>()
88 for (const row of rows) {
89 const group = groups.get(row.project) ?? []
90 group.push(row)
91 groups.set(row.project, group)
92 }
93 return [...groups.entries()]
94 .map(([project, group]) => ({
95 project,
96 rows: [...group].sort((a, b) => Number(b.state === 'running') - Number(a.state === 'running') || a.name.localeCompare(b.name)),
97 }))
98 .sort((a, b) => {
99 if (a.project === '') return 1
100 if (b.project === '') return -1
101 const aRunning = a.rows.some(row => row.state === 'running')
102 const bRunning = b.rows.some(row => row.state === 'running')
103 return Number(bRunning) - Number(aRunning) || a.project.localeCompare(b.project)
104 })
105}
106
107export function lastHeadMove(reflog: string): number {
108 for (const line of reflog.split('\n')) {
109 const match = line.match(/^(\d+)\s+(.*)$/)
110 if (match && !(match[2] ?? '').startsWith('commit')) return Number(match[1]) * 1000
111 }
112 return 0
113}
114
115function cleanPath(path: string): string {
116 const cleaned = path.replace(/\/+$/, '')
117 return cleaned === '' && path.startsWith('/') ? '/' : cleaned
118}
119
120export function isStale(row: ContainerRow, repoRoot: string, headMovedAt: number): boolean {
121 const work = cleanPath(row.workingDir)
122 const root = cleanPath(repoRoot)
123 const inside = (path: string, parent: string) => parent === '/' ? path.startsWith('/') : path === parent || path.startsWith(`${parent}/`)
124 const related = inside(work, root) || inside(root, work)
125 return headMovedAt > 0 && work !== '' && related && row.createdAt < headMovedAt
126}
127
128export function shellQuote(value: string): string {
129 return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`
130}
131
132export function logsCommand(row: ContainerRow): string {
133 return `docker logs -f --tail 200 ${shellQuote(row.name)}`
134}
135
136export function recreateCommand(row: ContainerRow): string | undefined {
137 if (row.project === '' || row.service === '') return undefined
138 const files = row.configFiles.map(file => ` -f ${shellQuote(file)}`).join('')
139 return `docker compose -p ${shellQuote(row.project)}${files} up -d --force-recreate ${shellQuote(row.service)}`
140}
141
142export function formatAge(ms: number): string {
143 const seconds = Math.max(0, Math.floor(ms / 1000))
144 if (seconds < 60) return `${seconds}s`
145 const minutes = Math.floor(seconds / 60)
146 if (minutes < 60) return `${minutes}m`
147 const hours = Math.floor(minutes / 60)
148 if (hours < 48) return `${hours}h`
149 return `${Math.floor(hours / 24)}d`
150}
151src/shared/bin.ts 9 lines1const COMMON_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']
2
3export function binaryCandidates(name: string, home: string | undefined, override?: string): string[] {
4 const homeDirs = home ? [`${home}/bin`, `${home}/.local/bin`] : []
5 return [override, name, ...[...COMMON_DIRS, ...homeDirs].map(dir => `${dir}/${name}`)].filter(
6 (bin): bin is string => typeof bin === 'string' && bin !== '',
7 )
8}
9src/shared/toggle.ts 31 lines1export 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}
31src/shared/chip.ts 28 lines1import { 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}
28ui/chip.tsx 35 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Chip } from '../src/shared/chip'
4import { DRACULA } from '../src/shared/theme'
5
6type Ui = Elements[RenderSurface]
7
8type ChipProps = { ui: Ui; running: number; stale: number; unhealthy: number; isDown: boolean; onOpen: () => unknown }
9
10export function DockerChip({ ui, running, stale, unhealthy, isDown, onOpen }: ChipProps) {
11 const { Box, Text, Button } = ui
12
13 return (
14 <Box flexDirection="row" gap={1} alignItems="center" marginBottom={1}>
15 <Text color={DRACULA.cyan}>🐳</Text>
16 <Button key="open-docker" label={isDown ? 'Docker off' : `Docker · ${running} up`} dimColor onPress={onOpen} />
17 {unhealthy > 0 && <Text color={DRACULA.red}>✗ {unhealthy} unhealthy</Text>}
18 {stale > 0 && <Text color={DRACULA.orange}>⚠ {stale} older than checkout</Text>}
19 </Box>
20 )
21}
22
23export function dockerChip(running: number, stale: number, unhealthy: number, isDown: boolean): Chip {
24 return {
25 icon: '🐳',
26 label: 'docker',
27 tone: 'info',
28 parts: [
29 { text: isDown ? 'docker off' : `${running} up`, tone: 'text', action: 'open' },
30 ...(unhealthy > 0 ? [{ text: `✗ ${unhealthy} unhealthy`, tone: 'bad' as const }] : []),
31 ...(stale > 0 ? [{ text: `⚠ ${stale} stale`, tone: 'warn' as const }] : []),
32 ],
33 }
34}
35ui/pane.tsx 113 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import { formatAge, groupByProject, isHealthy, isStale, recreateCommand, type ContainerRow } from '../src/containers'
4import { DRACULA } from '../src/shared/theme'
5
6type Ui = Elements[RenderSurface]
7
8export type Action = 'start' | 'stop' | 'restart' | 'logs' | 'recreate'
9
10export type DockerPaneProps = {
11 ui: Ui
12 width: number
13 containers: ContainerRow[]
14 repoRoot: string
15 headMovedAt: number
16 isLoading: boolean
17 error: string
18 updatedAt: number
19 busy: string
20 hideStopped: boolean
21 now: number
22 onRefresh: () => unknown
23 onToggleStopped: () => unknown
24 onAction: (row: ContainerRow, action: Action) => unknown
25}
26
27export function DockerPane(props: DockerPaneProps) {
28 const { ui, containers, isLoading, error, updatedAt, hideStopped } = props
29 const { Box, Text, Button } = ui
30 const running = containers.filter(c => c.state === 'running').length
31 const shown = hideStopped ? containers.filter(c => c.state === 'running') : containers
32 const groups = groupByProject(shown)
33 const nameWidth = Math.max(12, Math.min(40, props.width - 62))
34
35 return (
36 <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
37 <Box flexDirection="row" alignItems="center" gap={1}>
38 <Text color={DRACULA.cyan} bold>🐳 Containers</Text>
39 <Text color={DRACULA.comment}>{running} up · {containers.length - running} stopped</Text>
40 <Box flexGrow={1} />
41 <Text color={DRACULA.comment}>
42 {isLoading ? 'refreshing…' : updatedAt > 0 ? `updated ${new Date(updatedAt).toTimeString().slice(0, 8)}` : ''}
43 </Text>
44 <Button key="toggle-stopped" label={hideStopped ? 'Show stopped' : 'Hide stopped'} dimColor onPress={props.onToggleStopped} />
45 <Button key="refresh" label="Refresh" dimColor onPress={props.onRefresh} />
46 </Box>
47 {error !== '' && <Text color={DRACULA.red}>{error}</Text>}
48 {groups.length === 0 && !isLoading && error === '' && <Text color={DRACULA.comment} italic>No containers.</Text>}
49 {groups.map(group => (
50 <Box key={`group-${group.project || 'standalone'}`} flexDirection="column" marginTop={1}>
51 <Box backgroundColor={DRACULA.currentLine} paddingX={1}>
52 <Text color={DRACULA.purple} bold>{group.project || 'standalone'}</Text>
53 </Box>
54 {group.rows.map(row => <ContainerView {...props} row={row} nameWidth={nameWidth} />)}
55 </Box>
56 ))}
57 </Box>
58 )
59}
60
61function ContainerView(props: DockerPaneProps & { row: ContainerRow; nameWidth: number }) {
62 const { ui, row, nameWidth, now } = props
63 const { Box, Text, Button } = ui
64 const isUp = row.state === 'running'
65 const stale = isStale(row, props.repoRoot, props.headMovedAt)
66 const color = isUp ? (isHealthy(row) ? DRACULA.green : DRACULA.red) : DRACULA.comment
67 const isBusy = props.busy === row.name
68 const act = (action: Action) => () => props.onAction(row, action)
69
70 return (
71 <Box key={`container-${row.id}`} flexDirection="column" paddingX={1}>
72 <Box flexDirection="row" gap={1}>
73 <Box width={nameWidth}><Text color={isUp ? DRACULA.foreground : DRACULA.comment} wrap="truncate-middle">{row.service || row.name}</Text></Box>
74 <Box width={14}><Text color={color} wrap="truncate-end">{statusLabel(row)}</Text></Box>
75 <Box width={14}><Text color={DRACULA.cyan} wrap="truncate-end">{row.ports.join(' ')}</Text></Box>
76 <Box width={5}><Text color={stale ? DRACULA.orange : DRACULA.comment}>{row.createdAt > 0 ? formatAge(now - row.createdAt) : ''}</Text></Box>
77 {isBusy ? <Text color={DRACULA.yellow}>working…</Text> : (
78 <Box flexDirection="row" gap={1}>
79 {isUp
80 ? <Button key={`restart-${row.id}`} label="Restart" dimColor plain onPress={act('restart')} />
81 : <Button key={`start-${row.id}`} label="Start" dimColor plain onPress={act('start')} />}
82 {isUp && <Button key={`stop-${row.id}`} label="Stop" dimColor plain onPress={act('stop')} />}
83 <Button key={`logs-${row.id}`} label="Logs" dimColor plain onPress={act('logs')} />
84 {recreateCommand(row) !== undefined && <Button key={`recreate-${row.id}`} label="Recreate" dimColor plain onPress={act('recreate')} />}
85 </Box>
86 )}
87 </Box>
88 {stale && (
89 <Box paddingLeft={2}>
90 <Text color={DRACULA.orange}>⚠ created before your last checkout: Recreate it before trusting a failure</Text>
91 </Box>
92 )}
93 </Box>
94 )
95}
96
97function statusLabel(row: ContainerRow) {
98 if (row.state !== 'running') return row.status.replace(/^Exited/, 'exited').replace(/ ago$/, '')
99 if (row.status.includes('unhealthy')) return 'unhealthy'
100 if (row.status.includes('healthy')) return 'healthy'
101 return 'running'
102}
103
104export function PaneError({ ui, message, onRefresh }: { ui: Ui; message: string; onRefresh: () => unknown }) {
105 const { Box, Text, Button } = ui
106 return (
107 <Box flexDirection="column" padding={1}>
108 <Text color={DRACULA.red}>Docker pane failed to draw: {message}</Text>
109 <Button key="refresh" label="Refresh" dimColor onPress={onRefresh} />
110 </Box>
111 )
112}
113src/shared/theme.ts 15 lines1// 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
15types/index.d.ts 38 lines1export 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 ContainerRow = {
7 id: string
8 name: string
9 image: string
10 state: string
11 status: string
12 ports: string[]
13 createdAt: number
14 project: string
15 service: string
16 workingDir: string
17 configFiles: string[]
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'mod-manager': { bar: number; press: ChipPress | null }
23 'docker-pane': {
24 chip: Chip | null
25 active: boolean
26 containers: ContainerRow[]
27 repoRoot: string
28 headMovedAt: number
29 isOpen: boolean
30 isLoading: boolean
31 error: string
32 updatedAt: number
33 busy: string
34 hideStopped: boolean
35 }
36 }
37}
38