Hold Jira, Confluence and Lucid writes, git push and gh pr create/merge until the user approves the exact payload

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

Clone the dotfiles repository to your home directory as ~/.dotfiles.
git clone https://github.com/puffin/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
Run install/backup.sh to back up any existing symlinked files to ~/dotfiles-backup. The installation scripts will not overwrite existing files.
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 files to your home directory (e.g. zshrc.symlink becomes ~/.zshrc)config directory contents to ~/.config/Brewfileinstall/osx.sh, including remapping Caps Lock to Control on every keyboard (via hidutil, persisted with a LaunchAgent)./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.
To support italic fonts in tmux:
tic -x resources/xterm-256color-italic.terminfo
tic -x resources/tmux.terminfo
ZSH is configured in zshrc.symlink. Key features:
EDITOR set to nvim~/.localrc for machine-specific config (API keys, etc.)RPROMPT+ 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 cleanNeovim 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.
Language servers are managed by Mason and configured via Neovim's native vim.lsp.config (0.11+). Autocompletion is powered by blink.cmp.
| Language | Server | Features |
|---|---|---|
| Terraform | terraformls | Completions, diagnostics, prefill required fields |
| Python | pyright + ruff | Pyright for completions/types, Ruff for linting/formatting |
| YAML | yamlls | Schema-aware completions (K8s, Docker Compose, GitHub Actions, etc.) |
| JSON | jsonls | Schema-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.
| Key | Action |
|---|---|
gd | Go to definition |
gy | Go to type definition |
gi | Go to implementation |
gr | Find references |
K | Show documentation |
<leader>rn | Rename symbol |
<leader>ca | Code action |
| Key | Action |
|---|---|
Tab | Next completion |
S-Tab | Previous completion |
CR | Confirm selection |
C-Space | Trigger / toggle docs |
C-e | Dismiss completion |
C-b / C-f | Scroll documentation |
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.
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 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.
| Key | Action | |
|---|---|---|
prefix + I | Install plugins | |
prefix + U | Update plugins | |
prefix + w | Window/pane selection | |
prefix + c | New window | |
prefix + , | Rename window | |
prefix + & | Kill window | |
prefix + [1-9] | Select window | |
prefix + - | Split vertically | |
| `prefix + \ | ` | Split horizontally |
prefix + x | Kill pane | |
prefix + [h,j,k,l] | Move to pane | |
prefix + z | Toggle pane fullscreen | |
prefix + shift + [h,j,k,l] | Resize pane |
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.
| Key | Action | |
|---|---|---|
prefix + w | Workspace/agent picker | |
prefix + c | New tab | |
prefix + shift + t | Rename tab | |
prefix + shift + x | Close tab | |
prefix + [1-9] | Select tab | |
alt + [1-9] | Select tab (no prefix) | |
prefix + minus | Split stacked | |
| `prefix + \ | ` | Split side-by-side |
prefix + x | Close pane | |
prefix + [h,j,k,l] | Move to pane | |
prefix + z | Toggle pane fullscreen | |
prefix + shift + [h,j,k,l] | Swap pane | |
prefix + r | Resize pane mode | |
prefix + [ | Copy mode (vim-style) | |
ctrl + shift + [left,right] | Reorder current tab | |
prefix + q | Detach |
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.
| Plugin | What it does | Key |
|---|---|---|
| herdr-auto-title | Renames tabs to match what's running in them | (automatic) |
| vim-herdr-navigation | ctrl+h/j/k/l crosses seamlessly between herdr panes and Neovim splits (vim-tmux-navigator, ported to herdr) | ctrl + [h,j,k,l] |
| herdr-reviewr | Diff/review pane - comment on an agent's changes, send feedback back to it | prefix + shift + c |
| herdr-sessionizer | Fuzzy-open projects/worktrees, bootstrap a workspace layout from TOML | prefix + shift + s |
herdr-sessionizer needs bun to build (in the Brewfile via the oven-sh/bun tap).
Terminal of choice is Alacritty. Configuration is in config/alacritty/alacritty.yml.
SauceCodePro NF, installed via Homebrew.
vim-one in light mode. Comments are displayed in light grey italic.
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:
You can also run toggle-theme from the command line, optionally with light or dark as an argument.

Claude Code is integrated into Neovim via the claudecode.nvim plugin, providing an in-editor AI assistant panel.
Leader key is Space.
| Key | Action |
|---|---|
<leader>ac | Toggle Claude Code panel |
<leader>as | Send selection to Claude |
<leader>aa | Add current file to Claude |
<C-w> | Navigate away from terminal (e.g. Claude panel) |
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.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.Leader key is Space.
| Key | Action |
|---|---|
<leader>k | Toggle file explorer (see explorer keymaps) |
<leader>st | Start screen |
<leader>b | Close buffer (keep split) |
<leader>t | Git file finder |
<leader>e | All files finder |
<leader>r | Buffer finder |
<leader>s | Git status files |
:Rg | Ripgrep search |
<leader>gs | Git status |
<leader>gd | Git 3-way diff |
gdh / gdl | Take left/right in diff |
<leader>dvo | Open Diffview |
<leader>dvc | Close Diffview |
<leader>dvh | Diffview file history |
]g / [g | Next/previous git hunk |
gs | Preview git hunk |
gu | Reset git hunk |
gc / gcc | Comment toggle |
The file explorer (nvim-tree) uses coc-explorer-style keybindings. Confirmations (y/n) are single-keypress — no Enter needed.
| Key | Action |
|---|---|
yy | Copy file/directory (toggle, visual mode supported) |
dd | Cut file/directory (toggle, visual mode supported) |
p | Paste from clipboard |
df | Delete file/directory (trash) |
dF | Delete permanently |
yp | Copy absolute path to system clipboard |
yn | Copy filename to system clipboard |
A | Create new directory |
a | Create new file |
E | Open in vertical split |
V | Visual select (then yy/dd/df for multi-file operations) |
Copied files are highlighted in green, cut files in red with strikethrough.
| Key | Action |
|---|---|
Alt + Right/Left | Move one word forward/backward |
Cmd + Right/Left | Move to end/beginning of line |
Alt + D | Delete word after cursor |
Alt + Backspace | Delete word before cursor |
Ctrl + U | Clear entire line |
Ctrl + R | Command history |
Ctrl + T | File history |
If you encounter permission errors during installation:
sudo chown -R $(whoami):admin /usr/local/
sudo chmod -R 755 /usr/local
If you have questions or notice issues, please open an issue.
hooks/register.tsx 111 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending } from '../types'
5import { APPROVE, classify, decide, REJECT } from './gate'
6
7const PANE = 'write-gate'
8const LOG = '.claude/write-gate.log'
9
10const ACCENT = 'yellow'
11const MUTED = 'gray'
12const BAR_BG = 'blackBright'
13
14const pending = atom({ plugin: 'write-gate', key: 'pending' } as const, null)
15
16async function git($: EngineInterface, args: string[]) {
17 const { exitCode, stdout } = await $.process
18 .run(['git', ...args], { cwd: await $.session.cwd(), timeoutMs: 5000 })
19 .catch(() => ({ exitCode: 1, stdout: '' }))
20 return exitCode === 0 ? stdout.trim() : null
21}
22
23// For git push and gh pr: the branch, and the commits no remote has yet.
24async function withGitContext($: EngineInterface, gate: Pending): Promise<Pending> {
25 if (gate.tool !== 'Bash') return gate
26 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? ''
27 const commits =
28 (await git($, ['log', '--oneline', '@{u}..HEAD'])) ??
29 (await git($, ['log', '--oneline', '-n', '20', 'HEAD', '--not', '--remotes'])) ??
30 ''
31 return { ...gate, target: branch, meta: commits ? `Commits not on the remote:\n${commits}` : '' }
32}
33
34async function audit($: EngineInterface, gate: Pending, outcome: string) {
35 const home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
36 const line = JSON.stringify({ at: new Date().toISOString(), outcome, ...gate }) + '\n'
37 await $.process.run(['sh', '-c', 'cat >> "$0"', `${home}/${LOG}`], { stdin: line }).catch(() => undefined)
38}
39
40async function ask($: EngineInterface, gate: Pending, isShown: boolean) {
41 // The pane did not fit: put the payload in the transcript (never sent to the model).
42 if (!isShown) $.ui.log([`write-gate · ${gate.action} ${gate.target}`, gate.body, gate.meta].filter(Boolean).join('\n\n'))
43 const where = gate.target ? ` to ${gate.target}` : ''
44 // Reject first, so a default or idle pick never sends anything.
45 const answer = await $.ui.ask(`Send this ${gate.action}${where}? (payload in the write-gate pane)`, {
46 header: 'write-gate',
47 options: [REJECT, APPROVE],
48 })
49 return decide(answer)
50}
51
52export const register: Register = on => {
53 on('tool.call', async ($, e, next) => {
54 const found = classify(e)
55 if (!found) return next(e)
56
57 const gate = await withGitContext($, found)
58 await update($, pending, () => gate)
59 const { isPlaced } = await $.ui
60 .open({ id: PANE, title: `write-gate · ${gate.action}` })
61 .catch(() => ({ isPlaced: false }))
62
63 const decision = await ask($, gate, isPlaced).catch(() => ({ isApproved: false as const, reason: 'no one answered the approval dialog' }))
64 await update($, pending, () => null)
65 void $.ui.close({ id: PANE }).catch(() => undefined)
66 await audit($, gate, decision.isApproved ? 'approved' : `rejected: ${decision.reason}`)
67
68 if (decision.isApproved) return next(e)
69 return { deny: `write-gate held this ${gate.action} for approval and ${decision.reason}. Nothing was sent.` }
70 }).catch(($, e, next) =>
71 // Fail closed: a gate that broke before passing the call on blocks it.
72 next.called ? next(e) : { deny: 'write-gate could not ask for approval, so the write was blocked.' },
73 )
74
75 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
76 const { Box, Code, Markdown, Text } = $.ui.resolve(e)
77 const gate = await read($, pending)
78 if (gate === null) return <Text dimColor>Nothing is waiting for approval.</Text>
79
80 const isCommand = gate.tool === 'Bash'
81 return (
82 <Box flexDirection="column">
83 <Box backgroundColor={BAR_BG} paddingX={1} gap={1} width={e.props.bodyColumns}>
84 <Text color={ACCENT} bold>
85 ⏸ {gate.action}
86 </Text>
87 {gate.target && <Text color={MUTED}>→ {gate.target}</Text>}
88 </Box>
89 <Box flexDirection="column" paddingX={1} marginTop={1}>
90 {gate.body === '' ? (
91 <Text dimColor>(no body)</Text>
92 ) : isCommand ? (
93 <Code source={gate.body} language="bash" />
94 ) : (
95 <Markdown text={gate.body.slice(0, 9000)} />
96 )}
97 {gate.meta && (
98 <Box flexDirection="column" marginTop={1}>
99 <Text color={MUTED}>{isCommand ? 'git' : 'other arguments'}</Text>
100 <Code source={gate.meta} language={isCommand ? 'text' : 'json'} />
101 </Box>
102 )}
103 </Box>
104 <Box paddingX={1} marginTop={1}>
105 <Text color={MUTED}>Answer in the dialog: {APPROVE} or {REJECT}</Text>
106 </Box>
107 </Box>
108 )
109 })
110}
111hooks/gate.ts 84 lines1import type { Pending } from '../types'
2
3// One-way writes to ticketing and diagram systems, by tool name. The server
4// part of the name varies (atlassian, claude_ai_Atlassian_Rovo, ...), so it
5// is matched loosely and the tool part exactly.
6const ATLASSIAN = new Map(Object.entries({
7 addCommentToJiraIssue: 'Jira comment',
8 addWorklogToJiraIssue: 'Jira worklog',
9 createJiraIssue: 'new Jira issue',
10 editJiraIssue: 'Jira edit',
11 transitionJiraIssue: 'Jira transition',
12 createIssueLink: 'Jira issue link',
13 createConfluencePage: 'new Confluence page',
14 updateConfluencePage: 'Confluence page update',
15 createConfluenceFooterComment: 'Confluence comment',
16 createConfluenceInlineComment: 'Confluence inline comment',
17}))
18
19const LUCID = new Map(Object.entries({
20 post_document_thread_comment: 'Lucid comment',
21 share_document_with_collaborators: 'Lucid share',
22 lucid_create_document_share_link: 'Lucid share link',
23 lucid_update_document: 'Lucid document update',
24}))
25
26const PUSH = /\bgit\b(\s+-[Cc]\s+\S+)*\s+push\b/
27const PR = /\bgh\s+pr\s+(create|merge)\b/
28
29// Keys the engine adds to a tool call's input, never the tool's own.
30const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent', 'cloudId'])
31const TARGET_KEYS = ['issueIdOrKey', 'issueKey', 'pageId', 'parentId', 'spaceId', 'documentId', 'projectKey']
32const BODY_KEYS = ['commentBody', 'body', 'content', 'description', 'text', 'comment', 'message']
33
34function mcpAction(tool: string) {
35 const match = /^mcp__(.+)__([^_].*)$/.exec(tool)
36 if (!match) return null
37 const [, server = '', name = ''] = match
38 if (/atlassian|rovo/i.test(server)) return ATLASSIAN.get(name) ?? null
39 if (/lucid/i.test(server)) return LUCID.get(name) ?? null
40 return null
41}
42
43const show = (value: unknown) => (typeof value === 'string' ? value : JSON.stringify(value, null, 2))
44
45// The write this call would make, or null when the call is not gated.
46export function classify(e: { tool: string; [key: string]: unknown }): Pending | null {
47 if (e.tool === 'Bash') {
48 const command = typeof e.command === 'string' ? e.command : ''
49 const pr = PR.exec(command)
50 const action = pr ? `gh pr ${pr[1]}` : PUSH.test(command) ? 'git push' : null
51 if (!action) return null
52 return { tool: e.tool, action, target: '', body: command, meta: '' }
53 }
54
55 const action = mcpAction(e.tool)
56 if (!action) return null
57
58 const args = Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED.has(key)))
59 const targetKey = TARGET_KEYS.find(key => args[key] !== undefined)
60 const bodyKey = BODY_KEYS.find(key => args[key] !== undefined)
61 const rest = Object.fromEntries(Object.entries(args).filter(([key]) => key !== bodyKey))
62
63 return {
64 tool: e.tool,
65 action,
66 target: targetKey ? String(args[targetKey]) : '',
67 body: bodyKey ? show(args[bodyKey]) : '',
68 meta: Object.keys(rest).length ? JSON.stringify(rest, null, 2) : '',
69 }
70}
71
72export type Decision = { isApproved: true } | { isApproved: false; reason: string }
73
74export const APPROVE = 'Post it'
75export const REJECT = 'Reject'
76
77// Reads the dialog's answer: only an explicit "Post it" approves. Anything
78// typed under Other rejects and becomes the reason Claude reads.
79export function decide(answer: string): Decision {
80 if (answer === APPROVE) return { isApproved: true }
81 if (answer === REJECT || answer.trim() === '') return { isApproved: false, reason: 'the user rejected it' }
82 return { isApproved: false, reason: `the user said: ${answer}` }
83}
84types/index.d.ts 17 lines1// A write held for the user's approval: what it does, where, and its payload.
2export type Pending = {
3 tool: string
4 action: string
5 target: string
6 // The text that will be posted (comment body, page content, command).
7 body: string
8 // The remaining arguments, as JSON, or extra context such as unpushed commits.
9 meta: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'write-gate': { pending: Pending | null }
15 }
16}
17