Initial public release

A chezmoi-based fleet-dotfiles template for macOS workstations:

- Two-way auto-sync via launchd watcher + 5-min puller
- Mesh SSH via modify_authorized_keys driven by .chezmoidata/fleet.yaml
- age-encrypted secrets file
- Bundled Claude Code agentic team (11 agents) + /lite + /lite-sub commands
- Verify-before-claiming Stop hook
- Generic statusline + project-boundary validate-path hook
- Reference launchd plist for cross-fleet task-durations aggregation
  (companion repo: gitea.tojo.team/cardinale/task-durations)
- AGENTS.md walks an agent through the entire setup Q&A interactively
- docs/ covers architecture, security model, fleet onboarding
This commit is contained in:
Anthony Cardinale
2026-05-02 17:26:32 -04:00
commit ebccdda936
42 changed files with 2994 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
# Onboarding an additional fleet machine
Once your first machine is set up (see [`setup-new-fleet.md`](setup-new-fleet.md)), each subsequent machine joins via this much shorter flow.
## On the existing fleet (any one machine)
Add the new machine's eventual hostname + user to `.chezmoidata/fleet.yaml`. The pubkey can be a placeholder for now — you'll fill in the real one after the new machine generates its identity key.
```yaml
fleet:
# ... existing entries ...
newmachine:
user: alice
pubkey: "PLACEHOLDER_WILL_FILL_IN_AFTER_NEW_MACHINE_RUNS_KEYGEN"
```
Save. The watcher commits + pushes within ~10 seconds.
## On the new machine
### 1. Prerequisites
```bash
brew install chezmoi age duckdb
```
### 2. Get the age private key
The new machine needs the SAME age private key as every other fleet machine. Copy it from an existing machine via:
- Encrypted USB drive
- Password manager attachment
- 1Password Secure Notes
- Encrypted message (Signal, iMessage, etc.)
Place at `~/.config/chezmoi/key.txt` and `chmod 600` it.
**Do NOT** use plain email, Slack, or any cloud sync that decrypts at rest.
### 3. Initialize chezmoi from the fleet's forge
```bash
chezmoi init https://<forge>/<you>/fleet-dotfiles.git
chezmoi apply
```
`chezmoi apply` will:
- Decrypt `secrets.env` and write it to `~/.config/fleet-dotfiles/`.
- Render the launchd plists with this machine's `$HOME`, write them to `~/Library/LaunchAgents/`.
- Run the `run_onchange` script which loads the watcher, puller, and pull-fleet daemons.
- Run the `modify_authorized_keys` script which appends every existing fleet machine's pubkey to `~/.ssh/authorized_keys`.
### 4. Generate this machine's identity SSH key
```bash
[ -f ~/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub
```
Copy that pubkey.
### 5. Update fleet.yaml with the real pubkey
```bash
chezmoi edit .chezmoidata/fleet.yaml
```
Replace the placeholder you set in step 0 with the real pubkey.
```bash
chezmoi apply
```
The watcher fires, commits the change, pushes it. Within ~7 minutes, every existing fleet machine pulls the update and adds the new pubkey to its `authorized_keys`.
### 6. Verify mesh SSH
From the new machine, try SSHing into one of the existing peers:
```bash
ssh <existing-peer-hostname> 'echo OK from $(hostname -s)'
```
If it returns `OK from <peer>`, mesh is working. If it prompts for a password or rejects, wait a few minutes (the puller hasn't fanned out the new pubkey yet) or run `chezmoi update --force` on the peer manually.
### 7. Install task-durations (optional)
```bash
git clone https://gitea.tojo.team/cardinale/task-durations.git ~/.claude/scripts/task-durations
chmod +x ~/.claude/scripts/task-durations/*.{py,sh}
python3 ~/.claude/scripts/task-durations/extract.py
```
The pull-fleet launchd job (already registered by step 3) will start mesh-rsyncing peers' parquets on the next 5-minute tick.
### 8. Confirm
```bash
launchctl list | grep -E "chezmoi|taskdurations"
chezmoi diff # should be empty (everything in sync)
```
You're done.
## Removing a machine
If a machine is decommissioned or lost:
1. On any remaining machine, edit `.chezmoidata/fleet.yaml` to delete the entry.
2. `chezmoi apply` (or just save — watcher will fire).
3. Every remaining peer will, on next apply, NOT re-add that pubkey to its `authorized_keys`. **Note:** existing entries are NOT removed by the `modify_` script — you'll need to manually `ssh-keygen -R` or hand-edit `~/.ssh/authorized_keys` to remove the line.
4. Rotate any secrets the lost machine had access to (since it could have copied the unencrypted `~/.config/fleet-dotfiles/secrets.env` to disk).
+86
View File
@@ -0,0 +1,86 @@
# Fleet sync architecture
This template provides a two-way dotfile sync across N macOS machines. Every change you make on any machine propagates to the others within ~7 minutes. There's no central server — each machine is a peer.
## Three moving parts
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Machine A Machine B │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Watcher │ on file change │ Watcher │ │
│ │ (launchd) │ ─────┐ │ (launchd) │ │
│ └──────────────┘ │ └──────────────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ │ chezmoi-auto-sync.sh │ │
│ │ • git pull --rebase │ │
│ │ • chezmoi add <managed> │ │
│ │ • git commit + push ──────────► forge (gitea/github) │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Puller │ every 5 min: │ Puller │ every 5 min: │
│ │ (launchd) │ chezmoi update │ (launchd) │ chezmoi update │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Watcher (`com.chezmoi.claude-watcher.plist`)
Launchd's `WatchPaths` fires the watcher script (`~/.local/bin/chezmoi-auto-sync.sh`) within ~2 seconds of any change to a watched path. The script:
1. Acquires a lockfile (prevents concurrent runs from racing).
2. Sleeps 2 s to let batch saves settle.
3. `git pull --rebase` against the forge to incorporate any updates that landed since.
4. `chezmoi add` for each path on the managed list (a hardcoded set of `chezmoi add` lines in the script).
5. If chezmoi's autoCommit didn't pick up everything (e.g., direct edits inside `~/.local/share/chezmoi/docs/`), a `git add -A; git commit; git push` fallback catches them.
The watched paths are listed in the plist's `WatchPaths` array. Adding a new tracked path: edit the plist template AND the `chezmoi add` block in the script — both are chezmoi-managed and propagate fleet-wide.
### Puller (`com.chezmoi.claude-puller.plist`)
Runs `chezmoi update --force` every 5 minutes. `update` is `pull` + `apply`: it fetches the forge repo, then materializes any new content to the live disk paths. The `--force` skips interactive prompts on conflicts (the watcher's `git pull --rebase` upstream is supposed to keep machines in lockstep, so conflicts should be rare — when they happen, the puller wins with the source's version).
### Pull-fleet (`com.taskdurations.pull-fleet.plist`)
Optional, if you use the bundled task-durations system. Runs `pull-fleet.sh` every 5 minutes, which mesh-rsyncs each peer's `local.parquet` into a Hive-partitioned tree, so `estimate.sh --fleet` can union across the whole fleet. See [task-durations' own architecture doc](https://gitea.tojo.team/cardinale/task-durations/src/branch/main/docs/fleet-architecture.md) for details.
## Why this shape
| Choice | Why |
|---|---|
| Two daemons (watcher + puller), not one | The watcher is event-driven (instant push); the puller is timer-driven (eventual pull). Different cadences, different jobs. |
| Forge in the middle, not direct mesh | One git server is dead-simple to reason about; conflicts resolve via `git pull --rebase` semantics; offline machines just lag without breaking the others. |
| `chezmoi add` per path (not `chezmoi re-add` on the whole tree) | Surgical — a watcher fire only commits the path that changed. |
| `run_onchange` to reload launchd | When a plist's rendered content changes, launchd needs an unload/load cycle. The hash-of-template trick in `run_onchange_after_reload-launchd-agents.sh.tmpl` re-runs the reload only when a plist actually changes. |
| chezmoi templates with `{{ .chezmoi.homeDir }}` and `{{ .chezmoi.hostname }}` | Lets the same source render correctly on machines with different usernames (e.g., `/Users/alice` on one, `/Users/bob` on another). |
| age encryption for secrets | Decoupled from chezmoi; one private key per machine; secrets file is a single env-style flat file that's encrypted-at-rest in the source repo and decrypted-on-apply at runtime. |
| `modify_` script for `authorized_keys` | Preserves machine-local entries (e.g., GitHub keys) while ensuring fleet pubkeys are always present. Runs on every apply. |
## Why NOT alternatives
- **Dropbox / iCloud Drive / sync.com / Resilio:** great for documents, terrible for `~/.claude/` and dotfiles. Path conflicts, lock files, partial syncs, no encryption boundary, no version history when something breaks.
- **One mega `~/dotfiles` git repo with stow / GNU stow:** works for one user, but no per-machine templating (HOME path differences, hostname-keyed conditions) and no encrypted secret support.
- **Ansible push from a central machine:** reliable but heavyweight. Requires the orchestrator to be online; you can't iterate from a laptop while the orchestrator is asleep.
- **NixOS / nix-darwin:** awesome but a much bigger commitment than chezmoi. Makes sense if you're already running Nix.
- **Tailscale Funnel + a central API:** introduces a new dependency for something git-over-SSH already does.
## Failure modes and what happens
| Failure | Effect |
|---|---|
| Forge offline | Watcher's push fails; commit stays local. Puller's pull fails; live state stays at last-applied. Both retry on next event/tick. |
| Two machines edit the same file simultaneously | Whichever pushes first wins; the second's `git pull --rebase` rebases its commit on top. If git can't auto-rebase, the watcher logs `WARNING: git pull --rebase failed`. Manual fix in `$(chezmoi source-path)`. |
| `chezmoi update --force` overwrites an in-flight local edit | The watcher's debounce + lockfile makes this rare, but possible. The "managed list" is the contract: anything in the list is sync-managed; anything outside is local-only and won't be touched. |
| External skill repo (`.chezmoiexternal.toml`) is unreachable | Single-line failure; chezmoi reports `exit status 1` but other paths still apply. Switch the entry from HTTPS to SSH (or vice versa) if it's an auth issue. |
| Age private key compromised | All encrypted files in the source repo are now decryptable by the holder. **Regenerate**: new keypair, decrypt + re-encrypt secrets with the new public key, distribute new private key to fleet via secure channel, force-rotate any tokens that were inside the secrets file. |
## Adding a new tracked path
1. Edit `~/.local/bin/chezmoi-auto-sync.sh`: append a `chezmoi add ~/path/to/new 2>> "$LOG" || true` line in the `chezmoi add` block.
2. If the path is outside the watcher's existing `WatchPaths`, edit the watcher plist template at `private_Library/LaunchAgents/com.chezmoi.claude-watcher.plist.tmpl` to add it.
3. Run `chezmoi add ~/path/to/new` once manually to seed the chezmoi source with current content (otherwise the next puller cycle will overwrite your live file with whatever empty/stale content was in the source).
4. The watcher script and plists are themselves chezmoi-managed, so the change propagates to the fleet within ~7 minutes.
+75
View File
@@ -0,0 +1,75 @@
# Security model
What's encrypted, what isn't, and where each line is drawn.
## Encryption boundaries
| Lives where | Encrypted at rest? | Why |
|---|---|---|
| `~/.config/fleet-dotfiles/secrets.env` (live, on disk) | **No** — plaintext, mode 600 | Sourced by `.zshrc` on shell start; encryption would block that. |
| `dot_config/private_fleet-dotfiles/encrypted_private_secrets.env.age` (chezmoi source) | **Yes** — age | Pushed to a forge that may be public-readable. |
| `private_dot_ssh/encrypted_private_id_ed25519_*.age` (chezmoi source) | **Yes** — age | Same. |
| `~/.ssh/id_ed25519_*` (live, on disk) | **No** — plaintext, mode 600 | OpenSSH reads it directly; no decryption hook. |
| `~/.ssh/id_ed25519` (machine identity) | n/a — never enters chezmoi | Each machine generates its own; this key never travels. |
| `~/.ssh/authorized_keys` | n/a — plaintext is the format | Composed by a `modify_` script that appends fleet pubkeys; pubkeys aren't secret. |
| `~/.config/chezmoi/key.txt` (age private key) | n/a — never enters chezmoi | Distributed via secure side-channel only. |
## Threat model
This template is designed for a **fleet of personal machines you control**, syncing through a forge **you trust** (your own gitea, a private GitHub repo, etc.).
It assumes:
- The forge can read the encrypted artifacts but can't decrypt them.
- A network observer can read the forge HTTPS traffic (just encrypted blobs).
- The age private key is distributed out-of-band (USB, password manager, secure messaging).
- Each machine's local disk is encrypted at the OS level (FileVault on macOS).
It does NOT defend against:
- A forge admin running arbitrary code on the server (they could swap `chezmoi-auto-sync.sh` and exfiltrate plaintext secrets the next time the watcher fires). Mitigation: only use forges you control or fully trust.
- A compromised local user account (they'd have read access to the cleartext `secrets.env` and the age private key). Mitigation: standard endpoint hygiene; rotate aggressively if a machine is lost.
- Side-channel disclosure of secrets via shell history, tmux scrollback, or process arg lists. Mitigation: never `echo` secrets, never pass them as command-line args, prefer env-var sourcing.
## Age key handling
- The fleet uses a **single age recipient public key** for encryption, stored in `.chezmoi.toml.tmpl`. It's safe to commit publicly.
- Every fleet machine carries the **matching private key** at `~/.config/chezmoi/key.txt`. This is what decrypts the encrypted source files on apply. **Never commit this file.** Never email it. Never drop it in cloud storage. Move it via secure messaging or a USB drive.
- If the private key is lost, you can still decrypt the source files on any machine that still has its copy of the key. Re-encrypt with a new keypair, distribute the new private key, and rotate keys-of-keys (any tokens that lived inside the encrypted secrets) since you'd want to assume any machine that lost track of its key may have leaked it.
## What gets pushed to the forge
After a successful watcher fire, the chezmoi source repo on the forge contains:
- `dot_*` and `private_dot_*` directories with cleartext config (paths, hostnames, settings).
- `encrypted_*.age` files for anything containing secrets.
- `*.tmpl` files (chezmoi templates with cleartext placeholders).
- The chezmoi-auto-sync.sh script (cleartext bash).
It does NOT contain:
- The age private key.
- The cleartext `secrets.env`.
- The machine-identity SSH private key (`~/.ssh/id_ed25519`).
- Any `~/.claude/projects/` transcripts (these are local-only).
- Any task-durations parquet output (lives at `~/.local/share/task-durations/`, outside the watch tree).
## SSH between fleet machines
Mesh access is set up by `private_dot_ssh/modify_private_authorized_keys.tmpl`. On every `chezmoi apply`, it reads `.chezmoidata/fleet.yaml`, walks every machine's pubkey, and appends any that aren't already in `~/.ssh/authorized_keys`. Existing entries (e.g., a GitHub deploy key, a colleague's pubkey) are preserved.
The flow when a new machine joins:
1. New machine generates `~/.ssh/id_ed25519` locally.
2. You add its pubkey to `.chezmoidata/fleet.yaml` (committed via the watcher).
3. Within ~7 minutes, every existing fleet machine pulls the change and the `modify_` script appends the new pubkey to its `authorized_keys`.
4. Now the new machine can SSH any peer.
## Rotation playbook
| Compromise | Action |
|---|---|
| One token leaked | Edit `~/.config/fleet-dotfiles/secrets.env` with the new value → save → watcher commits + pushes encrypted form → puller fans it out. Rotate the token at the issuing service. |
| Age private key leaked | Generate new keypair → decrypt all `*.age` files in source → re-encrypt with new public key (`chezmoi re-add --encrypt`) → distribute new private key out-of-band → rotate every token in secrets.env (assume they leaked). |
| One machine lost / stolen | Remove its entry from `.chezmoidata/fleet.yaml` → commit → on next puller cycle, remaining machines no longer accept the lost machine's pubkey. Revoke any tokens the lost machine had local cleartext access to. |
| Forge compromised | Treat as if all encrypted secrets leaked: rotate every token. Move the fleet to a new forge. |
+161
View File
@@ -0,0 +1,161 @@
# Setup — first machine of a new fleet
If you'd rather have an agent (Claude Code, Codex, etc.) walk you through this interactively, point the agent at this repo and tell it to follow [`AGENTS.md`](../AGENTS.md). The instructions below are the same flow, written for a human to follow directly.
## Prerequisites
```bash
brew install chezmoi age duckdb
```
You need git access to whichever forge you'll host the fleet repo on (gitea, GitHub, forgejo, gitlab — anything chezmoi can clone over HTTPS or SSH).
## 1. Fork this template
Fork `fleet-dotfiles-template` to your forge:
- **Gitea:** `https://gitea.tojo.team/cardinale/fleet-dotfiles-template` → click Fork.
- **GitHub:** if mirrored there, fork it.
- **Other forges:** clone, push to a new repo on your forge.
Name the fork however you want (`my-fleet-dotfiles`, `<your-handle>-dotfiles`, etc.). The rest of this doc assumes you've named it `fleet-dotfiles` on your forge.
## 2. Generate an age keypair (one for the whole fleet)
This keypair encrypts the secrets file. The private key never touches the forge. Every machine in the fleet gets a copy of it via secure side-channel.
```bash
mkdir -p ~/.config/chezmoi
age-keygen -o ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt
```
`age-keygen` prints the public key to stderr and writes it as the first commented line of `key.txt` prefixed with `# public key: `. Capture it — you'll paste it into the chezmoi config in the next step.
## 3. Initialize chezmoi from your fork
```bash
chezmoi init https://<forge>/<you>/fleet-dotfiles.git
```
This clones the fork to `~/.local/share/chezmoi/` and runs the template prompts. When it asks for the age recipient, paste the public key you captured.
If you missed the prompt, run `chezmoi edit-config` and replace `REPLACE_ME_WITH_YOUR_AGE_PUBLIC_KEY` in the rendered config.
## 4. Replace placeholders with your real config
The example files live in `examples/`. Move and edit them into the chezmoi source layout:
```bash
chezmoi cd
# Fleet roster
mv examples/fleet.yaml.example .chezmoidata/fleet.yaml
$EDITOR .chezmoidata/fleet.yaml
# (replace placeholder hostnames, users, pubkeys; for now you only need this machine's pubkey)
# Git identity
mv examples/gitconfig.example dot_gitconfig
$EDITOR dot_gitconfig
# (replace <YOUR_EMAIL> and <YOUR_NAME>)
# SSH config (templated)
mv examples/ssh-config.tmpl.example private_dot_ssh/config.tmpl
$EDITOR private_dot_ssh/config.tmpl
# (replace placeholder hostnames + usernames with your real fleet)
# Shell config (templated)
mv examples/zshrc.tmpl.example dot_zshrc.tmpl
$EDITOR dot_zshrc.tmpl
# (add your own PATH, aliases, completions; keep the secrets.env source line)
# Claude Code top-level instructions
mv examples/CLAUDE.md.example dot_claude/CLAUDE.md
$EDITOR dot_claude/CLAUDE.md
# (add personal sections if you want; the template only carries fleet-relevant ones)
```
## 5. Generate this machine's identity SSH key
If `~/.ssh/id_ed25519` doesn't exist:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
```
Then add this machine's pubkey to `.chezmoidata/fleet.yaml`:
```bash
PUBKEY=$(cat ~/.ssh/id_ed25519.pub)
echo "Add this to .chezmoidata/fleet.yaml under your machine's entry:"
echo " pubkey: \"$PUBKEY\""
```
## 6. Set up encrypted secrets
```bash
mkdir -p ~/.config/fleet-dotfiles
cp $(chezmoi source-path)/examples/secrets.env.example ~/.config/fleet-dotfiles/secrets.env
chmod 600 ~/.config/fleet-dotfiles/secrets.env
$EDITOR ~/.config/fleet-dotfiles/secrets.env
# (fill in real values for tokens you actually use; delete the rest)
# Encrypt + add to chezmoi source:
chezmoi add --encrypt ~/.config/fleet-dotfiles/secrets.env
```
Verify:
```bash
ls $(chezmoi source-path)/dot_config/private_fleet-dotfiles/
# Expect: encrypted_private_secrets.env.age
```
## 7. Apply
```bash
chezmoi diff # review changes
chezmoi apply # materialize them
```
Verify the launchd jobs registered:
```bash
launchctl list | grep -E "chezmoi|taskdurations"
# Expect 3 lines: claude-watcher, claude-puller, taskdurations.pull-fleet
```
If any are missing, run:
```bash
for plist in ~/Library/LaunchAgents/com.{chezmoi,taskdurations}.*.plist; do
launchctl unload "$plist" 2>/dev/null
launchctl load "$plist"
done
```
## 8. Install task-durations (optional)
If you want fleet-wide time estimates:
```bash
git clone https://gitea.tojo.team/cardinale/task-durations.git ~/.claude/scripts/task-durations
chmod +x ~/.claude/scripts/task-durations/*.{py,sh}
python3 ~/.claude/scripts/task-durations/extract.py
```
The Stop hook in your `~/.claude/settings.json` will keep the local corpus fresh; the `com.taskdurations.pull-fleet` launchd job will mesh-rsync peer parquets every 5 minutes.
## 9. Push to your forge
```bash
chezmoi cd
git status # confirm no unencrypted secrets are staged
git add -A
git commit -m "Initial fleet dotfiles for $(hostname -s)"
git push -u origin main
```
You're done. Open the forge URL and confirm the encrypted secrets file is present (`encrypted_private_secrets.env.age`, NOT `secrets.env`).
To onboard the next machine, see [`add-machine.md`](add-machine.md).