SLOPSHOPPER

Kube Pane

A kubectl context chip above the prompt and a Dracula-themed pods pane: switch context for Claude's session without touching your kubeconfig, filter pods, and…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kube-pane
│ ┃ kube-pane ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ This mod is off. Turn it on in /mods. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pods │ ⎿ kube-pane: This mod is off. Turn it on in /mods. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kube-pane
This mod is off. Turn it on in /mods.
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 275 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { ContainerChoice, PodRow } from '../types'
5import { pickNamespace, shellCommand, shortContext, toRows, withContext, type PodJson } from '../src/pods'
6import { binaryCandidates } from '../src/shared/bin'
7import { ContextChip, contextChip } from '../ui/chip'
8import { PaneError, PodsPane } from '../ui/pane'
9import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
10import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
11
12const MOD = 'kube-pane'
13const CHECK_MS = 5000
14const active = atom({ plugin: 'kube-pane', 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: 'kube-pane', 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 = 'kube-pane'
40
41const context = atom({ plugin: 'kube-pane', key: 'context' } as const, '')
42const contexts = atom({ plugin: 'kube-pane', key: 'contexts' } as const, [])
43const namespace = atom({ plugin: 'kube-pane', key: 'namespace' } as const, '')
44const namespaces = atom({ plugin: 'kube-pane', key: 'namespaces' } as const, [])
45const pods = atom({ plugin: 'kube-pane', key: 'pods' } as const, [])
46const isOpen = atom({ plugin: 'kube-pane', key: 'isOpen' } as const, false)
47const isLoading = atom({ plugin: 'kube-pane', key: 'isLoading' } as const, false)
48const lastError = atom({ plugin: 'kube-pane', key: 'error' } as const, '')
49const updatedAt = atom({ plugin: 'kube-pane', key: 'updatedAt' } as const, 0)
50const filter = atom({ plugin: 'kube-pane', key: 'filter' } as const, '')
51const page = atom({ plugin: 'kube-pane', key: 'page' } as const, 0)
52const containers = atom({ plugin: 'kube-pane', key: 'containers' } as const, {} as ContainerChoice)
53
54type Settings = { production: RegExp; defaultNamespace: string; kubectlPath: string; refreshMs: number; pageSize: number }
55
56function readSettings(options: PluginOptions): Settings {
57  return {
58    production: new RegExp(String(options.productionPattern ?? 'prod'), 'i'),
59    defaultNamespace: String(options.defaultNamespace ?? 'default') || 'default',
60    kubectlPath: String(options.kubectlPath ?? ''),
61    refreshMs: Math.max(5, Number(options.refreshSeconds ?? 15)) * 1000,
62    pageSize: Math.max(5, Number(options.pageSize ?? 25)),
63  }
64}
65
66async function kubectl($: EngineInterface, settings: Settings, args: readonly string[]) {
67  for (const bin of binaryCandidates('kubectl', await $.env.get('HOME'), settings.kubectlPath)) {
68    try {
69      return await $.process.run([bin, ...args], { timeoutMs: 15_000 })
70    } catch {
71      continue
72    }
73  }
74  throw new Error('kubectl not found; set its path in /config')
75}
76
77async function setContext($: EngineInterface, name: string) {
78  await update($, context, () => name)
79  await $.env.set('CLAUDE_KUBE_CONTEXT', name)
80}
81
82async function loadContexts($: EngineInterface, settings: Settings) {
83  const [list, current] = await Promise.all([
84    kubectl($, settings, ['config', 'get-contexts', '-o', 'name']),
85    kubectl($, settings, ['config', 'current-context']),
86  ])
87  await update($, contexts, () => [...new Set(list.stdout.split('\n').map(s => s.trim()).filter(Boolean))])
88  if ((await read($, context)) === '' && current.exitCode === 0) await setContext($, current.stdout.trim())
89}
90
91async function loadNamespaces($: EngineInterface, settings: Settings) {
92  const ran = await kubectl($, settings, ['--context', await read($, context), 'get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'])
93  const names = ran.exitCode === 0 ? ran.stdout.split(/\s+/).filter(Boolean).sort() : []
94  await update($, namespaces, () => names)
95  await update($, namespace, current => pickNamespace(names, current, settings.defaultNamespace))
96}
97
98async function loadPods($: EngineInterface, settings: Settings) {
99  if (await read($, isLoading)) return
100  await update($, isLoading, () => true)
101  try {
102    const ran = await kubectl($, settings, ['--context', await read($, context), '-n', await read($, namespace), 'get', 'pods', '-o', 'json'])
103    if (ran.exitCode !== 0) throw new Error(ran.stderr.trim().split('\n')[0] || 'kubectl get pods failed')
104    const parsed = JSON.parse(ran.stdout) as { items: PodJson[] }
105    await update($, pods, () => toRows(parsed.items, Date.now()))
106    await update($, lastError, () => '')
107    await update($, updatedAt, () => Date.now())
108  } catch (err) {
109    await update($, lastError, () => (err instanceof Error ? err.message : String(err)))
110  } finally {
111    await update($, isLoading, () => false)
112  }
113}
114
115async function switchContext($: EngineInterface, settings: Settings, name: string) {
116  await setContext($, name)
117  await update($, pods, () => [])
118  await update($, page, () => 0)
119  await loadNamespaces($, settings)
120  await loadPods($, settings)
121}
122
123async function switchNamespace($: EngineInterface, settings: Settings, name: string) {
124  await update($, namespace, () => name)
125  await update($, page, () => 0)
126  await loadPods($, settings)
127}
128
129async function setFilter($: EngineInterface, query: string) {
130  await update($, filter, () => query)
131  await update($, page, () => 0)
132}
133
134async function openPane($: EngineInterface, settings: Settings) {
135  const opened = await $.ui.open({ id: PANE_ID, title: 'Pods' })
136  if (opened.isPlaced) await update($, isOpen, () => true)
137  if ((await read($, contexts)).length === 0) await loadContexts($, settings)
138  if ((await read($, namespaces)).length === 0) await loadNamespaces($, settings)
139  await loadPods($, settings)
140}
141
142async function confirmShell($: EngineInterface, pod: string, container: string | undefined, ctx: string) {
143  try {
144    const answer = await $.ui.ask(
145      `Open a shell in ${pod}${container ? ` (${container})` : ''} on ${shortContext(ctx)}? Avoid heavy work inside production pods.`,
146      { header: 'Prod shell', options: ['Open shell', 'Cancel'] },
147    )
148    return answer === 'Open shell'
149  } catch {
150    return false
151  }
152}
153
154async function openShell($: EngineInterface, settings: Settings, pod: PodRow) {
155  const ctx = await read($, context)
156  const ns = await read($, namespace)
157  const container = pod.containers.length > 1 ? ((await read($, containers))[pod.name] ?? pod.containers[0]) : undefined
158
159  if (settings.production.test(ctx) && !(await confirmShell($, pod.name, container, ctx))) return
160
161  try {
162    await $.tool.call({ tool: 'mcp__terminal__run_in_terminal', command: shellCommand(ctx, ns, pod.name, container), title: pod.name.slice(0, 40) })
163  } catch (err) {
164    $.ui.toast(`Could not open the terminal: ${err instanceof Error ? err.message : String(err)}`)
165  }
166}
167
168async function publishChip($: EngineInterface, settings: Settings) {
169  const ctx = await read($, context)
170  await setChip($, (await read($, active)) ? contextChip(ctx, settings.production.test(ctx)) : null)
171}
172
173export const register: Register = (on, options) => {
174  const settings = readSettings(options)
175
176  on('state.set', async ($, e, next) => {
177    const ran = await next(e)
178    if (e.plugin === 'mod-manager' && e.key === 'press') {
179      const pressed = e.value as ChipPress | null
180      if (pressed?.plugin === MOD && (await isActive($))) await openPane($, settings)
181    }
182    return ran
183  })
184
185  on('session.start', async ($, e, next) => {
186    void isActive($)
187    $.clock.every(CHECK_MS, () => {
188      void Promise.all([isActive($), read($, context)])
189        .then(([enabled, ctx]) => (enabled && ctx === '' ? loadContexts($, settings) : undefined))
190        .catch(() => undefined)
191    })
192    await $.command.register({ name: 'pods', description: 'Open the pods pane for the session kubectl context' })
193    void isActive($).then(enabled => (enabled ? loadContexts($, settings) : undefined)).catch(() => undefined)
194    $.clock.every(settings.refreshMs, () => {
195      void Promise.all([isActive($), read($, isOpen)]).then(([enabled, open]) => (enabled && open ? loadPods($, settings) : undefined))
196    })
197
198    $.clock.every(CHECK_MS, () => void publishChip($, settings).catch(() => undefined))
199    return next(e)
200  })
201
202  on('command.run', { command: 'pods' }, async $ => {
203    if (!(await isActive($))) return { text: OFF_TEXT }
204    await openPane($, settings)
205    return { text: 'Pods pane opened.' }
206  })
207
208  on('ui.close', async ($, e, next) => {
209    if (!(await isActive($))) return next(e)
210    if (e.id === PANE_ID) await update($, isOpen, () => false)
211    return next(e)
212  })
213
214  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
215    if (!(await isActive($))) return next(e)
216    const command = withContext(e.command, await read($, context))
217    return next(command === e.command ? e : { ...e, command })
218  })
219
220  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221    if (!(await read($, active)) || (await barOwnsChips($))) return next(e)
222    const rest = await next(e)
223    const ctx = await read($, context)
224    if (e.props.hasSurvey) return rest
225
226    const ui = $.ui.resolve(e)
227    return (
228      <ui.Box flexDirection="column">
229        <ContextChip ui={ui} context={ctx} isProduction={settings.production.test(ctx)} onOpen={() => openPane($, settings)} />
230        {rest}
231      </ui.Box>
232    )
233  })
234
235  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
236    const ui = $.ui.resolve(e)
237    if (!(await read($, active))) {
238      return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
239    }
240    try {
241      const ctx = await read($, context)
242      return (
243        <PodsPane
244          ui={ui}
245          width={e.props.bodyColumns}
246          isProduction={settings.production.test(ctx)}
247          context={ctx}
248          contexts={await read($, contexts)}
249          namespace={await read($, namespace)}
250          namespaces={await read($, namespaces)}
251          pods={await read($, pods)}
252          isLoading={await read($, isLoading)}
253          error={await read($, lastError)}
254          updatedAt={await read($, updatedAt)}
255          filter={await read($, filter)}
256          page={await read($, page)}
257          pageSize={settings.pageSize}
258          containers={await read($, containers)}
259          onRefresh={() => loadPods($, settings)}
260          onContext={name => switchContext($, settings, name)}
261          onNamespace={name => switchNamespace($, settings, name)}
262          onFilter={query => setFilter($, query)}
263          onPage={index => update($, page, () => index)}
264          onContainer={(pod, name) => update($, containers, prev => ({ ...prev, [pod]: name }))}
265          onShell={pod => openShell($, settings, pod)}
266        />
267      )
268    } catch (err) {
269      const message = err instanceof Error ? err.message : String(err)
270      $.ui.log(`kube-pane render failed: ${message}`)
271      return <PaneError ui={ui} message={message} onRefresh={() => loadPods($, settings)} />
272    }
273  })
274}
275
src/pods.ts 107 lines
1import type { PodRow } from '../types'
2
3export type PodJson = {
4  metadata: { name: string; creationTimestamp: string; deletionTimestamp?: string }
5  spec?: { containers?: { name: string }[] }
6  status: {
7    phase?: string
8    reason?: string
9    containerStatuses?: {
10      ready: boolean
11      restartCount: number
12      state?: { waiting?: { reason?: string }; terminated?: { reason?: string } }
13    }[]
14  }
15}
16
17export function shortContext(name: string) {
18  return name.replace(/^arn:aws:eks:[^:]+:\d+:cluster\//, '')
19}
20
21const COMMAND_POSITION = /((?:^|[|;&(`]|\$\()\s*(?:\w+=\S*\s+)*(?:(?:sudo|watch|time|exec)\s+(?:-\S+\s+)*)?)kubectl\b/g
22
23function isQuoted(text: string, index: number) {
24  let single = false
25  let double = false
26  for (let i = 0; i < index; i++) {
27    const ch = text[i]
28    if (ch === '\\') i++
29    else if (ch === "'" && !double) single = !single
30    else if (ch === '"' && !single) double = !double
31  }
32  return single || double
33}
34
35// Only rewrites kubectl in command position, outside quotes and before a heredoc body
36export function withContext(command: string, context: string) {
37  if (context === '' || !command.includes('kubectl')) return command
38
39  const lines = command.split('\n')
40  const heredoc = lines.findIndex(line => line.includes('<<'))
41  const last = heredoc < 0 ? lines.length - 1 : heredoc
42
43  return lines
44    .map((line, n) => {
45      if (n > last) return line
46      return line.replace(COMMAND_POSITION, (match, prefix: string, offset: number) => {
47        const at = offset + prefix.length
48        const segment = line.slice(at).split(/[|;&]/)[0] ?? ''
49        if (isQuoted(line, at) || segment.includes('--context')) return match
50        return `${prefix}kubectl --context '${context}'`
51      })
52    })
53    .join('\n')
54}
55
56export function shellCommand(context: string, namespace: string, pod: string, container?: string) {
57  const target = container ? ` -c '${container}'` : ''
58  return `kubectl --context '${context}' -n '${namespace}' exec -it '${pod}'${target} -- sh -c 'command -v bash >/dev/null && exec bash || exec sh'`
59}
60
61export function isHealthy(status: string) {
62  return ['Running', 'Completed', 'Succeeded'].includes(status)
63}
64
65export function isSettling(status: string) {
66  return ['Pending', 'ContainerCreating', 'PodInitializing', 'Terminating'].includes(status)
67}
68
69export function toRows(items: readonly PodJson[], now: number): PodRow[] {
70  return items
71    .map(pod => {
72      const statuses = pod.status.containerStatuses ?? []
73      const waiting = statuses.map(s => s.state?.waiting?.reason ?? s.state?.terminated?.reason).find(Boolean)
74      const status = pod.metadata.deletionTimestamp ? 'Terminating' : (waiting ?? pod.status.reason ?? pod.status.phase ?? 'Unknown')
75      return {
76        name: pod.metadata.name,
77        ready: `${statuses.filter(s => s.ready).length}/${statuses.length}`,
78        status,
79        restarts: statuses.reduce((sum, s) => sum + s.restartCount, 0),
80        ageMs: now - Date.parse(pod.metadata.creationTimestamp),
81        containers: (pod.spec?.containers ?? []).map(c => c.name),
82      }
83    })
84    .sort((a, b) => Number(isHealthy(a.status)) - Number(isHealthy(b.status)) || a.name.localeCompare(b.name))
85}
86
87export function visiblePods(list: readonly PodRow[], query: string, page: number, pageSize: number) {
88  const needle = query.trim().toLowerCase()
89  const matching = needle === '' ? list : list.filter(p => p.name.toLowerCase().includes(needle) || p.status.toLowerCase().includes(needle))
90  const pages = Math.max(1, Math.ceil(matching.length / pageSize))
91  const current = Math.min(Math.max(0, page), pages - 1)
92  return { rows: matching.slice(current * pageSize, (current + 1) * pageSize), total: matching.length, pages, current }
93}
94
95export function formatAge(ms: number) {
96  const minutes = Math.floor(ms / 60_000)
97  if (minutes < 60) return `${minutes}m`
98  const hours = Math.floor(minutes / 60)
99  if (hours < 48) return `${hours}h`
100  return `${Math.floor(hours / 24)}d`
101}
102
103export function pickNamespace(available: readonly string[], current: string, preferred: string) {
104  if (current !== '' && available.includes(current)) return current
105  return [preferred, 'default'].find(n => available.includes(n)) ?? available[0] ?? preferred
106}
107
src/shared/bin.ts 9 lines
1const 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}
9
ui/chip.tsx 32 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import { shortContext } from '../src/pods'
4import type { Chip } from '../src/shared/chip'
5import { DRACULA } from '../src/shared/theme'
6
7type Ui = Elements[RenderSurface]
8
9export function ContextChip({ ui, context, isProduction, onOpen }: { ui: Ui; context: string; isProduction: boolean; onOpen: () => unknown }) {
10  const { Box, Text, Button } = ui
11
12  return (
13    <Box flexDirection="row" gap={1} alignItems="center" marginBottom={1}>
14      <Text color={isProduction ? DRACULA.red : DRACULA.cyan} bold>⎈</Text>
15      <Button key="open-pods" label={context === '' ? 'kubectl…' : shortContext(context)} dimColor onPress={onOpen} />
16      {isProduction && <Text color={DRACULA.red} bold>PROD</Text>}
17    </Box>
18  )
19}
20
21export function contextChip(context: string, isProduction: boolean): Chip {
22  return {
23    icon: '⎈',
24    label: 'kube',
25    tone: isProduction ? 'bad' : 'info',
26    parts: [
27      { text: context === '' ? 'kubectl…' : shortContext(context), tone: 'text', action: 'open' },
28      ...(isProduction ? [{ text: 'PROD', tone: 'bad' as const, bold: true }] : []),
29    ],
30  }
31}
32
ui/pane.tsx 177 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { ContainerChoice, PodRow } from '../types'
4import { formatAge, isHealthy, isSettling, shortContext, visiblePods } from '../src/pods'
5import { DRACULA } from '../src/shared/theme'
6
7type Ui = Elements[RenderSurface]
8type Option = { value: string; label?: string }
9
10export type PodsPaneProps = {
11  ui: Ui
12  width: number
13  isProduction: boolean
14  context: string
15  contexts: string[]
16  namespace: string
17  namespaces: string[]
18  pods: PodRow[]
19  isLoading: boolean
20  error: string
21  updatedAt: number
22  filter: string
23  page: number
24  pageSize: number
25  containers: ContainerChoice
26  onRefresh: () => unknown
27  onContext: (context: string) => unknown
28  onNamespace: (namespace: string) => unknown
29  onFilter: (query: string) => unknown
30  onPage: (page: number) => unknown
31  onContainer: (pod: string, container: string) => unknown
32  onShell: (pod: PodRow) => unknown
33}
34
35export function PodsPane(props: PodsPaneProps) {
36  const { ui, width, isProduction, pods, isLoading, error, updatedAt, filter } = props
37  const { Box, Text, Button } = ui
38  const unhealthy = pods.filter(p => !isHealthy(p.status) && !isSettling(p.status)).length
39  const view = visiblePods(pods, filter, props.page, props.pageSize)
40  const nameWidth = Math.max(10, Math.min(44, width - 48))
41
42  return (
43    <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
44      <Box flexDirection="row" alignItems="center" gap={1}>
45        <Text color={isProduction ? DRACULA.red : DRACULA.purple} bold>⎈ Pods</Text>
46        <Text color={DRACULA.comment}>{pods.length} pods</Text>
47        {unhealthy > 0 && <Text color={DRACULA.red}>✗ {unhealthy} unhealthy</Text>}
48        <Box flexGrow={1} />
49        <Text color={DRACULA.comment}>
50          {isLoading ? 'refreshing…' : updatedAt > 0 ? `updated ${new Date(updatedAt).toTimeString().slice(0, 8)}` : ''}
51        </Text>
52        <Button key="refresh" label="Refresh" dimColor onPress={props.onRefresh} />
53      </Box>
54
55      <Picker ui={ui} id="context" label="Context" value={props.context}
56        options={props.contexts.map(name => ({ value: name, label: shortContext(name) }))} onPick={props.onContext} />
57      <Picker ui={ui} id="namespace" label="Namespace" value={props.namespace}
58        options={props.namespaces.map(name => ({ value: name }))} onPick={props.onNamespace} />
59      <FilterField ui={ui} value={filter} onChange={props.onFilter} />
60
61      {isProduction && <Text color={DRACULA.red}>Production context: read-only here; writes still ask for confirmation.</Text>}
62      {error !== '' && <Text color={DRACULA.red}>{error}</Text>}
63
64      <Box flexDirection="row" marginTop={1} backgroundColor={DRACULA.currentLine} paddingX={1}>
65        <Box width={nameWidth}><Text color={DRACULA.comment} bold>NAME</Text></Box>
66        <Box width={7}><Text color={DRACULA.comment} bold>READY</Text></Box>
67        <Box width={18}><Text color={DRACULA.comment} bold>STATUS</Text></Box>
68        <Box width={5}><Text color={DRACULA.comment} bold>RST</Text></Box>
69        <Box width={6}><Text color={DRACULA.comment} bold>AGE</Text></Box>
70      </Box>
71      {view.total === 0 && !isLoading && error === '' && (
72        <Text color={DRACULA.comment} italic>{filter === '' ? `No pods in ${props.namespace}.` : `No pods match "${filter}".`}</Text>
73      )}
74      {view.rows.map(pod => (
75        <PodRowView ui={ui} pod={pod} nameWidth={nameWidth}
76          container={props.containers[pod.name] ?? pod.containers[0] ?? ''}
77          onContainer={name => props.onContainer(pod.name, name)} onShell={() => props.onShell(pod)} />
78      ))}
79
80      {view.pages > 1 && (
81        <Box flexDirection="row" gap={1} marginTop={1} alignItems="center">
82          {view.current > 0 && <Button key="page-prev" label="‹ Prev" dimColor onPress={() => props.onPage(view.current - 1)} />}
83          <Text color={DRACULA.comment}>page {view.current + 1}/{view.pages} · {view.total} pods</Text>
84          {view.current < view.pages - 1 && <Button key="page-next" label="Next ›" dimColor onPress={() => props.onPage(view.current + 1)} />}
85        </Box>
86      )}
87    </Box>
88  )
89}
90
91export function PaneError({ ui, message, onRefresh }: { ui: Ui; message: string; onRefresh: () => unknown }) {
92  const { Box, Text, Button } = ui
93  return (
94    <Box flexDirection="column" padding={1}>
95      <Text color={DRACULA.red}>Pods pane failed to draw: {message}</Text>
96      <Button key="refresh" label="Refresh" dimColor onPress={onRefresh} />
97    </Box>
98  )
99}
100
101type PodRowProps = {
102  ui: Ui
103  pod: PodRow
104  nameWidth: number
105  container: string
106  onContainer: (name: string) => unknown
107  onShell: () => unknown
108}
109
110function PodRowView({ ui, pod, nameWidth, container, onContainer, onShell }: PodRowProps) {
111  const { Box, Text, Button } = ui
112  const [ready, total] = pod.ready.split('/')
113  const statusColor = isHealthy(pod.status) ? DRACULA.green : isSettling(pod.status) ? DRACULA.yellow : DRACULA.red
114
115  return (
116    <Box key={`pod-${pod.name}`} flexDirection="column" paddingX={1}>
117      <Box flexDirection="row">
118        <Box width={nameWidth}><Text color={DRACULA.foreground} wrap="truncate-middle">{pod.name}</Text></Box>
119        <Box width={7}><Text color={ready === total ? DRACULA.green : DRACULA.orange}>{pod.ready}</Text></Box>
120        <Box width={18}><Text color={statusColor} wrap="truncate-end">{pod.status}</Text></Box>
121        <Box width={5}><Text color={pod.restarts > 0 ? DRACULA.orange : DRACULA.comment}>{pod.restarts}</Text></Box>
122        <Box width={6}><Text color={DRACULA.comment}>{formatAge(pod.ageMs)}</Text></Box>
123        <Button key={`shell-${pod.name}`} label="⌘ Shell" dimColor plain onPress={onShell} />
124      </Box>
125      {pod.containers.length > 1 && (
126        <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
127          <Text color={DRACULA.comment}>container:</Text>
128          {pod.containers.map(name =>
129            name === container
130              ? <Text color={DRACULA.pink} bold>{name}</Text>
131              : <Button key={`container-${pod.name}-${name}`} label={name} dimColor plain onPress={() => onContainer(name)} />,
132          )}
133        </Box>
134      )}
135    </Box>
136  )
137}
138
139type PickerProps = { ui: Ui; id: string; label: string; value: string; options: Option[]; onPick: (value: string) => unknown }
140
141function Picker({ ui, id, label, value, options, onPick }: PickerProps) {
142  const { Box, Text, Button } = ui
143  if (options.length === 0) return undefined
144
145  if ('Select' in ui) {
146    const { Select } = ui
147    return (
148      <Box marginTop={1}>
149        <Select key={id} label={label} value={value} options={options} onSelect={(picked: string) => void onPick(picked)} />
150      </Box>
151    )
152  }
153
154  return (
155    <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
156      <Text color={DRACULA.comment}>{label}:</Text>
157      {options.map(option =>
158        option.value === value
159          ? <Text color={DRACULA.pink} bold>{option.label ?? option.value}</Text>
160          : <Button key={`${id}-${option.value}`} label={option.label ?? option.value} dimColor plain onPress={() => onPick(option.value)} />,
161      )}
162    </Box>
163  )
164}
165
166function FilterField({ ui, value, onChange }: { ui: Ui; value: string; onChange: (query: string) => unknown }) {
167  if (!('Input' in ui)) return undefined
168  const { Box, Input } = ui
169
170  return (
171    <Box marginTop={1}>
172      <Input key="filter" label="Filter" placeholder="pod name or status" value={value}
173        onInput={(query: string) => void onChange(query)} onSubmit={(query: string) => void onChange(query)} />
174    </Box>
175  )
176}
177
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
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
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 30 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 PodRow = { name: string; ready: string; status: string; restarts: number; ageMs: number; containers: string[] }
7export type ContainerChoice = Record<string, string>
8
9declare module 'claude-code' {
10  interface PluginState {
11    'mod-manager': { bar: number; press: ChipPress | null }
12    'kube-pane': {
13      chip: Chip | null
14      active: boolean
15      context: string
16      contexts: string[]
17      namespace: string
18      namespaces: string[]
19      pods: PodRow[]
20      isOpen: boolean
21      isLoading: boolean
22      error: string
23      updatedAt: number
24      filter: string
25      page: number
26      containers: ContainerChoice
27    }
28  }
29}
30