Dokploy-Deploy-Status des aktuellen Repos als Leiste über dem Eingabefeld

Four Claude Code mods (plugins that change the interface itself): a deploy status bar, a tasks pane, a RAM indicator and a Git worktree overview. The three that draw above the input field share one bar.
The UI texts of all mods are in German.
| Mod | What it shows | Works for you if |
|---|---|---|
worktrees | All Git worktrees with open work | you use Git (nothing else needed) |
dokploy-statusbar | Last Dokploy deploy per app | you deploy with Dokploy and have the private dokploy CLI |
todos | Open tasks of the current repo from a G-Brain | you have an MCP server claude.ai G-Brain with get_page |
ram | Used memory in the bar | you use macOS |
claude plugin marketplace add allorders1studio/claude-code-mods
claude plugin install worktrees@claude-code-mods
Replace worktrees with dokploy-statusbar, todos or ram as needed, then run /reload-plugins inside Claude Code.
The only mod that needs nothing but Git. /worktrees lists every worktree of the current repo and tells you which ones can be deleted.
╭──────────────────────────────────────────────────────────╮
│ feature-search feat/search │
│ 3 Dateien uncommittet · letzter Commit heute │
│ Pfad kopieren │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ fix-login fix/login │
│ 2 Commits nicht in main, nicht gepusht · letzter Commit gestern │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ old-experiment exp/cache │
│ nichts offen – kann weg · letzter Commit vor 12 Tagen │
╰──────────────────────────────────────────────────────────╯
Neu laden 3 Worktrees · 2 mit offener Arbeit · Stand 23:20:56
Status line while worktrees hold open work:
2 von 3 Worktrees mit offener Arbeit · /worktrees
origin/<default branch>)Command: /worktrees. Requirements: Git.
A bar above the input field with the last Dokploy deploy of every app belonging to the current repo.
● api-app ● web-app ● worker running 1:20 ● docs ausstehend 23:20
> _
running m:ss while a build is in progressgit push, polls every 5 s for 2 minutes instead of every 30 sCommand: /deploys (refresh now).
Requirements, honestly:
dokploy CLI from allorders1studio/dokploy-cli. That repository is currently private; it is not the npm package of the same name./opt/homebrew/bin/dokploy, /opt/homebrew/lib/node_modules/dokploy-cli and /opt/homebrew/bin/node are hard-coded in hooks/register.tsx and hooks/discover.cjs.hooks/discover.cjs asks Dokploy (through the CLI's API module) for all services and keeps those whose Git source matches git remote get-url origin. It also reads each service's watchPaths. No configuration per repo is needed. API keys stay inside the CLI; the mod never sees them.
/tickets opens a pane "Aufgaben" with the open tasks of the current repo, read from a G-Brain (GBrain) knowledge store through its MCP server. The bar above the input field shows Aufgaben N (parked tasks not counted): red if one is overdue, yellow if one is due today or has high priority. A click on it opens the pane.
╭──────────────────────────────────────────────────────────╮
│ Abo-Bestellungen per Tag kennzeichnen │
│ überfällig · gestern │
╰──────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────╮
│ HubSpot-Workflow für die Ankündigungsmail │
│ seit 2026-10-14 · Prio hoch │
╰──────────────────────────────────────────────────────────╯
Neu laden 2 offen · Stand 23:20:56
Order: tasks with a due date first (by date), then high priority, then the rest in page order, parked tasks last and dimmed. A click on a task writes it, with its detail lines, into the input field; "Details ›" opens the detail view.
How it works: the mod reads the page todo, takes the list slugs from the lines - [[todo/<name>]] — … under ## Projekte, reads each list and keeps those whose Repos: line names the repo of git remote get-url origin. A repo may be on several lists; each is shown as its own section. Loaded at session start, every 5 minutes, on /tickets and on "Neu laden".
# To-do — My project
Repos: `owner/repo`, `owner/other-repo`
## Offen
- [ ] Task title · seit 2026-10-14 · fällig 2026-10-16 · Prio hoch
- any number of detail lines
- [ ] Waiting task · wartet auf the supplier
- [ ] Parked task · geparkt
## Erledigt
- [x] …
Parts after · in the title line are read as seit, fällig (both YYYY-MM-DD), wartet auf …, Prio hoch and geparkt; anything else stays in the text. [[a/b]] is shown as a/b.
Requirements, honestly: an MCP server whose tools include get_page, put_page and remember (the mod finds it by these tools, since its name differs between terminal and desktop app); get_page takes { slug, content_only } and answers with JSON containing compiled_truth. In auto mode a mod's MCP call is refused unless get_page is allowed in permissions.allow, e.g. "mcp__<server>__get_page". Without such a store this mod is of no use to you. No credentials are configured here; the connection is the session's own.
Command: /tickets.
Used memory in the bar, polled every 10 seconds from memory_pressure -Q (macOS only): RAM 66% is 100 minus the "free percentage". Grey below 80 %, yellow from 80 %, red from 90 %. Where the command is missing or fails, the entry is left out without a message.
dokploy-statusbar, todos and ram draw into the same band above the input field, which only one hook can fill. Each mod reads the tree of the hooks inside it (next(e)), adds its entry under a sorted key and rebuilds the bar, so the layout does not depend on plugin order. Apps on the left, the rest on the right, separated by · , the Dokploy fetch time last:
● api-app ● web-app ● worker running 1:20 Aufgaben 3 · RAM 66% · 23:20
> _
Without Dokploy only Aufgaben and RAM appear. hooks/bar.tsx is the same file in all three mods.
.claude-plugin/marketplace.json marketplace "claude-code-mods"
<plugin>/
.claude-plugin/plugin.json manifest (name, version)
hooks/register.tsx the mod: panes, bands, commands, atoms
hooks/*.ts pure logic (parsing, scanning) plus tests
types/index.d.ts state types per plugin
Mods use the claude-code mod API. Tests of the pure logic sit next to the code (*.test.ts); UI behavior can be tested with claude-code/testing.
MIT, see LICENSE.
hooks/register.tsx 155 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Deploy } from '../types'
5import { addToBar } from './bar'
6import { deployedCommit, elapsed, pathspecs, linkOf, parseLatest, parseWhen, tailLog, targetsFor } from './parse'
7
8const DOKPLOY = '/opt/homebrew/bin/dokploy'
9const NODE = '/opt/homebrew/bin/node'
10const deploys = atom({ plugin: 'dokploy-statusbar', key: 'deploys' } as const, [])
11const checkedAt = atom({ plugin: 'dokploy-statusbar', key: 'checkedAt' } as const, '')
12const services = atom({ plugin: 'dokploy-statusbar', key: 'services' } as const, [])
13// Sekundentakt nur für die laufende Zeit; ohne Atom würde die Leiste zwischen zwei Polls stehen.
14const now = atom({ plugin: 'dokploy-statusbar', key: 'now' } as const, 0)
15
16const COLOR: Record<string, string> = { done: 'green', error: 'red', running: 'yellow', idle: 'gray' }
17const SLOW = 30000
18const FAST = 5000
19const FAST_FOR = 120000
20const DISCOVER_EVERY = 600000
21
22// Modulzustand statt Atom: nur der Takt braucht ihn, gezeichnet wird er nicht.
23let polling = false
24let lastPoll = 0
25let lastDiscover = 0
26let fastUntil = 0
27
28// Dienste samt Repo aus allen Dokploy-Panels; discover.cjs gibt nur öffentliche Felder aus, nie Keys.
29async function discover($: EngineInterface) {
30 lastDiscover = Date.now()
31 try {
32 const r = await $.process.run([NODE, `${$.plugin.root}/hooks/discover.cjs`], { timeoutMs: 30000 })
33 if (r.exitCode === 0) await update($, services, () => JSON.parse(r.stdout))
34 } catch (err) {
35 void $.ui.toast(`Dokploy-Zuordnung fehlgeschlagen: ${err}`)
36 }
37}
38
39// Liest das Repo bei jedem Lauf neu, damit ein Projektwechsel in der Session sofort greift.
40async function poll($: EngineInterface) {
41 if (polling) return
42 polling = true
43 lastPoll = Date.now()
44 try {
45 const cwd = await $.session.cwd()
46 const remote = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd })
47 const targets = targetsFor(await read($, services), remote.exitCode === 0 ? remote.stdout : '')
48 const before = await read($, deploys)
49 const next = await Promise.all(
50 targets.map(async t => {
51 try {
52 const r = await $.process.run([DOKPLOY, '-p', t.panel, 'logs', t.app, '--deployments'], { timeoutMs: 20000 })
53 const d = parseLatest(t, r.exitCode === 0 ? r.stdout : r.stderr || r.stdout)
54 // Ohne `git fetch`: ein Push aktualisiert origin/<branch> lokal. Git-Fehler oder kein Treffer → keine Markierung.
55 // Ausstehend heißt: seit dem deployten Commit kam einer, der die watchPaths des Dienstes berührt.
56 let pending = false
57 if (d.status === 'done') {
58 const ref = `origin/${t.branch}`
59 const g = await $.process.run(['git', 'log', '-200', '--format=%H%x09%s', ref], { cwd })
60 const hash = g.exitCode === 0 ? deployedCommit(g.stdout, d.title) : ''
61 if (hash) {
62 const c = await $.process.run(['git', 'rev-list', '--count', `${hash}..${ref}`, ...pathspecs(t.watchPaths)], { cwd })
63 pending = c.exitCode === 0 && Number(c.stdout) > 0
64 }
65 }
66 return { ...d, pending }
67 } catch (err) {
68 return { ...t, status: '?', when: '', title: '', error: String(err) } as Deploy
69 }
70 }),
71 )
72 for (const d of next) {
73 const old = before.find(b => b.panel === d.panel && b.app === d.app)
74 if (old && old.status === 'running' && d.status !== 'running') void $.ui.toast(`${d.app}: Deploy ${d.status}`)
75 }
76 await update($, deploys, () => next)
77 await update($, checkedAt, () => new Date().toLocaleTimeString('de-DE'))
78 } finally {
79 polling = false
80 }
81}
82
83async function showLog($: EngineInterface, d: Deploy) {
84 try {
85 const r = await $.process.run([DOKPLOY, '-p', d.panel, 'logs', d.app, '-b', '1'], { timeoutMs: 30000 })
86 if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout).trim().split('\n')[0] || `Exit ${r.exitCode}`)
87 const text = `Build-Log von ${d.app} (${d.panel}), letzter Deploy fehlgeschlagen:\n\`\`\`\n${tailLog(r.stdout)}\n\`\`\`\n`
88 await $.prompt.fill({ text })
89 } catch (err) {
90 void $.ui.toast(`Build-Log von ${d.app} nicht abrufbar: ${err}`)
91 }
92}
93
94const runTime = (d: Deploy, tick: number) =>
95 d.status === 'running' && !Number.isNaN(parseWhen(d.when)) ? elapsed(tick - parseWhen(d.when)) : (d.when.split(', ')[1]?.slice(0, 5) ?? '')
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await $.command.register({ name: 'deploys', description: 'Deploy-Status des Projekts sofort neu abfragen' })
100 void discover($).then(() => poll($))
101 // Ein Sekundentakt für alles: Poll (30 s, nach Push 5 s), Discovery (10 min), laufende Zeit.
102 // shortcut: in Projekten ohne Zuordnung kostet ein Poll nur `git remote`, bei Bedarf Takt je Aufgabe trennen.
103 $.clock.every(1000, async () => {
104 const t = Date.now()
105 if (t - lastDiscover >= DISCOVER_EVERY) void discover($)
106 if (t - lastPoll >= (t < fastUntil ? FAST : SLOW)) void poll($)
107 if ((await read($, deploys)).some(d => d.status === 'running')) await update($, now, () => t)
108 })
109 return next(e)
110 })
111
112 // Nach erfolgreichem `git push` zwei Minuten schneller pollen; der Aufruf bleibt unverändert.
113 // `tool.call` statt PostToolUse, weil `next(e)` das Ergebnis liefert (isError) und der Aufruf unberührt bleibt.
114 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115 const r = await next(e)
116 if (e.tool === 'Bash' && /\bgit\s+push\b/.test(e.command) && !r.deny && !r.isError) {
117 fastUntil = Date.now() + FAST_FOR
118 lastPoll = 0
119 }
120 return r
121 })
122
123 on('command.run', { command: 'deploys' }, async $ => {
124 await poll($)
125 const n = (await read($, deploys)).length
126 return { text: n ? 'Deploy-Status aktualisiert.' : 'Für dieses Projekt gibt es keine Dokploy-Dienste.' }
127 })
128
129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130 const inner = await next(e)
131 const list = await read($, deploys)
132 if (e.props.hasSurvey || list.length === 0) return inner
133 const ui = $.ui.resolve(e)
134 const { Box, Button, Link, Text } = ui
135 const at = await read($, checkedAt)
136 const tick = await read($, now)
137 const apps = (
138 <Box gap={1}>
139 {list.map(d => (
140 <Box key={`app:${d.panel}/${d.app}`}>
141 <Text color={d.pending ? 'gray' : (COLOR[d.status] ?? 'magenta')}>● </Text>
142 <Link href={linkOf(d)}>{d.app}</Link>
143 {d.status === 'error' && <Button key={`log:${d.panel}/${d.app}`} plain label=" Log" onPress={() => showLog($, d)} />}
144 {/* „done“ sagt schon der grüne Punkt; Status und Zeit nur, wenn etwas anderes los ist */}
145 <Text dimColor>{d.error ? ' Fehler beim Abruf' : d.pending ? ' ausstehend' : d.status !== 'done' ? ` ${d.status} ${runTime(d, tick)}` : ''}</Text>
146 </Box>
147 ))}
148 </Box>
149 )
150 // Abrufzeit ganz rechts (Schlüssel „9“) und nur als Randnotiz; eine Schriftgröße kennt die Oberfläche nicht, daher nur blasser
151 const withApps = addToBar(ui, inner, 'left', '1-dokploy', apps)
152 return addToBar(ui, withApps, 'right', '9-zeit', <Text color="subtle">{at.slice(0, 5)}</Text>)
153 })
154}
155hooks/bar.tsx 35 lines1import type { EngineInterface, RenderElement } from 'claude-code'
2
3type Tree = { props?: { key?: string }; children?: Tree[] }
4type UI = ReturnType<EngineInterface['ui']['resolve']>
5
6// Gemeinsame Leiste über dem Eingabefeld, byte-gleich in dokploy-statusbar, todos und ram (Plugins sind getrennt).
7// `AbovePrompt` ist eine Instanz, die Hooks nesten (erster = äußerster) und deren `next(e)` den Baum der inneren liefert.
8// Jeder Mod liest daher den Baum der inneren, hängt seinen Eintrag unter einem Schlüssel ein und baut die Leiste neu;
9// links/rechts und Reihenfolge ergeben sich aus dem Schlüssel (Sortierung), nicht aus der Plugin-Reihenfolge.
10// shortcut: erkennt nur Bäume mit `key="bar"`; fremde Inhalte stehen darüber, bei Bedarf gezielter einordnen.
11export function addToBar(ui: UI, inner: RenderElement, side: 'left' | 'right', key: string, node: RenderElement): RenderElement {
12 const { Box, Text } = ui
13 const tree = (inner ?? {}) as Tree // falls `next(e)` doch nichts liefert
14 const isBar = tree.props?.key === 'bar'
15 const part = (k: string) => (isBar ? (tree.children?.find(c => c.props?.key === k)?.children ?? []) : [])
16 const entry = (<Box key={`bar:${key}`}>{node}</Box>) as Tree
17 const place = (k: string, items: Tree[]) =>
18 [...items.filter(c => c.props?.key !== `bar:${key}` && c.props?.key !== 'bar:sep'), ...(side === k ? [entry] : [])].sort((a, b) =>
19 String(a.props?.key).localeCompare(String(b.props?.key)),
20 ) as RenderElement[]
21 const left = place('left', part('bar:left'))
22 const right = place('right', part('bar:right'))
23 const bar = (
24 <Box key="bar" justifyContent="space-between">
25 <Box key="bar:left" gap={1}>
26 {left}
27 </Box>
28 <Box key="bar:right">
29 {right.flatMap((c, i) => (i ? [<Box key="bar:sep"><Text color="subtle"> · </Text></Box>, c] : [c]))}
30 </Box>
31 </Box>
32 )
33 return !isBar && (tree.children?.length ?? 0) > 0 ? <Box flexDirection="column">{inner}{bar}</Box> : bar
34}
35hooks/parse.ts 60 lines1import type { Deploy, Target } from '../types'
2
3// Erste Zeile von `dokploy logs <app> --deployments`:
4// 1. [done] 7.10.2026, 17:53:48 — feat(...): ...
5export const parseLatest = (t: Target, out: string): Deploy => {
6 const m = out.match(/^\s*1\.\s+\[(\w+)\]\s+(\S+,\s*\S+)\s+—\s+(.*)$/m)
7 return m
8 ? { ...t, status: m[1], when: m[2], title: m[3].trim() }
9 : { ...t, status: '?', when: '', title: '', error: out.trim().split('\n')[0] || 'keine Ausgabe' }
10}
11
12// Wohin ein Klick führt: bei Fehler direkt zu den Deployments mit dem Build-Log, sonst auf die Dienst-Seite.
13export const linkOf = (d: Deploy) => (d.status === 'error' || d.error ? `${d.url}?tab=deployments` : d.url)
14
15export const repoOf = (remoteUrl: string) =>
16 remoteUrl.trim().toLowerCase().replace(/\.git$/, '').match(/github\.com[:/]([^/]+\/[^/]+)$/)?.[1] ?? ''
17
18// Dienste (Ausgabe von discover.cjs) zum Remote. Am Remote statt am Ordnernamen, weil OMS / OMS-2.0 sich sonst verwechseln.
19// Nur der Repo-Name zählt, nicht der Owner: Dokploy behält nach einer Umbenennung des GitHub-Accounts den alten Owner.
20const nameOf = (repo: string) => repo.toLowerCase().split('/')[1] ?? ''
21export const targetsFor = (services: (Target & { repo: string })[], remoteUrl: string): Target[] => {
22 const name = nameOf(repoOf(remoteUrl))
23 return name ? services.filter(s => nameOf(s.repo) === name).sort((a, b) => a.app.localeCompare(b.app)) : []
24}
25
26// Tolerant, weil Dokploy den Titel evtl. kürzt: getrimmt, gleich oder Präfix in eine Richtung.
27const sameTitle = (a: string, b: string) => {
28 const x = a.trim()
29 const y = b.trim()
30 return x !== '' && y !== '' && (x.startsWith(y) || y.startsWith(x))
31}
32
33// Hash des deployten Commits: erster Eintrag aus `git log --format=%H%x09%s` mit passendem Betreff; '' wenn keiner passt.
34export const deployedCommit = (logOut: string, title: string) => {
35 for (const line of logOut.split('\n')) {
36 const [hash, ...rest] = line.split('\t')
37 if (hash && sameTitle(rest.join('\t'), title)) return hash
38 }
39 return ''
40}
41
42// watchPaths → Git-Pathspecs; leer = jeder Commit deployt, also kein `--`.
43export const pathspecs = (watchPaths: string[]) => (watchPaths.length ? ['--', ...watchPaths.map(w => `:(glob)${w}`)] : [])
44
45// `7.10.2026, 17:53:48` (deutsch, lokale Zeit) → ms; NaN, wenn das Format nicht passt.
46export const parseWhen = (when: string) => {
47 const m = when.match(/^(\d+)\.(\d+)\.(\d+),\s*(\d+):(\d+):(\d+)$/)
48 return m ? new Date(+m[3], +m[2] - 1, +m[1], +m[4], +m[5], +m[6]).getTime() : NaN
49}
50
51// m:ss; negative oder ungültige Spannen als 0:00 (Uhrenabweichung zum Server).
52export const elapsed = (ms: number) => {
53 const s = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
54 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
55}
56
57// Letzte `n` Zeilen ohne ANSI-Farbcodes.
58export const tailLog = (out: string, n = 80) =>
59 out.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trimEnd().split('\n').slice(-n).join('\n')
60types/index.d.ts 9 lines1export type Target = { panel: string; app: string; kind: 'application' | 'compose'; branch: string; watchPaths: string[]; url: string }
2export type Deploy = Target & { status: string; when: string; title: string; error?: string; pending?: boolean }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'dokploy-statusbar': { deploys: Deploy[]; checkedAt: string; services: (Target & { repo: string })[]; now: number }
7 }
8}
9