SLOPSHOPPER

dokploy-statusbar

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

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dokploy-statusbar
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ dokploy-statusbar │ ⏺ Read(src/auth.ts) │ Dokploy-Zuordnung fehlgeschlagen: │ ⎿ Read 6 lines │ SyntaxError: JSON Parse error: Unexpected │ ⏺ Update(src/auth.ts) │ EOF │ ⎿ 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 › /deploys ⎿ dokploy-statusbar: Für dieses Projekt gibt es keine Dokploy-Dienste. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

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.

ModWhat it showsWorks for you if
worktreesAll Git worktrees with open workyou use Git (nothing else needed)
dokploy-statusbarLast Dokploy deploy per appyou deploy with Dokploy and have the private dokploy CLI
todosOpen tasks of the current repo from a G-Brainyou have an MCP server claude.ai G-Brain with get_page
ramUsed memory in the baryou use macOS

Installation

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.

worktrees

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
  • Uncommitted files per worktree
  • Commits not in main (compared against origin/<default branch>)
  • Pushed or not pushed
  • "kann weg" (can go) for worktrees with nothing left to lose; "Stand unklar" when the comparison with main fails
  • Card colors: red uncommitted, yellow not pushed, cyan pushed, green can go

Command: /worktrees. Requirements: Git.

dokploy-statusbar

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
 > _
  • Dot: green (done), red (error), yellow (running), grey (pending), magenta (unknown)
  • App name links to its Dokploy page; on error it links to the deployments tab
  • Button "Log" on error: writes the last 80 build-log lines into the input field
  • "ausstehend" (pending): a commit since the deployed one touched the service's watch paths
  • running m:ss while a build is in progress
  • After git push, polls every 5 s for 2 minutes instead of every 30 s
  • Toast when a deploy finishes; the last fetch time is shown faintly at the far right of the shared bar (see below)

Command: /deploys (refresh now).

Requirements, honestly:

  • The dokploy CLI from allorders1studio/dokploy-cli. That repository is currently private; it is not the npm package of the same name.
  • macOS with Homebrew paths: /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.

How it works

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.

todos

/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.

ram

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.

The shared bar

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.

Structure

.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.

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 155 lines
1import { 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}
155
hooks/bar.tsx 35 lines
1import 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}
35
hooks/parse.ts 60 lines
1import 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')
60
types/index.d.ts 9 lines
1export 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