SLOPSHOPPER

runwatch

Watch Terrakube jobs, Jenkins builds and PR checks in a pane and toast when they finish

newpaneguardcommandtoaststatus
★ 3v0.1.0no licenseupdated 2026-10-09puffin/dotfiles/config/claude-mods/runwatch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · runwatch
│ ┃ runwatch ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ runwatch 0 running · 0 done d: clear don │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing watched. /watch tk|jenkins|pr … ⎿ Read 6 lines │ ┃ checks every 20s · d clear done · c close ⏺ 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 │ │ › /watch │ ⎿ runwatch: Nothing watched yet. Usage: /watch [tk <org> <job> | j │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · runwatch
◆ runwatch 0 running · 0 done d: clear done c: close Nothing watched. /watch tk|jenkins|pr … checks every 20s · d clear done · c close
README

Dotfiles

A collection of neovim, tmux, and zsh configurations for macOS. Built for DevOps workflows with Terraform, Python, YAML, and JSON.

light theme

Contents

Setup and Installation

Clone the dotfiles repository to your home directory as ~/.dotfiles.

git clone https://github.com/puffin/dotfiles.git ~/.dotfiles
cd ~/.dotfiles

Backup

Run install/backup.sh to back up any existing symlinked files to ~/dotfiles-backup. The installation scripts will not overwrite existing files.

Installation

Install XCode CLI tools and Homebrew first:

xcode-select --install

Follow instructions at https://brew.sh/ to install Homebrew, then:

./install.sh

This will:

  • Symlink all *.symlink files to your home directory (e.g. zshrc.symlink becomes ~/.zshrc)
  • Symlink the config directory contents to ~/.config/
  • Install Homebrew packages from Brewfile
  • Run macOS-specific configurations via install/osx.sh, including remapping Caps Lock to Control on every keyboard (via hidutil, persisted with a LaunchAgent)

Uninstallation

./uninstall.sh

Reverses the above: removes the symlinks (only if they still point at this repo), reverts the shell change, uninstalls exactly the packages/casks/taps listed in Brewfile, clears zinit/fzf/tf-helper/nvim/tmux-plugin caches, and deletes the specific macOS defaults keys install/osx.sh set. It prompts for confirmation before doing anything, since most of it is destructive, and it deliberately leaves ~/.ssh, ~/.gnupg (besides the generated gpg-agent.conf), and any nvim/tmux session history alone, since those can hold data of your own. Note claude-code is itself a Brewfile cask, so it gets uninstalled too.

Terminal Capabilities

To support italic fonts in tmux:

tic -x resources/xterm-256color-italic.terminfo
tic -x resources/tmux.terminfo

ZSH Setup

ZSH is configured in zshrc.symlink. Key features:

  • EDITOR set to nvim
  • Zinit plugin manager for zsh plugins
  • Sources ~/.localrc for machine-specific config (API keys, etc.)
  • Custom prompt with git status on RPROMPT

Git Prompt Symbols

  • + New files added
  • ! Existing files modified
  • ? Untracked files
  • >> Files renamed
  • ✘ Tracked file deleted
  • $ Stashed files
  • = Unmerged files
  • ⇡ Branch ahead of remote
  • ⇣ Branch behind remote
  • ⇕ Branches diverged
  • ✔ Working directory clean

Neovim Setup

Neovim is configured entirely in Lua with the following structure:

~/.config/nvim/
├── init.lua                  -- Entry point
└── lua/user/
    ├── options.lua           -- Editor settings
    ├── keymaps.lua           -- Key mappings
    ├── autocmds.lua          -- Autocommands
    ├── plugins.lua           -- Plugin declarations (lazy.nvim)
    └── lsp.lua               -- LSP server configuration

Plugins are managed by lazy.nvim and installed automatically on first launch. Run :Lazy inside neovim to manage plugins.

LSP and Autocompletion

Language servers are managed by Mason and configured via Neovim's native vim.lsp.config (0.11+). Autocompletion is powered by blink.cmp.

LanguageServerFeatures
TerraformterraformlsCompletions, diagnostics, prefill required fields
Pythonpyright + ruffPyright for completions/types, Ruff for linting/formatting
YAMLyamllsSchema-aware completions (K8s, Docker Compose, GitHub Actions, etc.)
JSONjsonlsSchema-aware completions (package.json, tsconfig, etc.)

Schemas are provided by SchemaStore.nvim (300+ schemas).

Note: For Terraform, run terraform init in each project directory for provider-aware completions.

LSP Keymaps

KeyAction
gdGo to definition
gyGo to type definition
giGo to implementation
grFind references
KShow documentation
<leader>rnRename symbol
<leader>caCode action

Completion Keymaps

KeyAction
TabNext completion
S-TabPrevious completion
CRConfirm selection
C-SpaceTrigger / toggle docs
C-eDismiss completion
C-b / C-fScroll documentation

Diagnostics

Diagnostics show inline virtual text, gutter signs, and underlines. Holding the cursor on an error line auto-opens a floating window with the full message.

Plugins

UI: vim-one (colorscheme), lualine (statusline), nvim-web-devicons, vim-smoothie (smooth scrolling)

Editor: vim-surround, vim-repeat, vim-unimpaired, vim-sleuth, vim-abolish, Comment.nvim, splitjoin.vim, nvim-autopairs, editorconfig

Git: fugitive, gitsigns, diffview.nvim, vim-flog, vim-twiggy

Navigation: FZF (files, buffers, ripgrep), nvim-tree (file explorer)

Session: vim-obsession + vim-prosession (auto-save/restore sessions)

Syntax: Treesitter with parsers for Terraform, HCL, Python, TypeScript, JSON, YAML, Lua, and more

Tmux Configuration

Tmux is configured in ~/.tmux.conf with prefix set to control+a. Sessions are automatically saved every minute via tmux-continuum and restored on tmux start via tmux-resurrect.

Tmux Commands

KeyAction
prefix + IInstall plugins
prefix + UUpdate plugins
prefix + wWindow/pane selection
prefix + cNew window
prefix + ,Rename window
prefix + &Kill window
prefix + [1-9]Select window
prefix + -Split vertically
`prefix + \`Split horizontally
prefix + xKill pane
prefix + [h,j,k,l]Move to pane
prefix + zToggle pane fullscreen
prefix + shift + [h,j,k,l]Resize pane

Herdr Configuration

Herdr is an agent-aware terminal multiplexer - it covers the same sessions/windows/panes ground as tmux, plus status tracking for AI coding agents (Claude Code, Codex, etc.) running in its panes. It's configured in ~/.config/herdr/config.toml with the same control+a prefix as tmux, so the muscle memory carries over. Both tools are installed; use either as your daily driver, or reach for herdr specifically when running coding agents you want to keep tabs on. Sessions persist across restarts and reattaches natively, with no plugin manager needed.

Herdr Commands

KeyAction
prefix + wWorkspace/agent picker
prefix + cNew tab
prefix + shift + tRename tab
prefix + shift + xClose tab
prefix + [1-9]Select tab
alt + [1-9]Select tab (no prefix)
prefix + minusSplit stacked
`prefix + \`Split side-by-side
prefix + xClose pane
prefix + [h,j,k,l]Move to pane
prefix + zToggle pane fullscreen
prefix + shift + [h,j,k,l]Swap pane
prefix + rResize pane mode
prefix + [Copy mode (vim-style)
ctrl + shift + [left,right]Reorder current tab
prefix + qDetach

Herdr Plugins

Installed automatically by install.sh via herdr plugin install (source lives outside this repo under ~/.config/herdr/plugins/, gitignored - not vendored). The marketplace is a self-tagged, unreviewed GitHub index; these were picked and their READMEs checked by hand, not exhaustively vetted against the ~1000 plugins listed there.

PluginWhat it doesKey
herdr-auto-titleRenames tabs to match what's running in them(automatic)
vim-herdr-navigationctrl+h/j/k/l crosses seamlessly between herdr panes and Neovim splits (vim-tmux-navigator, ported to herdr)ctrl + [h,j,k,l]
herdr-reviewrDiff/review pane - comment on an agent's changes, send feedback back to itprefix + shift + c
herdr-sessionizerFuzzy-open projects/worktrees, bootstrap a workspace layout from TOMLprefix + shift + s

herdr-sessionizer needs bun to build (in the Brewfile via the oven-sh/bun tap).

Terminal Configuration

Terminal of choice is Alacritty. Configuration is in config/alacritty/alacritty.yml.

Fonts

SauceCodePro NF, installed via Homebrew.

Color Scheme

vim-one in light mode. Comments are displayed in light grey italic.

Toggle Light/Dark

Press Ctrl+x Ctrl+t to toggle between light and dark themes. This works in both neovim and the shell, and switches all three simultaneously:

  • Neovim colorscheme (vim-one light/dark)
  • Alacritty terminal colors
  • Tmux status bar

You can also run toggle-theme from the command line, optionally with light or dark as an argument.

Dark Theme

dark theme

Claude Code

Claude Code is integrated into Neovim via the claudecode.nvim plugin, providing an in-editor AI assistant panel.

Claude Code Keymaps

Leader key is Space.

KeyAction
<leader>acToggle Claude Code panel
<leader>asSend selection to Claude
<leader>aaAdd current file to Claude
<C-w>Navigate away from terminal (e.g. Claude panel)

Claude Code CLI Hooks

config/claude-hooks/ holds portable Claude Code hook scripts, symlinked into ~/.claude/hooks/ by install/link.sh. ~/.claude/settings.json itself is not tracked here (it's inherently per-machine — permissions, plugins, org-specific config), so after installing, register a hook manually in its hooks block, e.g.:

"hooks": {
    "PreCompact": [
        {
            "matcher": "auto",
            "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/precompact-nudge.sh" }]
        }
    ]
}
  • precompact-nudge.sh — fires only on automatic compaction and prints a visible reminder to /clear instead if you're switching to an unrelated task, rather than letting one session run indefinitely.

Claude Code Mods

config/claude-mods/ holds Claude Code mods (plugins of function hooks). zsh/config.zsh exports CLAUDE_CODE_PLUGIN_DIRS with every folder there that has a .claude-plugin/, so each claude launched from a shell loads them; no settings.json change is needed. Interactive sessions watch these folders, so editing a mod reloads it live. Check one with claude plugin validate config/claude-mods/<name>.

  • context-gauge — context fill as a bar at the end of the prompt hint line (ctx ▰▱▱▱▱▱▱▱▱▱ 6% · 62k), plus a band above the prompt from 50% of the window (red from 75%) suggesting /clear before switching tasks. Thresholds are WARN_PCT / HOT_PCT in hooks/register.tsx.
  • git — a status-line entry with the folder, git branch and the branch's PR checks and review (~/.dotfiles ⎇ my-branch · #31 ✓5 ✗1 ●2 approved). PR status comes from gh and is polled every minute.
  • preview — /preview [file.md] (default README.md) renders a markdown file in a side pane: markdown through glow with One Dark / One Light styles (styles/*.json) that follow bin/toggle-theme, and mermaid blocks drawn as text diagrams by termaid (both in the Brewfile). Falls back to Claude Code's own markdown renderer, or the mermaid source, when either tool is missing.
  • runwatch — watches Terrakube jobs, Jenkins builds and PR checks in a side pane and toasts when each finishes (Terrakube shows the plan summary, e.g. plan: +2 ~0 -1, and toasts when a job waits for approval). Runs started by terrakube.sh run … --confirm, jenkins.sh trigger, gh pr create or git push are picked up automatically; /watch tk|jenkins|pr … adds one by hand, and Claude can hand one off through the mod's watch tool, which wakes it with the result instead of it polling. Polls every 20s through the ge-cloudops skills' wrapper scripts, so credentials stay in them.
  • write-gate — holds every Jira/Confluence write (comment, transition, edit, create, link, worklog, page), Lucid comment/share/update, git push and gh pr create|merge until you pick Post it. The exact payload (and, for a push, the commits no remote has) shows in a pane; Reject, or anything typed under Other, blocks it and tells Claude why. Fails closed, and logs each decision to ~/.claude/write-gate.log.

Usage

Vim Quick Reference

Leader key is Space.

KeyAction
<leader>kToggle file explorer (see explorer keymaps)
<leader>stStart screen
<leader>bClose buffer (keep split)
<leader>tGit file finder
<leader>eAll files finder
<leader>rBuffer finder
<leader>sGit status files
:RgRipgrep search
<leader>gsGit status
<leader>gdGit 3-way diff
gdh / gdlTake left/right in diff
<leader>dvoOpen Diffview
<leader>dvcClose Diffview
<leader>dvhDiffview file history
]g / [gNext/previous git hunk
gsPreview git hunk
guReset git hunk
gc / gccComment toggle

File Explorer Keymaps

The file explorer (nvim-tree) uses coc-explorer-style keybindings. Confirmations (y/n) are single-keypress — no Enter needed.

KeyAction
yyCopy file/directory (toggle, visual mode supported)
ddCut file/directory (toggle, visual mode supported)
pPaste from clipboard
dfDelete file/directory (trash)
dFDelete permanently
ypCopy absolute path to system clipboard
ynCopy filename to system clipboard
ACreate new directory
aCreate new file
EOpen in vertical split
VVisual select (then yy/dd/df for multi-file operations)

Copied files are highlighted in green, cut files in red with strikethrough.

Zsh Shortcuts

KeyAction
Alt + Right/LeftMove one word forward/backward
Cmd + Right/LeftMove to end/beginning of line
Alt + DDelete word after cursor
Alt + BackspaceDelete word before cursor
Ctrl + UClear entire line
Ctrl + RCommand history
Ctrl + TFile history

Troubleshooting

If you encounter permission errors during installation:

sudo chown -R $(whoami):admin /usr/local/
sudo chmod -R 755 /usr/local

Questions

If you have questions or notice issues, please open an issue.

Source 3 files
hooks/register.tsx 419 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Watch, WatchStatus } from '../types'
5import {
6  detect,
7  elapsed,
8  isDone,
9  isPush,
10  lastLines,
11  newestVersion,
12  newWatch,
13  prSeed,
14  readChecks,
15  readJenkins,
16  readPrState,
17  readQueue,
18  readTerrakube,
19  terrakubeTail,
20} from './watch'
21import type { Reading, Seed } from './watch'
22
23const PANE = 'runwatch'
24const POLL_MS = 20_000
25// Polls in a row that may fail to reach a service before the watch gives up.
26const MAX_MISSES = 6
27// A PR that reports no checks for this long has none to wait for.
28const NO_CHECKS_MS = 10 * 60_000
29const MAX_WATCHES = 20
30const TOAST_MS = 10_000
31// The ge-cloudops skills' wrappers hold the credentials; the mod only runs them.
32const SKILLS = '.claude/plugins/cache/ge-cloudops'
33
34const ACCENT = 'cyan'
35const MUTED = 'gray'
36const BAR_BG = 'blackBright'
37const LOOK: Record<WatchStatus, { icon: string; color: string }> = {
38  queued: { icon: '…', color: MUTED },
39  running: { icon: '⟳', color: 'yellow' },
40  waiting: { icon: '⏸', color: 'magenta' },
41  passed: { icon: '✓', color: 'green' },
42  failed: { icon: '✗', color: 'red' },
43}
44
45const watches = atom({ plugin: 'runwatch', key: 'watches' } as const, [])
46
47let home: string | null = null
48async function homeDir($: EngineInterface) {
49  if (home === null) home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
50  return home
51}
52
53async function script($: EngineInterface, name: 'terrakube' | 'jenkins') {
54  const root = `${await homeDir($)}/${SKILLS}/${name}`
55  const version = newestVersion((await $.fs.list(root).catch(() => [])).map(entry => entry.name))
56  return version ? `${root}/${version}/scripts/${name}.sh` : null
57}
58
59async function run($: EngineInterface, argv: string[], cwd?: string) {
60  return $.process
61    .run(argv, { cwd, timeoutMs: 30_000 })
62    .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
63}
64
65function json(text: string): unknown {
66  try {
67    return JSON.parse(text)
68  } catch {
69    return undefined
70  }
71}
72
73const firstLine = (text: string) => text.trim().split('\n')[0]?.slice(0, 120) || 'no output'
74
75type Poll = { reading: Reading; tail?: string[] } | { error: string }
76
77// One look at the run. Output is fetched only when the status moves, since a
78// plan or console log can be large.
79async function poll($: EngineInterface, w: Watch): Promise<Poll> {
80  if (w.kind === 'terrakube') {
81    const tk = await script($, 'terrakube')
82    if (!tk) return { error: 'the terrakube skill is not installed' }
83    const job = await run($, [tk, 'job', w.org ?? '', w.job ?? ''])
84    if (job.exitCode !== 0) return { error: firstLine(job.stderr) }
85    const reading = readTerrakube(json(job.stdout))
86    const hasOutput = reading.status === 'waiting' || isDone(reading.status)
87    if (!hasOutput || reading.status === w.status) return { reading }
88    // A plugin older than step-log exits non-zero: finish without the log lines.
89    const log = await run($, [tk, 'step-log', w.org ?? '', w.job ?? ''])
90    return { reading, tail: log.exitCode === 0 ? terrakubeTail(log.stdout) : [] }
91  }
92
93  if (w.kind === 'jenkins') {
94    const jk = await script($, 'jenkins')
95    if (!jk) return { error: 'the jenkins skill is not installed' }
96    const path = w.path ?? ''
97    if (w.queueId && !w.build) {
98      const queue = await run($, [jk, 'queue'])
99      if (queue.exitCode !== 0) return { error: firstLine(queue.stderr) }
100      const queued = readQueue(json(queue.stdout), w.queueId)
101      if (queued) return { reading: queued }
102      // Left the queue: its build is the job's newest.
103    }
104    const info = await run($, [jk, 'build-info', path, w.build ?? 'lastBuild'])
105    if (info.exitCode !== 0) return { error: firstLine(info.stderr) }
106    const reading = readJenkins(json(info.stdout))
107    if (!isDone(reading.status)) return { reading }
108    const build = reading.build ?? w.build ?? 'lastBuild'
109    const log = await run($, ['sh', '-c', '"$0" log "$1" "$2" | tail -n 12', jk, path, build])
110    return { reading, tail: lastLines(log.stdout, 12) }
111  }
112
113  // A repo without PR checks would otherwise wait out NO_CHECKS_MS even after the merge.
114  const view = await run($, ['gh', 'pr', 'view', w.pr ?? '', '--json', 'state'], w.cwd)
115  const ended = readPrState(json(view.stdout))
116  if (ended) return { reading: ended }
117
118  const checks = await run($, ['gh', 'pr', 'checks', w.pr ?? '', '--json', 'name,bucket'], w.cwd)
119  // gh exits non-zero while checks are pending or failing, so read its output first.
120  const list = json(checks.stdout) ?? (/no checks reported/i.test(checks.stderr) ? [] : undefined)
121  if (list === undefined) return { error: firstLine(checks.stderr) }
122  const { failing, ...reading } = readChecks(list)
123  if (reading.detail === 'waiting for checks' && Date.now() - w.startedAt > NO_CHECKS_MS) {
124    return { reading: { status: 'passed', detail: 'no checks reported' } }
125  }
126  return { reading, tail: failing }
127}
128
129const defined = <T extends object>(value: T) =>
130  Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>
131
132async function check($: EngineInterface, id: string) {
133  const w = (await read($, watches)).find(one => one.id === id)
134  if (!w || isDone(w.status)) return
135
136  const result = await poll($, w).catch((err: unknown): Poll => ({ error: String(err) }))
137  const now = Date.now()
138  let fresh: Watch
139  if ('error' in result) {
140    const misses = w.misses + 1
141    fresh =
142      misses >= MAX_MISSES
143        ? { ...w, misses, status: 'failed', detail: `lost contact: ${result.error}`, checkedAt: now, endedAt: now }
144        : { ...w, misses, error: result.error, checkedAt: now }
145  } else {
146    const { reading, tail } = result
147    fresh = { ...w, ...defined(reading), tail: tail ?? w.tail, misses: 0, error: undefined, checkedAt: now }
148    if (isDone(fresh.status)) fresh.endedAt = now
149  }
150
151  // Decided inside the write, so two polls of one run never both announce it.
152  let isFinished = false
153  let isNowWaiting = false
154  await update($, watches, list =>
155    list.map(one => {
156      if (one.id !== id || isDone(one.status)) return one
157      isFinished = isDone(fresh.status)
158      isNowWaiting = one.status !== 'waiting' && fresh.status === 'waiting'
159      return fresh
160    }),
161  )
162
163  const summary = fresh.kind === 'terrakube' && /^(plan:|applied:|no changes)/.test(fresh.tail[0] ?? '') ? ` · ${fresh.tail[0]}` : ''
164  if (isNowWaiting) $.ui.toast(`⏸ ${fresh.label} is waiting for approval${summary}`, { timeoutMs: TOAST_MS })
165  if (isFinished) {
166    $.ui.toast(`${LOOK[fresh.status].icon} ${fresh.label}: ${fresh.detail}${summary}`, { timeoutMs: TOAST_MS })
167    if (fresh.wake) void $.prompt.submit({ text: wakeText(fresh) }).catch(() => undefined)
168  }
169}
170
171function wakeText(w: Watch) {
172  const link = w.kind === 'terrakube' && w.url ? `\nRun: ${w.url}` : ''
173  const tail = w.tail.length ? `\n\n\`\`\`\n${w.tail.join('\n')}\n\`\`\`` : ''
174  return `[runwatch] ${w.label} finished: ${w.status} (${w.detail}) after ${elapsed((w.endedAt ?? w.checkedAt) - w.startedAt)}.${link}${tail}`
175}
176
177// The status line stands in for the pane: none while the pane is on screen.
178async function refreshStatus($: EngineInterface) {
179  const active = (await read($, watches)).filter(w => !isDone(w.status)).length
180  const panes = await $.ui.panes().catch(() => [])
181  const isPaneVisible = panes.some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
182  $.ui.status(active && !isPaneVisible ? `⟳ ${active} running` : undefined)
183}
184
185let isTicking = false
186async function tick($: EngineInterface) {
187  if (isTicking) return
188  isTicking = true
189  try {
190    for (const w of await read($, watches)) if (!isDone(w.status)) await check($, w.id)
191    await refreshStatus($)
192  } finally {
193    isTicking = false
194  }
195}
196
197async function add($: EngineInterface, seed: Seed) {
198  const w = newWatch(seed, Date.now())
199  // The same run started again replaces its old watch.
200  await update($, watches, list => [...list.filter(one => one.id !== w.id), w].slice(-MAX_WATCHES))
201  const opened = $.ui.open({ id: PANE, title: 'runwatch' }).catch(() => undefined)
202  void Promise.all([opened, check($, w.id)]).then(() => refreshStatus($))
203  return w
204}
205
206async function prUrl($: EngineInterface, cwd: string, pr?: string) {
207  if (pr && /^https:\/\//.test(pr)) return pr
208  const view = await run($, ['gh', 'pr', 'view', ...(pr ? [pr] : []), '--json', 'url'], cwd)
209  const url = (json(view.stdout) as { url?: unknown } | undefined)?.url
210  return typeof url === 'string' ? url : null
211}
212
213type Request = { kind?: unknown; org?: unknown; job?: unknown; path?: unknown; build?: unknown; pr?: unknown }
214const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : typeof value === 'number' ? String(value) : undefined)
215
216// A watch asked for by name, from the model's tool or /watch.
217async function fromRequest($: EngineInterface, req: Request, wake: boolean): Promise<Seed | { error: string }> {
218  const [org, job, path, build, pr] = [str(req.org), str(req.job), str(req.path), str(req.build), str(req.pr)]
219  if (req.kind === 'terrakube') {
220    if (!org || !job) return { error: 'terrakube needs org and job' }
221    return { id: `tk:${job}`, kind: 'terrakube', label: `Terrakube job ${job.slice(0, 8)}`, org, job, wake }
222  }
223  if (req.kind === 'jenkins') {
224    if (!path) return { error: 'jenkins needs path (the job path, e.g. folder/job)' }
225    return { id: `jk:${path}:${build ?? 'last'}`, kind: 'jenkins', label: `Jenkins ${path}`, path, build, wake }
226  }
227  if (req.kind === 'pr') {
228    const cwd = await $.session.cwd()
229    const url = await prUrl($, cwd, pr)
230    if (!url) return { error: pr ? `no PR found for ${pr}` : 'no PR found for the current branch' }
231    return prSeed(url, cwd, wake)
232  }
233  return { error: 'kind must be terrakube, jenkins or pr' }
234}
235
236const USAGE = 'Usage: /watch [tk <org> <job> | jenkins <path> [build] | pr [number|url] | clear]'
237
238function parseArgs(args: string): Request | 'open' | 'clear' | null {
239  const [what, ...rest] = args.trim().split(/\s+/).filter(Boolean)
240  if (!what) return 'open'
241  if (what === 'clear') return 'clear'
242  if (what === 'tk' || what === 'terrakube') return { kind: 'terrakube', org: rest[0], job: rest[1] }
243  if (what === 'jenkins' || what === 'jk') return { kind: 'jenkins', path: rest[0], build: rest[1] }
244  if (what === 'pr') return { kind: 'pr', pr: rest[0] }
245  return null
246}
247
248const clearDone = ($: EngineInterface) => update($, watches, list => list.filter(w => !isDone(w.status)))
249
250export const register: Register = on => {
251  on('session.start', async ($, e, next) => {
252    await $.command.register({ name: 'watch', description: 'Watch a Terrakube job, Jenkins build or PR checks in a pane' })
253    await $.tool.register({
254      name: 'watch',
255      description:
256        'Hand a long-running Terrakube job, Jenkins build or GitHub PR checks to a background watcher instead of polling it yourself. ' +
257        'The user sees it live in a pane and gets a toast when it finishes, and you receive a prompt with the result then. ' +
258        "Never poll or sleep-wait on a run you've handed over. Runs started with terrakube.sh run --confirm, jenkins.sh trigger, " +
259        'gh pr create or git push are picked up automatically; use this for anything else.',
260      inputSchema: {
261        type: 'object',
262        properties: {
263          kind: { type: 'string', enum: ['terrakube', 'jenkins', 'pr'] },
264          org: { type: 'string', description: 'terrakube: organization id or name' },
265          job: { type: 'string', description: 'terrakube: job id' },
266          path: { type: 'string', description: 'jenkins: job path, e.g. folder/job' },
267          build: { type: 'string', description: 'jenkins: build number (default: newest)' },
268          pr: { type: 'string', description: 'pr: number or URL (default: the current branch)' },
269        },
270        required: ['kind'],
271      },
272      isDeferred: false,
273    })
274    // Watches live in session state, so a reload picks them up where they were.
275    $.clock.every(POLL_MS, () => void tick($))
276    void refreshStatus($)
277    return next(e)
278  })
279
280  // Runs started from Bash: watch them without being asked. No .catch: a
281  // failure here skips the hook, leaving the command's result untouched.
282  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
283    const ran = await next(e)
284    if (ran.deny !== undefined || ran.isError || e.run_in_background) return ran
285    try {
286      const cwd = await $.session.cwd()
287      let seed = detect(e.command, ran.result.stdout, cwd)
288      if (!seed && isPush(e.command)) {
289        const url = await prUrl($, cwd)
290        if (url) seed = prSeed(url, cwd, false)
291      }
292      if (!seed) return ran
293      const w = await add($, seed)
294      return {
295        ...ran,
296        context: [
297          ...(ran.context ?? []),
298          `runwatch is now watching ${w.label} in its pane, and the user gets a toast when it finishes. Don't poll it.`,
299        ],
300      }
301    } catch {
302      return ran
303    }
304  })
305
306  on('tool.call', { tool: 'mcp__runwatch__watch' }, async ($, e) => {
307    const seed = await fromRequest($, e, true)
308    if ('error' in seed) return { deny: `runwatch: ${seed.error}` }
309    const w = await add($, seed)
310    return { result: `Watching ${w.label}. You'll get a prompt with the result when it finishes; don't poll it.` }
311  }).catch(() => ({ deny: 'runwatch could not start the watch.' }))
312
313  on('command.run', { command: 'watch' }, async ($, e) => {
314    const req = parseArgs(e.args)
315    if (req === null) return { text: USAGE }
316    if (req === 'clear') {
317      await clearDone($)
318      return { text: 'Cleared finished watches.' }
319    }
320    if (req === 'open') {
321      await $.ui.open({ id: PANE, title: 'runwatch' })
322      await refreshStatus($)
323      const count = (await read($, watches)).length
324      return { text: count ? `${count} watch${count === 1 ? '' : 'es'}.` : `Nothing watched yet. ${USAGE}` }
325    }
326    const seed = await fromRequest($, req, false)
327    if ('error' in seed) return { text: `${seed.error}. ${USAGE}` }
328    const w = await add($, seed)
329    return { text: `Watching ${w.label}.` }
330  })
331
332  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
333    const { Box, Button, Link, Text } = $.ui.resolve(e)
334    const list = await read($, watches)
335    const now = Date.now()
336    const active = list.filter(w => !isDone(w.status)).length
337
338    const header = (
339      <Box key="header" backgroundColor={BAR_BG} paddingX={1} justifyContent="space-between" width={e.props.bodyColumns}>
340        <Box gap={1}>
341          <Text color={ACCENT} bold>
342            ◆ runwatch
343          </Text>
344          <Text color={MUTED}>
345            {active} running · {list.length - active} done
346          </Text>
347        </Box>
348        <Box gap={2}>
349          <Button key="clear" label="clear done" hotkey="d" plain onPress={() => clearDone($)} />
350          <Button key="close" label="close" hotkey="c" plain onPress={() => $.ui.close({ id: PANE }).then(() => refreshStatus($))} />
351        </Box>
352      </Box>
353    )
354
355    const rows = [...list].reverse().map(w => {
356      const look = LOOK[w.status]
357      const took = elapsed((w.endedAt ?? now) - w.startedAt)
358      // A run in progress shows its last line; a finished one its whole tail.
359      const tail = isDone(w.status) || w.status === 'waiting' ? w.tail.slice(0, 8) : w.tail.slice(-1)
360      return (
361        <Box key={w.id} flexDirection="column" marginBottom={1}>
362          <Box key={`head-${w.id}`} justifyContent="space-between">
363            <Box gap={1} flexShrink={1}>
364              <Text color={look.color} bold>
365                {look.icon}
366              </Text>
367              <Text bold wrap="truncate-end">
368                {w.label}
369              </Text>
370              <Text color={look.color} wrap="truncate-end">
371                {w.detail}
372              </Text>
373            </Box>
374            <Box gap={2} flexShrink={0}>
375              <Text color={MUTED}>{took}</Text>
376              <Button
377                key={`x-${w.id}`}
378                label="✕"
379                plain
380                onPress={() => update($, watches, all => all.filter(one => one.id !== w.id)).then(() => refreshStatus($))}
381              />
382            </Box>
383          </Box>
384          {/* Its own line: a terminal without hyperlinks draws the whole URL, which would push the status off the header. */}
385          {w.url && (
386            <Box paddingLeft={2}>
387              <Link key={`open-${w.id}`} href={w.url} label="open" />
388            </Box>
389          )}
390          {/* A watch that gave up says why in its detail. */}
391          {w.misses > 0 && !isDone(w.status) && (
392            <Text color="red" wrap="truncate-end">
393              {'  '}can't reach it ({w.misses}/{MAX_MISSES}){w.error ? `: ${w.error}` : ''}
394            </Text>
395          )}
396          {tail.map((line, i) => (
397            <Text key={`${w.id}-t${i}`} color={MUTED} wrap="truncate-end">
398              {'  '}
399              {line}
400            </Text>
401          ))}
402        </Box>
403      )
404    })
405
406    return (
407      <Box flexDirection="column">
408        {header}
409        <Box flexDirection="column" paddingX={1} marginTop={1}>
410          {list.length === 0 ? <Text dimColor>Nothing watched. /watch tk|jenkins|pr …</Text> : rows}
411        </Box>
412        <Box key="footer" paddingX={1}>
413          <Text color={MUTED}>checks every {POLL_MS / 1000}s · d clear done · c close</Text>
414        </Box>
415      </Box>
416    )
417  })
418}
419
hooks/watch.ts 185 lines
1import type { Watch, WatchStatus } from '../types'
2
3// Pure parsing and detection, kept apart from the engine so tests reach it.
4
5export type Seed = Pick<Watch, 'id' | 'kind' | 'label' | 'wake'> &
6  Partial<Pick<Watch, 'org' | 'job' | 'path' | 'build' | 'queueId' | 'pr' | 'cwd' | 'url'>>
7
8export type Reading = Pick<Watch, 'status' | 'detail'> & Partial<Pick<Watch, 'url' | 'build' | 'queueId'>>
9
10export const isDone = (status: WatchStatus) => status === 'passed' || status === 'failed'
11
12export function newWatch(seed: Seed, now: number): Watch {
13  return { ...seed, status: 'queued', detail: 'starting', tail: [], startedAt: now, checkedAt: now, misses: 0 }
14}
15
16const unquote = (s: string) => s.replace(/^['"]|['"]$/g, '')
17const PR_URL = /https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/
18
19export const prLabel = (url: string) => {
20  const m = /github\.com\/[^/]+\/([^/]+)\/pull\/(\d+)/.exec(url)
21  return m ? `PR ${m[1]}#${m[2]}` : 'PR checks'
22}
23
24export const isPush = (command: string) => /\bgit\b(\s+-[Cc]\s+\S+)*\s+push\b/.test(command)
25
26function jsonId(text: string) {
27  try {
28    const value = JSON.parse(text) as { id?: unknown }
29    return typeof value.id === 'string' ? value.id : null
30  } catch {
31    return /"id"\s*:\s*"([^"]+)"/.exec(text)?.[1] ?? null
32  }
33}
34
35// The run a Bash call just started, from its command and output. A git push
36// is not here: its PR is looked up with gh (isPush).
37export function detect(command: string, stdout: string, cwd: string): Seed | null {
38  const tk = /terrakube\.sh['"]?\s+run\s+(\S+)\s+\S+\s+\S+\s+\S+\s+--confirm\b/.exec(command)
39  if (tk) {
40    const job = jsonId(stdout)
41    if (!job) return null
42    const org = unquote(tk[1] ?? '')
43    return { id: `tk:${job}`, kind: 'terrakube', label: `Terrakube job ${job.slice(0, 8)}`, org, job, wake: false }
44  }
45
46  const jk = /jenkins\.sh['"]?\s+trigger\s+(\S+)/.exec(command)
47  if (jk) {
48    const queueId = /^location:.*\/queue\/item\/(\d+)/im.exec(stdout)?.[1]
49    if (!queueId) return null
50    const path = unquote(jk[1] ?? '')
51    return { id: `jk:${path}:q${queueId}`, kind: 'jenkins', label: `Jenkins ${path}`, path, queueId, wake: false }
52  }
53
54  if (/\bgh\s+pr\s+create\b/.test(command)) {
55    const url = PR_URL.exec(stdout)?.[0]
56    if (!url) return null
57    return prSeed(url, cwd, false)
58  }
59  return null
60}
61
62export const prSeed = (url: string, cwd: string, wake: boolean): Seed => ({
63  id: `pr:${url}`,
64  kind: 'pr',
65  label: prLabel(url),
66  pr: url,
67  url,
68  cwd,
69  wake,
70})
71
72const TERRAKUBE: Record<string, WatchStatus> = {
73  pending: 'queued',
74  queue: 'queued',
75  running: 'running',
76  approved: 'running',
77  waitingApproval: 'waiting',
78  completed: 'passed',
79  noChanges: 'passed',
80  failed: 'failed',
81  rejected: 'failed',
82  cancelled: 'failed',
83  unknown: 'failed',
84}
85
86// `terrakube.sh job <org> <id>`: { id, attributes: { status, ... }, ui_url? }. ui_url is the run's page in the web UI.
87export function readTerrakube(job: unknown): Reading {
88  const value = job as { attributes?: { status?: unknown }; ui_url?: unknown } | undefined
89  const status = String(value?.attributes?.status ?? '')
90  const url = typeof value?.ui_url === 'string' ? value.ui_url : undefined
91  return { status: Object.hasOwn(TERRAKUBE, status) ? TERRAKUBE[status]! : 'running', detail: status || 'unknown', url }
92}
93
94// `terrakube.sh step-log <org> <id>`: the plain-text log of each step. The plan
95// summary first, then which resources change, or the log's last lines when
96// there are none (a failed run ends with its error).
97export function terrakubeTail(log: string): string[] {
98  const text = log.trim()
99  if (!text) return []
100  const summary = planSummary(text)
101  const actions = lastLines(text, Number.MAX_SAFE_INTEGER).filter(line => /^\s*# \S.* (will be|must be) /.test(line))
102  const body = actions.length ? actions.slice(0, 10).map(line => line.trim()) : lastLines(text, 10)
103  return [...(summary ? [summary] : []), ...body]
104}
105
106export function planSummary(text: string) {
107  const plan = /Plan: (\d+) to add, (\d+) to change, (\d+) to destroy/.exec(text)
108  if (plan) return `plan: +${plan[1]} ~${plan[2]} -${plan[3]}`
109  const apply = /Apply complete! Resources: (\d+) added, (\d+) changed, (\d+) destroyed/.exec(text)
110  if (apply) return `applied: +${apply[1]} ~${apply[2]} -${apply[3]}`
111  if (/No changes\./.test(text)) return 'no changes'
112  return null
113}
114
115// `jenkins.sh queue`: whether the item is still waiting, and why.
116export function readQueue(queue: unknown, queueId: string): Reading | null {
117  const items = (queue as { items?: { id?: unknown; why?: unknown }[] })?.items ?? []
118  const item = items.find(one => String(one.id) === queueId)
119  if (!item) return null
120  return { status: 'queued', detail: typeof item.why === 'string' ? item.why : 'queued' }
121}
122
123// `jenkins.sh build-info <path> <build>`: { number, building, result, url }
124export function readJenkins(info: unknown): Reading {
125  const build = info as { number?: number; building?: boolean; result?: string | null; url?: string }
126  const number = build.number === undefined ? '' : `#${build.number} `
127  const reading = { url: build.url, build: build.number === undefined ? undefined : String(build.number) }
128  if (build.building || !build.result) return { ...reading, status: 'running', detail: `${number}building` }
129  return { ...reading, status: build.result === 'SUCCESS' ? 'passed' : 'failed', detail: `${number}${build.result}` }
130}
131
132// `gh pr checks <pr> --json name,bucket`: bucket is pass, fail, pending, skipping or cancel.
133export function readChecks(checks: unknown): Reading & { failing: string[] } {
134  const list = Array.isArray(checks) ? (checks as { name?: string; bucket?: string }[]) : []
135  if (list.length === 0) return { status: 'queued', detail: 'waiting for checks', failing: [] }
136  const count = (bucket: string) => list.filter(check => check.bucket === bucket).length
137  const failing = list.filter(check => check.bucket === 'fail' || check.bucket === 'cancel').map(check => `✗ ${check.name}`)
138  const pending = count('pending')
139  const done = list.length - pending
140  if (pending > 0) return { status: 'running', detail: `${done}/${list.length} checks done${failing.length ? `, ${failing.length} failing` : ''}`, failing }
141  if (failing.length) return { status: 'failed', detail: `${failing.length}/${list.length} checks failed`, failing }
142  return { status: 'passed', detail: `${count('pass')}/${list.length} checks passed`, failing }
143}
144
145// `gh pr view <pr> --json state`: a merged or closed PR ends the watch, whatever its checks say.
146export function readPrState(view: unknown): Reading | null {
147  const state = (view as { state?: unknown } | undefined)?.state
148  if (state === 'MERGED') return { status: 'passed', detail: 'merged' }
149  if (state === 'CLOSED') return { status: 'failed', detail: 'closed without merging' }
150  return null
151}
152
153export const lastLines = (text: string, n: number) =>
154  text
155    .replace(/\r/g, '')
156    .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
157    .split('\n')
158    .map(line => line.trimEnd())
159    .filter(Boolean)
160    .slice(-n)
161
162export function elapsed(ms: number) {
163  const s = Math.max(0, Math.round(ms / 1000))
164  if (s < 60) return `${s}s`
165  const m = Math.floor(s / 60)
166  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
167  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
168}
169
170// Plugin cache folders are versions ("0.2.1"): the newest wins.
171export function newestVersion(names: string[]) {
172  const parts = (v: string) => v.split('.').map(n => Number.parseInt(n, 10) || 0)
173  return [...names]
174    .filter(name => /^\d+(\.\d+)*$/.test(name))
175    .sort((a, b) => {
176      const [pa, pb] = [parts(a), parts(b)]
177      for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
178        const d = (pa[i] ?? 0) - (pb[i] ?? 0)
179        if (d) return d
180      }
181      return 0
182    })
183    .at(-1)
184}
185
types/index.d.ts 53 lines
1export type WatchKind = 'terrakube' | 'jenkins' | 'pr'
2export type WatchStatus = 'queued' | 'running' | 'waiting' | 'passed' | 'failed'
3
4export type Watch = {
5  // Stable per run: a run started again under the same id replaces the old watch.
6  id: string
7  kind: WatchKind
8  label: string
9  // terrakube: org and job ids
10  org?: string
11  job?: string
12  // jenkins: job path, build number (once known), queue item until it leaves the queue
13  path?: string
14  build?: string
15  queueId?: string
16  // pr: the PR's URL, checked from cwd
17  pr?: string
18  cwd?: string
19  status: WatchStatus
20  // The run's own word for where it is ("running", "#41 SUCCESS", "3/5 checks done").
21  detail: string
22  // Last lines of output, or failing check names, once there is something to show.
23  tail: string[]
24  url?: string
25  startedAt: number
26  checkedAt: number
27  endedAt?: number
28  // Queue a prompt for Claude when it finishes (watches Claude handed off).
29  wake: boolean
30  // Polls in a row that failed to reach the service, and why the last one did.
31  misses: number
32  error?: string
33}
34
35// The input of the mod's own tool, as the model calls it.
36export type WatchRequest = {
37  kind: 'terrakube' | 'jenkins' | 'pr'
38  org?: string
39  job?: string
40  path?: string
41  build?: string
42  pr?: string
43}
44
45declare module 'claude-code' {
46  interface McpToolInputs {
47    mcp__runwatch__watch: WatchRequest
48  }
49  interface PluginState {
50    runwatch: { watches: Watch[] }
51  }
52}
53