Personal macOS development environment, managed declaratively with chezmoi (dotfiles), mise (CLI tools), and Homebrew (GUI apps). One command on a bare machine → a fully configured setup. Secrets never touch the repo.
git clone https://github.com/sanchpet/dotfiles ~/dotfiles && ~/dotfiles/bootstrap.shbootstrap.sh is idempotent and runs, in order (mise-first):
- mise — install the base tool manager (
curl https://mise.run), thenchezmoivia mise - chezmoi source — point chezmoi at this clone as its source (
chezmoi init --source), so edits apply with no commit/push/re-clone round-trip and no duplicate clone in~/.local/share/chezmoi. Also generate a per-machineed25519SSH key if missing (no passphrase; disk is encrypted) — it must exist before step 7 so the rendered git config turns commit signing on - apply mise config — lay down
~/.config/mise/config.tomlbefore installing tools (breaks the chicken-and-egg: the mise config is itself a managed dotfile) - mise install — install CLI tools from the config (bitwarden-cli, uv, …)
- Bitwarden — point the
bwCLI at.bitwarden.server(self-hosted, blank = cloud), then login + unlock; interactive, skipped without a TTY (CI) - Oh My Zsh — install the zsh framework (without touching
.zshrcor changing the shell) - chezmoi apply — render and place all dotfiles
- GitHub SSH keys — register this machine's key (generated in step 2) on GitHub as both an authentication key (push) and a signing key (Verified badge), then switch the dotfiles clone's origin from HTTPS to SSH so it's push-ready. Interactive on a TTY: runs
gh auth loginif unauthenticated and refreshes the token scope when needed. Idempotent; on CI / headless it prints the key and skips - brew bundle — GUI casks (Homebrew is installed lazily, only if the Brewfile needs it)
| Tool | Purpose | Link |
|---|---|---|
| mise | Polyglot tool & runtime manager — single declarative source for CLI tooling | https://mise.jdx.dev · github |
| chezmoi | Dotfiles manager — templating, per-machine, secrets | https://www.chezmoi.io · github |
| Oh My Zsh | Zsh configuration framework | https://ohmyz.sh · github |
| Homebrew | macOS package manager — used only for GUI casks | https://brew.sh |
Grouped by purpose. The same groups, in the same order, run through dot_config/mise/config.toml, Brewfile.tmpl and every table below, so an entry keeps its place between the declaration and the documentation.
| Tool | Purpose | Link |
|---|---|---|
Claude Code (claude) |
Anthropic agentic CLI — self-update off (DISABLE_AUTOUPDATER), update via mise up claude |
docs |
| claudeline | Real-time Claude Code statusline (quota / context / model) — wired via ~/.claude/settings.json statusLine |
github |
| rtk | CLI proxy that compresses command output before an agent reads it — wired as a PreToolUse hook, see Agent bash output |
docs · github |
| Serena | MCP server giving the agent LSP-backed symbolic navigation and editing (serena-agent on PyPI, binary serena) — see Agent access |
docs · github |
| mcp-tg | Telegram MCP server — lets an agent read chats over MTProto. Version-pinned, not latest: it holds a session that authorises the whole account. See Agent access |
github |
wolt (wolt, wolt-mcp) |
Unofficial Wolt CLI + MCP server — venue search, menus, cart, checkout preview. One archive, both binaries. Version-pinned for the same reason as mcp-tg: it holds a session tied to payment methods. Ordering still happens in the app; the tool has no order placement | github |
| Tool | Purpose | Link |
|---|---|---|
| Starship | Cross-shell prompt (zsh prompt; starship init in .zshrc) |
docs |
| zoxide | Frecency cd — replaces cd (--cmd cd); cdi = interactive |
github |
| fzf | Fuzzy finder (fzf --zsh in .zshrc) |
github |
ripgrep (rg) |
Fast recursive search | github |
| bat | cat with syntax highlighting & paging (aliased to cat) |
github |
| fd | Fast, user-friendly find |
github |
| dust | Intuitive du — disk-usage tree (aliased to du) |
github |
| duf | Better df — disk free, tabular (aliased to df) |
github |
dua (dua i) |
Interactive disk-usage explorer — find & delete big dirs | github |
| hyperfine | Command-line benchmarking tool | github |
| Tool | Purpose | Link |
|---|---|---|
GitHub CLI (gh) |
GitHub from the terminal | docs |
| delta | Syntax-highlighting pager for git diffs (wired as git core.pager) — invoked by git, not typed |
github |
GitLab CLI (glab) |
GitLab from the terminal — work profile only (conf.d/work.toml); personal work is on GitHub |
docs |
| Tool | Purpose | Link |
|---|---|---|
| go | Go toolchain | docs |
| node | Node.js runtime | docs |
| python | Python runtime | docs |
| uv | Fast Python package & project manager — also backs mise's pipx: tools (settings.pipx.uvx) |
docs · github |
| Tool | Purpose | Link |
|---|---|---|
| kubectl | Kubernetes cluster CLI | docs |
| kubectx | Switch kubectl context / namespace (aliased kctx) |
github |
| kubectl-view-secret | kubectl view-secret <secret> [key] — decode a Secret in place, no base64 -d pipeline. Installed via the aqua backend, which renames the binary to kubectl-view_secret so kubectl picks it up as a plugin |
github |
| kubelogin | kubectl credential plugin for OIDC clusters — no local context uses it yet, kept for the LDAP-login lab stands | github |
| helm | Kubernetes package manager | docs |
flux2 (flux) |
GitOps continuous delivery for Kubernetes | docs |
cilium-cli (cilium) |
Cilium CNI — install, status, connectivity tests | docs |
| terraform | Infrastructure as code | docs |
| terragrunt | Terraform/OpenTofu wrapper (aliased tg) |
docs |
| terraform-docs | Generate module documentation from Terraform sources | docs |
ansible (ansible-core) |
IT automation engine — installed via uv (pipx: backend) |
docs |
| regctl | The OCI registry client — manifests, indexes, artifacts, copy/retag and image mod without a daemon. Deliberately the only one: see Design decisions |
github |
| Tool | Purpose | Link |
|---|---|---|
awscli (aws) |
AWS CLI | docs |
Yandex Cloud CLI (yc) |
Manage Yandex Cloud resources (IAM, compute, k8s, …) | docs |
| vault | Secrets management CLI | docs |
Bitwarden CLI (bw) |
Secret retrieval at chezmoi apply |
https://bitwarden.com/help/cli/ · github |
1Password CLI (op) |
Secret retrieval on work machines — work profile only; personal machines use bw |
docs |
| age | Modern file encryption | github |
teleport-community (tsh, tctl, teleport) |
Access plane for infrastructure — client, admin CLI, and the node agent this Mac runs (see Remote access) | docs |
| wstunnel | Tunnel traffic over websocket/HTTP2 — client side; ansible installs its own pinned Linux build on the homelab hub | github |
| sweb | CLI for the SpaceWeb (sweb.ru) hosting API — my own tool (github backend) | github |
| Tool | Purpose | Link |
|---|---|---|
| shellcheck | Static analysis for shell scripts. Declared twice on purpose: the pre-commit hook brings its own copy (shellcheck-py), the mise one is for running it by hand |
shellcheck · hook |
| actionlint | GitHub Actions workflow linter — catches a broken workflow before a push burns a CI run | github |
| hadolint | Dockerfile linter — a native binary, so no container pull stands between an edit and its findings | github |
| yamllint | YAML linter (via uv) | github |
| ansible-lint | Ansible playbook linter (via uv) | github |
| Tool | Purpose | Link |
|---|---|---|
| typst | Markup-based typesetting (LaTeX alternative) | github |
| Tool | Purpose | Link |
|---|---|---|
| aqua | Declarative CLI version manager — kept to author/test aqua-registry packages (aqua gr), not to resolve them: mise's aqua backend needs no CLI |
docs · github |
mise and chezmoi are declared here too, but documented under Foundation — they carry the whole setup rather than serve one purpose.
Declared in the repo-local mise.toml and .pre-commit-config.yaml, not in the machine-wide config: they belong to this repository's workflow, not to the machine.
| Tool | Purpose | Link |
|---|---|---|
| pre-commit | Git pre-commit hook framework | https://pre-commit.com · github |
| pre-commit-hooks | Standard hygiene hooks (whitespace, EOF, YAML, …) | github |
Same groups, same order. Most casks go on every machine; where one does not, its purpose says so. The personal-only ones sit in a profile-guarded block in Brewfile.tmpl, so their physical position there differs from their group position here.
| Tool | Purpose | Link |
|---|---|---|
| cmux | Ghostty-based terminal with vertical tabs + notifications for AI coding agents | site |
| Visual Studio Code | Primary code editor (self-updating; adopted into brew) — personal profile only; a work machine gets it from the employer's MDM | docs |
| Obsidian | Markdown knowledge base / vault editor (hypomnemata exocortex; self-updating cask) | site |
| Tool | Purpose | Link |
|---|---|---|
| OrbStack | Docker-compatible container & Linux VM runtime, replaces Docker Desktop (launch once to start the engine) | docs |
| Freelens | Kubernetes IDE (open-source Lens fork) | github |
| Tool | Purpose | Link |
|---|---|---|
| Bitwarden | Password manager — also serves the SSH agent that signs commits and authenticates git. Cask rather than the App Store build, which cannot be reinstalled without a signed-in Store — personal profile only; a work machine gets its password manager from the employer's MDM | site |
| AmneziaVPN | VPN client — the desktop side of the VPN fleet. Personal profile only | github |
| GLKVM | GL.iNet KVM-over-IP client — remote console/BIOS access to homelab nodes. Cask rather than App Store: the two are separate channels for the same app, and a vendor-site update leaves a mas declaration unsatisfiable. Personal profile only — the nodes are personal infrastructure |
site |
| Tool | Purpose | Link |
|---|---|---|
| Yandex Music | Desktop music player (self-updating cask) | site |
| Tool | Purpose | Link |
|---|---|---|
| Slack | Team chat client (self-updating cask) — personal profile only; the corporate workspace runs on the MDM-supplied build | site |
| Super Productivity | To-do list + Pomodoro + time tracking (MIT, local-first) — the time-accounting instrument; see Agent access | github |
| WakaTime | Menu-bar time tracker — whole-system activity beyond editor plugins | docs |
| Pearcleaner | App uninstaller + orphaned-file finder (open-source CleanMyMac alt) | github |
Installed via the mas CLI. A one-time App Store sign-in is the only step that can't live in code; the entries are skipped in CI (the runner isn't signed in). Every entry is installed on every machine, so the profile column is dropped in favour of the group.
| App | Purpose | Group | Link |
|---|---|---|---|
| WireGuard | WireGuard VPN client | Cloud, secrets & networking | site |
| v2RayTun | V2Ray / proxy client | Cloud, secrets & networking | site |
| MKPlayer | Media player | Media & documents | — |
| one sec | Delay distracting apps — digital-hygiene gate (a mindful pause before Telegram/feeds) | System & productivity | site |
| Focus To-Do | Superseded by Super Productivity; kept only until its history is migrated out (#3) | System & productivity | site |
Installed on every machine, so the profile column is dropped in favour of the group.
| Tool | Purpose | Group | Link |
|---|---|---|---|
| eza | Modern ls — git-aware, colors (aliased to ls/ll/la/tree); eza ships no macOS binary upstream so mise can't provide it cleanly (asdf 404s, cargo needs Rust) — brew has a bottle |
Shell, terminal & editing | github |
| libpq | PostgreSQL client (psql, pg_dump, …) without the server — mise's postgres builds the full server; keg-only, so .zshrc adds its bin to PATH |
Language & data toolchains | docs |
| sshpass | Non-interactive ssh password auth — ansible needs it for the -k root-password bootstrap play; not in the mise registry |
Cloud, secrets & networking | docs |
| gnupg | gpg — verifies the signed helm plugin tarballs (run_onchange_after_install-helm-plugins.sh); declared in its own right rather than inherited through skopeo, which is what let skopeo be dropped without taking plugin verification with it |
Cloud, secrets & networking | site |
| ffmpeg | Video/audio transcoding — mise offers only conda: (a conda backend with its own cache) or an asdf plugin that builds from source; brew's bottle ships the encoders needed (svt-av1, libvpx, x264, opus) |
Media & documents | site |
| qrencode | QR encoder — not in the mise registry, and upstream ships source only | Media & documents | site |
| gnu-sed | GNU sed as gsed — Darwin-aware build scripts call it for in-place edits BSD sed can't do (external-secrets' make reviewable); not in the mise registry |
System & productivity | docs |
| mas | Mac App Store CLI — installs/declares the App Store apps above | System & productivity | github |
The prompt is Starship (dot_config/starship.toml — the kubernetes, aws and terraform modules are on, so the active cluster / profile / workspace is always visible). Oh My Zsh loads plugins only (theme off — Starship draws the prompt). Built-in plugins ship with Oh My Zsh; external ones are cloned into $ZSH_CUSTOM/plugins by bootstrap.sh.
| Plugin | Source | Purpose |
|---|---|---|
| git | built-in | Git aliases (gst, gco, gp, …) |
| kubectl | built-in | k* aliases + completion (kgp, kgaa, kdp, …) |
| helm | built-in | Helm completion |
| terraform | built-in | tf* aliases + completion + workspace |
| aws | built-in | asp/acp profile switch + completion |
| ansible | built-in | Ansible aliases + completion |
| gh | built-in | GitHub CLI completion |
| colored-man-pages | built-in | Colored man pages |
| extract | built-in | x <archive> — extract any archive |
| sudo | built-in | Double-Esc prepends sudo |
| copypath / copybuffer | built-in | Copy $PWD / the current command line to the clipboard |
| dirhistory | built-in | Alt+←/→ directory history, Alt+↑ parent dir |
| forgit | external | fzf-powered git (ga, glo, gd) |
| zsh-completions | external | Extra completion definitions |
| zsh-autosuggestions | external | Fish-style suggestions from history |
| zsh-you-should-use | external | Reminds you when a typed command already has an alias |
| zsh-syntax-highlighting | external | Command-line syntax highlighting |
| zsh-autocomplete | external | Live menu completion (loaded last so its keybindings win) |
Load order matters.
zsh-autocompleteowns the completion/history UI, so it loads last, and plugins that fight over the same keys —fzf-tab,zsh-history-substring-search— are deliberately not used. Beyond the plugins,dot_zshrc.tmpladds custom aliases (kg,kgy,kctx; modern-CLI swapscat→bat,ls→eza,du→dust,df→duf) and themiseg/miserm/miseuphelpers (add / remove a global mise tool and re-import the config;miseupupgrades with a fresh version list — clears mise's cached release list first so a just-published release is picked up).brewdiffreports drift between installed Homebrew packages and the renderedBrewfile.tmpl(brew has nomiseg-style auto-sync — the manifest is a curated template, so new packages are ported in by hand).updatesreports available mise + Homebrew package updates (cached; the first interactive shell of the day refreshes it in the background and prints the summary — never blocks the prompt;updates -rrechecks now, upgrades stay manual viabrew upgrade/mise upgrade/mise self-update).tgaliasesterragrunt(the omzterraformplugin coverstf*, but terragrunt has no plugin); terragrunt ships no completion script, so its built-inCOMP_LINEcompletion is wired viabashcompinit+complete -Cand shared with thetgalias throughcompdef.
This repository is public. Shell code that names an employer's tools, hosts or paths does not belong in it, and "be careful" does not scale past the second line — so there is a place for it instead:
${XDG_CONFIG_HOME:-$HOME/.config}/zsh/work.d/*.zsh
dot_zshrc.tmpl sources every .zsh file there, last, after the plugins and after compinit, so a snippet can use anything the rc file set up and override any of it. Nothing tracks, renders or creates that directory; on a machine without one the loop does nothing.
What belongs there: shell code a tool prints for itself. That is the property that makes an untracked file safe — losing it costs one command, not an afternoon of remembering what was in it. Settings typed by hand belong in this repository, sanitised, or nowhere.
rtk is a PreToolUse hook on the Bash tool: it rewrites a command to its rtk equivalent (git status → rtk git status) so the agent reads a compressed rendering instead of raw output. It applies to Bash calls only — Claude Code's built-in Read, Grep and Glob bypass hooks entirely.
Three things about this deployment are choices rather than defaults:
- The hook command is the mise shim's absolute path, not a bare
rtk. mise puts tools onPATHat shell activation, so a bare name resolves only under an already-activated interactive shell. The shim resolves without one, the same reason thestatusLineentry is absolute. Note this only hardens the hook itself: the command it emits is a barertk …, which still needsrtkon the agent'sPATH. An agent started before rtk was installed keeps the oldPATHand fails every rewritten command withcommand not found— restart it after installing. - Never run
rtk init -gon this machine. It patches~/.claude*/settings.json, writesRTK.md, and appends the@RTK.mdimport — all in$HOME, i.e. chezmoi targets. Those edits survive until the nextchezmoi applysilently reverts them. The hook,RTK.mdand the import live in the source here; runinitonly against a throwawayCLAUDE_CONFIG_DIRto see what a new version would write, then port it. - Output is lossy by design. Where a command's exact output is the evidence (a checksum, a full log, a diff being quoted verbatim), reach for
rtk proxy <cmd>to bypass the filter, or add the command toexclude_commandsin~/Library/Application Support/rtk/config.tomlif it should never be rewritten.
Five MCP servers give Claude Code a browser, a Telegram reader, the time-accounting instrument, that instrument's UI, and symbolic navigation over source. Each hands an agent something with real reach, so what bounds that reach is written down here rather than left implicit.
An MCP server that puts a language server between the agent and the code: find a symbol, find what references it, replace a symbol's body — instead of reading whole files and editing by text match. On trial in the work profile only, against declared criteria and a review date; whether it belongs in the personal contour is a later decision.
The registration is reproducible (run_onchange_after_register-serena.sh), idempotent, and pins the profile for the same reason the others do. --project-from-cwd means one registration serves every repository — Serena activates whichever project the agent is working in.
Its global memories are a symlink into the vault (~/.serena/memories/global → ~/tv/adversaria/70-memory). Serena's own path for these is hardcoded (serena_config.py, no setting for it), and leaving them in ~/.serena would put durable knowledge outside version control. The symlink survives Serena's mkdir(exist_ok=True) on startup. Note the ordering trap: any serena invocation, --version included, creates that directory, so the symlink has to be in place before the first run or it lands inside the directory instead of replacing it.
Project memories are a different matter and are not adopted yet. Not because of where they land — project_serena_folder_location relocates the whole .serena folder anywhere, $projectDir and $projectFolderName included — but because of what Serena calls a project. It identifies one by directory path, with no git awareness, so under a one-worktree-per-branch workflow every branch is a separate project and neither placeholder can express "the repository". The one arrangement where the memories follow the code is committing them, which is what upstream expects (their own .serena/.gitignore excludes only cache and project.local.yml) — and that is a team decision, not a personal one. Global memories only until it is asked.
Registered at user scope in the personal profile pointing at http://127.0.0.1:9222, i.e. it attaches to a browser that is already running rather than launching its own. cometdbg in dot_zshrc.tmpl starts that browser: Comet is Chromium, so it speaks the DevTools protocol unchanged (verified — it reports Chrome/150, protocol 1.3).
The registration is reproducible (run_onchange_after_register-chrome-devtools.sh), for the same reason mcp-tg's is: --scope user writes into whichever profile CLAUDE_CONFIG_DIR names, so a hand-typed registration silently belongs to one contour and is missing from the other. That is not hypothetical — this server sat in the default profile alone until a session under the personal one reported having no browser at all. The script pins the profile and is idempotent.
It runs a dedicated profile (~/.cache/comet-debug), not the everyday one. Whoever holds a CDP endpoint can read every open tab and its cookies, and act as you on any site you are signed into. The separate profile keeps that to one window. This is the whole reason cometdbg exists instead of a note saying "pass --remote-debugging-port".
Super Productivity is the instrument for tracking physical time by task, replacing Focus To-Do. The choice was driven by reachability rather than features: Focus To-Do publishes no API at all, so every adapter for it is built on reverse-engineered endpoints and authenticates with the account password in plain environment variables. Super Productivity keeps its data in a local directory and publishes a plugin API first-party, so an adapter over it is an ordinary client — when it breaks, that is a bug report rather than a second round of reverse engineering.
The registration is reproducible (run_onchange_after_register-super-productivity-mcp.sh.tmpl), idempotent, and gated to the personal machine — a stronger condition than the personal profile the other two settle for. The app is declared for every machine, because work time is tracked on the work machine too, but that instance stays agentless on purpose: its task titles belong to an employer, and the cheapest way to keep them away from an agent is to never register a reader for them. Only weekly per-bucket totals cross that boundary, carried by hand.
Unpinned @latest, unlike mcp-tg and wolt-cli. Those hold live sessions — one authorises a whole Telegram account, the other is tied to payment methods — so a surprise release there reaches further than this machine. This server holds no session: there are no credentials in its design and the data is a local folder. The looser rule is a deliberate exception, accepted knowingly.
Two steps stay manual, and no amount of declaration removes them: the plugin is uploaded through the app's own Settings UI, and SP ≥ 18.13.0 then raises a one-time Node execution consent dialog. The script prints both when it registers.
Point the app's sync at a folder inside the vault. The accounting history is the evidence base for a practice measured in months; keeping it in a vendor's cloud makes it unrecoverable from a clean clone, which is exactly the defect this move was meant to fix.
Task text is data, not instructions. The agent may edit the tasks it reads, so anything that arrives through a task title or note is treated as content — the same rule that governs venue and menu text in the Wolt contour.
A second chrome-devtools-mcp instance on http://127.0.0.1:9223, so an agent can click through the app's own interface instead of asking the owner to. Electron is Chromium, so the browser server attaches unchanged; what the second registration buys is a second endpoint, since chrome-devtools is fixed to :9222. spdbg (dot_zshrc.tmpl) opens the port on demand — the app runs without a debugging port until someone asks for one, and that is the isolation: unlike the browser there is no separate profile to sandbox into, because there is one app and one data store.
The reach is narrower than the browser endpoint's — one application rather than every signed-in site — but it is read/write over the instrument that holds the time accounting.
The Node-execution consent dialog stays with the owner. It is a security prompt whose entire purpose is a human answer, and consent an agent gives on the owner's behalf is not consent. Native dialogs generally are out of reach anyway: they live outside the renderer, where CDP cannot follow.
MTProto with a user session, because a bot cannot read a conversation between two people. mcp-tg login takes the phone, code and 2FA on a TTY — the credentials never pass through an agent's transcript — and stores the session in the login keychain rather than a file on disk.
The registration itself is reproducible (run_onchange_after_register-mcp-tg.sh.tmpl): mise pins the binary, and that script registers the server in the personal profile, reading the API credentials from Vault (homelab/telegram/mcp-tg) at apply time so they stay out of git. It is idempotent, personal-profile-only, and skips with a message when Vault is off the mesh — a bootstrap must not fail on reachability. The account login stays manual and interactive by design: that is the one step that should never be automated.
All 78 tools are enabled, deliberately. The server has no read-only mode, and an earlier permissions.deny listing every write tool was removed at the owner's decision: the agent is a working instrument on a machine the owner controls, and a tool that cannot act is worth less than the risk it avoids here.
What that means concretely, so it is never a surprise: an agent can send, edit and delete messages as the account holder, forward, react, join and leave chats, block users, and change the profile. Messages it sends are indistinguishable from the owner's to whoever receives them — this is the only capability that reaches other people, and the one worth thinking about before granting a session to any agent.
Two properties of the mechanism, unchanged by the above:
- The session authorises the entire account. It is a bearer credential that has already passed 2FA. Keychain storage protects it at rest; nothing protects it from a process that can ask the keychain.
- No Telegram MCP server can be scoped to a single conversation. Access is per-account, never per-chat. Upstream tracks per-chat allowlists as an open request.
To reinstate a restriction later, permissions.deny in ~/.claude/settings.json takes tool names as mcp__mcp-tg__<tool>. The current surface:
gh api repos/lexfrei/mcp-tg/contents/docs/tools.md --jq .content | base64 -dThis Mac is a Teleport SSH node: the agent dials out to the cluster proxy on :443 and holds a reverse tunnel, so there is no inbound port, no port forwarding on the router, and no dependence on the network it sits behind — home, office or a cafe are the same to it.
Two properties are worth stating because they are choices, not accidents:
- It runs as a login agent, not a system daemon. A non-root Teleport node can only serve sessions as the user it runs as, so the blast radius is that one account even if cluster RBAC were wrong. The cost is that the machine is reachable only while that user is logged in: after a cold boot FileVault holds the disk and nothing starts. No remote-access scheme fixes that — plan around it rather than expect it to be solved.
- Only a personal machine becomes a node.
.chezmoiignorewithholds the config and the agent on any other profile: reaching a node and being one are different things, and a corporate machine should only ever do the former.
Joining is a one-time out-of-band step, like every other bootstrap credential — the token never lives in this repo:
tctl tokens add --type=node --ttl=15m --format=text > ~/.config/teleport/join-token
chezmoi apply ~/.config/teleport/teleport.yaml # then the agent picks it upThe node writes its own certificates into ~/.local/share/teleport on first start and never reads the token again. Diagnostics: ~/Library/Logs/teleport-node.log.
Reaching the desktop from another Mac — home-desktop forwards the port and opens Apple's own Screen Sharing client (Mac-to-Mac negotiates a far better path than a generic VNC viewer):
home-desktop # tunnel + viewer; closing the shell closes both
tsh ssh sanchpet@macbook-air # terminal onlyScreen Sharing itself is a macOS service, enabled once per machine outside chezmoi (it needs root). The reliable path is System Settings → General → Sharing → Screen Sharing; the launchctl equivalent (enable then kickstart -k system/com.apple.screensharing) is fussy about ordering and silently unhelpful when the service is still disabled. Sleep is separate and matters as much:
sudo pmset -c sleep 0 # a sleeping laptop answers nothingThe tunnel is the access path, not a shield. macOS binds Screen Sharing on 0.0.0.0:5900, so the port answers on every network the machine joins — authenticated, but answering. Reaching it through Teleport is what gives the audited, certificate-gated path; if the machine sits on networks you do not trust, turn on the application firewall (currently off on this Mac) or narrow the allowed users in the same Sharing pane.
Note that a closed lid still sleeps an Apple Silicon laptop without an external display, so a machine meant to be reachable stays open.
| Path | Role |
|---|---|
dot_* |
Dotfiles rendered into $HOME by chezmoi (e.g. dot_gitconfig → ~/.gitconfig) |
dot_config/mise/config.toml |
Global mise config → ~/.config/mise/config.toml (user CLI tools) |
dot_config/mise/conf.d/work.toml |
Work-only CLI tools; mise merges every conf.d/*.toml, and .chezmoiignore withholds this one from personal machines |
.chezmoitemplates/claude-settings.json |
Single source for Claude Code's settings.json (model, theme, claudeline statusline, screencapture sandbox exclusion, rtk PreToolUse hook), included by every account profile below. Plugin marketplaces and enabled plugins are rendered from claudePlugins in the machine-local chezmoi config and omitted when it is absent — the URLs and plugin names belong to an employer and must not land in this public repository |
private_dot_claude/private_settings.json.tmpl |
~/.claude/settings.json (0600) — default profile. Secrets/permissions stay in settings.local.json (untracked) |
private_dot_claude/RTK.md |
~/.claude/RTK.md — rtk's agent-facing reference, pulled into CLAUDE.md by an @RTK.md import. Vendored from rtk init; refresh it from a new rtk init rather than editing by hand |
private_dot_claude-personal/, private_dot_claude-work/ |
~/.claude-personal, ~/.claude-work (0700) — separate accounts selected by CLAUDE_CONFIG_DIR (claude-personal / claude-work functions in .zshrc). Same settings as the default profile; CLAUDE.md and RTK.md are symlinks to the canonical copies under ~/.claude/ |
run_onchange_after_install-krew-plugins.sh.tmpl |
Bootstraps krew and installs the kubectl plugins it serves. mise ships the krew installer; kubectl krew only resolves a binary named kubectl-krew, which krew produces by installing itself into ~/.krew. The private index is added on the work machine only, and the step skips with a message when the host is unreachable |
dot_config/starship.toml |
Starship prompt config → ~/.config/starship.toml (kubernetes/aws/terraform modules) |
dot_zshrc.tmpl |
~/.zshrc — Oh My Zsh (plugins only) + Starship prompt + zoxide + mise + aliases (kubectl, modern CLI); secrets pending |
dot_local/bin/ |
Executable scripts symlinked to ~/.local/bin/ by chezmoi |
dot_local/bin/executable_cleanup |
~/.local/bin/cleanup — disk-reclaim tool (reports by default; --apply deletes Tier 1 caches + orphan caches of removed tools, --deep adds Go modcache) |
dot_local/bin/executable_updates |
~/.local/bin/updates — reports available mise + Homebrew package updates |
dot_local/bin/executable_statusline |
~/.local/bin/statusline — Claude Code statusline: prefixes a marker for the active account profile (🏢 work, 🏠 personal, read from CLAUDE_CONFIG_DIR), then execs claudeline with every segment intact |
dot_local/bin/executable_git-agent-sign.tmpl |
~/.local/bin/git-agent-sign — signing shim: forces this machine's vault agent socket, then execs ssh-keygen, so non-login shells sign too |
private_dot_ssh/private_config.tmpl |
~/.ssh/config (0600) — IdentityAgent pointed at the machine's vault agent, OrbStack's include, and an Include ~/.ssh/conf.d/*.conf glob for unpublishable per-host config |
dot_local/bin/executable_login-agents |
~/.local/bin/login-agents — bootout/bootstrap cycle for the login agents below; run by the run_onchange hook and by bootstrap step 10 |
Library/LaunchAgents/*.plist |
~/Library/LaunchAgents/ — launchd agents started at login, one file per app (dev.sanchpet.orbstack starts the OrbStack engine so the Docker socket is up without opening the app). Add an app = add a plist |
run_onchange_after_login-agents.sh.tmpl |
Reloads the login agents on chezmoi apply whenever a plist changes (keyed on their hashes) |
run_onchange_after_install-helm-plugins.sh |
Installs helm-unittest from its signed release tarball into helm's own plugin directory — a plugin is not a tool on PATH, so mise cannot declare it. Verifies the signature against a pinned key fingerprint in an isolated keyring; skips (not fails) when helm or gpg is absent |
run_onchange_after_sudo-touch-id.sh |
Installs /etc/pam.d/sudo_local (Touch ID for sudo) + /etc/sudoers.d/timestamp (no credential cache). Idempotent, macOS-only, skips rather than prompts without a terminal |
dot_config/teleport/teleport.yaml.tmpl |
~/.config/teleport/teleport.yaml — SSH node config: reverse tunnel to the personal cluster's proxy, SSH service only. Personal profile only (.chezmoiignore) |
Library/LaunchAgents/dev.sanchpet.teleport-node.plist |
Runs the node as a login agent, so it serves sessions only as the logged-in user. Personal profile only |
dot_local/bin/executable_home-desktop |
~/.local/bin/home-desktop — forwards a local port to a node's Screen Sharing over Teleport and opens the viewer; the shell it drops you in is the tunnel's lifetime |
dot_local/bin/add-podkop-subnet |
~/.local/bin/add-podkop-subnet — route a domain through Podkop (VLESS) on Cudy router, then podkop reload. Default: resolve domain → subnet → user_subnets (for FortiClient VPN, where FakeIP routing fails). --domain: add the name verbatim → user_domains (FakeIP), e.g. for a domain whose anycast IPs are partially blackholed on the RU path |
dot_local/bin/executable_age-archive |
~/.local/bin/age-archive — encrypt a directory to your age key with a verify gate (-s DIR [-o FILE] [-d DEST]… [--rclone REMOTE]… [-R age1…]…), or restore one (--restore ARCHIVE TARGET). The secret key comes from --identity-cmd (default $AGE_IDENTITY_CMD, e.g. bw get item <item>) or stdin; the self-recipient is derived from it, so no age1… on the CLI. Plaintext and the secret key never hit disk; distribution is gated behind a passing round-trip decrypt; pass -R recipients to widen access (e.g. add a YubiKey key). --help for the full interface |
.chezmoi.toml.tmpl |
Generates per-machine chezmoi config at init (prompts profile); never deployed |
bootstrap.sh |
Bare-machine bootstrap (operational, not deployed) |
Brewfile.tmpl |
GUI casks + Mac App Store apps for brew bundle; templated to withhold the personal-only casks from a work machine and skip the App Store entries in CI (operational; rendered at bootstrap) |
mise.toml |
Repo-local dev tooling (pre-commit) |
.pre-commit-config.yaml |
Lint hooks (shellcheck + hygiene) |
.chezmoiignore |
Keeps operational files in the repo but out of $HOME |
- Touch ID for
sudo, never aNOPASSWDrule. An agent working in this shell cannot type a password — its commands run without a controlling terminal. The tempting fix, aNOPASSWDline insudoers.d, hands those rights not to one agent but to every process running as this user (a packagepostinstall, any script that gets executed), and no honestly narrow allowlist exists:launchctlas root is a loaded arbitrary daemon, i.e. full root anyway.auth sufficient pam_tid.soin/etc/pam.d/sudo_localinverts that — the module short-circuits before the password prompt, so the missing terminal never matters, and the prompt is a system dialog raised insidesudoitself. Verified: asudoissued from a non-TTY agent process does raise it, because the process inherits the GUI session's bootstrap namespace. Paired withDefaults timestamp_timeout=0(/etc/sudoers.d/timestamp) it means every single root action costs one live fingerprint — an agent can ask for root and never hold it. Consequences worth remembering: insidetmuxthis needspam_reattach; over a remote session (Teleport, SSH) it cannot work at all, since there is no finger at that end — remotesudofalls back to a password, which a non-TTY caller cannot supply. Both files live outside$HOMEand need root, so chezmoi cannot own them as targets;run_onchange_after_sudo-touch-id.shinstalls them instead. It costs one password on a fresh machine and nothing ever after — it exits early when both files are already right, and refuses to prompt when there is no terminal, so a headless bootstrap prints what is left to do rather than hanging on a prompt nobody can answer. The sudoers drop-in is validated withvisudo -cbefore it is installed, because a malformed one locks the account out of root entirely. - chezmoi over GNU Stow / bare-git. Needed templating (per-machine values), first-class secret handling, and a source tree where dotfiles stay visible (
dot_prefix) instead of hidden. Stow only symlinks; bare-git has no templating or secrets. - mise-first for CLI tools. All CLI tooling is declared in mise (
config.toml), versioned and cross-machine. Homebrew is reserved for what mise can't provide — GUI casks, plus the rare CLI with heavy native deps or no upstream release (e.g.sshpass). This keeps the toolchain reproducible and the Brewfile minimal. - Per-profile mise tools live in
conf.d/, not in a template. The obvious move is a profile-gated block insideconfig.toml, the way.chezmoiignoregates whole files — butmiseg/miserm(.zshrc) runchezmoi add ~/.config/mise/config.tomlafter every change, andchezmoi addon a template overwrites the source with rendered content, silently destroying the directives. Soconfig.tomlstays a plain file and work-only tools go indot_config/mise/conf.d/work.toml, which mise merges automatically and.chezmoiignorewithholds from personal machines. The residue:misegon a work machine still writes into the sharedconfig.toml, so a work-only addition has to be moved intoconf.d/by hand. - Tool declarations are grouped by purpose, and re-combed by hand.
misegappends to the end of[tools], so the grouping drifts as tools are added. That drift is accepted rather than automated: order is a readability property, not a correctness one — nothing breaks while it slips — so a hook that rewrites[tools], or a CI check that rejects an ungrouped line, would trade a cheap problem for an expensive one. Periodic tidying stays cheap because the invariant is verifiable in seconds rather than by careful reading: diff the sorted tool sets before and after the move, then check README parity in both directions. - One OCI registry client, not four.
crane,orasandskopeowere dropped in favour ofregctlat the 2026-08-15 inventory, which found a year of shell history with zero invocations of any of them — the surest sign that choosing between overlapping tools costs more than the tools save. Their unique parts serve ecosystems absent here (podman transports, Apptainer, signing outside cosign), whileregctl's — manifest indexes and OCI artifacts — are exactly the GHCR chart-publishing case. - Bitwarden for secrets. Secrets are pulled from Bitwarden at
chezmoi applyvia{{ bitwarden ... }}templates — nothing secret (encrypted or otherwise) lives in this public repo. Trade-off: bootstrap needs an interactivebw unlockbefore applying secret-bearing files (vs.age/secrets.env, which keep apply offline but place material in/near the repo). - pre-commit + shellcheck. Every commit lints shell scripts and runs hygiene checks, so
bootstrap.shand friends stay correct. pre-commit itself is installed via mise (postinstallwires the git hooks automatically). - Bootstrap ordering. The mise config is itself a managed dotfile, so it is applied before
mise installto break the chicken-and-egg; Homebrew is installed lazily, only when GUI casks are present. - mise hooks enabled (
settings.experimental). Turned on globally so a project'smise.tomlcan self-activate its git hooks with[hooks] enter = "git config core.hooksPath .githooks", instead of a manualgit configon every clone/machine. Kept at the machine level (not duplicated per repo) so individual projects only declare the[hooks]they need. - Backend preference: aqua first, then github, then http, and a language manager last. mise's registry has acceptance tiers, and the ladder here mirrors them:
aqua(most features and security, and no plugin code runs at install), thengithub/gitlabfor what aqua lacks (attestation + SLSA verification), thenhttpfor vendor binaries with no Git host — pinned by version and sha256, because nothing else vouches for them — andpipx/npm/gem/cargoonly where the tool is native to that ecosystem. A bare tool name is left alone unless the registry's own first choice is wrong here; an explicit backend always carries a comment saying why.aquaalso wins overgithubwhen the aqua-registry entry does something the raw release does not:kubectl-view-secretships its binary dash-named, and only aqua'sfiles:rename tokubectl-view_secretmakes kubectl discover it as a plugin. - Two tools stay on
vfox, deliberately. mise no longer accepts vfox/asdf tools, since a plugin is arbitrary code run at install time.1password-cli(work profile only) andteleport-communityare grandfathered because neither can move, and the reasons are worth recording so nobody re-litigates them.rediswas the third until the 2026-08-15 inventory dropped it as unused — it published no prebuilt binaries at all, which is a model mismatch with aqua rather than a missing package, so the exception it needed was the most expensive of the three. TheopCLI is closed-source with no public GitHub repository, so although an aqua package exists it is a baretype: httpagainst the AgileBits CDN with norepo_owner/repo_name; aqua resolves version lists only from GitHub releases or tags, solatestcannot work and the tool would have to be hand-pinned and hand-bumped. For a credential CLI, falling behind on updates is the worse trade.teleport-communitylooks like the easier of the two to move — an aqua package exists, resolveslatest, and halves the install — but on macOS it ships only the client tools (tsh,tctl). The personal profile also runs theteleportnode daemon out of that same install dir (see Remote access), and the launch agent execs it by absolute path with an[ -x ] || exit 0guard — so dropping the server binary would not fail loudly, it would leave the node silently serving nothing. Both plugins come from mise's own orgs (mise-plugins,jdx), so they share a trust root with mise itself — which is what makes the exception tolerable. New vfox/asdf tools are still refused. - The
ubi:backend is banned (settings.disable_backends). mise deprecated it in favour ofgithub:, which resolves the same GitHub releases and additionally verifies artifact attestations and SLSA provenance; it disappears in mise 2027.1.0. Rather than let aubi:tool be added and quietly rot until that release, mise is told to refuse the backend outright. The mise registry itself no longer routes any tool through ubi, so the ban costs nothing — only a hand-writtenubi:line can hit it. Because a setting binds one machine at install time, CI carries the matching repo-level guard, which rejects both aubi:declaration and the removal of the ban itself. - Per-machine via
profile, not per-machine directories. One source tree; machine-specific variation is driven by a singleprofilevalue (work/personal), prompted once atchezmoi init(override in CI/headless withDOTFILES_PROFILE) and stored in the machine-local chezmoi config (never in this repo). Templates branch on it —Brewfile.tmplwithholds the personal-only casks from a work machine, and.chezmoiignorewithholds the work-only mise tools from a personal machine and the Teleport node from a work one. Git identity, by contrast, is directory-based and kept out of this public repo:dot_gitconfig.tmpldefaults to the personal identity (sanchpet) with SSH commit signing everywhere. A machine that also does corporate work sets its work identity (work.name/work.email) and the dir its repos live under (work.gitdir) in the machine-local chezmoi data — never in this repo. When both are set, anincludeIf "gitdir:…"pulls indot_config/git/work.incto switch to that identity under the work dir; a machine with no corporate identity gets neither theincludeIfnorwork.inc. Settingwork.emailwhile leavingwork.gitdirblank is refused at render time — git reads an emptygitdir:pattern as matching every repository, which would make the corporate identity the global default. Corporate commits are signed by a key declared for work (work.signingKey, machine-local like the rest), trusted inallowed_signersunder the work email as its own principal; registering that key with the corporate host is what makes them verify there. Declare no work key and signing is switched off under the work dir rather than inheriting the personal key — a host that has never seen that key cannot verify it, so such a signature only discloses which personal key made the commit, and unlike an unsigned commit it does not look like one. Personal signing is separately gated on its key existing, so a machine without it still commits. This keeps a single declarative source of truth, keeps the employer identity out of the public repo, and avoids the duplication/drift of per-machine dirs. - SSH keys live in a vault agent — never on disk. Each machine keeps its own
auth@…andsigning@…keys in its password manager and serves them over that app's SSH agent, unlocked by Touch ID: personal machines use Bitwarden, work machines 1Password. The choice is one value in the machine-local chezmoi data (agent.kind), alongside that machine's signing public key (agent.signingKey), whichdot_gitconfig.tmplrenders as akey::literal foruser.signingKey. Onboarding a machine means creating the keys in the vault, not runningssh-keygen—bootstrap.shgenerates nothing and reads the public halves from the agent when registering them on GitHub.~/.ssh/configsetsIdentityAgentto the machine's socket for all hosts. Because that only coversssh, git additionally pointsgpg.ssh.programat a shim (~/.local/bin/git-agent-sign) forcing the same socket for signing, so non-login shells (Claude Code, background agents, scripts) sign too. Verification is declarative: every machine's signing public key is listed inallowed_signers, which is how commits made elsewhere verify locally. Corporate repos sign as well — only the identity differs there, not the key. Under the same flag, Teleport'stshis told not to load its short-lived cert into this sign-only agent (TELEPORT_USE_LOCAL_SSH_AGENT=false) — the add would fail and abort the login;tshkeeps its certs in~/.tshregardless. - Corporate network config is included, not committed.
~/.ssh/configends at theIdentityAgentline; anything host-specific arrives throughInclude ~/.ssh/conf.d/*.conf, read first so a drop-in wins on any keyword. That split exists because a bastion topology — jump hosts,ProxyJumpchains, datacenter host names — describes an employer's network and cannot go into a public repository, and because such a config wants options this one should never grant globally: reprovisioned hosts behind shared bastion addresses need host-key checking off, which belongs to those patterns and not to GitHub. A drop-in is not a chezmoi target, sochezmoi applyleaves it alone (private_dot_sshis not anexact_directory — renaming it to one would delete every drop-in). The cost is that the file is outside the declarative loop: it is reproduced from the employer's own documentation on a new machine, not from here. - Bitwarden server — self-hosted, per machine.
bootstrap.shpoints thebwCLI at.bitwarden.server(asked once atchezmoi init, overrideDOTFILES_BW_SERVER) before login, so a self-hosted Vaultwarden works out of the box; blank keeps thebitwarden.comdefault. The URL is live infra, so it is never defaulted in this public repo — it lives only in the machine-local chezmoi config. Login is TTY-gated: a non-TTY run (CI/headless) skips it instead of hanging.
chezmoi has two locations: the source (this repo, chezmoi source-path) and the live files in $HOME. Always edit the source, then push it to live — never edit the live file directly.
| Scenario | Command |
|---|---|
Changed a dotfile (e.g. .zshrc) |
edit the source (dot_zshrc.tmpl), then chezmoi apply ~/.zshrc (alias cza) |
| Pull latest on another machine | chezmoi update (= git pull + apply) (alias czu) |
| Check source ↔ live drift | chezmoi diff (alias czd) |
A tool wrote to a non-templated target (e.g. mise use -g → ~/.config/mise/config.toml) |
re-import: chezmoi add <target> (see the miseg helper) |
Never run
chezmoi add ~/.zshrc. It is a template (dot_zshrc.tmpl) —addwould overwrite it with the rendered content and destroy the{{ ... }}directives (incl. future secrets). Templated files are source-edited only;chezmoi addis for non-templated targets.
Secrets are never committed. They are resolved at apply time from Bitwarden via chezmoi templates. On a fresh machine, bootstrap.sh prompts for bw unlock only when the source actually contains secret templates.
For interactive use, bwu (defined in .zshrc) logs in once per machine and unlocks per session, exporting BW_SESSION. ~/.local/bin/age-archive then reads its key via AGE_IDENTITY_CMD (a bw get item …), so the age secret is fetched from the vault at run time — never pasted, never on disk.
The zsh config (dot_zshrc.tmpl) is kept as a .tmpl so a {{ bitwarden ... }} secret line can be added later without a rename — see Zsh shell for the plugin set and prompt.
Pending: the
OBSIDIAN_API_KEYsecret reference (via Bitwarden) is not wired yet —.zshrcis kept as a.tmplso the{{ bitwarden ... }}line can be added without a rename.