A small, tested dotfiles framework: symlink-managed zsh, tmux, Ghostty, Git and Neovim config, with safe backups, a doctor, and a privacy scan.

English | 简体中文
A terminal workbench for AI coding agents. Claude Code, Codex, Gemini CLI, Grok CLI and the like are command-line programs that read and write plain text, so the best place to run them is a terminal you control: tmux sessions per project, an editor one key away from the agent, ripgrep, fzf and jq for text, and every setting in a Git repository you can read, diff and fork. This repository is that environment, deployed with chezmoi.
prefix + P -> pick a project -> one tmux session, one window per repository
┌───────────────────────────────────┐
│ claude / codex / gemini / grok │ the first installed agent starts here,
│ │ in the project's directory
├───────────────────────────────────┤
│ shell │
└───────────────────────────────────┘
prefix + e / prefix + g: Neovim in a popup, to browse code or review changes
| Project → session, agent included | A directory under ~/dev/projects is a workspace. A folder holding several repositories is one workspace with a window per repository, each with its own agent pane. Nothing to register: it is discovered from the directory tree. Auto-detects claude, codex, gemini, grok (extend the list with WORKSPACE_SWITCH_AGENTS, pick one with WORKSPACE_SWITCH_AGENT, turn off with none). |
| Plain text all the way down | Settings, history, notes and agent instructions are files. rg, fzf, jq, nvim and git are the tools; there is no GUI state and no database to lose. One instruction text is deployed to every agent (CLAUDE.md, AGENTS.md, GEMINI.md), with your private rules appended from an untracked file. |
| Safe to make public | Agents need API keys, and keys leak through dotfiles. scripts/privacy-scan runs as a pre-commit hook and again in CI, and recognises Anthropic (Claude), OpenAI, Google (Gemini) and xAI (Grok) keys, GitHub and AWS tokens, private keys, personal paths and e-mail addresses. A pre-push hook scans commit metadata (author, committer, message) too. tmux pane contents are not saved to disk by default. Secrets live in an untracked file. |
| Reversible | Before every chezmoi apply, the files it would replace are backed up to ~/.cli-workbench-backup/ with a restore note. |
| The promises are tested | The quick start below is run end to end in a throwaway home directory on macOS and Ubuntu. The agent pane (with stand-in agents), the backup and the privacy scanner each have their own tests. |
Agent-specific today: the workspace switcher starts any of the four agents; the shared instruction text reaches Claude Code, Codex and Gemini CLI (Grok CLI is not wired up: the location of its instruction file is not known); the status line (~/.claude/statusline.sh) and the mods (below) are for Claude Code only. Full agent settings files are deliberately not tracked. Codex's portable UI preferences are merged into its local configuration, see below. The agents themselves are not installed by this repository.
What this is: a personal-dotfiles repository laid out as a chezmoi source tree. You fork it, edit
home/, and keep it as your own.What this is not: a package manager, a one-click installer, a theme pack, or an agent framework. It does not install software and it does not manage secrets.
| Platform | macOS is the primary platform (tested on macOS 26, Apple Silicon). The zsh and tmux configs also pass the full test suite and the first-run flow on Ubuntu 24.04 (checked in a container); the Ghostty config and the Homebrew casks are macOS-oriented. Windows is not supported. |
| Shell | zsh 5.8 or newer (tested with 5.9). The helper scripts are bash 3.2 compatible, i.e. the macOS system bash is enough. |
| tmux | 3.2 or newer recommended (tested with 3.5a). Older versions still load the config, minus the workspace switcher key. |
| Required | git and chezmoi (brew install chezmoi) |
| Optional | fzf (0.48+ for the shell integration), zoxide, starship, zsh-syntax-highlighting, jq (status line), translate-shell (the translation popup), Ghostty and a Nerd Font, Homebrew |
What it changes on your machine: exactly the files under home/ that chezmoi deploys (the table below), a zsh completion cache in ~/.cache/zsh/, and backups of the files it replaces in ~/.cli-workbench-backup/<timestamp>/ (chezmoi itself keeps none; see Safety model).
brew install chezmoi
git clone https://github.com/dishangyijiao/cli-workbench.git ~/dev/cli-workbench # or your fork; any location works
chezmoi init --source ~/dev/cli-workbench # use this clone as the source, and install the backup hook (below)
chezmoi diff # read-only: what would change in $HOME
chezmoi apply ~/.tmux.conf # apply ONE file first, then look at the result
chezmoi apply # then everything
Or let chezmoi do the clone: chezmoi init <your-github-user>/cli-workbench, then the same diff and apply.
What gets deployed (the layout follows chezmoi's naming: dot_ becomes ., executable_ sets the mode, private_ makes the directory mode 700):
Source in home/ | Target | Notes |
|---|---|---|
dot_tmux.conf, dot_tmux/scripts/ | ~/.tmux.conf, ~/.tmux/scripts | prefix Ctrl-a, vi keys, mouse, workspace switcher with the agent pane |
dot_zshrc, dot_config/private_zsh/ | ~/.zshrc, ~/.config/zsh/{path,tmux-autostart}.zsh | replaces your .zshrc; move your own tweaks to ~/.config/zsh/local.zsh first. Installers (nvm, bun, ...) append to ~/.zshrc; chezmoi diff shows that, so move such lines into local.zsh |
dot_config/ghostty/config | ~/.config/ghostty/config | Catppuccin Mocha, Nerd Font, macOS tabs title bar |
dot_claude/executable_statusline.sh | ~/.claude/statusline.sh | the Claude Code status line (project, branch, model, context, cost, rate limits); only if you use Claude Code |
dot_claude/workbench-mods/ | ~/.claude/workbench-mods/ | four Claude Code mods (chezmoi-guard, reply-polish, agent-state, md-open) as a local marketplace; deployed, not installed: see "Claude Code mods" |
dot_codex/modify_private_config.toml | ~/.codex/config.toml | merge portable status-line and completion-bell preferences; preserve other local values |
.chezmoitemplates/agent-instructions.md, dot_claude/CLAUDE.md.tmpl, dot_codex/AGENTS.md.tmpl, dot_gemini/GEMINI.md.tmpl | ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md | the same short text for every agent: how this workbench works (config lives in the repo, secrets stay out, one tmux session per project). Your own rules: see "Agent instructions" |
dot_config/git/config | ~/.config/git/config | portable Git settings; Git reads this file by itself, and ~/.gitconfig (identity, credentials) stays yours |
dot_config/nvim/ | ~/.config/nvim | optional Neovim setup (lazy.nvim, LSP, Telescope, Git, debugging; no AI plugin: agents run in their own pane); plugins install on first launch and need network access. Delete the directory from your fork if you have your own |
Suggested order and the manual steps (Homebrew tools, tmux plugin manager) are in docs/bootstrap.md.
These are opinions, not requirements. Edit the files in home/ and run chezmoi apply (or chezmoi edit --apply ~/.tmux.conf).
Ctrl-a (not Ctrl-b); vi-style copy mode; mouse on; windows numbered from 1. Pane contents are not saved to disk by default (that would store anything printed in a pane, tokens included); see the comment next to @resurrect-capture-pane-contents to turn it on.starship, zoxide, fzf and zsh-syntax-highlighting only if installed.SauceCodePro Nerd Font Mono (install the font or change the line).templates/local.zsh.example to ~/.config/zsh/local.zsh. chezmoi does not manage it and it is loaded last.templates/secrets.zsh.example to ~/.config/zsh/secrets.zsh, mode 600. Never commit it. The repository must never contain keys or tokens.chezmoi add ~/.config/<tool>/config copies the file into home/; commit it.lazy-lock.json after :Lazy update): the copy in $HOME changes, the repository does not. chezmoi diff shows it; chezmoi re-add pulls it back into home/.home/dot_config/nvim from your fork.chezmoi diff | status | verify # what differs between home/ and $HOME (verify exits non-zero if anything does)
chezmoi doctor # chezmoi's own health check
tests/run.sh # the repository's tests, including an end-to-end run of this quick start in a throwaway HOME
scripts/privacy-scan [--all] # secrets, personal paths and e-mail addresses in staged (or all tracked) files, or in commit metadata (--commits)
scripts/lint-shell # shellcheck (warning level and above) over every bash/sh script in the repository
chezmoi diff and chezmoi apply --dry-run change nothing.chezmoi init installs a hook (scripts/backup-before-apply) that copies every file the apply is about to replace, including files you edited after chezmoi wrote them, to ~/.cli-workbench-backup/<timestamp>/ first. Directory mode 700, symlinks kept as symlinks, a RESTORE note with one copy-paste command per file. If nothing would change, nothing is created. If the backup fails, the apply is refused. --dry-run has no side effects.chezmoi init writes. If you only create chezmoi.toml by hand, or run chezmoi apply --source ... without having run init, there is no backup.chezmoi destroy).scripts/privacy-scan guards what you commit; see "Safe to make public" above.Claude Code, Codex and Gemini CLI each read a plain-text instruction file from their home directory. Here they are generated from one source, home/.chezmoitemplates/agent-instructions.md, so the agents get the same facts about the machine and the same engineering principles: one source of truth with layers that only add differences, least privilege, files over conversation, the simplest thing that works, and evidence before "done" with a way back.
~/.config/cli-workbench/agent-instructions.local.md. It is not tracked, so personal preferences never reach a public fork. It is appended after the shared text in all three files. Remove the file and the next chezmoi apply removes its text.~/.claude/CLAUDE.md (and the others) are replaced on apply and backed up first. Move their content into the local file beforehand if you want to keep it as it is.~/.claude/CLAUDE.md is overwritten at the next apply. The generated files cannot be pulled back with chezmoi re-add.settings.json, config.toml, auth.json, histories, sessions and databases. They hold machine paths, proxies and credentials, and the tools rewrite them. A test fails if such a file appears under home/.home/dot_codex/modify_private_config.toml merges five UI settings into ~/.codex/config.toml: the status line (model, directory, session name, five-hour and weekly remaining limits), status-line colors, and completion notifications using a terminal bell regardless of focus. With the existing tmux bell settings, Ghostty can mark the background tab with a bell.
On a new machine, install Codex separately and run the usual chezmoi init / chezmoi apply setup, then sign in to Codex. The configuration is created if absent. Existing model choices, local paths, notification commands, project trust, and other settings are preserved; credentials and sessions are not migrated. Restart an existing Codex CLI after applying (use codex resume --last to continue).
Edit the merge template to change these shared preferences. Changes to these five settings made inside Codex are replaced by the next apply. Do not use chezmoi add or re-add to copy the full local file into the repository. When values need changing, the TOML is reserialized, so comments and formatting are lost; matching files remain unchanged. The existing pre-apply backup hook keeps the original before replacement. Invalid TOML stops the merge without overwriting the file.
A mod is a Claude Code plugin whose behavior is a small TypeScript file that Claude Code calls when something happens: a tool is about to run, a reply is about to be drawn. This repository ships four, as a local marketplace (a folder Claude Code installs plugins from) in home/dot_claude/workbench-mods/:
| Mod | What it does |
|---|---|
chezmoi-guard | Refuses Edit, Write and NotebookEdit on a file chezmoi deploys (~/.zshrc, ~/.tmux.conf, ...) and names the source file to edit instead, so the next chezmoi apply cannot overwrite the change. Shows a line above the prompt while the deployed files differ from the source (chezmoi status). It cannot see edits made through Bash (sed -i, > file). If chezmoi is missing or fails, it blocks nothing. |
agent-state | Records, in one small file per tmux pane, whether the agent in it is working, waiting for you or idle, so that the overview popup (see "Agent overview") can list them. It runs ~/.tmux/scripts/agent-state.sh on Claude Code events and changes nothing else: outside tmux, or if the script fails, nothing happens, and a hook waits for the script for at most one second. |
md-open | Adds /md: a pane lists the Markdown files this conversation mentioned (paths in tool calls, Bash commands, replies, your prompts and tool results; absolute, ~/ and relative to the session's directory), newest first, each once, only those that exist, with ~ for your home directory. Pick one with Enter or its digit (1-9) and it opens read-only (nvim -R) in a tmux popup, rendered by render-markdown.nvim; quitting Neovim closes the popup. The path goes to tmux as an argument, never through a shell. Outside tmux, or when tmux fails, a toast shows the path instead. It needs no model turn, and changes no file. |
reply-polish | Lays out the assistant's replies for a wide terminal. The text is one column, at most 80 cells (about 40 Chinese characters) and at most 72% of the window, left-aligned and centered on the screen. Headings are bold, the first two levels in cyan; lists use • and ◦; a table is drawn with box lines when it fits the column and becomes a list when it does not; lines are cut so that a number stays with its unit and closing punctuation never starts a line; a code block longer than 30 lines is shortened to 12. Only the drawing changes: the stored reply, and ctrl+o, keep the original. |
Install once per machine (needs Claude Code 2.1.287 or later; chezmoi apply only deploys the files, it does not install anything):
chezmoi apply # deploys ~/.claude/workbench-mods
claude plugin marketplace add ~/.claude/workbench-mods
claude plugin install chezmoi-guard@cli-workbench --scope user
claude plugin install reply-polish@cli-workbench --scope user
claude plugin install agent-state@cli-workbench --scope user
claude plugin install md-open@cli-workbench --scope user
Then restart Claude Code, or run /reload-plugins in a running session.
home/dot_claude/workbench-mods/, run chezmoi apply, then /reload-plugins. A marketplace that is a folder is read from the folder itself, so no version bump is needed. claude plugin disable <name> turns one off, claude plugin uninstall <name> removes it.tests/mods.test.sh always checks the layout of the marketplace. With Claude Code installed it also runs claude plugin validate and each mod's own tests (claude plugin test); without it that part is skipped.md-open with 2.1.295). Mods do not load under --safe-mode or --bare..claude-plugin/types/ when it loads the mod.The keys you use most, on one page to print: docs/keybindings.md. A test keeps it in step with the config.
Press prefix then P (Ctrl-a P) for a picker over ~/dev/projects. Every directory directly under a root is a workspace, opened as one tmux session: an AI agent on top, a shell under it. There is no editor pane; prefix e and prefix g open Neovim in a popup when you want to read code (see below). A directory that is not a repository but contains several (a multi-repo product) is one workspace with one window per repository. Choosing an existing workspace only switches to it. It needs tmux 3.2+ and fzf.
claude codex gemini grok is started in the project directory. If none is installed, the window has just a shell. WORKSPACE_SWITCH_AGENT="claude --continue" picks one (with arguments), none turns it off, WORKSPACE_SWITCH_AGENTS="aider claude" changes the candidates and their order. Set these with set-environment -g in home/dot_tmux.conf, next to WORKSPACE_ROOTS.WORKSPACE_ROOTS in home/dot_tmux.conf to scan other directories.home/dot_tmux/scripts/executable_workspace-switch.sh.Read code without leaving the agent's pane. Both keys open Neovim in a popup over the current pane, in that pane's directory; closing Neovim closes the popup and you are back where you were. They need tmux 3.2+.
prefix then e: browse the project with the usual Neovim keys (<leader>ff find a file, <leader>fg search, <leader>e file tree). :qa closes it.<leader>mp switches the rendering off and on. The text is a centred column of about 80 characters, so it stays readable on a wide screen.prefix then g: review changes. A Telescope list of every file the branch changed since it left the default branch (origin/HEAD, else origin/main, main and so on): committed, uncommitted, deleted and untracked files, each marked, with a colored diff as the preview. Enter opens the file in a new tab beside its version at the fork point, in Neovim's own diff mode (a deleted file: its old version beside an empty side) (]c/[c jump between changes). Inside Neovim the same list is :Changes or <leader>gv. :qa closes the popup. Outside a Git repository it says so. No extra plugin is needed.home/dot_tmux/scripts/executable_code-popup.sh; tmux passes it only the pane id, never a directory name.Select some English text with the mouse (or v ... y in copy mode), then press prefix then t (Ctrl-a t). A popup shows a translation, so you do not leave the pane you are reading. One word gives a dictionary entry; anything longer gives its translation. Close the popup with q.
translate-shell (brew install translate-shell; without it the popup says so).LOOKUP_LANG sets the target language (default zh-CN). Details are in the header of home/dot_tmux/scripts/executable_lookup.sh.Write an idea down the moment you have it, without leaving the pane or interrupting the agent. Press prefix then a (Ctrl-a a), type one line, press Enter; the popup closes and you are back where you were. An empty line cancels. In a shell, idea some text does the same (or idea alone to be asked). It needs tmux 3.2+.
~/.config/cli-workbench/inbox.md (private, mode 600, not in this repository), for all projects: - [ ] 2026-10-06 17:42 · ~/dev/projects/foo · the idea. The project is the repository's top directory, for a worktree the repository it belongs to; ? if the pane was gone.- [ ] to - [x], never deleting; afterwards check that every line from before is still there; ask before anything leaves the machine, such as opening a GitHub issue.When several agents work at once, the question is which one needs you. Press prefix then O (Ctrl-a O) for a popup that lists every agent session, one row each: state, project, branch and how long it has been in that state. Waiting comes first (a permission prompt or a question; the longest wait on top), then working, then idle. Enter jumps to that pane (its session, window and pane); Esc closes the popup. With fzf it is a picker you can type into; without it, a numbered menu. Plain text, no colors. It needs tmux 3.2+ and jq.
agent-state mod (see "Claude Code mods") runs ~/.tmux/scripts/agent-state.sh: a turn starts, working; a permission prompt or an MCP question appears, waiting; a turn ends for any reason (an answer, an interrupt, an error), idle; a session starts, idle (a compaction does not count); a session ends, the record is removed. When a tool finishes, fails, is denied, or a dialog is answered while the pane still says waiting, it goes back to working. Without the mod the popup is empty.${XDG_STATE_HOME:-~/.local/state}/cli-workbench/sessions/<tmux server>/<pane id>.json, with state, project, branch, since and session id. The server directory is the tmux server's pid and start time, so a pane id reused after a tmux restart never meets an old record. The last event wins; the file is replaced atomicallhooks/register.ts 77 lines1import type { Engine, Register } from 'claude-code'
2
3import { asksYou } from './state'
4import type { AgentAction } from './state'
5
6// The script is deployed by chezmoi with the tmux scripts. Only the action word reaches the command line, as a separate
7// argument; the session id travels on stdin.
8const SCRIPT = 'exec "$HOME/.tmux/scripts/agent-state.sh" "$1"'
9
10// The most a hook waits for the script: it takes a few milliseconds, and a slow or stuck one must not hold the chain.
11const BUDGET_MS = 1000
12
13// Outside tmux the script does nothing; any failure is dropped. The overview is a hint, so a lost event costs one glance.
14const record = async ($: Engine, action: AgentAction) => {
15 try {
16 await $.process.run(['sh', '-c', SCRIPT, 'sh', action], {
17 stdin: JSON.stringify({ session_id: await $.session.id() }),
18 timeoutMs: BUDGET_MS,
19 })
20 } catch {
21 // tmux or the script is missing: the overview just does not list this pane.
22 }
23}
24
25export const register: Register = on => {
26 // The main agent's real turns. A turn start is working; any turn end (an answer, an interrupt, a refusal, an API error)
27 // is idle. A subagent's turns are not the agent being idle.
28 on('turn.start', async ($, e, next) => {
29 // A subagent starting must not turn a wait on the main agent back into working.
30 if (e.agentId === undefined) await record($, 'working')
31 return next(e)
32 })
33 on('turn.complete', async ($, e, next) => {
34 if (e.agentId === undefined) await record($, 'idle')
35 return next(e)
36 })
37
38 // A compaction fires the session start in the middle of a turn: it is not a new start.
39 on('classic.SessionStart', async ($, e, next) => {
40 if (e.source !== 'compact') await record($, 'idle')
41 return next(e)
42 })
43 on('classic.SessionEnd', async ($, e, next) => {
44 await record($, 'end')
45 return next(e)
46 })
47
48 // The agent needs you. A subagent's prompt counts too: you are the one who has to answer it.
49 on('classic.PermissionRequest', async ($, e, next) => {
50 await record($, 'waiting')
51 return next(e)
52 })
53 on('classic.Notification', async ($, e, next) => {
54 if (asksYou(e.notification_type)) await record($, 'waiting')
55 return next(e)
56 })
57
58 // Something ran, failed, was denied or was answered after the wait began: if the pane still says waiting, it is not.
59 // No matching of requests to results; a wrong guess is corrected by the next event.
60 on('classic.PostToolUse', async ($, e, next) => {
61 await record($, 'heal')
62 return next(e)
63 })
64 on('classic.PostToolUseFailure', async ($, e, next) => {
65 await record($, 'heal')
66 return next(e)
67 })
68 on('classic.PermissionDenied', async ($, e, next) => {
69 await record($, 'heal')
70 return next(e)
71 })
72 on('classic.ElicitationResult', async ($, e, next) => {
73 await record($, 'heal')
74 return next(e)
75 })
76}
77hooks/state.ts 12 lines1// The words ~/.tmux/scripts/agent-state.sh takes:
2// working a turn starts waiting the agent needs you idle a turn ended or a session starts
3// heal waiting becomes working, anything else stays (something ran or was answered, so you must have answered)
4// end the session is over
5export type AgentAction = 'working' | 'waiting' | 'idle' | 'heal' | 'end'
6
7// Notification types that ask you something. The others (idle_prompt, auth_success) only remind or inform, and must not
8// turn an idle agent into a waiting one.
9const ASKS = ['permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog']
10
11export const asksYou = (type?: string): boolean => type !== undefined && ASKS.includes(type)
12