Watches AWS CodeBuild projects: a chip above the prompt with the last build, a warning when a push to the deploy branch started no build, and a pane with…

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 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { BuildRow, Project } from '../types'
5import { awsFlags, BUILD_QUERY, chipState, isSettled, logsCommand, parseProjects, pushedProjects, toBuildRows } from '../src/builds'
6import { binaryCandidates } from '../src/shared/bin'
7import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
8import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
9import { DeployChip, deployChip } from '../ui/chip'
10import { DeploysPane, PaneError } from '../ui/pane'
11
12const MOD = 'deploy-watch'
13const CHECK_MS = 5000
14const active = atom({ plugin: 'deploy-watch', key: 'active' } as const, false)
15let checkedAt = 0
16
17async function isActive($: EngineInterface) {
18 if (Date.now() - checkedAt > CHECK_MS) {
19 checkedAt = Date.now()
20 const home = await $.env.get('HOME').catch(() => undefined)
21 const text = await $.fs.read(statePath(home)).catch(() => '')
22 const on = isOn(parseState(text), MOD)
23 await update($, active, prev => (prev === on ? prev : on))
24 }
25 return read($, active)
26}
27
28const chip = atom({ plugin: 'deploy-watch', key: 'chip' } as const, null)
29
30async function barOwnsChips($: EngineInterface) {
31 const { value } = await $.state.get({ plugin: 'mod-manager', key: 'bar' })
32 return barIsLive(value, Date.now())
33}
34
35async function setChip($: EngineInterface, next: Chip | null) {
36 await update($, chip, prev => (sameChip(prev, next) ? prev : next))
37}
38
39const PANE_ID = 'deploy-watch'
40const BUSY_POLL_MS = 15_000
41const BUILDS_KEPT = 5
42
43const builds = atom({ plugin: 'deploy-watch', key: 'builds' } as const, {})
44const pushes = atom({ plugin: 'deploy-watch', key: 'pushes' } as const, {})
45const errors = atom({ plugin: 'deploy-watch', key: 'errors' } as const, {})
46const isOpen = atom({ plugin: 'deploy-watch', key: 'isOpen' } as const, false)
47const updatedAt = atom({ plugin: 'deploy-watch', key: 'updatedAt' } as const, 0)
48let polledAt = 0
49const warned = new Set<string>()
50
51type Settings = {
52 projects: Project[]
53 profile: string
54 region: string
55 awsPath: string
56 pollMs: number
57 missingMs: number
58 production: RegExp
59}
60
61function readSettings(options: PluginOptions): Settings {
62 return {
63 projects: parseProjects(String(options.projects ?? '')),
64 profile: String(options.awsProfile ?? ''),
65 region: String(options.region ?? ''),
66 awsPath: String(options.awsPath ?? ''),
67 pollMs: Math.max(15, Number(options.pollSeconds ?? 60)) * 1000,
68 missingMs: Math.max(1, Number(options.missingBuildMinutes ?? 3)) * 60_000,
69 production: new RegExp(String(options.productionPattern ?? 'prod'), 'i'),
70 }
71}
72
73async function aws($: EngineInterface, settings: Settings, args: readonly string[]) {
74 const argv = [...args, ...awsFlags(settings.profile, settings.region)]
75 for (const bin of binaryCandidates('aws', await $.env.get('HOME'), settings.awsPath)) {
76 try {
77 return await $.process.run([bin, ...argv], { timeoutMs: 30_000, env: { AWS_PAGER: '' } })
78 } catch {
79 continue
80 }
81 }
82 throw new Error('aws CLI not found; set its path in /config')
83}
84
85function firstLine(text: string, fallback: string) {
86 return text.trim().split('\n')[0] || fallback
87}
88
89async function loadProject($: EngineInterface, settings: Settings, project: Project) {
90 try {
91 const listed = await aws($, settings, ['codebuild', 'list-builds-for-project', '--project-name', project.name, '--max-items', String(BUILDS_KEPT), '--query', 'ids', '--output', 'json'])
92 if (listed.exitCode !== 0) throw new Error(firstLine(listed.stderr, 'list-builds-for-project failed'))
93 const ids = (JSON.parse(listed.stdout || '[]') as string[]).slice(0, BUILDS_KEPT)
94 let rows: BuildRow[] = []
95 if (ids.length > 0) {
96 const got = await aws($, settings, ['codebuild', 'batch-get-builds', '--ids', ...ids, '--query', BUILD_QUERY, '--output', 'json'])
97 if (got.exitCode !== 0) throw new Error(firstLine(got.stderr, 'batch-get-builds failed'))
98 rows = toBuildRows(got.stdout)
99 }
100 await update($, builds, prev => ({ ...prev, [project.name]: rows }))
101 await update($, errors, prev => ({ ...prev, [project.name]: '' }))
102 } catch (err) {
103 await update($, errors, prev => ({ ...prev, [project.name]: err instanceof Error ? err.message : String(err) }))
104 }
105}
106
107async function settlePushes($: EngineInterface, settings: Settings) {
108 const all = await read($, builds)
109 const pending = await read($, pushes)
110 for (const project of settings.projects) {
111 const pushedAt = pending[project.name] ?? 0
112 if (pushedAt === 0) continue
113 const rows = all[project.name] ?? []
114 if (rows.some(b => b.startedAt >= pushedAt - 30_000)) {
115 await update($, pushes, prev => ({ ...prev, [project.name]: 0 }))
116 continue
117 }
118 const state = chipState(rows, pushedAt, await $.clock.now(), settings.missingMs)
119 const key = `${project.name}:${pushedAt}`
120 if (state.kind === 'missing' && !warned.has(key)) {
121 warned.add(key)
122 $.ui.toast(`deploy-watch: ${project.name} has no build yet, ${state.label}. Start it from the Deploys pane.`, { timeoutMs: 10_000 })
123 }
124 }
125}
126
127async function poll($: EngineInterface, settings: Settings) {
128 polledAt = await $.clock.now()
129 await Promise.all(settings.projects.map(project => loadProject($, settings, project)))
130 const now = await $.clock.now()
131 await update($, updatedAt, () => now)
132 await settlePushes($, settings)
133}
134
135async function isBusy($: EngineInterface, settings: Settings) {
136 const all = await read($, builds)
137 const pending = await read($, pushes)
138 return settings.projects.some(p => !isSettled(all[p.name] ?? [], pending[p.name] ?? 0))
139}
140
141async function tick($: EngineInterface, settings: Settings) {
142 if (settings.projects.length === 0 || !(await isActive($))) return
143 const every = (await isBusy($, settings)) ? BUSY_POLL_MS : settings.pollMs
144 if ((await $.clock.now()) - polledAt >= every) await poll($, settings)
145}
146
147async function openPane($: EngineInterface, settings: Settings) {
148 const opened = await $.ui.open({ id: PANE_ID, title: 'Deploys' })
149 if (opened.isPlaced) await update($, isOpen, () => true)
150 await poll($, settings)
151}
152
153async function confirmStart($: EngineInterface, settings: Settings, project: Project) {
154 const target = project.branch === '' ? 'its default source' : `branch ${project.branch}`
155 const warning = settings.production.test(project.name) ? ' This looks like PRODUCTION.' : ''
156 try {
157 const answer = await $.ui.ask(`Start a CodeBuild build of ${project.name} from ${target}?${warning}`, {
158 header: 'Start build',
159 options: ['Start build', 'Cancel'],
160 })
161 return answer === 'Start build'
162 } catch {
163 return false
164 }
165}
166
167async function startBuild($: EngineInterface, settings: Settings, project: Project) {
168 if (!(await confirmStart($, settings, project))) return
169 const args = ['codebuild', 'start-build', '--project-name', project.name, ...(project.branch ? ['--source-version', project.branch] : []), '--query', 'build.buildNumber', '--output', 'text']
170 try {
171 const ran = await aws($, settings, args)
172 if (ran.exitCode !== 0) throw new Error(firstLine(ran.stderr, 'start-build failed'))
173 $.ui.toast(`deploy-watch: started ${project.name} #${ran.stdout.trim()}`)
174 const now = await $.clock.now()
175 await update($, pushes, prev => ({ ...prev, [project.name]: now }))
176 } catch (err) {
177 await update($, errors, prev => ({ ...prev, [project.name]: err instanceof Error ? err.message : String(err) }))
178 }
179 await poll($, settings)
180}
181
182async function openLogs($: EngineInterface, settings: Settings, build: BuildRow) {
183 try {
184 await $.tool.call({ tool: 'mcp__terminal__run_in_terminal', command: logsCommand(build, settings.profile, settings.region), title: `build #${build.number}` })
185 } catch (err) {
186 $.ui.toast(`Could not open the terminal: ${err instanceof Error ? err.message : String(err)}`)
187 }
188}
189
190async function publishChip($: EngineInterface, settings: Settings) {
191 if (settings.projects.length === 0 || !(await read($, active))) return setChip($, null)
192 const all = await read($, builds)
193 const pending = await read($, pushes)
194 const now = await $.clock.now()
195 await setChip($, deployChip(settings.projects.map(p => ({ name: p.name, state: chipState(all[p.name] ?? [], pending[p.name] ?? 0, now, settings.missingMs) }))))
196}
197
198export const register: Register = (on, options) => {
199 const settings = readSettings(options)
200
201 on('state.set', async ($, e, next) => {
202 const ran = await next(e)
203 if (e.plugin === 'mod-manager' && e.key === 'press') {
204 const pressed = e.value as ChipPress | null
205 if (pressed?.plugin === MOD && (await isActive($))) await openPane($, settings)
206 }
207 return ran
208 })
209
210 on('session.start', async ($, e, next) => {
211 polledAt = 0
212 await $.command.register({ name: 'deploys', description: 'Open the CodeBuild deploys pane' })
213 $.clock.every(CHECK_MS, () => void tick($, settings).catch(() => undefined))
214 $.clock.every(CHECK_MS, () => void publishChip($, settings).catch(() => undefined))
215 return next(e)
216 })
217
218 on('command.run', { command: 'deploys' }, async $ => {
219 if (!(await isActive($))) return { text: OFF_TEXT }
220 await openPane($, settings)
221 return { text: settings.projects.length === 0 ? 'Deploys pane opened. Set the projects in /config.' : 'Deploys pane opened.' }
222 })
223
224 on('ui.close', async ($, e, next) => {
225 if (!(await isActive($))) return next(e)
226 if (e.id === PANE_ID) await update($, isOpen, () => false)
227 return next(e)
228 })
229
230 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
231 if (!(await isActive($))) return next(e)
232 const ran = await next(e)
233 const pushed = pushedProjects(e.command, settings.projects)
234 if (pushed.length > 0) {
235 const now = await $.clock.now()
236 await update($, pushes, prev => ({ ...prev, ...Object.fromEntries(pushed.map(name => [name, now])) }))
237 polledAt = 0
238 }
239 return ran
240 })
241
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 if (!(await read($, active)) || settings.projects.length === 0 || (await barOwnsChips($))) return next(e)
244 const rest = await next(e)
245 if (e.props.hasSurvey) return rest
246
247 const all = await read($, builds)
248 const pending = await read($, pushes)
249 const now = await $.clock.now()
250 const states = settings.projects.map(p => ({ name: p.name, state: chipState(all[p.name] ?? [], pending[p.name] ?? 0, now, settings.missingMs) }))
251 const ui = $.ui.resolve(e)
252 return (
253 <ui.Box flexDirection="column">
254 <DeployChip ui={ui} projects={states} onOpen={() => openPane($, settings)} />
255 {rest}
256 </ui.Box>
257 )
258 })
259
260 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
261 const ui = $.ui.resolve(e)
262 if (!(await read($, active))) {
263 return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
264 }
265 try {
266 return (
267 <DeploysPane
268 ui={ui}
269 projects={settings.projects}
270 builds={await read($, builds)}
271 pushes={await read($, pushes)}
272 errors={await read($, errors)}
273 updatedAt={await read($, updatedAt)}
274 now={await $.clock.now()}
275 missingMs={settings.missingMs}
276 onRefresh={() => poll($, settings)}
277 onStart={project => startBuild($, settings, project)}
278 onLogs={build => openLogs($, settings, build)}
279 />
280 )
281 } catch (err) {
282 const message = err instanceof Error ? err.message : String(err)
283 $.ui.log(`deploy-watch render failed: ${message}`)
284 return <PaneError ui={ui} message={message} onRefresh={() => poll($, settings)} />
285 }
286 })
287}
288src/builds.ts 111 lines1import type { BuildRow, ChipKind, Project } from '../types'
2
3// --query keeps only these fields, so build environment variables never reach the mod
4export const BUILD_QUERY =
5 'builds[].{id:id,number:buildNumber,status:buildStatus,phase:currentPhase,source:sourceVersion,resolved:resolvedSourceVersion,start:startTime,end:endTime,group:logs.groupName,stream:logs.streamName}'
6
7type BuildJson = {
8 id?: string
9 number?: number
10 status?: string
11 phase?: string
12 source?: string | null
13 resolved?: string | null
14 start?: string | number | null
15 end?: string | number | null
16 group?: string | null
17 stream?: string | null
18}
19
20export function parseProjects(option: string): Project[] {
21 return option
22 .split(',')
23 .map(entry => entry.trim())
24 .filter(Boolean)
25 .map(entry => {
26 const [name = '', branch = ''] = entry.split('=').map(part => part.trim())
27 return { name, branch }
28 })
29 .filter(project => project.name !== '')
30}
31
32export function parseTime(value: string | number | null | undefined): number {
33 if (typeof value === 'number') return value < 1e12 ? value * 1000 : value
34 if (!value) return 0
35 const parsed = Date.parse(value.replace(/(\.\d{3})\d+/, '$1'))
36 return Number.isNaN(parsed) ? 0 : parsed
37}
38
39export function toBuildRows(stdout: string): BuildRow[] {
40 let parsed: BuildJson[] = []
41 try {
42 parsed = JSON.parse(stdout) as BuildJson[]
43 } catch {
44 return []
45 }
46 return (Array.isArray(parsed) ? parsed : [])
47 .map(build => ({
48 id: build.id ?? '',
49 number: build.number ?? 0,
50 status: build.status ?? 'UNKNOWN',
51 phase: build.phase ?? '',
52 commit: (build.resolved || build.source || '').replace(/^refs\/heads\//, ''),
53 startedAt: parseTime(build.start),
54 endedAt: parseTime(build.end),
55 logGroup: build.group ?? '',
56 logStream: build.stream ?? '',
57 }))
58 .sort((a, b) => b.startedAt - a.startedAt)
59}
60
61export function pushedProjects(command: string, projects: Project[]): string[] {
62 const push = /\bgit\s+(?:-C\s+\S+\s+)?push\b([^;&|\n]*)/g
63 const targets = [...command.matchAll(push)].map(match => match[1] ?? '')
64 if (targets.length === 0) return []
65 return projects
66 .filter(p => p.branch !== '' && targets.some(args => new RegExp(`(^|[\\s:/])${escape(p.branch)}(\\s|$)`).test(args)))
67 .map(p => p.name)
68}
69
70function escape(text: string) {
71 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
72}
73
74export type ChipState = { kind: ChipKind; label: string }
75
76export function chipState(builds: BuildRow[], pushedAt: number, now: number, missingMs: number): ChipState {
77 const latest = builds[0]
78 if (pushedAt > 0 && !builds.some(b => b.startedAt >= pushedAt - 30_000)) {
79 const waited = now - pushedAt
80 return waited > missingMs ? { kind: 'missing', label: `no build ${formatAgo(waited)} after push` } : { kind: 'running', label: 'waiting for build' }
81 }
82 if (!latest) return { kind: 'idle', label: 'no builds' }
83 if (latest.status === 'IN_PROGRESS') return { kind: 'running', label: `#${latest.number} ${latest.phase.toLowerCase()} ${formatAgo(now - latest.startedAt)}` }
84 if (latest.status === 'SUCCEEDED') return { kind: 'ok', label: `#${latest.number} ${formatAgo(now - latest.endedAt)} ago` }
85 return { kind: 'failed', label: `#${latest.number} ${latest.status.toLowerCase().replace('_', ' ')}` }
86}
87
88export function isSettled(builds: BuildRow[], pushedAt: number) {
89 return builds[0]?.status !== 'IN_PROGRESS' && (pushedAt === 0 || builds.some(b => b.startedAt >= pushedAt - 30_000))
90}
91
92export function formatAgo(ms: number) {
93 const s = Math.max(0, Math.round(ms / 1000))
94 if (s < 60) return `${s}s`
95 if (s < 3600) return `${Math.floor(s / 60)}m`
96 if (s < 172_800) return `${Math.floor(s / 3600)}h`
97 return `${Math.floor(s / 86_400)}d`
98}
99
100export function quote(value: string) {
101 return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
102}
103
104export function awsFlags(profile: string, region: string): string[] {
105 return [...(profile ? ['--profile', profile] : []), ...(region ? ['--region', region] : [])]
106}
107
108export function logsCommand(build: BuildRow, profile: string, region: string) {
109 return ['aws', 'logs', 'tail', build.logGroup, '--log-stream-names', build.logStream, '--follow', ...awsFlags(profile, region)].map(quote).join(' ')
110}
111src/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 46 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { ChipKind } from '../types'
4import type { ChipState } from '../src/builds'
5import type { Chip, ChipPart, ChipTone } from '../src/shared/chip'
6import { DRACULA } from '../src/shared/theme'
7
8type Ui = Elements[RenderSurface]
9
10export const KIND_STYLE: Record<ChipKind, { icon: string; color: string }> = {
11 idle: { icon: '·', color: DRACULA.comment },
12 running: { icon: '⟳', color: DRACULA.yellow },
13 ok: { icon: '✓', color: DRACULA.green },
14 failed: { icon: '✗', color: DRACULA.red },
15 missing: { icon: '⚠', color: DRACULA.orange },
16}
17
18export function DeployChip({ ui, projects, onOpen }: { ui: Ui; projects: { name: string; state: ChipState }[]; onOpen: () => unknown }) {
19 const { Box, Text, Button } = ui
20
21 return (
22 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center" marginBottom={1}>
23 <Text color={DRACULA.purple}>🚀</Text>
24 <Button key="open-deploys" label={projects.length === 1 ? (projects[0]?.name ?? 'Deploys') : 'Deploys'} dimColor onPress={onOpen} />
25 {projects.map(({ name, state }) => {
26 const style = KIND_STYLE[state.kind]
27 return (
28 <Text key={`deploy-${name}`} color={style.color}>
29 {projects.length > 1 ? `${name} ` : ''}{style.icon} {state.label}
30 </Text>
31 )
32 })}
33 </Box>
34 )
35}
36
37const KIND_TONE: Record<ChipKind, ChipTone> = { idle: 'muted', running: 'warn', ok: 'ok', failed: 'bad', missing: 'warn' }
38
39export function deployChip(projects: { name: string; state: ChipState }[]): Chip {
40 const parts = projects.flatMap(({ name, state }): ChipPart[] => [
41 { text: KIND_STYLE[state.kind].icon, tone: KIND_TONE[state.kind] },
42 { text: projects.length > 1 ? `${name} ${state.label}` : state.label, tone: 'text', action: 'open' },
43 ])
44 return { icon: '🚀', label: 'deploy', tone: 'accent', parts }
45}
46ui/pane.tsx 96 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { BuildRow, Project } from '../types'
4import { chipState, formatAgo } from '../src/builds'
5import { DRACULA } from '../src/shared/theme'
6import { KIND_STYLE } from './chip'
7
8type Ui = Elements[RenderSurface]
9
10export type DeploysPaneProps = {
11 ui: Ui
12 projects: Project[]
13 builds: Record<string, BuildRow[]>
14 pushes: Record<string, number>
15 errors: Record<string, string>
16 updatedAt: number
17 now: number
18 missingMs: number
19 onRefresh: () => unknown
20 onStart: (project: Project) => unknown
21 onLogs: (build: BuildRow) => unknown
22}
23
24const STATUS_COLOR: Record<string, string> = {
25 SUCCEEDED: DRACULA.green,
26 IN_PROGRESS: DRACULA.yellow,
27 FAILED: DRACULA.red,
28 FAULT: DRACULA.red,
29 TIMED_OUT: DRACULA.red,
30 STOPPED: DRACULA.orange,
31}
32
33export function DeploysPane(props: DeploysPaneProps) {
34 const { ui, projects, updatedAt } = props
35 const { Box, Text, Button } = ui
36
37 return (
38 <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
39 <Box flexDirection="row" alignItems="center" gap={1}>
40 <Text color={DRACULA.purple} bold>🚀 Deploys</Text>
41 <Box flexGrow={1} />
42 <Text color={DRACULA.comment}>{updatedAt > 0 ? `updated ${new Date(updatedAt).toTimeString().slice(0, 8)}` : 'loading…'}</Text>
43 <Button key="refresh" label="Refresh" dimColor onPress={props.onRefresh} />
44 </Box>
45 {projects.length === 0 && (
46 <Text color={DRACULA.comment}>No CodeBuild project set. Add them in /config → deploy-watch → Projects (e.g. my-app-staging=staging).</Text>
47 )}
48 {projects.map(project => <ProjectView {...props} project={project} />)}
49 </Box>
50 )
51}
52
53function ProjectView(props: DeploysPaneProps & { project: Project }) {
54 const { ui, project, now } = props
55 const { Box, Text, Button } = ui
56 const builds = props.builds[project.name] ?? []
57 const state = chipState(builds, props.pushes[project.name] ?? 0, now, props.missingMs)
58 const style = KIND_STYLE[state.kind]
59 const error = props.errors[project.name] ?? ''
60
61 return (
62 <Box key={`project-${project.name}`} flexDirection="column" marginTop={1}>
63 <Box flexDirection="row" alignItems="center" gap={1} backgroundColor={DRACULA.currentLine} paddingX={1}>
64 <Text color={DRACULA.cyan} bold>{project.name}</Text>
65 {project.branch !== '' && <Text color={DRACULA.comment}>⑂ {project.branch}</Text>}
66 <Text color={style.color}>{style.icon} {state.label}</Text>
67 <Box flexGrow={1} />
68 <Button key={`start-${project.name}`} label="Start build" dimColor plain onPress={() => props.onStart(project)} />
69 </Box>
70 {error !== '' && <Text color={DRACULA.red}>{error}</Text>}
71 {builds.map(build => (
72 <Box key={`build-${build.id}`} flexDirection="row" paddingX={1} gap={1}>
73 <Box width={6}><Text color={DRACULA.comment}>#{build.number}</Text></Box>
74 <Box width={12}><Text color={STATUS_COLOR[build.status] ?? DRACULA.comment} wrap="truncate-end">{build.status.toLowerCase().replace('_', ' ')}</Text></Box>
75 <Box width={9}><Text color={DRACULA.purple} wrap="truncate-end">{build.commit.slice(0, 8)}</Text></Box>
76 <Box width={9}><Text color={DRACULA.comment}>{build.startedAt > 0 ? `${formatAgo(now - build.startedAt)} ago` : ''}</Text></Box>
77 <Box width={7}>
78 <Text color={DRACULA.comment}>{build.endedAt > 0 ? formatAgo(build.endedAt - build.startedAt) : build.phase.toLowerCase()}</Text>
79 </Box>
80 {build.logGroup !== '' && <Button key={`logs-${build.id}`} label="Logs" dimColor plain onPress={() => props.onLogs(build)} />}
81 </Box>
82 ))}
83 </Box>
84 )
85}
86
87export function PaneError({ ui, message, onRefresh }: { ui: Ui; message: string; onRefresh: () => unknown }) {
88 const { Box, Text, Button } = ui
89 return (
90 <Box flexDirection="column" padding={1}>
91 <Text color={DRACULA.red}>Deploys pane failed to draw: {message}</Text>
92 <Button key="refresh" label="Refresh" dimColor onPress={onRefresh} />
93 </Box>
94 )
95}
96src/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 34 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 BuildRow = {
7 id: string
8 number: number
9 status: string
10 phase: string
11 commit: string
12 startedAt: number
13 endedAt: number
14 logGroup: string
15 logStream: string
16}
17export type Project = { name: string; branch: string }
18export type ChipKind = 'idle' | 'running' | 'ok' | 'failed' | 'missing'
19
20declare module 'claude-code' {
21 interface PluginState {
22 'mod-manager': { bar: number; press: ChipPress | null }
23 'deploy-watch': {
24 chip: Chip | null
25 active: boolean
26 builds: Record<string, BuildRow[]>
27 pushes: Record<string, number>
28 errors: Record<string, string>
29 isOpen: boolean
30 updatedAt: number
31 }
32 }
33}
34