diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl new file mode 100644 index 0000000..24df80a --- /dev/null +++ b/.chezmoi.toml.tmpl @@ -0,0 +1,102 @@ +{{- /* + Rendered into ~/.config/chezmoi/chezmoi.toml on `chezmoi init`. Cached + [data] values short-circuit prompts on subsequent init runs. + + Install profile (3 tiers, cascade): + core minimum to make these dotfiles functional. Installs: + - zsh, vim, tmux, git, curl, ca-certificates (Linux apt/dnf) + - Linux: ufw + tcpdump (network safety baseline) + - MesloLGS NF + Monaspace Neon fonts (chezmoi externals) + - mise + zoxide + fzf (zshrc integrations depend on these) + Use case: Hetzner VPS, DO droplet, jetson via SSH, Codespace, + devcontainer, recovery. ~50 MB. + dev core + CLI dev toolchain. Adds: + - 25 mise aqua tools (kubectl, helm, jq, gh, k9s, ...) + - Languages: Go, Python, Node, Rust (via mise core) + - Linux: docker.io, htop, tree, wget, nmap, telnet, libpq, + wireguard-tools, build-essential, xsel/wl-clipboard + - Mac brews: htop, tree, wget, nmap, telnet, libpq, + wireguard-tools, rtk (no docker on Mac dev — pick + workstation for Docker Desktop) + - goimports, ssh-audit (post-installs) + - AI npm globals: claude-code, codex + Use case: headless dev box (jetson, Ubuntu VM, Linux VPS for + actual work, Mac SSH'd into headless). ~1.9 GB first apply. + workstation dev + GUI apps. Adds: + - Mac: casks (iterm2, docker-desktop, vscode, ngrok, fonts) + - Linux: placeholder (empty for now) + Use case: primary GUI machine (Mac laptop, Linux desktop). + + Environment is auto-detected and shown as a HINT in the prompt — you + always pick explicitly. No silent auto-decision. Default = "core" (safe + fallback for CI / non-interactive / accidental enter). + + To re-prompt later: `chezmoi init --prompt`. + Skip prompt non-interactively (CI): default kicks in via --promptDefaults. +*/ -}} + +{{- /* ────── env detection (HINT ONLY — not a decision) ────── */ -}} +{{- $isSSH := or (ne (env "SSH_CONNECTION") "") (ne (env "SSH_CLIENT") "") (ne (env "SSH_TTY") "") -}} +{{- $hasGUI := false -}} +{{- if eq .chezmoi.os "darwin" -}} +{{- $hasGUI = ne (env "TERM_PROGRAM") "" -}} +{{- else if eq .chezmoi.os "linux" -}} +{{- $hasGUI = or (ne (env "DISPLAY") "") (ne (env "WAYLAND_DISPLAY") "") -}} +{{- end -}} +{{- $ephemeral := or (ne (env "CODESPACES") "") (ne (env "REMOTE_CONTAINERS") "") -}} + +{{- $detected := "core" -}} +{{- if and (not $ephemeral) $hasGUI -}}{{- $detected = "workstation" -}} +{{- else if and (not $ephemeral) (not $isSSH) -}}{{- $detected = "workstation" -}} +{{- else if not $ephemeral -}}{{- $detected = "dev" -}} +{{- end -}} + +{{- /* ────── identity prompts (cached after first init) ────── */ -}} +{{- $interactive := stdinIsATTY -}} +{{- if $ephemeral -}}{{- $interactive = false -}}{{- end -}} + +{{- $name := "Your Name" -}} +{{- if hasKey . "name" -}} +{{- $name = .name -}} +{{- else if $interactive -}} +{{- $name = promptString "Full name" $name -}} +{{- end -}} + +{{- $email := "you@example.com" -}} +{{- if hasKey . "email" -}} +{{- $email = .email -}} +{{- else if $interactive -}} +{{- $email = promptString "Email" $email -}} +{{- end -}} + +{{- /* ────── profile prompt (ALWAYS fires on first init via promptChoiceOnce; + default = "core" so CI / non-TTY get safe fallback) ────── */ -}} +{{- $hint := printf "Install profile [detected env: %s (GUI=%t, SSH=%t, ephemeral=%t)] — you pick" $detected $hasGUI $isSSH $ephemeral -}} +{{- $rawProfile := promptChoiceOnce . "profile" $hint (list "core" "dev" "workstation") "core" -}} + +{{- /* ────── legacy translation: previous design used profile="mac" for + workstation Mac. Map silently on next init so existing machines + don't need manual cleanup. ────── */ -}} +{{- $profile := $rawProfile -}} +{{- if eq $rawProfile "mac" -}}{{- $profile = "workstation" -}}{{- end -}} + +{{- /* ────── .osid composite key: "darwin" / "linux-". Single derived + value used by .chezmoiscripts/* — replaces nested if-elif on + .chezmoi.osRelease.id. Pattern from twpayne/dotfiles. ────── */ -}} +{{- $osid := .chezmoi.os -}} +{{- if eq .chezmoi.os "linux" -}} +{{- if hasKey .chezmoi.osRelease "id" -}} +{{- $osid = printf "linux-%s" .chezmoi.osRelease.id -}} +{{- end -}} +{{- end -}} + +[data] + name = {{ $name | quote }} + email = {{ $email | quote }} + profile = {{ $profile | quote }} + osid = {{ $osid | quote }} + +[hooks] + [hooks.read-source-state] + [hooks.read-source-state.pre] + command = "{{ .chezmoi.workingTree }}/hooks/ensure-prereqs.sh" diff --git a/.chezmoidata/packages.yaml b/.chezmoidata/packages.yaml new file mode 100644 index 0000000..7902665 --- /dev/null +++ b/.chezmoidata/packages.yaml @@ -0,0 +1,125 @@ +# Single source of truth for OS-package installation. +# Consumed by .chezmoiscripts/run_onchange_after_50-install-packages.sh.tmpl +# (renders into env vars) + lib/install-packages.sh (consumes env, installs). +# +# Install profile tiers (set in chezmoi.toml [data].profile): +# core minimum to make dotfiles functional. Installs zsh+vim+tmux+ +# git+curl+ca-certs via apt/dnf (on Mac these come from Apple +# base). Network safety baseline: ufw+tcpdump (Linux); Mac +# tcpdump system-provided, ufw has no equivalent (use App +# Firewall via System Settings → Privacy & Security). +# dev core + dev toolchain: htop/tree/wget/nmap/telnet/wireguard/ +# psql/build-essential (Linux+Mac symmetric), docker engine +# (Linux only — Mac users wanting Docker pick `workstation` +# which installs Docker Desktop), AI CLIs (claude-code, codex). +# workstation dev + GUI apps. Installs differ by OS: +# - Mac: casks (iterm2, docker-desktop, vscode, ngrok, fonts) +# - Linux: placeholder (empty for now — populated when I run +# Linux desktop machine) +# +# Cascade: dev includes core. workstation includes dev (+ core). +# +# Note: mise tools (kubectl, helm, k9s, jq, gh, fd, fzf, ...) live in +# dot_config/mise/config.toml.tmpl (cross-platform aqua backend), gated +# inside that file by `$isDev` (cascades dev+workstation). Not in this YAML. + +packages: + # === core tier — always installed === + core: + # Mac: zsh/vim/tmux/git/curl ship with macOS. No brew install at this + # tier. Fonts handled by .chezmoiexternal.toml.tmpl externals. + brews: [] + # Linux: bootstrap + safety baseline. + apt: + - zsh # shell (chsh switches login shell) + - vim # $EDITOR default + - tmux # dot_tmux.conf.local + - git # antidote clone + chezmoi update + - curl # curl-pipes (mise install, etc.) + - ca-certificates # HTTPS verification + - ufw # firewall (disabled by default; Mac uses App Firewall) + - tcpdump # packet capture (Mac tcpdump = /usr/sbin/tcpdump system) + dnf: + - zsh + - vim + - tmux + - git + - curl + - ca-certificates + - ufw + - tcpdump + + # === dev tier — adds CLI dev toolchain === + dev: + # Mac brew formulae (tools not in mise's aqua registry). + brews: + - htop # process monitor + - tree # directory tree + - wget # alt to curl + - nmap # network scanner + - telnet # legacy net tool + - libpq # psql/pg_dump (keg-only, force-linked) + - wireguard-tools # VPN CLI + - rtk # token-saving Claude/Codex proxy + # Linux apt: OS-native dev tools (symmetric to brews above + docker engine). + apt: + - build-essential # compilers (Xcode CLT equivalent — Mac gets via brew side-effect) + - xsel # tmux yank → clipboard (X11; Mac uses pbcopy built-in) + - wl-clipboard # tmux yank → clipboard (Wayland; Mac uses pbcopy built-in) + - docker.io # Docker engine (Mac dev tier gets nothing here — pick `workstation` for Docker Desktop) + - htop + - tree + - wget + - nmap + - inetutils-telnet # telnet (Debian/Ubuntu package name) + - wireguard-tools + - postgresql-client # psql + # Linux dnf (Fedora-family). + dnf: + - xsel + - wl-clipboard + - docker + - htop + - tree + - wget + - nmap + - telnet + - wireguard-tools + - postgresql + # AI CLI globals — installed via mise's npm after mise install. Available + # at dev tier and above (cascades into workstation). Cross-platform. + npm_global: + - "@anthropic-ai/claude-code" + - "@openai/codex" + + # === workstation tier — adds GUI apps (per-OS lists) === + gui: + # Mac casks. Installed when profile=workstation AND OS=darwin. + mac_casks: + - iterm2 # terminal + - docker-desktop # Docker engine + UI (Mac equivalent of Linux docker.io) + - visual-studio-code + - ngrok # tunnel UI + - font-meslo-lg-nerd-font # MesloLGS — backstop alongside .chezmoiexternal font drop + - font-monaspace # GitHub Monaspace family + # Linux GUI packages. Installed when profile=workstation AND OS=linux. + # Placeholder — populate when I actually run a Linux desktop (Ubuntu/Pop/ + # Fedora workstation). Candidates: code, firefox, vlc, gimp, ... + linux_apt: [] + linux_dnf: [] + +# Claude Code + Codex plugins — unchanged by tier system. Installed when +# claude/codex CLI is on PATH (dev tier and above). +plugins: + claude: + - gopls-lsp@claude-plugins-official + - figma@claude-plugins-official + - frontend-design@claude-plugins-official + - caveman@caveman + claude_marketplaces: + - caveman:JuliusBrussee/caveman + codex: + - github@openai-curated + - google-drive@openai-curated + codex_marketplaces: + [] diff --git a/.chezmoiexternal.toml.tmpl b/.chezmoiexternal.toml.tmpl new file mode 100644 index 0000000..a8bf312 --- /dev/null +++ b/.chezmoiexternal.toml.tmpl @@ -0,0 +1,55 @@ +{{- /* + Vendored external dependencies. + - gpakosz/.tmux: cross-platform, SHA-pinned via URL (URL = lockfile). + - MesloLGS NF + Monaspace Neon fonts: cross-platform, downloaded into the + OS-native font dir on machines with a graphical session. Skipped on + headless boxes (SSH session with no DISPLAY/WAYLAND_DISPLAY) — saves + ~2 MB of needless download and avoids fc-cache on systems where + fontconfig may not even be installed. +*/ -}} +{{- $monaspaceVer := "v1.400" -}} +{{- $headless := false -}} +{{- if and (ne (env "SSH_CONNECTION") "") (eq (env "DISPLAY") "") (eq (env "WAYLAND_DISPLAY") "") -}} +{{- $headless = true -}} +{{- end -}} + +[".config/tmux"] + type = "archive" + # Pinned to commit af33f07 (same as previous git-submodule HEAD) + url = "https://github.com/gpakosz/.tmux/archive/af33f07134b76134acca9d01eacbdecca9c9cda6.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "720h" # 30d safety net; SHA pinning is the real control + +{{ if not $headless -}} +{{- $fontDir := "" -}} +{{- if eq .chezmoi.os "darwin" -}}{{ $fontDir = "Library/Fonts" -}}{{- end -}} +{{- if eq .chezmoi.os "linux" -}}{{ $fontDir = ".local/share/fonts" -}}{{- end -}} +{{- if $fontDir }} + +# MesloLGS NF — the font Powerlevel10k expects. Glyphs include Powerline +# arrows used by tmux Powerline separators (U+E0B0–E0B3) and all p10k icons. +# Refresh weekly; the upstream repo updates infrequently. +{{- range $font := list "MesloLGS NF Regular.ttf" "MesloLGS NF Bold.ttf" "MesloLGS NF Italic.ttf" "MesloLGS NF Bold Italic.ttf" }} + +["{{ $fontDir }}/{{ $font }}"] + type = "file" + url = "https://github.com/romkatv/powerlevel10k-media/raw/master/{{ $font | replace " " "%20" }}" + refreshPeriod = "168h" +{{- end }} + +# Monaspace Neon — GitHub's monospace family (sans-serif neo-grotesque), +# optional alternative to MesloLGS for editors / iTerm2. Distributed only +# in release zips (no raw OTFs in main); extract the four canonical weights +# via archive-file. chezmoi caches the zip by URL — all 4 declarations +# trigger only one download. +{{- range $font := list "MonaspaceNeon-Regular.otf" "MonaspaceNeon-Bold.otf" "MonaspaceNeon-Italic.otf" "MonaspaceNeon-BoldItalic.otf" }} + +["{{ $fontDir }}/{{ $font }}"] + type = "archive-file" + url = "https://github.com/githubnext/monaspace/releases/download/{{ $monaspaceVer }}/monaspace-static-{{ $monaspaceVer }}.zip" + path = "Static Fonts/Monaspace Neon/{{ $font }}" + refreshPeriod = "720h" +{{- end }} +{{- end }} +{{- end }} diff --git a/.chezmoiignore b/.chezmoiignore new file mode 100644 index 0000000..b39d5c4 --- /dev/null +++ b/.chezmoiignore @@ -0,0 +1,45 @@ +# Patterns are TARGET paths (relative to destDir, default $HOME). +# Use `.foo` style — what the file is in $HOME — not source-state names. + +# Repo-level docs and project-level AI guides — not applied to $HOME +README.md +LICENSE +AGENTS.md +CLAUDE.md + +# iTerm2 plist lives at source root `iterm/` (outside dot_*) so chezmoi doesn't +# materialise it into $HOME. iTerm2 reads + writes the plist there directly, +# driven by `defaults write com.googlecode.iterm2 PrefsCustomFolder` (set by +# .chezmoiscripts/run_once_after_configure-iterm2.sh.tmpl). No symlink layer. +iterm + +# Custom hooks dir at source root (we don't use `.chezmoihooks/` because +# chezmoi doctor flags any `.chezmoi*` dir not in its hardcoded whitelist +# as 'suspicious-entries'). Scripts here are invoked via .chezmoi.toml.tmpl +# [hooks.read-source-state.pre.command] = "{{ .chezmoi.workingTree }}/hooks/..." +# Not for $HOME materialization — ignore the target path. +hooks + +# Bats post-apply smoke tests (CI runs them after `chezmoi apply` in a +# devcontainer). Never materialised into $HOME — only used from CI or +# locally via `bats tests/files/*.bats`. +tests + +# GitHub Actions workflows. Not part of $HOME state — only relevant to +# remote CI runners. +.github + +# Shell libraries sourced by .chezmoiscripts/ wrappers via {{ .chezmoi.sourceDir }}. +# Never materialised into $HOME — lib functions only callable from chezmoi +# scripts or bats unit tests, both of which know the source-dir path. +lib + +# Per-machine files written by external tools (rtk init writes RTK.md fresh +# each install — bundled rtk version's content, not ours to track) +.claude/RTK.md +.codex/RTK.md + +# rtk init writes RTK.md into cwd when run from project dir. We don't want it +# tracked at source root — the per-machine RTK.md lives at ~/.claude/RTK.md +# and ~/.codex/RTK.md (both in .chezmoiignore above). +RTK.md diff --git a/.chezmoiremove.tmpl b/.chezmoiremove.tmpl new file mode 100644 index 0000000..b1c81f1 --- /dev/null +++ b/.chezmoiremove.tmpl @@ -0,0 +1,11 @@ +{{- /* + Lists target-state paths (relative to $HOME) that chezmoi should remove on + next `chezmoi apply`. Use when deprecating a managed file — list it here + so chezmoi cleans it up on every machine, then remove from this file once + all machines have been applied. + + Empty by default. Add entries one per line, e.g.: + + .config/old-tool/config + .some-removed-dotfile +*/ -}} diff --git a/.chezmoiscripts/darwin/run_once_after_configure-iterm2.sh.tmpl b/.chezmoiscripts/darwin/run_once_after_configure-iterm2.sh.tmpl new file mode 100644 index 0000000..1a159e8 --- /dev/null +++ b/.chezmoiscripts/darwin/run_once_after_configure-iterm2.sh.tmpl @@ -0,0 +1,37 @@ +{{ if eq .chezmoi.os "darwin" -}} +#!/bin/sh +# Point iTerm2 at the chezmoi source dir directly. iTerm2 reads + writes +# preferences in this folder, so GUI edits flow straight to `git status` +# in the source repo (use `chezmoi cd` to navigate there). +# +# Why directly at sourceDir/iterm/ instead of ~/.config/iterm/? Because +# iTerm2 ALSO creates its own ~/.config/iterm2/ (for AppSupport + sockets) — +# having both `iterm/` and `iterm2/` in ~/.config/ was confusing. +# +# Runs once per machine (after first apply). +set -eu + +iterm_dir="{{ .chezmoi.sourceDir }}/iterm" + +# Sanity check: chezmoi sourceDir should contain the iterm/ plist directory. +# Fail loudly if it's missing — typically only happens during a manual +# misconfig of sourceDir. +if [ ! -d "$iterm_dir" ]; then + echo "ERROR: $iterm_dir does not exist" >&2 + echo "Verify chezmoi sourceDir resolves to this repo (try 'chezmoi cd')." >&2 + exit 1 +fi + +current=$(defaults read com.googlecode.iterm2 PrefsCustomFolder 2>/dev/null || true) +load=$(defaults read com.googlecode.iterm2 LoadPrefsFromCustomFolder 2>/dev/null || true) + +if [ "$current" = "$iterm_dir" ] && [ "$load" = "1" ]; then + echo "iTerm2 already loading prefs from $iterm_dir" + exit 0 +fi + +defaults write com.googlecode.iterm2 PrefsCustomFolder -string "$iterm_dir" +defaults write com.googlecode.iterm2 LoadPrefsFromCustomFolder -bool true +echo "iTerm2 configured to read prefs from $iterm_dir." +echo "Quit and relaunch iTerm2 (Don't Save if prompted) for changes to apply." +{{- end }} diff --git a/.chezmoiscripts/darwin/run_once_after_enable-touchid-sudo.sh.tmpl b/.chezmoiscripts/darwin/run_once_after_enable-touchid-sudo.sh.tmpl new file mode 100644 index 0000000..10d9ea2 --- /dev/null +++ b/.chezmoiscripts/darwin/run_once_after_enable-touchid-sudo.sh.tmpl @@ -0,0 +1,48 @@ +{{ if eq .chezmoi.os "darwin" -}} +#!/bin/sh +# Enable Touch ID for sudo on macOS. Idempotent; survives system updates. +# +# Sonoma+ (macOS 14+) ships /etc/pam.d/sudo_local.template (Apple-blessed +# extension point that survives system updates — /etc/pam.d/sudo gets +# rewritten on major macOS upgrades, /etc/pam.d/sudo_local does not). +# Pre-Sonoma falls back to direct edit of /etc/pam.d/sudo. +# +# Needs a TTY for sudo's password prompt on first run; skips with a notice +# on non-interactive applies (chezmoi will retry on next interactive apply). +set -eu + +if ! tty -s; then + echo "[touchid-sudo] non-interactive run — skipping (re-apply from a TTY)." >&2 + exit 0 +fi + +PAM_LINE='auth sufficient pam_tid.so' + +if [ -f /etc/pam.d/sudo_local.template ]; then + # Sonoma+ path. + target=/etc/pam.d/sudo_local + if [ ! -f "$target" ]; then + echo "[touchid-sudo] creating $target from template" + sudo cp /etc/pam.d/sudo_local.template "$target" + fi + # Template ships the pam_tid line commented out — uncomment it. + if grep -qE '^#auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so' "$target"; then + echo "[touchid-sudo] uncommenting pam_tid.so in $target" + sudo sed -i '' -E 's/^#(auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so)/\1/' "$target" + elif ! grep -qF 'pam_tid.so' "$target"; then + echo "[touchid-sudo] appending pam_tid line to $target" + echo "$PAM_LINE" | sudo tee -a "$target" >/dev/null + else + echo "[touchid-sudo] pam_tid.so already active in $target" + fi +else + # Pre-Sonoma: edit /etc/pam.d/sudo directly. Prepend so pam_tid runs first. + if ! grep -qF 'pam_tid.so' /etc/pam.d/sudo; then + echo "[touchid-sudo] prepending pam_tid to /etc/pam.d/sudo (pre-Sonoma)" + sudo sed -i '' "1s|^|${PAM_LINE}\\ +|" /etc/pam.d/sudo + else + echo "[touchid-sudo] pam_tid.so already in /etc/pam.d/sudo" + fi +fi +{{- end }} diff --git a/.chezmoiscripts/linux/run_once_after_chsh.sh.tmpl b/.chezmoiscripts/linux/run_once_after_chsh.sh.tmpl new file mode 100644 index 0000000..6e07da3 --- /dev/null +++ b/.chezmoiscripts/linux/run_once_after_chsh.sh.tmpl @@ -0,0 +1,56 @@ +{{- if eq .chezmoi.os "linux" -}} +#!/bin/sh +# Set zsh as the login shell on Linux if not already. Three paths: +# 1. Already zsh → idempotent no-op. +# 2. Passwordless sudo available (jetson, cloud-init machines) → use +# `sudo -n chsh -s ... $(id -un)` which doesn't prompt and works +# even without a TTY. Auto-adds zsh to /etc/shells if missing. +# 3. Interactive (TTY) but no passwordless sudo → bare `chsh -s` will +# prompt for password — fine for human users at a real terminal. +# 4. No TTY and no passwordless sudo → print warning, skip. +set -eu + +zsh_path=$(command -v zsh || true) +if [ -z "$zsh_path" ]; then + echo "WARNING: zsh not found — install-packages should have provided it; check apt/dnf output." >&2 + exit 0 +fi + +current_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7 || echo "") +if [ "$current_shell" = "$zsh_path" ]; then + exit 0 +fi + +# Prefer passwordless sudo path — works in both TTY and non-TTY contexts. +if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + # Ensure zsh is in /etc/shells (Debian's chsh requires this). + if ! grep -qxF "$zsh_path" /etc/shells 2>/dev/null; then + echo "$zsh_path" | sudo -n tee -a /etc/shells >/dev/null + fi + if sudo -n chsh -s "$zsh_path" "$(id -un)"; then + echo "default shell set to $zsh_path (re-login to take effect)" + exit 0 + fi + echo "WARNING: sudo chsh failed — manual fix: sudo chsh -s $zsh_path $(id -un)" >&2 + exit 0 +fi + +# No passwordless sudo. Bare chsh prompts the user's password — only +# usable when there's a TTY for them to type into. +if ! tty -s; then + echo "WARNING: default shell is $current_shell, not $zsh_path." >&2 + echo " Switch manually: chsh -s $zsh_path (will prompt for password)" >&2 + exit 0 +fi + +if ! grep -qxF "$zsh_path" /etc/shells 2>/dev/null; then + echo "WARNING: $zsh_path is not in /etc/shells; chsh may fail." >&2 + echo " Run: echo $zsh_path | sudo tee -a /etc/shells" >&2 +fi + +chsh -s "$zsh_path" || { + echo "WARNING: chsh failed — switch manually with: chsh -s $zsh_path" >&2 + exit 0 +} +echo "default shell set to $zsh_path (re-login to take effect)" +{{- end -}} diff --git a/.chezmoiscripts/linux/run_once_after_install-fonts.sh.tmpl b/.chezmoiscripts/linux/run_once_after_install-fonts.sh.tmpl new file mode 100644 index 0000000..9772c44 --- /dev/null +++ b/.chezmoiscripts/linux/run_once_after_install-fonts.sh.tmpl @@ -0,0 +1,23 @@ +{{- /* Only renders on Linux: file lives in .chezmoiscripts/linux/ but chezmoi + still processes every script regardless of subdir, so the OS guard + below stays as belt-and-suspenders. Headless guard skips the script + on SSH sessions without a graphical session — fontconfig may not be + installed, no point caching fonts no one will see. */ -}} +{{- $headless := false -}} +{{- if and (ne (env "SSH_CONNECTION") "") (eq (env "DISPLAY") "") (eq (env "WAYLAND_DISPLAY") "") -}} +{{- $headless = true -}} +{{- end -}} +{{- if and (eq .chezmoi.os "linux") (not $headless) -}} +#!/bin/sh +# Refresh fontconfig cache after MesloLGS NF fonts land in ~/.local/share/fonts +# (downloaded via .chezmoiexternal.toml.tmpl). macOS auto-discovers fonts in +# ~/Library/Fonts so no equivalent step needed there. +set -eu + +if command -v fc-cache >/dev/null 2>&1; then + fc-cache -f "$HOME/.local/share/fonts" >/dev/null 2>&1 + echo "fontconfig cache refreshed" +else + echo "fc-cache not found — install fontconfig (e.g. 'apt install fontconfig')" >&2 +fi +{{- end -}} diff --git a/.chezmoiscripts/run_onchange_after_50-install-packages.sh.tmpl b/.chezmoiscripts/run_onchange_after_50-install-packages.sh.tmpl new file mode 100644 index 0000000..1fe43b3 --- /dev/null +++ b/.chezmoiscripts/run_onchange_after_50-install-packages.sh.tmpl @@ -0,0 +1,48 @@ +{{- /* + Tier-cascade package installer (3 profiles: core / dev / workstation). + This script is a thin wrapper that renders chezmoi facts (profile, + osid, package lists from .chezmoidata/packages.yaml) into env vars, + then sources lib/install-packages.sh and calls main. + + All logic lives in lib/install-packages.sh — testable via bats unit + tests with mocked external commands. + + Re-runs whenever rendered output changes — chezmoi hashes rendered + script. packages.yaml, mise.toml, profile, or lib edits all trigger + a re-render. +*/ -}} +#!/bin/sh +set -eu + +# Force apt non-interactive (chezmoi child sh has no TTY — tzdata would prompt). +export DEBIAN_FRONTEND=noninteractive +export TZ=Etc/UTC + +# Make ~/.local/bin and mise shims discoverable for this script and post- +# installs. Debian's ~/.profile adds ~/.local/bin only for LOGIN shells; +# chezmoi exec()s without sourcing profile. +export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + +# Render chezmoi facts → env. Lib reads only these, never .chezmoi.* directly. +export DOTFILES_PROFILE={{ .profile | quote }} +export DOTFILES_OS={{ .chezmoi.os | quote }} +export DOTFILES_OSID={{ .osid | quote }} +export DOTFILES_OSRELEASE_IDLIKE={{ if hasKey .chezmoi.osRelease "idLike" }}{{ .chezmoi.osRelease.idLike | quote }}{{ else }}""{{ end }} + +export DOTFILES_CORE_BREWS={{ (.packages.core.brews | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_DEV_BREWS={{ (.packages.dev.brews | default (list)) | sortAlpha | uniq | join " " | quote }} + +export DOTFILES_CORE_APT={{ (.packages.core.apt | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_DEV_APT={{ (.packages.dev.apt | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_CORE_DNF={{ (.packages.core.dnf | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_DEV_DNF={{ (.packages.dev.dnf | default (list)) | sortAlpha | uniq | join " " | quote }} + +# Workstation tier (GUI apps). Per-OS lists from packages.gui. +export DOTFILES_GUI_MAC_CASKS={{ (.packages.gui.mac_casks | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_GUI_LINUX_APT={{ (.packages.gui.linux_apt | default (list)) | sortAlpha | uniq | join " " | quote }} +export DOTFILES_GUI_LINUX_DNF={{ (.packages.gui.linux_dnf | default (list)) | sortAlpha | uniq | join " " | quote }} + +export DOTFILES_DEV_NPM_GLOBAL={{ (.packages.dev.npm_global | default (list)) | sortAlpha | uniq | join " " | quote }} + +export INSTALL_PACKAGES_INVOKE=1 +. "{{ .chezmoi.sourceDir }}/lib/install-packages.sh" diff --git a/.chezmoiscripts/run_onchange_after_60-install-rtk.sh.tmpl b/.chezmoiscripts/run_onchange_after_60-install-rtk.sh.tmpl new file mode 100644 index 0000000..7c627df --- /dev/null +++ b/.chezmoiscripts/run_onchange_after_60-install-rtk.sh.tmpl @@ -0,0 +1,91 @@ +{{- /* + rtk install + init — only at profile=dev or workstation (where claude/codex + are installed). At profile=core the script exits early since rtk has no + consumer (Claude Code + Codex CLIs ship via dev tier's npm globals). +*/ -}} +{{- $profile := .profile | default "core" -}} +{{- $isDev := or (eq $profile "dev") (eq $profile "workstation") -}} +#!/bin/sh +# Install rtk (token-saving CLI proxy) and wire it into Claude Code + Codex. +# rtk init -g writes ~/.claude/RTK.md, adds @RTK.md reference to ~/.claude/CLAUDE.md, +# adds hook entry to ~/.claude/settings.json. Our modify_settings.json preserves +# that hook entry across applies. +# +# Idempotent: rtk init detects already-wired state. +set -eu + +{{- if not $isDev }} +echo "[install-rtk] profile=core — skipping (no claude/codex consumer at this tier)." +exit 0 +{{- end }} + +{{ if eq .chezmoi.os "darwin" -}} +# On fresh Mac (first-time `chezmoi init --apply` via the curl one-liner), +# the parent shell may not yet have /opt/homebrew/bin on PATH — chezmoi +# inherits that bare PATH and scripts can't find brew-installed binaries +# (rtk, claude, codex) even though script 50 just installed them. Re-eval +# brew shellenv per Mac script to set PATH deterministically. +if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" +elif [ -x /usr/local/bin/brew ]; then + eval "$(/usr/local/bin/brew shellenv)" +fi +{{- end }} + +# Ensure binary is on PATH. +# On macOS rtk lives in packages.yaml under packages.dev.brews and the +# install-packages script (50-install-packages) runs first because 50 < 60 +# alphabetically. Binary should be present here at dev/workstation profile. +# On Linux the install-packages script doesn't bundle rtk (not in apt/dnf), +# so the Linux fallback below is the install path. +if ! command -v rtk >/dev/null 2>&1; then +{{ if ne .chezmoi.os "darwin" -}} + curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +{{- else -}} + echo "rtk not found on PATH after install-packages." >&2 + echo "Common cause: brew bundle aborted (e.g. VSCode or Docker cask" >&2 + echo "adoption needs sudo, blocking non-interactive apply)." >&2 + echo "Fix: rerun 'chezmoi apply' from a TTY, or 'sudo -v' beforehand" >&2 + echo "to cache credentials, then retry." >&2 + exit 1 +{{- end }} +fi + +# rtk's Linux arm64 build is dynamically linked against glibc >=2.39 +# (Ubuntu 24.04+); on Ubuntu 22.04 jammy (glibc 2.35) it fails with +# "version `GLIBC_2.39' not found". No musl/static alternative is shipped +# upstream as of 2026-05. Skip rtk init gracefully on too-old glibc rather +# than fail the whole apply — claude/codex still work without the rtk hook. +if ! rtk --version >/dev/null 2>&1; then + echo "rtk binary fails to execute on this system (likely glibc too old)." >&2 + echo "Upstream ships glibc-2.39 build only for linux-arm64; needs Ubuntu 24.04+." >&2 + echo "Skipping rtk init. claude/codex still usable without rtk hook." >&2 + exit 0 +fi + +# Init for Claude Code (only if claude binary is present). +# --auto-patch is Claude-specific: writes the PreToolUse hook entry into +# ~/.claude/settings.json without prompting. Codex doesn't have an equivalent +# settings hook (rtk wires Codex via @RTK.md in AGENTS.md instead), so the +# flag is intentionally absent below. +if command -v claude >/dev/null 2>&1; then + rtk init -g --auto-patch +fi + +# Init for Codex (only if codex binary is present). +# `-g --codex` writes to ~/.codex/{AGENTS.md, RTK.md} — GLOBAL Codex config. +# Without `-g`, rtk init --codex would write to cwd (which is $HOME when run +# from chezmoi apply), polluting $HOME with stray AGENTS.md and RTK.md. +# Verified in rtk source: src/hooks/init.rs::run_codex_mode() — when global=true, +# paths resolve to resolve_codex_dir() which uses $CODEX_HOME or ~/.codex/. +if command -v codex >/dev/null 2>&1; then + rtk init -g --codex +fi + +echo "rtk: $(rtk --version) — initialized" + +# Script content hash already triggers re-run on changes. No need to embed +# `output "rtk" "--version"` here — that would call rtk binary at chezmoi +# template-render time, which aborts apply on systems where rtk can't +# execute (e.g. jammy arm64 — rtk requires glibc 2.39 not in Ubuntu 22.04). diff --git a/.chezmoiscripts/run_onchange_after_70-install-plugins.sh.tmpl b/.chezmoiscripts/run_onchange_after_70-install-plugins.sh.tmpl new file mode 100644 index 0000000..39da8e6 --- /dev/null +++ b/.chezmoiscripts/run_onchange_after_70-install-plugins.sh.tmpl @@ -0,0 +1,82 @@ +{{- /* + Install + update Claude Code and OpenAI Codex plugins from + .chezmoidata/packages.yaml (.plugins.{claude, claude_marketplaces, + codex_marketplaces}). Re-runs when the list changes (chezmoi hashes + rendered script content). + + Claude: + - `claude plugin marketplace add` for each non-default marketplace. + Defensively adds `anthropics/claude-plugins-official` first — that + marketplace is normally auto-registered on first interactive + `claude` launch, but a fresh box where claude was npm-installed + but never run won't have it. + - `claude plugin install @ --scope user` for each + .plugins.claude entry. `claude plugin update` bumps to latest. + Both idempotent. + - Caveman is just a row in .plugins.claude + .plugins.claude_marketplaces + (canonical install per its INSTALL.md per-agent table). The plugin + manages its own hooks via plugin.json — no settings.json mutation. + + Codex: + - `openai-curated` marketplace is reserved (built into the codex + binary); enable flags via [plugins."x@y"] in config.toml are + sufficient. Those flags come from + .chezmoitemplates/codex-config-base.toml (rendered through + modify_config.toml). + - Non-reserved marketplaces need `codex plugin marketplace add`. + - Skill-based plugins (e.g. caveman for Codex) install via `npx + skills add ... -a codex` — but the skills CLI doesn't currently + wire the per-agent symlinks Codex needs for discovery, so they're + a manual step, not part of this script. +*/ -}} +#!/bin/sh +set -eu + +{{ if eq .chezmoi.os "darwin" -}} +# Same as script 60: ensure /opt/homebrew/bin is on PATH so command -v claude +# / codex find the freshly-installed binaries from script 50, even on first +# apply where the parent shell didn't have brew on PATH yet. +if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" +elif [ -x /usr/local/bin/brew ]; then + eval "$(/usr/local/bin/brew shellenv)" +fi +{{- end }} + +if command -v claude >/dev/null 2>&1; then + if ! out=$(claude plugin marketplace add anthropics/claude-plugins-official 2>&1); then + echo "WARNING: claude plugin marketplace add (official) failed:" >&2 + echo "$out" >&2 + fi +{{ range .plugins.claude_marketplaces -}} +{{- $parts := splitn ":" 2 . -}} +{{- $name := $parts._0 -}} +{{- $repo := $parts._1 }} + if ! out=$(claude plugin marketplace add {{ $repo | quote }} 2>&1); then + echo "WARNING: claude plugin marketplace add {{ $name }} failed:" >&2 + echo "$out" >&2 + fi +{{ end -}} +{{ range .plugins.claude -}} + claude plugin install {{ . | quote }} --scope user >/dev/null 2>&1 \ + || echo "claude plugin install {{ . }} failed" >&2 + claude plugin update {{ . }} >/dev/null 2>&1 || true +{{ end -}} + claude plugin marketplace update >/dev/null 2>&1 || true +else + echo "claude CLI not found, skipping Claude plugin install" >&2 +fi + +if command -v codex >/dev/null 2>&1; then +{{ range .plugins.codex_marketplaces -}} + repo={{ . | quote }} + mkt_name="${repo##*/}" + if ! grep -q "^\[marketplaces\.${mkt_name}\]" "$HOME/.codex/config.toml" 2>/dev/null; then + codex plugin marketplace add "$repo" >/dev/null 2>&1 \ + || echo "codex plugin marketplace add $repo failed" >&2 + fi +{{ end -}} + codex plugin marketplace upgrade >/dev/null 2>&1 || true +else + echo "codex CLI not found, skipping Codex plugin install" >&2 +fi diff --git a/claude/settings.json b/.chezmoitemplates/claude-settings-base.json similarity index 66% rename from claude/settings.json rename to .chezmoitemplates/claude-settings-base.json index 1ca4510..3cbdd75 100644 --- a/claude/settings.json +++ b/.chezmoitemplates/claude-settings-base.json @@ -7,9 +7,10 @@ "command": "bash $HOME/.claude/statusline-command.sh" }, "enabledPlugins": { - "gopls-lsp@claude-plugins-official": true, - "figma@claude-plugins-official": true, - "frontend-design@claude-plugins-official": true +{{- range $i, $p := .plugins.claude }} +{{- if $i }},{{ end }} + "{{ $p }}": true +{{- end }} }, "alwaysThinkingEnabled": true, "skipAutoPermissionPrompt": true, diff --git a/.chezmoitemplates/codex-config-base.toml b/.chezmoitemplates/codex-config-base.toml new file mode 100644 index 0000000..87476a9 --- /dev/null +++ b/.chezmoitemplates/codex-config-base.toml @@ -0,0 +1,14 @@ +model = "gpt-5.5" +model_reasoning_effort = "xhigh" +personality = "pragmatic" + +{{ range .plugins.codex -}} +[plugins."{{ . }}"] +enabled = true + +{{ end -}} +# Runtime-mutated sections ([projects.*], [notice], [tui.*], +# [tool_suggest], windows_wsl_setup_acknowledged) are stripped on every +# `chezmoi apply` by dot_codex/modify_config.toml's `unset` chain. +# When Codex's `ConfigEdit` enum (codex-rs/core/src/config/edit.rs) +# gains a new runtime-section variant, add it there too. diff --git a/.chezmoiversion b/.chezmoiversion new file mode 100644 index 0000000..5fa6a27 --- /dev/null +++ b/.chezmoiversion @@ -0,0 +1 @@ +2.70.3 diff --git a/.dotbot b/.dotbot deleted file mode 160000 index a7fe585..0000000 --- a/.dotbot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7fe585f087617f5d897754b970d1088af50084c diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 10981be..0000000 --- a/.gitattributes +++ /dev/null @@ -1,7 +0,0 @@ -# Run the awk script in codex/scripts/ as a `clean` filter on `git add`, -# stripping runtime-mutated TOML sections so Codex's continuous writes to -# the symlinked ~/.codex/config.toml don't pollute commits. -# -# The filter itself is registered per-clone by ./install (see the shell -# hook in .install.conf.yaml that calls `git config --local`). -codex/config.toml filter=codex-strip diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..deabb94 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,63 @@ +name: ci +on: + push: + branches: [master, "chore/**", "feat/**", "fix/**", "docs/**"] + # `pull_request` deliberately omitted: solo repo, no fork PRs expected. + # Pushes to branches above already cover PR sync (PR view shows latest + # push's CI status). Re-enable if external contributors start opening PRs + # from forks (push trigger doesn't fire on fork sources; only + # `pull_request_target` or `pull_request` does). + +jobs: + unit: + name: unit tests (lib/install-packages.sh) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends bats + + - name: Run bats unit tests + run: bats tests/unit/install-packages.bats + + apply-core: + name: apply (profile=core, Ubuntu) + runs-on: ubuntu-latest + needs: unit + env: + # CODESPACES=true is read by .chezmoi.toml.tmpl as the "ephemeral" + # environment signal — surfaced in the prompt hint. Profile picker + # still defaults to "core" (promptChoiceOnce default; CI passes + # --promptDefaults below to accept it without prompting). + CODESPACES: "true" + DEBIAN_FRONTEND: noninteractive + + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats jq + + - name: Install chezmoi + run: | + sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: chezmoi init + apply + run: | + # Symlink workspace to chezmoi's default source dir so every + # subsequent `chezmoi *` invocation (e.g. `chezmoi diff` in bats) + # finds the source without needing --source flag. `chezmoi init + # --source=PATH` only takes effect for the current invocation; + # it does NOT persist sourceDir to ~/.config/chezmoi/chezmoi.toml. + mkdir -p "$HOME/.local/share" + ln -s "$GITHUB_WORKSPACE" "$HOME/.local/share/chezmoi" + chezmoi init --apply --promptDefaults + echo "--- chezmoi data ---" + chezmoi data + + - name: Verify post-apply state + run: bats tests/files/common.bats diff --git a/.gitignore b/.gitignore index d1ff6a9..28a6128 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,14 @@ dist/ .coverage* .claude -# Codex ships built-in skills (plan, skill-creator, etc.) under .system/ -# inside whatever path ~/.codex/skills points to. We symlink that dir -# into codex/skills/, so they'd otherwise show up here as untracked. -codex/skills/.system/ +# Skills directory is shared between Claude and Codex (~/.codex/skills → +# ~/.claude/skills via dot_codex/symlink_skills). Tools auto-write into it: +# Codex puts built-in skills under .system/, caveman/cavecrew installers add +# their own dirs. Use an allowlist so unknown tool-managed skills don't leak +# into the repo — every skill we *do* track needs an explicit `!` entry below. +dot_claude/skills/* +!dot_claude/skills/save-to-dotfiles/ + +# rtk init writes RTK.md into cwd when run from project root — don't track. +# Per-machine RTK.md lives at ~/.claude/RTK.md and ~/.codex/RTK.md (auto-gen). +/RTK.md diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f8fad2e..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule ".dotbot"] - path = .dotbot - url = https://github.com/anishathalye/dotbot.git -[submodule "tmux/oh-my-tmux"] - path = tmux/oh-my-tmux - url = https://github.com/gpakosz/.tmux.git diff --git a/.install.conf.yaml b/.install.conf.yaml deleted file mode 100644 index 27e9de0..0000000 --- a/.install.conf.yaml +++ /dev/null @@ -1,210 +0,0 @@ -- defaults: - link: - create: true - relink: true - -# `clean:` removes broken symlinks at these paths (dotbot doesn't recurse, -# so each subdir Claude/Codex own must be listed). Catches stale links -# from renamed/removed entries above (e.g. `commands/` → `rules/` rename). -- clean: ['~', '~/.claude', '~/.codex'] - -- link: - ~/.vimrc: - ~/.zshrc: - ~/.zsh_plugins.txt: zsh_plugins.txt - ~/.p10k.zsh: - path: p10k.zsh - force: true - ~/.tmux.conf: - path: tmux/oh-my-tmux/.tmux.conf - force: true - ~/.tmux.conf.local: - path: tmux/tmux.conf.local - force: true - # --- Claude Code --- - # Global Claude Code config: instructions, settings, statusline - # script, plus the three content dirs Claude auto-discovers: - # - agents/ — custom subagents - # - skills/ — custom skills (SKILL.md per subdir) - # - rules/ — markdown rules with optional `paths:` frontmatter - # for conditional loading (universal vs per-language) - # We deliberately don't symlink ~/.claude/commands (deprecated by - # skills per upstream docs) or ~/.claude/hooks (not a Claude - # convention — hooks live inline in settings.json). - # `force: true` replaces any pre-existing file at the target path. - ~/.claude/CLAUDE.md: - path: claude/CLAUDE.md - force: true - ~/.claude/settings.json: - path: claude/settings.json - force: true - ~/.claude/statusline-command.sh: - path: claude/statusline-command.sh - force: true - ~/.claude/agents: - path: claude/agents - force: true - ~/.claude/skills: - path: claude/skills - force: true - ~/.claude/rules: - path: claude/rules - force: true - # --- OpenAI Codex CLI --- - # config.toml is symlinked; Codex auto-writes runtime sections into it, - # which the git clean filter (registered below) strips on `git add`. - # skills/ is symlinked too; Codex creates `.system/` inside for built-in - # skills — that path is .gitignore'd at repo root. - # AGENTS.md is Codex's per-user global instructions file (its - # equivalent of Claude's CLAUDE.md) — loaded for every session. - ~/.codex/config.toml: - path: codex/config.toml - force: true - ~/.codex/skills: - path: codex/skills - force: true - ~/.codex/AGENTS.md: - path: codex/AGENTS.md - force: true - -- shell: - - description: Install MesloLGS Nerd Font (Powerlevel10k recommended) - command: | - case "$(uname -s)" in - Darwin) - if ! command -v brew >/dev/null 2>&1; then - echo "Skipping font install: Homebrew not available" - exit 0 - fi - if brew list --cask font-meslo-lg-nerd-font >/dev/null 2>&1; then - echo "MesloLGS Nerd Font already installed" - else - brew install --cask font-meslo-lg-nerd-font - fi - ;; - Linux) - # Headless SSH: glyphs render on the local terminal — skip remote install. - if [ -n "$SSH_CONNECTION" ] && [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then - echo "Headless/SSH session — font lives on the local terminal, skipping" - exit 0 - fi - dir="${XDG_DATA_HOME:-$HOME/.local/share}/fonts" - if command -v fc-list >/dev/null 2>&1 && fc-list | grep -qi "MesloLGS NF"; then - echo "MesloLGS Nerd Font already installed" - exit 0 - fi - if ! command -v curl >/dev/null 2>&1; then - echo "Skipping font install: curl not available" - exit 0 - fi - mkdir -p "$dir" - base="https://github.com/romkatv/powerlevel10k-media/raw/master" - for variant in Regular Bold Italic "Bold Italic"; do - fname="MesloLGS NF ${variant}.ttf" - # POSIX URL encoding (dotbot shell: runs under /bin/sh, which is dash on Ubuntu). - urlname=$(printf '%s' "$fname" | sed 's/ /%20/g') - [ -f "$dir/$fname" ] || curl -fsSL "$base/$urlname" -o "$dir/$fname" - done - command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$dir" >/dev/null - echo "MesloLGS Nerd Font installed to $dir" - ;; - *) - echo "Unsupported OS: $(uname -s)" >&2 - exit 1 - ;; - esac - stdout: true - stderr: true - - - description: Install Visual Studio Code via Homebrew cask (macOS only) - command: | - [ "$(uname -s)" = Darwin ] || exit 0 - # App presence is the source of truth — works for both brew-cask and - # pre-existing manual installs. `code` on PATH is handled by zshrc. - if [ -d "/Applications/Visual Studio Code.app" ]; then - echo "Visual Studio Code already installed" - exit 0 - fi - if ! command -v brew >/dev/null 2>&1; then - echo "Skipping VSCode install: Homebrew not available" - exit 0 - fi - brew install --cask visual-studio-code - stdout: true - stderr: true - - - description: Point iTerm2 at the repo's iterm/ folder for preferences (macOS only) - command: | - [ "$(uname -s)" = Darwin ] || exit 0 - iterm_dir="$PWD/iterm" - current=$(defaults read com.googlecode.iterm2 PrefsCustomFolder 2>/dev/null || true) - load=$(defaults read com.googlecode.iterm2 LoadPrefsFromCustomFolder 2>/dev/null || true) - if [ "$current" = "$iterm_dir" ] && [ "$load" = "1" ]; then - echo "iTerm2 already loading prefs from $iterm_dir" - exit 0 - fi - defaults write com.googlecode.iterm2 PrefsCustomFolder -string "$iterm_dir" - defaults write com.googlecode.iterm2 LoadPrefsFromCustomFolder -bool true - echo "iTerm2 configured. Quit and relaunch iTerm2 (Don't Save if asked) to apply." - stdout: true - stderr: true - - - description: Linux clipboard-tool notice (for tmux yank to system clipboard) - command: | - [ "$(uname -s)" = Linux ] || exit 0 - if ! command -v xsel >/dev/null 2>&1 \ - && ! command -v xclip >/dev/null 2>&1 \ - && ! command -v wl-copy >/dev/null 2>&1; then - echo "Note: no clipboard tool detected." - echo " tmux copy-mode yank will not reach the system clipboard." - echo " Install one: apt install xsel (or wl-clipboard for Wayland)" - fi - stdout: true - stderr: true - - - description: Register the codex-strip git filter (idempotent) - command: | - # Strip runtime-mutated TOML sections from codex/config.toml on - # `git add`. See codex/scripts/strip-runtime-sections.awk for the - # exclusion list and .gitattributes for the binding. - # - # `required = true` makes git expect BOTH clean (index ← working - # tree, on `git add`) and smudge (working tree ← index, on - # `git checkout`) to be defined. We don't transform on checkout, - # so smudge is `cat` (identity passthrough) — this is the - # canonical pattern for clean-only filters per the gitattributes - # man page ("indent"/"smudge=cat" example). Without explicit - # smudge, `git checkout` of any branch touching codex/config.toml - # fails with "smudge filter codex-strip failed". - clean="awk -f codex/scripts/strip-runtime-sections.awk" - if [ "$(git config --local filter.codex-strip.clean 2>/dev/null)" != "$clean" ] \ - || [ "$(git config --local filter.codex-strip.smudge 2>/dev/null)" != "cat" ]; then - git config --local filter.codex-strip.clean "$clean" - git config --local filter.codex-strip.smudge cat - git config --local filter.codex-strip.required true - echo "Registered git filter.codex-strip" - else - echo "git filter.codex-strip already registered" - fi - stdout: true - stderr: true - - - description: AI CLIs soft-prereq notice (claude, codex) - command: | - # Claude Code and OpenAI Codex aren't hard requirements — install - # still completes on a server-only machine. But the linked configs - # do nothing until the binaries are present, so flag it. - ai_missing="" - for tool in claude codex; do - command -v "$tool" >/dev/null 2>&1 || ai_missing="${ai_missing}${ai_missing:+ }${tool}" - done - if [ -n "$ai_missing" ]; then - printf '\nNote: AI CLI(s) not on PATH: %s\n' "$ai_missing" - printf ' Configs in claude/ and codex/ are linked but inert until installed.\n' - case "$(uname -s)" in - Darwin) printf ' Install: brew install --cask claude-code codex\n' ;; - Linux) printf ' Install: https://claude.com/code | https://github.com/openai/codex\n' ;; - esac - fi - stdout: true - stderr: true diff --git a/AGENTS.md b/AGENTS.md index a340be1..1f2acf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Working in this repo — guide for AI agents (Claude Code, OpenAI Codex CLI) -Auto-loaded by both tools when `cwd` is anywhere under `~/dotfiles/`: +Auto-loaded by both tools when `cwd` is anywhere under the chezmoi source dir (`~/.local/share/chezmoi/`, optionally symlinked as `~/dotfiles/`): Claude reads `CLAUDE.md` (symlinked to this file); Codex reads `AGENTS.md` directly. Everything below is **this repo's** conventions; for general behavioural rules see the global `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md`. @@ -8,10 +8,14 @@ see the global `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md`. ## 1. What this repo is Personal cross-platform (macOS + Linux) dotfiles managed with -[dotbot](https://github.com/anishathalye/dotbot). It is also the source of -truth for portable **Claude Code** + **OpenAI Codex** configs — `~/.claude/` -and `~/.codex/` are symlinks into this repo. Single source of truth, single -`./install`, identical state on every device. +[chezmoi](https://www.chezmoi.io/). It is also the source of truth for portable +**Claude Code** + **OpenAI Codex** configs. Single source of truth, single +`chezmoi apply` / `chezmoi update`, identical state on every device. + +Bootstrap on a new machine: +```sh +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply vaintrub +``` ## 2. Map of the repo @@ -19,120 +23,216 @@ and `~/.codex/` are symlinks into this repo. Single source of truth, single | Path | What it is | |---|---| -| `zshrc`, `zsh_plugins.txt`, `p10k.zsh` | zsh + antidote + Powerlevel10k | -| `vimrc` | vim + vim-plug | -| `tmux/tmux.conf.local` | tmux customizations on top of gpakosz | -| `iterm/com.googlecode.iterm2.plist` | iTerm2 preferences (binary plist) | -| `claude/` | Claude Code: instructions, settings, rules, statusline, agents, skills | -| `codex/` | Codex CLI: AGENTS.md, config.toml, skills, awk filter | -| `.install.conf.yaml` | dotbot links + shell hooks | -| `install` | bootstrap script (hard prereq checks + submodule init + dotbot) | +| `dot_zshrc`, `dot_zsh_plugins.txt`, `dot_p10k.zsh` | zsh + antidote + Powerlevel10k | +| `dot_vimrc` | vim + vim-plug | +| `symlink_dot_tmux.conf`, `dot_tmux.conf.local` | tmux: symlink to oh-my-tmux external (so gpakosz's shell-as-config trick works) + our customizations | +| `iterm/com.googlecode.iterm2.plist` | real iTerm2 binary plist (chezmoi-ignored). iTerm2 reads + writes here directly via `PrefsCustomFolder` (set by run_once_after script) | +| `dot_claude/` | Claude Code: instructions, settings (modify_), statusline, agents, skills, rules | +| `dot_codex/` | Codex CLI: AGENTS.md, config.toml (modify_), shared skills (symlink) | +| `.chezmoidata/packages.yaml` | single source of truth for all packages (Mac brews/casks, Linux apt/dnf/npm) AND Claude/Codex plugin lists | +| `.chezmoiscripts/` | install hooks (run_once_after_*, run_onchange_after_*). Per-OS subdirs: `darwin/`, `linux/`. Numerical prefix (50/60/70/80) controls ordering within `run_onchange_after_` group | +| `.chezmoihooks/` | hook scripts registered in `.chezmoi.toml.tmpl`. `ensure-prereqs.sh` runs BEFORE source-state read on every apply, installing minimum tools (git/zsh/vim/tmux/curl + brew on Mac) | +| `.chezmoitemplates/` | shared template partials (base configs read by `modify_` scripts via `includeTemplate`, not applied to $HOME) | +| `lib/` | shell libraries sourced by `.chezmoiscripts/*` wrappers via `{{ .chezmoi.sourceDir }}/lib/X.sh`. Pure POSIX `sh`, no template syntax. Chezmoi-ignored so it never deploys to $HOME — also makes the libraries `source`-able by bats unit tests with mocked externals. | +| `tests/files/*.bats` | post-apply asserts (CI: dotfile presence, tool functionality, content sanity). Profile-tagged (`common.bats` covers `core` tier today). | +| `tests/unit/*.bats` | function-level unit tests on `lib/*.sh`. Source the lib with `INSTALL_PACKAGES_INVOKE` unset so `main` doesn't auto-run, then mock externals (`sudo`/`apt-get`/`mise`/`id`) to drive each function's branches. | +| `.github/workflows/ci.yaml` | GitHub Actions: `unit` job (bats `tests/unit/`) → `apply-core` job (chezmoi apply in devcontainer + bats `tests/files/`). Triggers on push (master, chore/**, feat/**, fix/**, docs/**) + PRs. | +| `.chezmoiversion`, `.chezmoiremove.tmpl` | chezmoi-native metadata: minimum-version pin + deprecation-tracking list | +| `.chezmoi*.{toml.tmpl,ignore,external.toml.tmpl}` | chezmoi metadata (templated) | | `README.md`, `LICENSE` | docs / license | -| `AGENTS.md` (this file), `CLAUDE.md` (symlink → this file) | repo-level AI guide | +| `AGENTS.md` (this file), `CLAUDE.md` → `AGENTS.md` (symlink) | repo-level AI guide | ### Vendored / managed (don't edit directly) | Path | Source | How to influence it | |---|---|---| -| `.dotbot/` | github.com/anishathalye/dotbot submodule | upstream only | -| `tmux/oh-my-tmux/` | github.com/gpakosz/.tmux submodule | customize via `tmux/tmux.conf.local` | -| `codex/skills/.system/` | Codex's built-in skills, gitignored | Codex auto-manages | +| `~/.config/tmux/` | gpakosz/.tmux archive, pinned by commit SHA in `.chezmoiexternal.toml.tmpl` | bump SHA → `chezmoi apply -R` → commit | +| `dot_claude/skills/.system/` | Codex's built-in skills (written through the codex→claude skills symlink); `.gitignore`d | Codex auto-manages | +| `~/.cache/chezmoi/` | chezmoi external cache (outside repo) | chezmoi-managed | + +## 3. Source-state naming (chezmoi conventions) -## 3. Symlink table (source of truth for Claude / Codex) +The mapping from source file name to live `$HOME` path is mechanical and chezmoi-defined: -| Live path | Real path | +| Source attribute | Effect | |---|---| -| `~/.claude/CLAUDE.md` | `~/dotfiles/claude/CLAUDE.md` | -| `~/.claude/settings.json` | `~/dotfiles/claude/settings.json` | -| `~/.claude/statusline-command.sh` | `~/dotfiles/claude/statusline-command.sh` | -| `~/.claude/agents` | `~/dotfiles/claude/agents` | -| `~/.claude/skills` | `~/dotfiles/claude/skills` | -| `~/.claude/rules` | `~/dotfiles/claude/rules` | -| `~/.codex/AGENTS.md` | `~/dotfiles/codex/AGENTS.md` | -| `~/.codex/config.toml` | `~/dotfiles/codex/config.toml` | -| `~/.codex/skills` | `~/dotfiles/codex/skills` | - -**Always edit the dotfiles path**, never the live symlink — the diff stays -unambiguous and the source of truth is clear to anyone reviewing. +| `dot_X` | becomes `.X` in target ($HOME) | +| `executable_X` | mode +x | +| `private_X` | mode 0600 (or 0700 dir) | +| `readonly_X` | mode 0400 | +| `symlink_X` | becomes a symbolic link; file content = link target string | +| `modify_X` | runs as a script: stdin = existing file content, stdout = new file content | +| `modify_X` + `#chezmoi:modify-template` annotation | template; `.chezmoi.stdin` = existing; output = new content | +| `.tmpl` suffix | file rendered as Go template before use | +| `.chezmoiscripts/run_*` | scripts that don't create `$HOME` files (bootstrap, install hooks) | + +Live ↔ source mapping for our key paths: + +| Live ($HOME) | Source (this repo) | Pattern | +|---|---|---| +| `~/.zshrc` | `dot_zshrc` | plain | +| `~/.zsh_plugins.txt` | `dot_zsh_plugins.txt` | plain (antidote static-cache input) | +| `~/.zshrc_local` | `create_dot_zshrc_local` | `create_`: seeded ONCE at first apply with commented stub, then never overwritten. Per-machine override file (sourced last by `~/.zshrc`). | +| `~/.vimrc` | `dot_vimrc` | plain | +| `~/.p10k.zsh` | `dot_p10k.zsh` | plain (generated by `p10k configure`) | +| `~/.tmux.conf` | `symlink_dot_tmux.conf` (body: `.config/tmux/.tmux.conf`) | symlink (gpakosz's shell-as-config trick requires `$TMUX_CONF` to point at gpakosz's file, not a 1-liner wrapper) | +| `~/.tmux.conf.local` | `dot_tmux.conf.local` | plain | +| `~/.gitconfig` | `dot_gitconfig.tmpl` | Go template (renders `[user]` block conditionally; `[core] hooksPath` for gitleaks; `[delta]` block iff delta on PATH at apply time) | +| `~/.gitignore_global` | `dot_gitignore_global` | plain (referenced by `core.excludesFile` in dot_gitconfig.tmpl; OS junk + editor backups) | +| (no `~/.Brewfile`) | `.chezmoidata/packages.yaml` | rendered inline by install-packages script and piped to `brew bundle --file=/dev/stdin` | +| `~/.claude/CLAUDE.md` | `dot_claude/CLAUDE.md` | plain | +| `~/.claude/settings.json` | `dot_claude/modify_settings.json.tmpl` + `.chezmoitemplates/claude-settings-base.json` | modify_ jq merge | +| `~/.claude/statusline-command.sh` | `dot_claude/executable_statusline-command.sh` | plain +x | +| `~/.claude/rules/*.md` | `dot_claude/rules/*.md` | plain | +| `~/.claude/skills/save-to-dotfiles/SKILL.md` | `dot_claude/skills/save-to-dotfiles/SKILL.md` | plain | +| `~/.codex/AGENTS.md` | `dot_codex/AGENTS.md.tmpl` | Go template (renders `@{{ .chezmoi.homeDir }}/.codex/RTK.md` to absolute path) | +| `~/.codex/config.toml` | `dot_codex/modify_config.toml` + `.chezmoitemplates/codex-config-base.toml` | modify-template (fromToml / toToml) | +| `~/.codex/skills/save-to-dotfiles/SKILL.md` | `dot_codex/skills/save-to-dotfiles/symlink_SKILL.md` (body: `../../../.claude/skills/save-to-dotfiles/SKILL.md`) | file-level symlink to Claude's copy (single source for the only chezmoi-managed shared skill) | +| iTerm2 plist | `iterm/com.googlecode.iterm2.plist` (source root, chezmoi-ignored) | iTerm2 reads + writes directly via `PrefsCustomFolder` setting (no chezmoi-managed symlink) | +| `~/.config/tmux/` | `.chezmoiexternal.toml.tmpl` | archive external | +| `~/.config/mise/config.toml` | `dot_config/mise/config.toml.tmpl` | Go template (profile-aware: core tier = fzf+zoxide only; dev/workstation = full toolchain) | +| `~/.config/git/hooks/pre-push` | `dot_config/git/hooks/executable_pre-push` | plain +x. Wired globally via `core.hooksPath = ~/.config/git/hooks` in `dot_gitconfig.tmpl`. Runs `gitleaks git --log-opts=` against outbound commits; fails-open when `gitleaks` not on PATH (core-profile machines without dev toolchain). Per-repo opt-out: `git config --local core.hooksPath ''`. | +| `~/Library/Fonts/MesloLGS NF *.ttf` + `MonaspaceNeon-*.otf` (Mac) or `~/.local/share/fonts/...` (Linux) | `.chezmoiexternal.toml.tmpl` | externals (4 MesloLGS NF files + 4 Monaspace Neon via archive-file from the static release zip; headless-skip) | + +**Always edit the source path**, never the live target — the diff stays in source, ready to commit. + +Source-of-truth location is `~/.local/share/chezmoi/` — chezmoi's default (XDG_DATA_HOME compliant). No `sourceDir` override in `~/.config/chezmoi/chezmoi.toml`. On my Mac there is a back-compat symlink `~/dotfiles → ~/.local/share/chezmoi/` for muscle memory; not required. ## 4. Per-area conventions -### zsh — `zshrc` / `zsh_plugins.txt` / `p10k.zsh` +### zsh — `dot_zshrc` / `dot_zsh_plugins.txt` / `dot_p10k.zsh` - **Section dividers**: `# --- xxx ---`. Each logical section gets its own header. - **Plugins**: managed by [antidote](https://github.com/mattmc3/antidote) in - static-cache mode. Add a line to `zsh_plugins.txt`; the cache rebuilds on + static-cache mode. Add a line to `dot_zsh_plugins.txt`; cache rebuilds on next shell start. -- **`p10k.zsh`** is GENERATED by `p10k configure` — don't hand-edit for major - theme changes (rerun `p10k configure` instead). One-line tweaks (e.g. an - individual `POWERLEVEL9K_*` override) in-place are fine. -- **`code()` function** is non-trivial — see comment block in `zshrc` for +- **`dot_p10k.zsh`** is GENERATED by `p10k configure` — don't hand-edit for + major theme changes. One-line `POWERLEVEL9K_*` overrides in-place are fine. +- **`code()` function** is non-trivial — see comment block in `dot_zshrc` for VSCode Remote-SSH IPC-socket sweep and URL-fallback rationale. -- **SSH visual tints** at the bottom of `zshrc` are **coordinated with - `tmux/tmux.conf.local` palette**. Touching one without the other breaks - hue alignment (see §10). +- **SSH visual tints** at the bottom of `dot_zshrc` are **coordinated with + `dot_tmux.conf.local` palette**. See §10. +- **Cross-platform via runtime feature detection** (`command -v X`, + `case "$(uname -s)"` inside the file). chezmoi templates not used here — + the file applies as-is on Mac and Linux. -### vim — `vimrc` +### vim — `dot_vimrc` - **vim-plug bootstrap** at the top auto-installs on first launch. -- **Plugins** go inside the `call plug#begin … call plug#end` block; run +- **Plugins** inside the `call plug#begin … call plug#end` block; run `:PlugInstall` in vim after adding. -- **Comments in English** (we standardized; matches the rest of the repo). - -### tmux — `tmux/tmux.conf.local` - -- **Only `tmux.conf.local` is ours.** `tmux/oh-my-tmux/` is the gpakosz - submodule — **never edit there**. -- **Prefix** is `C-a` (screen-style), `C-b` unbound. -- **Reload after change**: `prefix r` in a running tmux session. -- Customizations group: own keybindings/colours at the top; `tmux_conf_*=` - flags (gpakosz native variables) below. See file header for boundaries. +- Comments in English. + +### tmux — `dot_tmux.conf` + `dot_tmux.conf.local` + +- **`dot_tmux.conf`** is a 1-liner: `source-file ~/.config/tmux/.tmux.conf`. + That target file comes from the **chezmoi external** (gpakosz/.tmux archive, + SHA-pinned in `.chezmoiexternal.toml.tmpl`). +- **`dot_tmux.conf.local`** is OUR customization layer (sources after the + upstream one per oh-my-tmux convention). +- **NEVER edit `~/.config/tmux/.tmux.conf`** directly — it's auto-extracted + from the upstream archive and re-extracted on every `chezmoi apply -R`. +- Bump cycle: pick newer commit SHA from gpakosz/.tmux, edit URL in + `.chezmoiexternal.toml.tmpl`, `chezmoi apply -R`, commit. +- Prefix is `C-a` (screen-style), `C-b` unbound. +- Reload after change: `prefix r` in a running tmux session. ### iTerm2 — `iterm/com.googlecode.iterm2.plist` -- **Binary plist** — do NOT hand-edit. Two safe paths: - - **Surgical key edit**: `defaults write com.googlecode.iterm2 ...` - - **GUI round-trip**: edit prefs in iTerm2 → `cp ~/Library/Preferences/com.googlecode.iterm2.plist iterm/` -- iTerm2 reads this folder via `PrefsCustomFolder` (set by `./install`). -- **Reload**: quit and relaunch iTerm2 ("Don't Save" if prompted). -- Trigger action names are bare ObjC class names (`ScriptTrigger`, not the - GUI label) — see "Gotcha" in README iTerm2 section. - -### claude/ — Claude Code +- **Binary plist**, do NOT hand-edit. Edit via iTerm2 GUI → preferences + written directly into `~/.local/share/chezmoi/iterm/com.googlecode.iterm2.plist` + → `git status` (in source) shows the dirty plist for review. +- The path `iterm/` at source root is INTENTIONALLY outside `dot_*` so chezmoi + doesn't try to apply it (it's in `.chezmoiignore`). iTerm2 reads + writes + here directly via `PrefsCustomFolder` setting. +- **No symlink in `~/.config/iterm/`** — that would clash visually with + iTerm2's own `~/.config/iterm2/` (AppSupport + sockets dir). + `PrefsCustomFolder` is set to `/iterm/` (renders to + `/Users/vaintrub/.local/share/chezmoi/iterm/` on this machine), so iTerm2 + operates directly on the source-tracked binary. +- `PrefsCustomFolder` is set on first apply by + `.chezmoiscripts/darwin/run_once_after_configure-iterm2.sh.tmpl`. +- Reload: quit and relaunch iTerm2 (answer "Don't Save" only if you don't want + your live edits persisted to source). + +### Claude Code — `dot_claude/` - See **§5 Three gates** for "save this globally" intents. -- `claude/rules/*.md` are auto-loaded by Claude every session. Optional +- `dot_claude/rules/*.md` are auto-loaded by Claude every session. Optional `paths:` glob in frontmatter makes a rule load only when matching files are open. -- `claude/skills/*/SKILL.md` are discoverable skills, intent-matched via +- `dot_claude/skills/*/SKILL.md` are discoverable skills, intent-matched via the frontmatter `description` field. -- `claude/settings.json` carries permissions / statusline / plugin enables. -- `claude/agents/` and `claude/skills/` directories use `.gitkeep` so - they stay tracked when empty. `claude/skills/save-to-dotfiles/` is the - first real skill (see §5); add new agents/skills as sibling subdirs. - -### codex/ — OpenAI Codex CLI - -- `codex/AGENTS.md` is Codex's global instructions (analogue of `CLAUDE.md`). -- `codex/config.toml` carries model + reasoning effort + plugin enables. - Runtime-mutated sections are stripped by the git clean filter (§9). -- `codex/skills/*/SKILL.md` mirror Claude skills. A skill can be shared - across both tools via a symlink (e.g. `codex/skills/save-to-dotfiles → - ../../claude/skills/save-to-dotfiles`). -- Codex auto-creates `codex/skills/.system/` for its built-in skills; - that path is `.gitignore`d at repo root. - -### dotbot — `.install.conf.yaml` + `install` - -- **`link:`** entries create symlinks under `$HOME`. `force: true` replaces - any pre-existing file at the target path. -- **`clean: ['~', '~/.claude', '~/.codex']`** sweeps broken symlinks at these - paths on each `./install`. dotbot doesn't recurse, so each subdir we own - must be listed. -- **`shell:`** hooks run in order during `./install`. Must work in POSIX `sh` - (`/bin/sh` on Ubuntu is `dash`) — no bashisms. See §8. -- **`./install` is idempotent** — every hook checks state before mutating - and prints "already X" when there's nothing to do. +- **`dot_claude/modify_settings.json.tmpl`** uses jq-additive merge to preserve + keys added by `rtk init`, `claude plugin install`, etc. See §9. +- The curated base lives at `.chezmoitemplates/claude-settings-base.json` + (canonical chezmoi location for files read by templates but not applied); + loaded via `includeTemplate "claude-settings-base.json" .`. + +### Codex — `dot_codex/` + +- `dot_codex/AGENTS.md` is Codex's global instructions (analogue of CLAUDE.md). +- **`dot_codex/modify_config.toml`** uses chezmoi-native `fromToml` / `toToml` + via `#chezmoi:modify-template` annotation. See §9. +- The curated base lives at `.chezmoitemplates/codex-config-base.toml` + (canonical chezmoi location); loaded via + `includeTemplate "codex-config-base.toml" .`. +- `dot_codex/skills/save-to-dotfiles/symlink_SKILL.md` makes the + `save-to-dotfiles` skill visible to Codex via a FILE-level symlink to + Claude's copy at `~/.claude/skills/save-to-dotfiles/SKILL.md`. The + `~/.codex/skills/` directory is otherwise its own dir (NOT a dir-level + symlink to `~/.claude/skills/`) — earlier dir-symlink design caused + cross-contamination (skills installed for Codex leaked into Claude's + scan path → duplicate registrations). Caveman uses Claude's plugin + install for its Claude side; Codex side is manual (`npx skills add ... + -a codex` doesn't currently wire per-agent symlinks). + +### Bootstrap scripts — `.chezmoiscripts/` + +Scripts here don't create `$HOME` files (no `dot_*` sibling). They run as part +of `chezmoi apply`: + +- **`run_once_before_*`** — run ONCE per machine, BEFORE file ops. Used for + hard prereq checks (zsh/vim/tmux), brew install (Mac). +- **`run_onchange_after_*`** — run AFTER all file ops, only when script + contents change (chezmoi tracks hash). Used for brew bundle, rtk install, + caveman install. +- **`run_once_after_*`** — run ONCE per machine, AFTER file ops. Used for + iTerm2 `defaults write PrefsCustomFolder`. + +All scripts are templates (`.tmpl`) — OS branching via `{{ if eq .chezmoi.os "darwin" }}`, +runtime feature detection via `command -v X`, etc. + +Distro dispatch keys off `.osid` — a composite key derived once in +`.chezmoi.toml.tmpl` from `.chezmoi.os` + `.chezmoi.osRelease.id`: + +- `darwin` +- `linux-ubuntu`, `linux-debian`, `linux-fedora`, `linux-pop`, ... + +Use as `{{ if eq .osid "linux-ubuntu" "linux-debian" }}` or via a +predicate function in `lib/install-packages.sh::is_debian_family`. +Fall back to `.chezmoi.osRelease.idLike` for derivatives that don't +match the canonical `id` list (Pop!_OS, Mint, Raspbian). + +### Bootstrap script split — thin wrapper + `lib/` + +The biggest install script (`run_onchange_after_50-install-packages.sh.tmpl`) +is intentionally a 25-LOC wrapper that: + +1. Renders chezmoi facts (profile, osid, osRelease.idLike, every package + list from `.chezmoidata/packages.yaml`) into `DOTFILES_*` env vars. +2. Sets `INSTALL_PACKAGES_INVOKE=1` and sources + `{{ .chezmoi.sourceDir }}/lib/install-packages.sh`. + +The library is pure POSIX `sh` with no template syntax — bats unit tests +under `tests/unit/install-packages.bats` `source` it (with the invoke +flag unset so `main` doesn't auto-run) and exercise each function with +mocked `sudo`/`apt-get`/`mise`/`id`. Pattern: shunk031/dotfiles +(https://github.com/shunk031/dotfiles) testable-dotfiles convention. + +When adding a new install script that has non-trivial logic, follow the +same split: wrapper renders env, library implements the logic, bats tests +the library. ## 5. Saving portable settings — three gates @@ -147,67 +247,102 @@ The gates, in summary: absolute local path, a specific hostname, hardware-specific behaviour, secrets / tokens, or per-machine auth state — it's **not** for dotfiles. Suggest a local alternative: `~/.zshrc.local`, an env var, 1Password, -or per-machine `settings.local.json`. +or per-machine `~/.claude/settings.local.json`. ### Gate 2 — Platform -*Cross-platform, macOS-only, or Linux-only?* Wrap macOS-only logic with -`[ "$(uname -s)" = "Darwin" ]`; Linux-only with `"Linux"`; conditional -on tool availability with `command -v X >/dev/null 2>&1`. If unsure, ask -the user explicitly. +*Cross-platform, macOS-only, or Linux-only?* For shell hooks, wrap macOS-only +logic with `{{ if eq .chezmoi.os "darwin" }}`; Linux-only with `"linux"`; +conditional on tool availability with `command -v X >/dev/null 2>&1`. +If unsure, ask the user explicitly. ### Gate 3 — Routing *Which file does this belong in?* Use the skill's routing table (alias → -zshrc, vim setting → vimrc, tmux binding → `tmux.conf.local`, Claude rule -→ `claude/rules/.md`, etc.). When multiple targets are reasonable, -propose options, don't guess. +`dot_zshrc`, vim setting → `dot_vimrc`, tmux binding → `dot_tmux.conf.local`, +Claude rule → `dot_claude/rules/.md`, etc.). When multiple targets +are reasonable, propose options, don't guess. + +Full procedure including taxonometers and routing table lives in the skill +body: see `dot_claude/skills/save-to-dotfiles/SKILL.md`. -Full procedure including taxonometers and the routing table lives in the -skill body: see `claude/skills/save-to-dotfiles/SKILL.md`. +## 6. chezmoi workflow -## 6. dotbot workflow +### Modifying an existing managed file -### Modifying an existing symlinked file +```sh +chezmoi edit ~/.zshrc # opens dot_zshrc in $EDITOR; chezmoi knows the mapping +# or: +chezmoi cd # opens subshell in source dir (~/.local/share/chezmoi/) +$EDITOR dot_zshrc # direct edit of source +chezmoi diff # preview what would change in $HOME +chezmoi apply # write changes to $HOME +``` -1. Edit the **dotfiles target path** (not the symlink itself). -2. No `./install` needed — the symlink resolves to the new content automatically. +Then commit (still inside the `chezmoi cd` subshell): +```sh +git commit -am '...' +git push +exit # leave the chezmoi-cd subshell +``` -### Adding a NEW symlink +### Adding a NEW managed file -1. Create the source file: `~/dotfiles//`. -2. Add a `link:` entry to `.install.conf.yaml` mapping live → dotfiles. -3. `cd ~/dotfiles && ./install` to create the symlink. +```sh +chezmoi add ~/.foo # copy live file → dot_foo in source +chezmoi cd # opens shell in source dir +$EDITOR dot_foo # customize +chezmoi apply # write back to $HOME +``` ### Adding an install-time hook -1. Add a `shell:` entry to `.install.conf.yaml`. -2. **POSIX `sh` only** (see §8). OS-branch with `case "$(uname -s)" in Darwin) … ;; Linux) … ;; esac`. -3. Be idempotent — check before mutating, print "already X" when done. +1. Create file in `.chezmoiscripts/run__.sh.tmpl`. Prefix: + - `run_once_before_` — runs once per machine, before file ops + - `run_onchange_after_` — runs when content hash changes, after file ops + - `run_once_after_` — runs once per machine, after file ops +2. POSIX `sh` only (template renders, then runs). +3. OS-branch via `{{ if eq .chezmoi.os "darwin" }}` template directive. +4. Be idempotent — check before mutating, print "Already X" when done. + +### Bumping the oh-my-tmux external + +```sh +# 1. Find newer SHA: https://github.com/gpakosz/.tmux/commits/master +# 2. Edit .chezmoiexternal.toml.tmpl — replace SHA in URL +# 3. Force refresh + apply: +chezmoi apply -R +# 4. Commit +chezmoi cd && git commit -am 'tmux: bump oh-my-tmux to ' && exit +``` ## 7. Cross-platform conventions ### Hard prerequisites -`git`, `python3`, `zsh`, `vim`, `tmux`. The `install` script fails fast with -`brew install …` / `apt install …` suggestion if any is missing. +`git`, `zsh`, `vim`, `tmux`, `curl`. Installed automatically by +`.chezmoihooks/ensure-prereqs.sh` (registered as `hooks.read-source-state.pre` +in `.chezmoi.toml.tmpl`) on every apply — runs apt/dnf on Linux, brew on Mac. +Hook skips with a warning if non-interactive (sudo prompt would block). ### Soft prerequisites (per-feature, skip with reason if missing) -`brew` (macOS casks), `curl` + `fontconfig` (Linux fonts), `claude`, -`codex`, `code`, `fnm`, `zoxide`. Missing tool → that feature skips with -a printed reason, not a hard fail. - -### Headless SSH detection +`brew` on Mac + `mise` cross-platform (both installed by the prereqs hook); +`claude`, `codex`, `code`, `zoxide` — installed by +`run_onchange_after_50-install-packages.sh.tmpl` from `.chezmoidata/packages.yaml`. +Each is guarded with `command -v` in scripts + zshrc — missing → feature skips, +not a hard fail. `mise` then materializes the toolchain per +`dot_config/mise/config.toml.tmpl` (profile-aware: `core` = fzf+zoxide only; +`dev`/`workstation` = full set including Go/Python/Node/Rust + 25 aqua tools). + +### Headless SSH detection (for things like font install) ```sh [ -n "$SSH_CONNECTION" ] && [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ] ``` -Used to skip font install on remote machines (glyphs render on the local -terminal — the remote box never sees them). -### OS branching pattern -```sh -case "$(uname -s)" in - Darwin) … ;; - Linux) … ;; - *) echo "Unsupported OS: $(uname -s)" >&2 ; exit 1 ;; -esac +### OS branching pattern in .tmpl files +``` +{{ if eq .chezmoi.os "darwin" -}} + # macOS-only block +{{- else if eq .chezmoi.os "linux" -}} + # Linux-only block +{{- end }} ``` ## 8. Shell conventions @@ -216,43 +351,51 @@ esac | File | Dialect | Notes | |---|---|---| -| `zshrc`, `p10k.zsh` | **zsh 5+** | Use zsh-specific features freely: `zsocket`, glob qualifiers `(Nom)`, `typeset`, `[[ ]]`, `${var:A}` | -| `install` | **bash** | `set -e` at top, `[[ ]]` OK, `(( … ))` for arithmetic, `${#arr[@]}` for arrays | -| `.install.conf.yaml` `shell:` hooks | **POSIX `sh`** (runs under `dash` on Ubuntu) | **NO bashisms**: no `[[ ]]`, no arrays, no `${var^^}`, no `<()`, no `local` | -| `claude/statusline-command.sh` | **POSIX `sh`** | shebang `#!/bin/sh`, `jq` allowed (it's the standard tool here) | -| `codex/scripts/*.awk` | **POSIX awk** | gnu/mawk-compatible | +| `dot_zshrc`, `dot_p10k.zsh` | **zsh 5+** | Full zsh syntax: `zsocket`, glob qualifiers `(Nom)`, `typeset`, `[[ ]]`, `${var:A}` | +| `.chezmoiscripts/*.sh.tmpl` | **POSIX `sh`** | No bashisms — must work in `dash`. Templates render to `sh` scripts. | +| `dot_claude/executable_statusline-command.sh` | **POSIX `sh`** | `#!/bin/sh`, `jq` allowed (standard here) | +| `dot_claude/modify_settings.json` | **POSIX `sh`** | jq-based merge, runs on every apply | +| `dot_codex/modify_config.toml` | **Go template** | `#chezmoi:modify-template` annotation, uses chezmoi `fromToml`/`toToml`/`mergeOverwrite` | ### Lint -- `shellcheck install` — clean (bash mode) -- `shellcheck -s sh codex/scripts/...` if shell hooks become non-trivial -- `shellcheck` doesn't support zsh — `zshrc` / `p10k.zsh` are not linted +- `shellcheck` for shell scripts in `.chezmoiscripts/` and `dot_claude/` +- `shellcheck` doesn't support zsh — `dot_zshrc` / `dot_p10k.zsh` not linted ### Style -- **Indent**: 4 spaces (matches `install` + `.install.conf.yaml`) +- **Indent**: 4 spaces - **Quote variables**: `"$var"` always. Exception: intentional glob expansion. -- **Tests**: `[ "$x" = "y" ]` (POSIX) or `[[ ]]` (bash/zsh) — never `[ $x == $y ]` +- **Tests**: `[ "$x" = "y" ]` (POSIX) — never `[ $x == $y ]` - **Feature detection**: `command -v foo >/dev/null 2>&1`, never `which` -- **Idempotency in hooks**: check state first, print "Already X" when there's nothing to do -- **Output**: prefer `printf '%s\n' "$x"` over `echo -e`; `echo` is fine for plain strings +- **Idempotency**: check state first, print "Already X" when there's nothing to do -## 9. Codex git clean filter +## 9. Codex runtime-section strip (via chezmoi `modify_`) -`~/.codex/config.toml` is symlinked into `codex/config.toml`. Codex auto-writes -several sections (`[projects.""]`, `[notice]`, `[tui.*]`, `[tool_suggest]`, -top-level `windows_wsl_setup_acknowledged`) into that file after every session. +Codex auto-writes several sections into `~/.codex/config.toml` after every +session: `[projects.""]` (trust state), `[notice]` (UI dismissals), +`[tui.*]` (theme + NUX counters), `[tool_suggest]` (disabled tools), +top-level `windows_wsl_setup_acknowledged`. -A `clean` filter at `codex/scripts/strip-runtime-sections.awk` strips these on -`git add`; the matching `smudge = cat` half is identity passthrough on -checkout. Both halves are **required** (`required = true`) — without smudge, -`git checkout` of any branch touching `codex/config.toml` fails with -`fatal: ... smudge filter codex-strip failed`. +These are per-machine runtime state — we don't sync them across machines. +`dot_codex/modify_config.toml` handles this: on every `chezmoi apply`, +the existing target file is piped in, runtime sections are dropped via +chezmoi's `fromToml` / `unset` template functions, our curated base from +`.chezmoitemplates/codex-config-base.toml` is `mergeOverwrite`-ed on top, +then serialized back via `toToml`. Idempotent — applies don't grow the file. -The filter is registered per-clone by `./install`'s shell hook. +The annotation `#chezmoi:modify-template` at the top of the file tells +chezmoi to render it as a Go template (not run as a script) and exposes +the existing target file's contents as `.chezmoi.stdin`. When the **`ConfigEdit` enum** in [`codex-rs/core/src/config/edit.rs`](https://github.com/openai/codex/tree/main/codex-rs/core/src/config/edit.rs) -gains a new variant, mirror it in `codex/scripts/strip-runtime-sections.awk`'s -section-skip regex. +gains a new runtime section variant, add it to the `unset` chain in +`dot_codex/modify_config.toml`. + +`dot_claude/modify_settings.json.tmpl` does the analogous thing for Claude +Code, but via shell + jq (instead of pure Go template) — Claude's +`settings.json` is JSON, easier to merge with jq's `*` deep-merge operator. +Both modify_ scripts pull their base from `.chezmoitemplates/` via +`includeTemplate`. ## 10. Visual coordination — SSH cues @@ -260,19 +403,18 @@ Three coordinated signals so I can't type into the wrong machine by accident: | Cue | Source | Colour | Hex | |---|---|---|---| -| p10k prompt `❯` | `zshrc` line ~136 | orange 208 | `#ff8700` | -| tmux bar background | `zshrc` lines ~149-151 | dark amber | `#5f2f00` | -| p10k REMOTE context segment | `p10k.zsh` | peach 180 | `#d7af87` | +| p10k prompt `❯` | `dot_zshrc` line ~136 | orange 208 | `#ff8700` | +| tmux bar background | `dot_zshrc` lines ~149-151 | dark amber | `#5f2f00` | +| p10k REMOTE context segment | `dot_p10k.zsh` | peach 180 | `#d7af87` | All three share ~30° hue (orange / amber / peach). When changing any of them, **preserve the hue family**. The dark amber for tmux bg must keep gpakosz's -grey #8a8a8a icons at WCAG AA-Large (3.21:1) — don't darken further without +grey `#8a8a8a` icons at WCAG AA-Large (3.21:1) — don't darken further without checking contrast. -The tmux overrides live in `zshrc` (not `tmux.conf.local`) because gpakosz's -`_apply_theme` reads `tmux_conf_*` via `printenv` — values must be in the env -**before** the tmux server starts. A `${SSH_CONNECTION:+…}` branch inside -`.tmux.conf.local` would reach the helper as a literal string and get rejected. +The tmux overrides live in `dot_zshrc` (not `dot_tmux.conf.local`) because +gpakosz's `_apply_theme` reads `tmux_conf_*` via `printenv` — values must +be in the env **before** the tmux server starts. ## 11. Git commit conventions (this repo) @@ -286,25 +428,36 @@ Observed prefixes (in `git log`): | `chore:` | housekeeping (whitespace, deps, license) | | `tweak:` | small adjustment to existing feature | | `revert:` | undo a previous change | -| area-prefixed (`tmux:`, `iterm:`, `code:`, `install:`, `zshrc:`) | when the change is scoped to one area | +| area-prefixed (`tmux:`, `iterm:`, `code:`, `chezmoi:`, `zshrc:`) | when the change is scoped to one area | Rules: - **Short, lowercase, imperative** — max 72 chars on summary line - **No AI attribution** (`Co-Authored-By: Claude` / `Codex` etc.) - **NEW commits**, not amends, unless explicitly requested -- **Body explains WHY** when non-obvious; the `what` is in the diff +- **Body explains WHY** when non-obvious - **Branch names**: kebab-case, prefix `feature/`, `fix/`, `docs/`, `chore/` -## 12. Submodules +## 12. Externals (replacing submodules) -| Submodule | Source | Customize via | -|---|---|---| -| `.dotbot/` | github.com/anishathalye/dotbot | upstream only — pin in `.gitmodules` | -| `tmux/oh-my-tmux/` | github.com/gpakosz/.tmux | `tmux/tmux.conf.local` (our file) | +| External | Source | Pin | Customize via | +|---|---|---|---| +| `~/.config/tmux/` | gpakosz/.tmux archive | commit SHA in URL | `dot_tmux.conf.local` (our file) | + +`.chezmoiexternal.toml.tmpl`: +```toml +[".config/tmux"] + type = "archive" + url = "https://github.com/gpakosz/.tmux/archive/.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "720h" +``` -Both are pinned to floating `master`. `./install` runs -`git submodule update --init --recursive` automatically on every invocation -— stale clones get re-synced. +**Never edit `~/.config/tmux/` contents directly** — re-extracted from the +archive on every `chezmoi apply -R`. Customizations go in our own +`dot_tmux.conf.local` (sources after the upstream one per oh-my-tmux +convention). -**Never edit anything inside a submodule directory.** Changes there will be -silently overwritten on the next submodule update. +No git submodules in this repo — chezmoi externals replace them. No +`.gitmodules` file, no `git submodule update --init` step needed for new +clones. diff --git a/README.md b/README.md index e00f82c..dc66979 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,229 @@ # Dotfiles -Personal cross-platform (macOS + Linux) dotfiles managed with [dotbot](https://github.com/anishathalye/dotbot). +Personal cross-platform (macOS + Linux) dotfiles managed with [chezmoi](https://www.chezmoi.io/). Includes: - **zsh** with [antidote](https://github.com/mattmc3/antidote) (static-cache) and [Powerlevel10k](https://github.com/romkatv/powerlevel10k) — `~/.p10k.zsh` tracked in the repo so the prompt is byte-identical everywhere - **vim** with [vim-plug](https://github.com/junegunn/vim-plug) (auto-bootstrapped on first launch) -- **tmux** with [gpakosz/.tmux](https://github.com/gpakosz/.tmux) ("oh-my-tmux") vendored as a submodule +- **tmux** with [gpakosz/.tmux](https://github.com/gpakosz/.tmux) ("oh-my-tmux") vendored as a chezmoi external (pinned by commit SHA) - **iTerm2** preferences (macOS only, auto-configured to load from the repo) -- **AI tooling** (Claude Code + OpenAI Codex CLI) — global instructions, settings, rules, AGENTS.md - -`./install` is OS-aware: a single config file branches on `uname -s` for the few OS-divergent steps (font install, brew casks, iTerm2 wiring). Same repo, same script, both platforms. +- **AI tooling** (Claude Code + OpenAI Codex CLI) — global instructions, settings, rules, AGENTS.md, with tool-mutation-safe merging via `modify_` scripts +- **mise** (cross-platform) — declarative dev-tool list (Go/Python/Node/Rust + 25 aqua tools) via `dot_config/mise/config.toml.tmpl`, tier-aware so `core`-profile machines only pull what dotfiles need +- **3-tier install profiles** — `core` (dotfile baseline) / `dev` (+ CLI toolchain) / `workstation` (+ GUI apps per OS); always prompted on init with detected-env hint +- **rtk** auto-installation + `rtk init` wiring for both Claude Code and Codex ## Install ### macOS -Prerequisites: `zsh`, `vim`, `tmux`, `git`, `python3`, [Homebrew](https://brew.sh), [iTerm2](https://iterm2.com). +Supported: Intel + Apple Silicon, macOS recent versions. + +One command: ```sh -git clone https://github.com/vaintrub/dotfiles.git ~/dotfiles -cd ~/dotfiles && ./install +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply vaintrub ``` -The installer: -- Installs **MesloLGS Nerd Font** and **Visual Studio Code** via Homebrew cask (skipped if already installed). -- Points iTerm2 at `iterm/com.googlecode.iterm2.plist` in the repo (sets `PrefsCustomFolder`) — your terminal config travels with the repo. -- Symlinks `~/.p10k.zsh` to the repo so your Powerlevel10k prompt is byte-identical on every machine. +That's it. `chezmoi init` prompts you for profile (`core` / `dev` / `workstation` — default `core` for safety; pick `workstation` for full Mac install with casks). `hooks/ensure-prereqs.sh` (registered via `hooks.read-source-state.pre`) installs Xcode CLT + Homebrew + mise on first run. Then `run_onchange_after_50-install-packages.sh.tmpl` reads `.chezmoidata/packages.yaml`, `brew bundle`s `packages.dev.brews` (htop, tree, nmap, libpq, wireguard-tools, rtk) + `packages.gui.mac_casks` if `workstation` (iterm2, docker-desktop, vscode, ngrok, fonts), then runs `mise install` for the dev toolchain. Claude Code + Codex CLIs install via mise's npm (no sudo, lands under mise shims). Plugins, rtk init, caveman all install automatically afterward. + +The repo lands at chezmoi's default source location: `~/.local/share/chezmoi/`. No `--source` flag, no install.sh, no convention overrides — `chezmoi cd` and `chezmoi edit` work seamlessly without per-machine setup. -After the first `./install`, **quit iTerm2 once and relaunch** (choose "Don't Save" if prompted) — iTerm2 reads its prefs at startup. Re-runs are idempotent. +After the first apply, **quit iTerm2 once and relaunch** (choose "Don't Save" if prompted) — iTerm2 reads its prefs at startup. -> Hard prerequisites are `git` and `python3` only — if either is missing, `./install` exits immediately with a clear `brew install …` / `apt install …` suggestion. Everything else (Homebrew, curl, fontconfig, zsh, vim, tmux, iTerm2) is checked per-feature: missing tools cause that feature to skip with a printed reason, not a hard fail. +### Linux (Debian / Ubuntu / Fedora) -### Linux +Supported: Debian 12+, Ubuntu 22.04+, Fedora 40+. Not supported: Alpine, Arch (the install-packages script's distro branch prints an explicit "unsupported, install manually" message in that case). -Prerequisites: `zsh git vim tmux curl fontconfig python3` (python3 is required by dotbot — preinstalled on most distros but absent on minimal Alpine/distroless images). Optional for tmux yank-to-clipboard: `xsel` (X11) or `wl-clipboard` (Wayland). +Bootstrap — install just enough to run chezmoi: ```sh -sudo apt install zsh git vim tmux curl fontconfig python3 xsel # Debian/Ubuntu -# or: sudo dnf install zsh git vim tmux curl fontconfig python3 xsel # Fedora -chsh -s "$(command -v zsh)" +sudo apt install -y git curl sudo # Debian/Ubuntu +# or +sudo dnf install -y git curl sudo # Fedora +``` + +Then the canonical one-liner: + +```sh +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply vaintrub +``` + +The repo lands at `~/.local/share/chezmoi/` (chezmoi's default source location). Default Linux profile is `dev` (auto-picked); Codespaces / devcontainers auto-pick `core`. From there chezmoi takes over: + +1. `hooks/ensure-prereqs.sh` apt/dnf installs `git zsh vim tmux curl ca-certificates` if not present + curl-pipes mise into `~/.local/bin/` (runs BEFORE chezmoi reads source state). +2. `run_onchange_after_50-install-packages.sh.tmpl` runs tier-cascade: (a) `apt-get`/`dnf install` core packages always; if `dev`, also dev packages; (b) `mise install` from `~/.config/mise/config.toml` — at `core` just fzf+zoxide, at `dev` the full toolchain; (c) `dev`-only post-installs: `goimports` via `go install`, `ssh-audit` via `uv tool install`, AI CLIs (Claude Code + Codex) via mise's `npm install -g`. +3. `60-install-rtk` curl-pipes the rtk installer into `~/.local/bin/rtk` (Linux; Mac brew handles it via `packages.dev.brews`). +4. `70-install-plugins` registers Claude Code + Codex plugins listed in `packages.yaml` (caveman included — installed via canonical `claude plugin install caveman@caveman`, no bespoke installer). +5. `linux/run_once_after_install-fonts.sh.tmpl` runs `fc-cache` after MesloLGS NF + Monaspace fonts are downloaded by `.chezmoiexternal.toml.tmpl`. Skipped on headless boxes (SSH with no `$DISPLAY`/`$WAYLAND_DISPLAY`). +6. `linux/run_once_after_chsh.sh.tmpl` sets `zsh` as your login shell. Skipped if `chsh` would block on non-interactive password prompt. + +## Install profile + +`chezmoi init` ALWAYS prompts for the install profile (no silent auto-decision). It detects the environment and shows it as a HINT in the prompt; you pick explicitly. The default is `core` (safe fallback for CI / non-interactive runs / accidental enter). Value cached in `~/.config/chezmoi/chezmoi.toml [data].profile`. Three tiers, use-case-driven: + +| Profile | What you get | Use case | +|---|---|---| +| `core` | OS-native baseline (`zsh`, `vim`, `tmux`, `git`, `curl`, `ca-certificates`; Linux only: `ufw`, `tcpdump`) + MesloLGS NF + Monaspace Neon fonts + `mise` + `fzf` + `zoxide`. ~50 MB total. | Hetzner VPS, DigitalOcean droplet, jetson over SSH first-touch, Codespace, recovery. | +| `dev` | core + CLI dev toolchain: 25 mise dev tools (Go/Python/Node/Rust + kubectl/helm/k9s/kustomize/stern/argocd/opentofu/awscli/rclone/jq/gh/delta/fd/yq/shellcheck/buf/golangci-lint/goreleaser/gotestsum/protoc-gen-go/pnpm/uv/cloudflared/websocat). Linux: `docker.io`, `htop`, `tree`, `wget`, `nmap`, `telnet`, `libpq`, `wireguard-tools`, `build-essential`, `xsel`/`wl-clipboard`. Mac brews (no docker — use `workstation` for Docker Desktop): `htop`, `tree`, `wget`, `nmap`, `telnet`, `libpq`, `wireguard-tools`, `rtk`. Post-installs (`goimports`, `ssh-audit`). AI CLIs (`claude-code`, `codex`). ~1.9 GB first apply. | Headless dev box (jetson, Linux VM, VPS for real work, Mac SSH'd into headless). | +| `workstation` | dev + GUI apps. Mac: casks (`iterm2`, `docker-desktop`, `visual-studio-code`, `ngrok`, `font-meslo-lg-nerd-font`, `font-monaspace`). Linux: placeholder (empty for now — populate when I run a Linux desktop). | Primary GUI machine (Mac laptop, Linux desktop). | + +**Hint format in prompt**: `Install profile [detected env: workstation (GUI=true, SSH=false, ephemeral=false)] — you pick (core/dev/workstation, default core)?`. Detected env helps you pick; doesn't pre-select. + +**To change later**: edit `[data].profile` in `~/.config/chezmoi/chezmoi.toml` directly (value: `"core"`, `"dev"`, or `"workstation"`), then `chezmoi apply`. The install script's rendered output changes → `run_onchange` re-fires automatically. The mise config (`~/.config/mise/config.toml`) also re-renders profile-aware: switching to `core` removes language toolchains from mise; switching to `dev` or `workstation` adds them. + +**To force re-prompt**: `chezmoi init --prompt`. + +**Migrating from older data shapes** (machines initialised before the use-case +refactor have stale `personal` / `headless` keys, or `profile = "mac"` from the +older `core/dev/mac` triplet): -git clone https://github.com/vaintrub/dotfiles.git ~/dotfiles -cd ~/dotfiles && ./install +```sh +chezmoi init --promptDefaults # rewrites chezmoi.toml from new template: + # translates "mac" → "workstation", + # derives .osid, drops stale personal/headless keys +chezmoi apply # re-runs install scripts under new profile ``` -The installer downloads MesloLGS NF (4 `.ttf` files from the official p10k media repo) into `~/.local/share/fonts/` and runs `fc-cache`. No root needed. +`mac` is silently translated to `workstation` by the template, so existing Mac +users keep their GUI install across the upgrade. After the single re-init, +`chezmoi.toml` is in the new shape; no leftover keys. + +**Mac firewall (ufw equivalent)**: not managed by dotfiles. Enable manually via +System Settings → Network → Firewall. Linux core tier installs `ufw` (disabled +by default — `sudo ufw enable` to activate). + +## Tools managed by mise (cross-platform via aqua backend) + +Defined declaratively in `dot_config/mise/config.toml.tmpl` (profile-aware — rendered to `~/.config/mise/config.toml`). Same tools install identically on Mac + Linux. + +`core` profile installs only what dotfile integrations need: + +| Category | Tools | +|---|---| +| Shell integrations | `fzf` (zshrc Ctrl-R, Ctrl-T bindings), `zoxide` (zshrc `z`/`zi`) | + +`dev` profile adds the full dev toolchain (`workstation` cascades — includes everything in `dev`): + +| Category | Tools | +|---|---| +| Languages | `node` (LTS), `go` (latest), `python` (3.12), `rust` (stable) | +| CLI utilities | `jq`, `gh`, `delta`, `shellcheck`, `fd`, `yq` | +| Cloud + K8s | `helm`, `kubectl`, `k9s`, `kustomize`, `stern`, `argocd`, `tofu` (opentofu), `aws` (awscli), `rclone`, `cloudflared` | +| Networking | `websocat` | +| Go ecosystem | `buf`, `golangci-lint`, `goreleaser`, `gotestsum`, `protoc-gen-go` | +| Package managers | `pnpm`, `uv` | +| Post-install | `goimports` (`go install`), `ssh-audit` (`uv tool install`), `claude-code` + `codex` (`npm install -g`) | + +**Adding a tool**: `mise registry | grep -i ` → use the aqua slug → add line to `dot_config/mise/config.toml.tmpl` (inside the `$isDev` block unless every tier needs it) → `chezmoi apply` (or `mise install`). + +**Removing a tool**: delete line, optionally `mise uninstall `. + +## OS-native packages (via brew / apt / dnf) + +Tools NOT in mise's aqua registry — C apps with libpcap/curses/PAM deps, system services, or platform-specific. Defined in `.chezmoidata/packages.yaml` under `packages.{core,dev,mac}` sections, installed cascade-style by `run_onchange_after_50-install-packages.sh.tmpl`. + +| Tier | OS | Packages | +|---|---|---| +| core | Linux apt/dnf | `zsh`, `vim`, `tmux`, `git`, `curl`, `ca-certificates`, `ufw`, `tcpdump` | +| core | Mac brew | (none — Apple bundles zsh/vim/tmux/git/curl + system `/usr/sbin/tcpdump`; no Mac equivalent for ufw — use `pfctl`) | +| dev | Mac brew | `htop`, `tree`, `wget`, `nmap`, `telnet`, `libpq` (psql, force-linked), `wireguard-tools`, `rtk` | +| dev | Linux apt | `build-essential`, `xsel`, `wl-clipboard`, `docker.io`, `htop`, `tree`, `wget`, `nmap`, `inetutils-telnet`, `wireguard-tools`, `postgresql-client` | +| dev | Linux dnf | (same as apt, modulo Fedora naming) | +| mac | Mac casks | `iterm2`, `docker-desktop`, `visual-studio-code`, `ngrok`, `font-meslo-lg-nerd-font`, `font-monaspace` | + +**Ad-hoc installs**: brew/apt/dnf still primary on each platform. Want `mongosh`? `brew install mongosh` (Mac) or follow MongoDB's apt repo (Linux). Want `kubectl` debug version? `mise use kubectl@1.34.2` (per-project) or edit the global config. + +**First-apply latency** at `dev` or `workstation`: cold install downloads ~1.9 GB (4 language toolchains × ~300 MB + 25 aqua tools). Realistic ranges: 5-10 min on fast link, 15-30 min on residential, 30-60+ min on slow links / jetson. `core` profile only pulls fzf+zoxide (~5 MB). + +**Troubleshooting slow zsh prompt**: `MISE_TIMINGS=1 exec zsh` — total < 50ms per prompt is healthy. If consistently above, switch to mise shim mode in `dot_zshrc`: +```zsh +command -v mise > /dev/null && eval "$(mise activate --shims zsh)" +``` -**Headless SSH servers**: the font install is skipped automatically (`$SSH_CONNECTION` is set and no `$DISPLAY`/`$WAYLAND_DISPLAY`). Glyphs render on the local terminal emulator — the remote machine never sees them. +**Per-machine mise overrides**: skip a heavy toolchain (e.g. Rust on jetson) by creating untracked `~/.config/mise/config.local.toml` with a `[tools]` block that overrides specific entries. mise auto-merges later files over earlier — don't commit, it's per-machine. + +## How it's managed (chezmoi essentials) + +Source files in this repo use chezmoi's naming convention. The mapping is mechanical: + +| Source name | Becomes in `$HOME` | +|---|---| +| `dot_zshrc` | `~/.zshrc` | +| `dot_claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | +| `dot_codex/AGENTS.md` | `~/.codex/AGENTS.md` | +| `executable_X` | mode +x | +| `private_X` | mode 0600 | +| `symlink_X.tmpl` | symbolic link (body = target) | +| `modify_X` | script: stdin = existing file, stdout = new contents | +| `modify_X` + `#chezmoi:modify-template` | template: `.chezmoi.stdin` = existing; output = new contents | +| `.chezmoiscripts/run_*` | scripts that don't create `$HOME` files (per-OS subdirs `darwin/` + `linux/`; root scripts use `50/60/70/80-` numerical prefix for ordering) | +| `.chezmoihooks/*` | hook scripts registered in `.chezmoi.toml.tmpl` (e.g. `read-source-state.pre`) | +| `.chezmoiexternal.toml.tmpl` | vendored externals (archives, file downloads) | +| `.chezmoiignore` | exclude target paths from apply | +| `.chezmoiversion`, `.chezmoiremove.tmpl` | minimum-version pin + deprecation-tracking list | + +**Source of truth** is `~/.local/share/chezmoi/` (chezmoi's default — XDG_DATA_HOME compliant). On my Mac there's also a back-compat symlink `~/dotfiles → ~/.local/share/chezmoi` for muscle memory; not required. + +### Editing `.chezmoi.toml.tmpl` (the config template) + +Changes to `.chezmoi.toml.tmpl` (prompts, hooks, [data] keys) make the rendered local config drift from the cached template hash. On the next apply chezmoi prints: + + chezmoi: warning: config file template has changed, run chezmoi init to regenerate config file + +Clear it with: + +```sh +chezmoi init --promptDefaults +``` + +`chezmoi init` rewrites `~/.config/chezmoi/chezmoi.toml` from the template. With no custom `sourceDir` override to lose, this is now idempotent — nothing manual to restore. + +**Day-to-day flow** (use `chezmoi cd` instead of remembering the source path): +```sh +chezmoi cd # opens subshell in source dir +vim dot_zshrc # edit source +chezmoi diff # preview what would change in $HOME +chezmoi apply # apply to $HOME +git commit -am '...' && git push +exit # leave the chezmoi-cd subshell +``` + +On another machine: `chezmoi update` (= git pull + apply). ## Tools ### tmux -Prefix is **`C-a`** (screen-style). Status bar, bindings, theme inherit from gpakosz/.tmux unchanged — see their [docs](https://github.com/gpakosz/.tmux#bindings). Useful defaults out of the box: `prefix h/j/k/l` for pane navigation, `prefix -` / `prefix _` for splits, `prefix e` to edit `.tmux.conf.local`, `prefix r` to reload, `prefix m` to toggle mouse. +Prefix is **`C-a`** (screen-style). Status bar, bindings, theme inherit from gpakosz/.tmux unchanged — see their [docs](https://github.com/gpakosz/.tmux#bindings). -Customizations in `tmux/tmux.conf.local`: +oh-my-tmux is vendored via `.chezmoiexternal.toml.tmpl` — pinned to a specific commit SHA (gpakosz/.tmux has no tagged releases). Bump cycle: edit SHA in URL → `chezmoi apply -R` → commit. + +Customizations in `dot_tmux.conf.local` (becomes `~/.tmux.conf.local`): - **Prefix `C-a`** as the sole prefix (verbatim from gpakosz's documented snippet) - **`mouse on`** — wheel scrolls tmux scrollback, drag-resizes panes, click selects -- **`mode-keys vi`** — copy-mode uses vim navigation (gpakosz ships vi-bindings; this activates them) +- **`mode-keys vi`** — copy-mode uses vim navigation - **`set-clipboard on`** (OSC 52) — vim/nvim `"+y` inside tmux reaches the system clipboard -- **`tmux_conf_copy_to_os_clipboard=true`** — copy-mode `y` writes to the OS clipboard (`pbcopy` on macOS; `xsel`/`xclip`/`wl-copy` on Linux, auto-detected by gpakosz). gpakosz default is `false`. +- **`tmux_conf_copy_to_os_clipboard=true`** — copy-mode `y` writes to OS clipboard. gpakosz default is `false`. - **`tmux_conf_24b_colour=true`** — forces 24-bit colour so Powerlevel10k renders identically inside tmux - **`COLORTERM=truecolor`** propagated to inner shells - **`history-limit 50000`** — gpakosz default 5000 is small -No plugin manager. [TPM](https://github.com/tmux-plugins/tpm) has been dormant since Feb 2023; the only plugin feature we needed (`tmux-yank` for OS clipboard) is already covered by gpakosz natively. - **Gotchas**: - iTerm2 **≥3.5.11** required — `3.5.0beta6`–`3.5.0beta10` had a regression where Nerd Font glyphs disappear inside tmux panes ([iTerm2 #10879](https://gitlab.com/gnachman/iterm2/-/issues/10879)). -- If Nerd Font glyphs render oddly inside tmux, enable iTerm2 → *Preferences → Profiles → Text → "Use built-in Powerline glyphs"*. - If Powerlevel10k complains about an *instant-prompt* warning inside tmux, add `typeset -g POWERLEVEL9K_INSTANT_PROMPT=quiet` to `~/.p10k.zsh`. -- Linux without `xsel`/`xclip`/`wl-clipboard`: tmux copy-mode `y` saves to the tmux paste buffer only, not the system clipboard. The installer prints a one-line notice. -- Linux X11 vim — `clipboard^=unnamed,unnamedplus` writes to both `*` (PRIMARY/middle-click) and `+` (CLIPBOARD/Ctrl-V). +- Linux without `xsel`/`xclip`/`wl-clipboard`: tmux copy-mode `y` saves to the tmux paste buffer only, not the system clipboard. ### iTerm2 (macOS) -The committed `iterm/com.googlecode.iterm2.plist` is loaded by iTerm2 via `PrefsCustomFolder` (configured by `./install`). After the first install, quit and relaunch iTerm2 once to apply. +The committed `iterm/com.googlecode.iterm2.plist` is loaded via `PrefsCustomFolder`. A `run_once_after_*` script tells iTerm2 to load prefs directly from the chezmoi source tree (`~/.local/share/chezmoi/iterm/`) — no `~/.config/iterm/` symlink layer. -Two iTerm2 stores aren't synced today because they're empty: +**GUI edits flow through to git**: when you change settings in iTerm2 and quit (answer "Save" if prompted), iTerm2 writes directly into `~/.local/share/chezmoi/iterm/com.googlecode.iterm2.plist`. `git status` (from `chezmoi cd`) shows the dirty plist — review and commit. -- **`~/Library/Application Support/iTerm2/DynamicProfiles/`** — JSON files that iTerm2 loads as additional profiles at startup. If you start using dynamic profiles, mirror them under `iterm/DynamicProfiles/` in this repo and symlink via dotbot. -- **`~/Library/Application Support/iTerm2/Scripts/`** — iTerm2 Python automation scripts. Same idea: mirror under `iterm/Scripts/` if you write any. +**Trigger setup** — the committed plist ships a `vscode://` Trigger pre-installed on the default profile (used by the [VSCode-from-any-terminal](#open-vscode-from-any-terminal) flow). -Everything else iTerm2 stores (`chatdb.sqlite`, `iTermServer-*`, `parsers/`, `SavedState/`, the auto-generated `*.plist.bak`, and `com.googlecode.iterm2.private.plist` which is explicitly `NoSync`-keyed) is runtime/transient/personal state — never sync. - -**Trigger setup** — the committed plist ships a `vscode://` Trigger pre-installed on the default profile (used by the [VSCode-from-any-terminal](#open-vscode-from-any-terminal) flow). If for any reason it disappears, re-add via *iTerm2 → Settings → Profiles → Default → Advanced → Triggers → Edit → +*: - -- **Regular Expression**: `vscode://[^[:space:]]+` -- **Action**: Run Command… -- **Parameters**: `open \0` -- **Instant**: ✗ (unchecked — fire on full line so it doesn't trigger on shell completion echoes) - -> **Gotcha** — iTerm2's plist stores trigger actions as bare Objective-C class names, **not** the human-readable labels you see in the GUI dropdown. "Run Command…" in the UI maps to `"ScriptTrigger"` in the plist, not `"RunCommandAction"`. iTerm2 calls `NSClassFromString(action)` at load time and **silently drops the trigger if the class doesn't exist** ([`Trigger.m`](https://github.com/gnachman/iTerm2/blob/master/sources/Triggers/Trigger.m) `+triggerFromUntrustedDict:`). The canonical list of action names lives in iTerm2's Python API at [`api/library/python/iterm2/iterm2/triggers.py`](https://github.com/gnachman/iTerm2/blob/master/api/library/python/iterm2/iterm2/triggers.py). When seeding a trigger programmatically, the safest path is to add it once via the iTerm2 GUI, then `cp ~/Library/Preferences/com.googlecode.iterm2.plist iterm/com.googlecode.iterm2.plist`. +> **Gotcha** — iTerm2's plist stores trigger actions as bare Objective-C class names. "Run Command…" in the UI maps to `"ScriptTrigger"` in the plist, not `"RunCommandAction"`. iTerm2 silently drops triggers whose action class doesn't exist. ## Integrations @@ -101,29 +231,41 @@ Everything else iTerm2 stores (`chatdb.sqlite`, `iTermServer-*`, `parsers/`, `Sa Global, cross-machine config for both CLIs. **Only user-curated settings are tracked**; auth tokens, marketplace registrations, plugin caches, NUX state, project trust-levels — all stay machine-local. -Both tools store under `~/.claude/` and `~/.codex/` on macOS *and* Linux (neither follows XDG, [tracked upstream](https://github.com/anthropics/claude-code/issues/1455)), so no OS conditionals are needed. +#### Layout -> **Tooling setup** — when editing settings, agents, skills, rules, or `CLAUDE.md`/`AGENTS.md` themselves, edit the **dotfiles target path** (`~/dotfiles/claude/` or `~/dotfiles/codex/`), not the live symlink path. After adding NEW files that need linking, update `.install.conf.yaml` and re-run `./install`. +| Source path | Becomes in `$HOME` | Pattern | Purpose | +|---|---|---|---| +| `dot_claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | plain | global instructions (bootstrap + TL;DR rule index) | +| `dot_claude/modify_settings.json.tmpl` | `~/.claude/settings.json` | `modify_` (jq merge) | global settings, preserves tool-added keys | +| `.chezmoitemplates/claude-settings-base.json` | (not applied) | template partial | curated base, loaded via `includeTemplate` from `modify_` | +| `dot_claude/executable_statusline-command.sh` | `~/.claude/statusline-command.sh` | plain +x | custom status-line renderer | +| `dot_claude/agents/` | `~/.claude/agents/` | plain dir | custom subagents | +| `dot_claude/skills/` | `~/.claude/skills/` | plain dir | custom skills (one dir per skill, `SKILL.md` inside) | +| `dot_claude/rules/*.md` | `~/.claude/rules/*.md` | plain | auto-loaded rules (optional `paths:` frontmatter for conditional load) | +| `dot_codex/AGENTS.md` | `~/.codex/AGENTS.md` | plain | global instructions for Codex | +| `dot_codex/modify_config.toml` | `~/.codex/config.toml` | `modify_` (toml merge) | model + plugin enables, drops runtime sections | +| `.chezmoitemplates/codex-config-base.toml` | (not applied) | template partial | curated base, loaded via `includeTemplate` from `modify_` | +| `dot_codex/skills/save-to-dotfiles/symlink_SKILL.md` | `~/.codex/skills/save-to-dotfiles/SKILL.md` | file-level symlink | → `~/.claude/skills/save-to-dotfiles/SKILL.md` (single source for the only chezmoi-managed skill shared between both AI tools) | -#### Layout +We deliberately don't manage `~/.claude/commands/` (deprecated by skills per upstream docs) or `~/.claude/hooks/` (not a Claude directory convention — hooks live inline in `settings.json`). -| Repo path | Symlinked to | Purpose | -|---|---|---| -| `claude/CLAUDE.md` | `~/.claude/CLAUDE.md` | global user instructions (bootstrap + TL;DR rule index) | -| `claude/settings.json` | `~/.claude/settings.json` | global settings (status line, permissions mode, plugin enables) | -| `claude/statusline-command.sh` | `~/.claude/statusline-command.sh` | custom status-line renderer | -| `claude/agents/` | `~/.claude/agents/` | custom subagents (empty for now) | -| `claude/skills/` | `~/.claude/skills/` | custom skills (directory-per-skill with `SKILL.md`) | -| `claude/rules/` | `~/.claude/rules/` | markdown rules auto-loaded by Claude Code (see below) | -| `codex/AGENTS.md` | `~/.codex/AGENTS.md` | global user instructions for Codex (its equivalent of `CLAUDE.md`) | -| `codex/config.toml` | `~/.codex/config.toml` | model, personality, plugin enables (runtime sections stripped — see implementation notes) | -| `codex/skills/` | `~/.codex/skills/` | custom Codex skills (Codex's built-in `.system/` is gitignored) | +#### Why `modify_` for settings.json / config.toml + +Both files are mutated at runtime by tools: +- `~/.claude/settings.json` — `rtk init -g` adds a `PreToolUse` hook entry; `claude plugin install` adds entries to `enabledPlugins` +- `~/.codex/config.toml` — Codex auto-writes `[projects.""]`, `[notice]`, `[tui.*]`, `[tool_suggest]` runtime sections + +The `modify_` pattern handles both gracefully: +- **settings.json**: jq additive merge between `.chezmoitemplates/claude-settings-base.json` (our curated base) and the existing file. Our base sets canonical permissions/statusLine/etc.; tool-added keys (`hooks.PreToolUse`, extra `enabledPlugins`) get UNION-preserved. +- **config.toml**: chezmoi-native template via `fromToml`/`toToml`. Parse existing → drop runtime sections → `mergeOverwrite` with `.chezmoitemplates/codex-config-base.toml` (our curated base) → serialize. Idempotent. + +Both bases live in `.chezmoitemplates/` — chezmoi's canonical location for shared template partials, automatically excluded from `$HOME`. They're loaded via `includeTemplate "name" .` from the respective `modify_` scripts. -We deliberately don't symlink `~/.claude/commands/` (deprecated by skills per upstream docs) or `~/.claude/hooks/` (not a Claude convention — hooks live inline in `settings.json`). +This replaces a previous git-clean-filter mechanism (`.gitattributes` + per-clone `git config filter.codex-strip.{clean,smudge,required}`) — that was always-on for the entire source repo, fragile across clones, and required `required=true` + `smudge=cat` setup. `modify_` is local to one file, no per-clone setup. -#### Rules architecture (claude/rules/) +#### Rules architecture (`dot_claude/rules/`) -Files under `claude/rules/*.md` are **auto-discovered by Claude Code at session start** (the rules directory is a [documented Claude location](https://code.claude.com/docs/en/memory.md), symlinks supported). Each rule file is a small markdown doc with YAML frontmatter: +Files under `dot_claude/rules/*.md` are **auto-discovered by Claude Code at session start**. Each rule file is a small markdown doc with YAML frontmatter: ```yaml --- @@ -136,49 +278,44 @@ paths: # OPTIONAL — conditional loading ``` - **Universal rules** (no `paths:`) load on every session. -- **Conditional rules** (with `paths:` glob array) only load when Claude is reading/editing files matching one of the globs — perfect for "frontend-only" or "Python-only" guidance that shouldn't pollute backend sessions. +- **Conditional rules** (with `paths:` glob array) only load when Claude is reading/editing files matching one of the globs. -Current set: three universal rules (`read-codebase-first`, `no-code-without-go`, `verify-before-fix`) and two frontend-only rules (`frontend-spec-first-workflow`, `visual-audit-mcp-gotchas`). Codex doesn't have an auto-discovery equivalent — `codex/AGENTS.md` references the rule files as advisory reading material. +Current set: three universal (`read-codebase-first`, `no-code-without-go`, `verify-before-fix`), two frontend-only (`frontend-spec-first-workflow`, `visual-audit-mcp-gotchas`). Codex doesn't have an auto-discovery equivalent — `dot_codex/AGENTS.md` references the rule files as advisory reading. #### Not tracked (intentionally) -- `~/.claude/settings.local.json` — per-machine permissions allowlist; Anthropic's documented convention is to gitignore it. Re-curate `/permissions` per machine. +- `~/.claude/settings.local.json` — per-machine permissions allowlist; Anthropic's documented convention is to gitignore it. - `~/.claude/plugins/{installed_plugins,known_marketplaces}.json` — contain absolute install paths and per-user marketplace registrations. - `~/.claude.json` — **lives at `$HOME`, not in `~/.claude/`** — holds OAuth tokens, MCP server credentials, NUX `tipsHistory` counters, marketplace registrations. **Never commit it.** +- `~/.claude/RTK.md`, `~/.codex/RTK.md` — written fresh by `rtk init -g` / `rtk init -g --codex` (bundled rtk version's content, not ours to track). - `~/.codex/auth.json`, `installation_id` — secrets and per-machine identifiers. - `~/.codex/{sessions,cache,log,tmp,history.jsonl,*.sqlite}` — runtime state. - `~/.codex/memories/`, `~/.codex/plugins/` — runtime state / cloned plugin repos. -#### Per-machine after `./install` — plugins +#### Plugin install (automated) -Neither tool auto-installs plugins from the synced config (the enable flags only activate already-installed plugins). After first install on a new machine, manually run: +Plugins listed in `.chezmoidata/packages.yaml` under `plugins.{claude,codex}` are installed automatically by `.chezmoiscripts/run_onchange_after_70-install-plugins.sh.tmpl`. The same YAML drives the enable flags in `.chezmoitemplates/{claude-settings-base.json,codex-config-base.toml}` — single source of truth, no drift between install and enable state. -**Claude Code** (via `/plugin install @` in the TUI): -- `gopls-lsp@claude-plugins-official` -- `figma@claude-plugins-official` -- `frontend-design@claude-plugins-official` +Mechanics: +- Claude: `claude plugin install @ --scope user` (idempotent), then `claude plugin update ` (auto-upgrade). The `claude-plugins-official` marketplace is auto-added defensively (it's the implicit default but Claude only auto-registers it on first interactive launch). Non-default marketplaces (listed under `plugins.claude_marketplaces` in `packages.yaml`) are registered via `claude plugin marketplace add` before the install loop. +- Codex: built-in `openai-curated` marketplace is reserved — only the `[plugins."x@y"] enabled = true` table in `config.toml` is needed (generated from the YAML). Non-reserved marketplaces (listed under `plugins.codex_marketplaces`) are registered via `codex plugin marketplace add`. -**Codex** (via `codex plugin install ` or `/plugins` in the TUI): -- `github@openai-curated` -- `google-drive@openai-curated` +`caveman` is just another entry in `plugins.claude` (caveman@caveman) + `plugins.claude_marketplaces` (caveman:JuliusBrussee/caveman). Per its [INSTALL.md](https://github.com/JuliusBrussee/caveman/blob/main/INSTALL.md) per-agent table, that's the canonical Claude install path. The plugin self-registers its `SessionStart` + `UserPromptSubmit` hooks via `plugin.json` (no settings.json mutation). Caveman for Codex is a manual step (`npx -y skills add JuliusBrussee/caveman -a codex`) — the skills CLI doesn't currently create the per-agent symlinks Codex needs. -#### Prerequisites +#### rtk auto-refresh on version change -The `claude` and `codex` CLIs are **soft prereqs** — `./install` runs without them and will link the configs anyway, but the linked files do nothing until the binaries are present. The installer prints a notice with install instructions. +`brew bundle` upgrades the rtk binary on each `chezmoi apply` (Homebrew's default). `RTK.md` awareness files (`~/.claude/RTK.md`, `~/.codex/RTK.md`) are bundled inside the binary and re-written by `rtk init`. The install script tracks `rtk --version` output in a trailing hash comment (`# {{ if lookPath "rtk" }}{{ output "rtk" "--version" | trim }}{{ else }}not-installed{{ end }}`), so a version bump changes the script's effective content and chezmoi re-runs it automatically — no manual `rtk init` needed. -Recommended install: -- macOS: `brew install --cask claude-code codex` -- Linux: see https://claude.com/code and https://github.com/openai/codex +On a fresh machine the script will run twice across the first two applies (install rtk → hash populates → next apply re-runs `rtk init`). Both runs are idempotent. ### Open VSCode from any terminal -`code ` works the same in any iTerm2 pane. Calling `code` **without arguments** opens the `*.code-workspace` file in the current directory if one exists, otherwise opens the current directory itself. +`code ` works the same in any iTerm2 pane. Calling `code` **without arguments** opens the `*.code-workspace` file in cwd if one exists, otherwise opens cwd itself. -- **Local Mac**: opens VSCode at that path. Works after `./install` (brew cask + PATH addition; no Command Palette "Install in PATH" step needed). +- **Local Mac**: opens VSCode at that path. Works after Brewfile installs the cask + zshrc adds it to PATH. - **Remote SSH** (iTerm2/tmux into a Linux box): a zshrc function (active when `$SSH_CONNECTION` is set) decides what to do: 1. If you're already in VSCode's *integrated* Remote-SSH terminal — calls the real `code` via the injected env. - 2. Otherwise tries to discover a live Remote-SSH IPC socket on the remote — if found, dispatches to the existing Mac-side VSCode window. - 3. Else prints a `vscode://vscode-remote/ssh-remote+/` URL. The iTerm2 Trigger (see [iTerm2 setup](#iterm2-macos)) matches the URL and runs `open ` → macOS launches Mac-side VSCode → it connects to the host via Remote-SSH and opens the folder. + 2. Else prints a `vscode://vscode-remote/ssh-remote+/` URL. The iTerm2 Trigger matches the URL and runs `open ` → macOS launches Mac-side VSCode → it connects via Remote-SSH and opens the folder. If the remote's `hostname -s` doesn't match the Host alias in your **local** `~/.ssh/config`, override per-host: @@ -186,31 +323,71 @@ If the remote's `hostname -s` doesn't match the Host alias in your **local** `~/ export VSCODE_REMOTE_HOST=my-ssh-alias ``` -The Remote-SSH IPC socket exists only after VSCode has connected to that host at least once — the first `code .` on a fresh remote box goes through the URL path. - ## Implementation notes -### OS conditionals +### Cross-platform via templates + +Top-level dotfiles (`dot_zshrc`, `dot_vimrc`, etc.) are plain — they handle platform differences via runtime feature-detection (`command -v X`, `case "$(uname -s)"` in shell). Where chezmoi templates are used: + +- `.chezmoiexternal.toml.tmpl` is templated for headless detection (skip fonts) + per-OS font destination +- `.chezmoiscripts/{darwin,linux}/` hold per-OS scripts; root holds cross-platform scripts with numerical prefix (`50-`, `60-`, `70-`, `80-`) for ordering within the `run_onchange_after_` group +- `.chezmoiscripts/run_onchange_after_50-install-packages.sh.tmpl` branches on `.chezmoi.os` (Mac brew bundle vs Linux apt/dnf) + `.chezmoi.osRelease.id` (Debian vs Fedora) +- `.chezmoihooks/ensure-prereqs.sh` is NOT a template (chezmoi doesn't template hooks) — it detects OS inline via `case "$(uname -s)"` + +### Headless SSH detection -Single `.install.conf.yaml`. Shell hooks branch on `case "$(uname -s)"` for the OS-divergent steps (Nerd Font install, Homebrew casks, iTerm2 prefs wiring, Linux clipboard-tool notice). Everything else is OS-neutral — zsh sees the OMZ `macos` plugin define harmless aliases/functions on Linux that simply never get invoked there. +Used by Linux font install (which is skipped on remote machines): -### Codex auto-rewrite — the git clean filter +```sh +[ -n "$SSH_CONNECTION" ] && [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ] +``` -Codex auto-writes several sections into `~/.codex/config.toml` at runtime (see [openai/codex#15433](https://github.com/openai/codex/issues/15433), [#14601](https://github.com/openai/codex/issues/14601), [#5160](https://github.com/openai/codex/issues/5160)): +Glyphs render on the local terminal emulator — remote box doesn't need the font. -- `[projects.""] trust_level = "trusted"` — added on each "trust this directory" prompt -- `[notice]` / `[notice.*]` — one-time UI dismissals + migration prompts -- `[tui.*]` — theme, keymap, NUX counters (`tui.model_availability_nux`) -- `[tool_suggest].disabled_tools` -- `windows_wsl_setup_acknowledged` +### Externals (vendored) -A git **clean filter** at `codex/scripts/strip-runtime-sections.awk` (registered per-clone by `./install` via `git config --local filter.codex-strip.{clean,smudge,required}`) strips these on `git add`. The smudge half is `cat` (identity passthrough on checkout) — the [gitattributes](https://git-scm.com/docs/gitattributes#_long_running_filter_process) `required=true` flag demands both halves of the filter pair exist, even when only `clean` does work. Working tree keeps whatever Codex wrote; the index gets only portable parts. Canonical list of mutable sections is the `ConfigEdit` enum in [`codex-rs/core/src/config/edit.rs`](https://github.com/openai/codex/tree/main/codex-rs/core/src/config/edit.rs). +`tmux/oh-my-tmux` is pulled by chezmoi from `.chezmoiexternal.toml.tmpl`: + +```toml +[".config/tmux"] + type = "archive" + url = "https://github.com/gpakosz/.tmux/archive/.tar.gz" + exact = true + stripComponents = 1 + refreshPeriod = "720h" +``` -> **TODO**: when [openai/codex#15433](https://github.com/openai/codex/issues/15433) lands (separates project trust state from config), delete `codex/scripts/` and the filter registration. +Pinning is via commit SHA in the URL (gpakosz/.tmux has no tagged releases). To bump: pick newer SHA from gpakosz commits, edit URL, `chezmoi apply -R`, commit. -If `git diff codex/config.toml` ever shows lines like `[projects.…]` reappearing, the filter isn't registered on this clone (check `git config --local --get-all filter.codex-strip.clean`). Re-run `./install` to re-register. +Cache lives in `~/Library/Caches/chezmoi/` (Mac) — outside the source repo, so `git status` stays clean. ## TODO -- Add a `Brewfile` (macOS) / `apt`/`dnf` bootstrapping (Linux) for one-shot dep install -- Add `git` and `ssh` configs +### Done (recent milestones) + +- **mise-as-primary-installer migration (2026-05)** — replaced bundle-based + install (1200 LOC) with mise.toml declarative tool list + slim OS-glue + install (~250 LOC). Cross-platform parity: same 28 dev tools install + identically on Mac + Linux via mise's aqua backend. +- **Linux bundle support** — superseded by mise migration. Bundle scripts + + helpers all deleted. + +### New tracked configs (deferred — surface too small for current workflow) + +- **`dot_ssh/config.tmpl`**: 5 portable lines (`AddKeysToAgent`, `UseKeychain`, + `HashKnownHosts`, `ServerAliveInterval/CountMax`). Per-host blocks stay in + untracked `~/.ssh/config.d/*.conf`. +- **`dot_kube/config.tmpl`**: kubectl config + krew. Per-cluster credentials + machine-local. +- **`dot_aws/config.tmpl`**: AWS CLI v2 named profiles + SSO sessions. + Credentials machine-local. + +### Soft hardening (review-flagged, not blocking) + +- Pin rtk install URL to a tagged release (currently `refs/heads/master` — + needs upstream tagging first). +- Renovate config for `dot_config/mise/config.toml` — auto-PR bumps for + pinned tool versions (replaces "track latest" with deterministic + upgrades). +- CI smoke tests (Docker matrix over Ubuntu 22.04 + Debian 12 arm64). +- `dot_p10k.zsh` top-of-file generation note. diff --git a/claude/agents/.gitkeep b/claude/agents/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/claude/skills/.gitkeep b/claude/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/claude/skills/save-to-dotfiles/SKILL.md b/claude/skills/save-to-dotfiles/SKILL.md deleted file mode 100644 index 3c4220c..0000000 --- a/claude/skills/save-to-dotfiles/SKILL.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: save-to-dotfiles -description: Use when the user asks to save, persist, or globally apply any - configuration change — shell aliases / functions / env vars / PATH (zshrc), - vim settings / mappings / plugins (vimrc), tmux bindings / colours / settings - (tmux.conf.local), iTerm2 prefs, p10k tweaks, Claude or Codex rules / agents / - skills / settings / instructions, new dotbot-managed symlinks, or install-time - hooks. The skill walks three gates (portability / platform / routing), routes - the edit to the correct ~/dotfiles/ target, considers OS-conditional wrapping, - and suggests the reload command. Trigger phrases include "сохрани", "запомни", - "всегда так делай", "добавь в config", "save this globally", "make this - permanent", "add to my config", "always do X", "add alias", "add binding", - "add rule". ---- - -# save-to-dotfiles - -Procedural handler for "make this config change permanent across all my -machines" requests. The goal: ask the right clarifying questions, route the -edit to the correct dotfiles target file, and never write to `~/.claude/` -or `~/.codex/` directly. - -## Workflow - -``` -0. Discover what user actually wants → may need clarifying questions -1. Gate 1 — Portability check → reject if machine-specific -2. Gate 2 — Platform check → decide OS-conditional wrapping -3. Gate 3 — Routing → which file, which section -4. Verify symlink coverage → .install.conf.yaml has the live path? -5. Edit dotfiles target file → never the live symlink path -6. Suggest reload command → so the change takes effect now -7. Show diff + offer commit → conventional commit message -``` - -## Step 0 — Discover - -User intent can be vague. Before routing, confirm you have: - -- The exact config change (the alias, the setting, the rule text) -- Whether it applies on **every** machine or just current one -- Whether it should work on **macOS, Linux, or both** - -If anything is unclear, propose 1-3 reasonable approaches with trade-offs and -let the user pick. Don't guess silently. - -## Step 1 — Gate 1: Portability - -A change is **machine-specific** and should NOT go in dotfiles when it matches -any row below: - -| Marker | Right home instead of dotfiles | -|---|---| -| Absolute local path (`/Users/X/projects/...`, `/home/X/...`) | env var, or local file like `~/.zshrc.local` | -| Specific hostname or SSH alias unique to one machine | `~/.ssh/config` (sometimes per-machine) | -| Hardware-specific (display scaling, physical keyboard mapping) | OS-native preferences, not dotfiles | -| Secret / API key / token / password | 1Password, env var, or `~/.config//.env` (gitignored) | -| Per-machine auth state (`~/.claude.json`, `~/.codex/auth.json`) | Already gitignored — don't touch | -| Per-machine permissions allowlist (`~/.claude/settings.local.json`) | Stays per-machine, never synced | - -If portability fails: **stop**, tell the user, suggest the alternative home, -and don't write to dotfiles. Examples of how to phrase it: - -- "This references `/Users//projects/myrepo` — that path won't exist on - other machines. Put it in `~/.zshrc.local` instead, which doesn't sync." -- "This is an API token — never commit. Use `op item get …` (1Password) or - source from a gitignored `.env`." - -## Step 2 — Gate 2: Platform - -Detect current platform: `uname -s` → `Darwin` (macOS) or `Linux`. - -Categorize the change: - -| Category | Examples | Wrap pattern | -|---|---|---| -| **Cross-platform** | zsh aliases that don't call OS-specific tools, vim settings, tmux bindings, Claude/Codex behavioural rules | no wrapping needed | -| **macOS-only** | `brew install …`, `defaults write …`, `pbcopy`, `osascript`, iTerm2 plist edits, `/Applications/...` paths | `[ "$(uname -s)" = "Darwin" ] && …` or `case` | -| **Linux-only** | `apt`/`dnf`/`pacman`, `xsel`/`xclip`/`wl-copy`, `/run/user/$UID/...`, `fc-cache` | `[ "$(uname -s)" = "Linux" ] && …` | -| **Conditional on tool availability** | `fnm`, `zoxide`, `code` (VSCode CLI) | `command -v X >/dev/null 2>&1 && …` | -| **Both but divergent** | clipboard tool (pbcopy on Mac, xsel/xclip/wl-copy on Linux) | `case "$(uname -s)" in Darwin) … ;; Linux) … ;; esac` | - -If unsure which category the user wants: **ask**. Don't pick silently. - -## Step 3 — Gate 3: Routing - -Map intent → file → reload: - -| Intent | Target file (in `~/dotfiles/`) | Reload after | -|---|---|---| -| Shell alias / function | `zshrc` → `# --- Aliases ---` (alias) or near existing functions (function) | new shell or `exec zsh` | -| Env var | `zshrc` → `# --- Locale ---` (LANG-class) or create a new `# --- xxx ---` section if it's a new logical area | new shell | -| PATH addition | `zshrc` → `# --- PATH ---` | new shell | -| zsh plugin | `zsh_plugins.txt` (add line; antidote rebuilds on next shell start) | new shell | -| Vim setting / mapping | `vimrc` (matching section) | re-open vim or `:source $MYVIMRC` | -| Vim plugin | `vimrc` inside `call plug#begin … call plug#end`, then `:PlugInstall` | next vim start | -| Tmux setting / binding / colour | `tmux/tmux.conf.local` (NEVER `tmux/oh-my-tmux/`) | `prefix r` in running tmux | -| iTerm2 preference | edit via GUI → `cp ~/Library/Preferences/com.googlecode.iterm2.plist iterm/` ; OR `defaults write com.googlecode.iterm2 ` for surgical edits | quit + relaunch iTerm2 | -| p10k tweak (one-line override) | `p10k.zsh` in-place | new shell | -| p10k major theme change | run `p10k configure` (it rewrites `p10k.zsh`) | (done by p10k itself) | -| Claude global rule | `claude/rules/.md` with frontmatter (see below) | next Claude session | -| Claude setting / statusline / plugin enable | `claude/settings.json` | next Claude session | -| Claude custom agent | `claude/agents//...` | next Claude session | -| Claude custom skill | `claude/skills//SKILL.md` | next Claude session | -| Codex behavioural rule / global instruction | `codex/AGENTS.md` (inline section) | next Codex session | -| Codex setting (model, reasoning effort, plugin) | `codex/config.toml` | next Codex session | -| Codex skill | `codex/skills//SKILL.md` (or symlink to a Claude skill) | next Codex session | -| New dotbot-managed symlink | `.install.conf.yaml` `link:` entry + `cd ~/dotfiles && ./install` | symlink active immediately | -| Install-time hook | `.install.conf.yaml` `shell:` entry | next `./install` | - -If multiple targets are reasonable (e.g. "make this an alias OR a function?"), -**propose options** with trade-offs. - -## Step 4 — Verify symlink coverage - -For files NOT at dotfiles root (i.e. inside `claude/`, `codex/`, or new -locations), confirm a `link:` entry exists in `.install.conf.yaml` mapping -the live path to the dotfiles path: - -```sh -rg -n '~/.claude|~/.codex' ~/dotfiles/.install.conf.yaml -``` - -If the target is NOT yet symlinked (a new file type or live path): - -1. Create the file at `~/dotfiles//`. -2. Add a `link:` entry to `.install.conf.yaml`: - ```yaml - ~/.claude/: - path: claude/ - force: true - ``` -3. Run `cd ~/dotfiles && ./install` so the symlink is created. - -## Step 5 — Edit the dotfiles target - -**Always** edit `~/dotfiles/`, never `~/.claude/` or `~/.codex/`. -Edits via the symlink resolve to the same file but obscure the source of -truth in diffs and reviews. - -### Per-area specifics - -**zshrc** — pick the matching `# --- xxx ---` section. If a new logical -section, add one with a clear header. Maintain 4-space indent inside any -multi-line constructs. - -**Claude rules** — frontmatter is required. Universal rule (every session): - -```yaml ---- -name: -description: Universal — ---- -``` - -Conditional rule (loads only on matching file types): - -```yaml ---- -name: -description: -paths: - - "**/*.tsx" - - "**/*.css" ---- -``` - -Ask the user: universal or path-conditional? If conditional, which file globs? - -**Codex AGENTS.md additions** — Codex has no `claude/rules/`-style -auto-discovery. Behavioural rules go inline into `codex/AGENTS.md` as a -new section or appended to an existing one. Keep concise — context budget -matters. - -**iTerm2 plist** — binary format, can't hand-edit. Either: -- Surgical key edit: `defaults write com.googlecode.iterm2 -type ` -- Multiple changes via GUI: edit prefs → `cp ~/Library/Preferences/com.googlecode.iterm2.plist iterm/` - -**`.install.conf.yaml` shell hooks** — **POSIX `sh` only** (runs under -`dash` on Ubuntu). No bashisms: no `[[ ]]`, no arrays, no `${var^^}`, no -`<()`, no `local`. OS-branch with `case "$(uname -s)" in`. Be idempotent — -check state first, print "Already X" when there's nothing to do. - -## Step 6 — Reload - -Tell the user the exact command to apply the change now (see Step 3 table). -For most cases this is just opening a new shell, but tmux / iTerm2 / etc. -need specific reload commands. - -## Step 7 — Commit - -Show the diff. Suggest a conventional commit message (see repo conventions -in `~/dotfiles/AGENTS.md` §11): - -- `feat:` new feature, `fix:` bug fix, `docs:` docs, `chore:` housekeeping, - `tweak:` small adjustment, plus area prefixes (`zshrc:`, `tmux:`, `iterm:`, - `code:`, `install:`) when scoped to one area. -- Short, lowercase, imperative, max 72 chars on summary line. -- No AI attribution. -- Body explains WHY when non-obvious. - -Ask before committing — user may want to test the reload first. - -## Don'ts - -- **Don't edit `~/.claude/` or `~/.codex/` directly.** Always go through - `~/dotfiles/`. Edits via the symlink resolve to the same file under the hood, - but obscure the source of truth. -- **Don't edit `tmux/oh-my-tmux/`** — gpakosz submodule, customize via - `tmux/tmux.conf.local`. -- **Don't edit `.dotbot/`** — anishathalye submodule, upstream only. -- **Don't edit `codex/skills/.system/`** — Codex auto-managed, gitignored. -- **Don't hand-edit `p10k.zsh` for major theme changes.** Run `p10k configure` - and let it regenerate the file. -- **Don't put bashisms in `.install.conf.yaml` shell hooks.** They run under - `dash` on Ubuntu. -- **Don't put absolute local paths, hostnames, secrets, or per-machine state - in dotfiles.** Those break on other machines. Use the alternatives from - Step 1. -- **Don't write to `~/.claude/settings.local.json`** through dotfiles — - it's per-machine permission state, gitignored. - -## Test the skill mentally - -If the user says "сохрани, чтобы в zsh всегда был алиас `ll='ls -la'`": -- **Gate 1**: portable (no path / host / secret). ✓ -- **Gate 2**: cross-platform — `ls -la` is the same on macOS and Linux. ✓ -- **Gate 3**: target = `zshrc` `# --- Aliases ---` section. -- Edit `~/dotfiles/zshrc`, add `alias ll='ls -la'` under Aliases. -- Reload: `exec zsh` or open new shell. -- Commit: `feat: add ll alias for ls -la`. - -If the user says "запомни, что Claude должен всегда писать комменты по-русски": -- **Gate 1**: portable. ✓ -- **Gate 2**: cross-platform — applies to any Claude session. ✓ -- **Gate 3**: target = `claude/rules/.md` (new file). - - Ask: universal or path-conditional? Likely universal. - - Name suggestion: `comments-in-russian` or similar. -- Create `~/dotfiles/claude/rules/comments-in-russian.md` with frontmatter - (no `paths:` — universal) + Rule / Why / How sections. -- Reload: next Claude session. -- Commit: `feat: add claude rule for Russian comments`. - -If the user says "сохрани, что я хочу всегда `brew install ` после клона": -- **Gate 1**: portable in intent, but `brew` is macOS-only. ✓ -- **Gate 2**: macOS-only → wrap in `case "$(uname -s)" in Darwin) … ;; esac`. -- **Gate 3**: target = `.install.conf.yaml` `shell:` hook. -- Add a new `shell:` entry with `Darwin` branch and idempotent `brew list - --formula >/dev/null || brew install `. -- Reload: `./install`. -- Commit: `install: auto-install via Homebrew (macOS)`. diff --git a/claude/statusline-command.sh b/claude/statusline-command.sh deleted file mode 100755 index 9a55f37..0000000 --- a/claude/statusline-command.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -input=$(cat) -cwd=$(echo "$input" | jq -r '.cwd') -dir=$(basename "$cwd") - -# Get git branch (skip optional locks) -git_branch=$(git -C "$cwd" --no-optional-locks branch --show-current 2>/dev/null) - -# Colors -CYAN='\033[1;36m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -RESET='\033[0m' - -# Build prompt similar to robbyrussell theme -if [ -n "$git_branch" ]; then - printf "${CYAN}%s${RESET} ${BLUE}git:(${GREEN}%s${BLUE})${RESET}" "$dir" "$git_branch" -else - printf "${CYAN}%s${RESET}" "$dir" -fi diff --git a/codex/config.toml b/codex/config.toml deleted file mode 100644 index d277c6e..0000000 --- a/codex/config.toml +++ /dev/null @@ -1,16 +0,0 @@ -model = "gpt-5.5" -model_reasoning_effort = "xhigh" -personality = "pragmatic" - -[plugins."github@openai-curated"] -enabled = true - -[plugins."google-drive@openai-curated"] -enabled = true - -# Runtime-mutated sections ([projects.*], [notice], [tui.*], etc.) are -# stripped on `git add` by the clean filter in codex/scripts/. See the -# README "AI tooling" section for details. -# -# TODO: when https://github.com/openai/codex/issues/15433 lands (separate -# project trust state from config), drop the filter and simplify. diff --git a/codex/scripts/strip-runtime-sections.awk b/codex/scripts/strip-runtime-sections.awk deleted file mode 100644 index 0ae08fe..0000000 --- a/codex/scripts/strip-runtime-sections.awk +++ /dev/null @@ -1,34 +0,0 @@ -# Git clean filter for ~/.codex/config.toml. -# -# Codex auto-mutates several sections of config.toml at runtime; without -# this filter every `codex` invocation that adds a new trust prompt or -# decrements a NUX counter would dirty the working tree. -# -# Canonical list of mutable sections lives in the `ConfigEdit` enum in -# https://github.com/openai/codex (codex-rs/core/src/config/edit.rs) -# Strip targets here mirror that enum's runtime-only variants: -# [projects.""] — auto-added on each "trust this dir" prompt -# [notice] / [notice.*] — one-time UI dismissals + migration prompts -# [tui.*] — theme, keymap, nux counters -# [tool_suggest] — disabled_tools list (toggled via UI) -# windows_wsl_setup_acknowledged (top-level key, Windows-WSL only) -# -# User-curated sections kept as-is: model, personality, model_reasoning_effort, -# [plugins.*], [profiles.*], [permissions.*], [model_providers.*], [agents], -# [hooks], [analytics], [memories], [otel]. -# -# Note: [mcp_servers.*] is NOT stripped here. OAuth-backed entries get -# rewritten by `codex mcp login` (via ReplaceMcpServers), but command-based -# entries are perfectly portable. Revisit when MCP usage grows. -# -# TODO: drop this filter when openai/codex#15433 lands. - -BEGIN { skip = 0 } - -# Section headers: enter skip-mode for runtime-mutated tables. -/^\[/ { skip = ($0 ~ /^\[(projects\.|notice\]|notice\.|tui\.|tool_suggest\])/) ? 1 : 0 } - -# Top-level runtime key (rare, Windows-WSL only). -/^windows_wsl_setup_acknowledged[[:space:]]*=/ { next } - -!skip { print } diff --git a/codex/skills/.gitkeep b/codex/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/codex/skills/save-to-dotfiles b/codex/skills/save-to-dotfiles deleted file mode 120000 index 9199bed..0000000 --- a/codex/skills/save-to-dotfiles +++ /dev/null @@ -1 +0,0 @@ -../../claude/skills/save-to-dotfiles \ No newline at end of file diff --git a/create_dot_zshrc_local b/create_dot_zshrc_local new file mode 100644 index 0000000..2aa434e --- /dev/null +++ b/create_dot_zshrc_local @@ -0,0 +1,20 @@ +# Per-machine zsh overrides — sourced from ~/.zshrc near the end. +# +# Seeded ONCE by chezmoi at first apply via the `create_` source-state +# attribute. After that chezmoi never touches this file again — your +# edits here are durable. +# +# Use for things that aren't portable: +# - per-machine secrets / tokens (prefer 1Password or env vars set +# by direnv, but for now this works too) +# - hostname-specific aliases / functions +# - PATH additions for tools installed outside mise / brew +# - SSH_AUTH_SOCK overrides +# - work-only env vars (HTTP_PROXY, CORP_VPN_HOST) +# +# Examples (uncomment + edit): +# +# export GITHUB_TOKEN="ghp_..." +# alias k=kubectl +# export PATH="/opt/local-tool/bin:$PATH" +# eval "$(direnv hook zsh)" diff --git a/claude/CLAUDE.md b/dot_claude/CLAUDE.md similarity index 82% rename from claude/CLAUDE.md rename to dot_claude/CLAUDE.md index dd63fc4..d7a52be 100644 --- a/claude/CLAUDE.md +++ b/dot_claude/CLAUDE.md @@ -2,9 +2,9 @@ ## Saving portable settings -When the user asks to save anything globally — a rule, setting, alias, binding, skill, plugin enable, hook — **run the `save-to-dotfiles` skill**. It walks three gates (portability / platform / routing) and edits the right `~/dotfiles/` target. +When the user asks to save anything globally — a rule, setting, alias, binding, skill, plugin enable, hook — **run the `save-to-dotfiles` skill**. It walks three gates (portability / platform / routing) and edits the right target in the chezmoi source dir. -Never write to `~/.claude/` or `~/.codex/` directly — those are symlinks into the dotfiles repo. The full symlink table and per-area guidance live in `~/dotfiles/AGENTS.md`, which Claude auto-loads when cwd is in that repo. +Never write to `~/.claude/` or `~/.codex/` directly — these paths are chezmoi-managed: edits there get overwritten on the next `chezmoi apply` (or surface as drift the user has to clean up). Use `chezmoi cd` to enter the source dir, then edit `dot_claude/` / `dot_codex/`. The full source↔target mapping and per-area guidance live in `AGENTS.md` at the source-dir root, which Claude auto-loads when cwd is anywhere in the repo. --- @@ -56,7 +56,7 @@ Never write to `~/.claude/` or `~/.codex/` directly — those are symlinks ## Working principles by project type -Detailed rules live under `~/dotfiles/claude/rules/` and **auto-load at session start** (universal rules always; frontend rules when working on `.tsx/.css/.astro/.svelte/.vue/...` files via `paths:` frontmatter). The list below is a human-readable TL;DR index — the rule files themselves are the source of truth. +Detailed rules live under `dot_claude/rules/` in the chezmoi source dir (use `chezmoi cd` to navigate; materialized as `~/.claude/rules/*.md` on apply) and **auto-load at session start** (universal rules always; frontend rules when working on `.tsx/.css/.astro/.svelte/.vue/...` files via `paths:` frontmatter). The list below is a human-readable TL;DR index — the rule files themselves are the source of truth. ### Universal (any project, always loaded) @@ -77,3 +77,5 @@ Placeholder for future rule files. Likely candidates: - Migration safety (don't drop columns; deprecate first) - Schema/API changes through explicit review - Logs as evidence (since no visual feedback) + +@RTK.md diff --git a/dot_claude/executable_statusline-command.sh b/dot_claude/executable_statusline-command.sh new file mode 100755 index 0000000..10c31e0 --- /dev/null +++ b/dot_claude/executable_statusline-command.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Claude Code statusline — extends the default cwd/branch view with rtk + +# caveman health indicators so wired-up state is visible at a glance. +# +# Render budget: <50ms (Claude polls frequently). Measured: ~30ms with +# two jq probes on ~1KB settings.json. No cache layer needed at this +# scale; revisit if probes grow. +set -eu + +input=$(cat) +cwd=$(echo "$input" | jq -r '.cwd // ""') +[ -z "$cwd" ] && cwd="$PWD" +dir=$(basename "$cwd") + +git_branch=$(git -C "$cwd" --no-optional-locks branch --show-current 2>/dev/null || true) + +# rtk health: wired if hooks.PreToolUse[*].hooks[*].command contains +# "rtk hook claude" (what `rtk init -g --auto-patch` writes). +settings="$HOME/.claude/settings.json" +if [ -r "$settings" ] && \ + jq -e '[.hooks.PreToolUse[]?.hooks[]?.command | select(. and contains("rtk hook claude"))] | length > 0' \ + "$settings" >/dev/null 2>&1; then + rtk_state="ok" +else + rtk_state="bad" +fi + +# caveman health: enabled flag in enabledPlugins. The plugin's own +# SessionStart/UserPromptSubmit hooks fire from plugin.json (not +# settings.json), so the enable flag is the source of truth. +if [ -r "$settings" ] && \ + jq -e '.enabledPlugins["caveman@caveman"] == true' \ + "$settings" >/dev/null 2>&1; then + caveman_state="ok" +else + caveman_state="bad" +fi + +# Colors — actual ESC byte (POSIX sh; `$'\033'` would be bashism). +# Needed so badges can be composed in variables AND passed via printf %s +# (printf only interprets backslash escapes in the format string, not args). +ESC=$(printf '\033') +CYAN="${ESC}[1;36m" +GREEN="${ESC}[0;32m" +RED="${ESC}[0;31m" +BLUE="${ESC}[0;34m" +GREY="${ESC}[0;90m" +RESET="${ESC}[0m" + +if [ "$rtk_state" = "ok" ]; then + rtk_badge="${GREY}rtk:${GREEN}✓${RESET}" +else + rtk_badge="${GREY}rtk:${RED}✗${RESET}" +fi +if [ "$caveman_state" = "ok" ]; then + cave_badge="${GREY}cave:${GREEN}✓${RESET}" +else + cave_badge="${GREY}cave:${RED}✗${RESET}" +fi + +if [ -n "$git_branch" ]; then + printf "${CYAN}%s${RESET} ${BLUE}git:(${GREEN}%s${BLUE})${RESET} %s %s" \ + "$dir" "$git_branch" "$rtk_badge" "$cave_badge" +else + printf "${CYAN}%s${RESET} %s %s" "$dir" "$rtk_badge" "$cave_badge" +fi diff --git a/dot_claude/modify_settings.json b/dot_claude/modify_settings.json new file mode 100755 index 0000000..1f137b4 --- /dev/null +++ b/dot_claude/modify_settings.json @@ -0,0 +1,41 @@ +#chezmoi:modify-template +{{- /* + chezmoi modify-template for ~/.claude/settings.json. The annotation line + above tells chezmoi to render this as a Go template (replacing the .tmpl + suffix); .chezmoi.stdin = current target file contents (empty on first + apply). Template output IS new file content. + + Replaces a prior shell+jq pipeline whose `base='{{ includeTemplate ... }}'` + single-quoted shell literal would have broken on any apostrophe character + in claude-settings-base.json. Now everything stays in Go-template land. + + Strategy: deep-merge curated base with existing target. + - mergeOverwrite $base $existing → existing wins at scalar conflicts + (preserves rtk init's PreToolUse hook entry + claude plugin install's + enabledPlugins additions written since the previous apply). + - mergeOverwrite is recursive on maps: nested keys (hooks.PreToolUse, + enabledPlugins) merge automatically — the jq version needed explicit + per-key logic that's subsumed here. + - Lists OVERWRITE wholesale (no concat). Currently no list-valued keys + in base; the `hasKey` guard below catches the hooks case. If base + grows a new list-valued key whose values should union with existing, + add explicit `concat` logic for that key. + + CONSTRAINT: claude-settings-base.json must NOT define `hooks` keys — + rtk owns hooks via `rtk init -g --auto-patch`. The fail below makes + any future regression of this invariant impossible to ignore. + + Refs: + - chezmoi modify-template: https://www.chezmoi.io/reference/special-files-and-directories/source-state-attributes/ + - sprig mergeOverwrite: http://masterminds.github.io/sprig/dicts.html +*/ -}} +{{- $existing := dict -}} +{{- if .chezmoi.stdin -}} +{{- $existing = fromJson .chezmoi.stdin -}} +{{- end -}} +{{- $base := fromJson (includeTemplate "claude-settings-base.json" .) -}} +{{- if hasKey $base "hooks" -}} +{{- fail "claude-settings-base.json must not define 'hooks' (rtk owns it)" -}} +{{- end -}} +{{- $merged := mergeOverwrite $base $existing -}} +{{ $merged | toPrettyJson }} diff --git a/claude/rules/frontend-spec-first-workflow.md b/dot_claude/rules/frontend-spec-first-workflow.md similarity index 100% rename from claude/rules/frontend-spec-first-workflow.md rename to dot_claude/rules/frontend-spec-first-workflow.md diff --git a/claude/rules/no-code-without-go.md b/dot_claude/rules/no-code-without-go.md similarity index 100% rename from claude/rules/no-code-without-go.md rename to dot_claude/rules/no-code-without-go.md diff --git a/claude/rules/read-codebase-first.md b/dot_claude/rules/read-codebase-first.md similarity index 100% rename from claude/rules/read-codebase-first.md rename to dot_claude/rules/read-codebase-first.md diff --git a/claude/rules/verify-before-fix.md b/dot_claude/rules/verify-before-fix.md similarity index 100% rename from claude/rules/verify-before-fix.md rename to dot_claude/rules/verify-before-fix.md diff --git a/claude/rules/visual-audit-mcp-gotchas.md b/dot_claude/rules/visual-audit-mcp-gotchas.md similarity index 100% rename from claude/rules/visual-audit-mcp-gotchas.md rename to dot_claude/rules/visual-audit-mcp-gotchas.md diff --git a/dot_claude/skills/save-to-dotfiles/SKILL.md b/dot_claude/skills/save-to-dotfiles/SKILL.md new file mode 100644 index 0000000..a0e61e8 --- /dev/null +++ b/dot_claude/skills/save-to-dotfiles/SKILL.md @@ -0,0 +1,241 @@ +--- +name: save-to-dotfiles +description: Use when the user asks to save, persist, or globally apply any + configuration change — shell aliases / functions / env vars / PATH (zshrc), + vim settings / mappings / plugins (vimrc), tmux bindings / colours / settings + (tmux.conf.local), iTerm2 prefs, p10k tweaks, Claude or Codex rules / agents / + skills / settings / instructions, new chezmoi-managed dotfiles, install-time + hooks. The skill walks three gates (portability / platform / routing), routes + the edit to the correct path in the chezmoi source dir, considers + OS-conditional wrapping, and suggests the reload command. Trigger phrases + include "сохрани", "запомни", "всегда так делай", "добавь в config", + "save this globally", "make this permanent", "add to my config", + "always do X", "add alias", "add binding", "add rule". +--- + +# save-to-dotfiles + +Procedural handler for "make this config change permanent across all my +machines" requests. The dotfiles repo is managed by **chezmoi**; the source +dir lives at `~/.local/share/chezmoi/` by default (`chezmoi cd` to navigate +there). Source files use the `dot_X` naming convention; `chezmoi apply` +materializes them into `$HOME`. + +## Workflow + +``` +0. Discover what user actually wants → may need clarifying questions +1. Gate 1 — Portability check → reject if machine-specific +2. Gate 2 — Platform check → decide OS-conditional wrapping +3. Gate 3 — Routing → which source file, which section +4. Edit dotfiles source path → never the live $HOME path +5. chezmoi apply (preview with diff first) +6. Suggest reload command → so the change takes effect now +7. Show diff + offer commit → conventional commit message +``` + +## Step 0 — Discover + +User intent can be vague. Before routing, confirm: + +- The exact config change (the alias, the setting, the rule text) +- Whether it applies on **every** machine or just current one +- Whether it should work on **macOS, Linux, or both** + +If anything is unclear, propose 1-3 reasonable approaches with trade-offs and +let the user pick. Don't guess silently. + +## Step 1 — Gate 1: Portability + +A change is **machine-specific** and should NOT go in dotfiles when: + +| Marker | Right home instead of dotfiles | +|---|---| +| Absolute local path (`/Users/X/projects/...`, `/home/X/...`) | env var, or local file like `~/.zshrc.local` | +| Specific hostname or SSH alias unique to one machine | `~/.ssh/config` (sometimes per-machine) | +| Hardware-specific (display scaling, physical keyboard mapping) | OS-native preferences, not dotfiles | +| Secret / API key / token / password | 1Password, env var, or `~/.config//.env` (gitignored) | +| Per-machine auth state (`~/.claude.json`, `~/.codex/auth.json`) | Already gitignored — don't touch | +| Per-machine permissions allowlist (`~/.claude/settings.local.json`) | Stays per-machine; chezmoi can manage as `private_` but typically left local | + +If portability fails: **stop**, tell user, suggest the alternative home, don't +write to dotfiles. + +## Step 2 — Gate 2: Platform + +Detect current platform: `uname -s` → `Darwin` (macOS) or `Linux`. + +| Category | Examples | How to wrap | +|---|---|---| +| **Cross-platform** | zsh aliases, vim settings, tmux bindings, Claude/Codex rules | no wrapping; if a CLI may be missing on some machines, use runtime `command -v X` checks | +| **macOS-only** | `brew install …`, `defaults write …`, `pbcopy`, `osascript`, iTerm2 plist | shell file: `case "$(uname -s)" in Darwin) … ;; esac`. For `.chezmoiscripts/*.sh.tmpl`: `{{ if eq .chezmoi.os "darwin" }} … {{ end }}` | +| **Linux-only** | `apt`/`dnf`, `xsel`/`xclip`/`wl-copy`, `/run/user/$UID/...` | analogous: `linux` | +| **Conditional on tool availability** | `mise`, `zoxide`, `code` | `command -v X >/dev/null 2>&1 && …` | + +If unsure: ask. Don't pick silently. + +## Step 3 — Gate 3: Routing + +Map intent → source file (in the chezmoi source dir; `chezmoi cd` to navigate) → reload command: + +| Intent | Source path | Reload after | +|---|---|---| +| Shell alias / function | `dot_zshrc` → `# --- Aliases ---` section (alias) or near existing functions | new shell or `exec zsh` | +| Env var | `dot_zshrc` → `# --- Locale ---` (LANG-class) or create a new `# --- xxx ---` section | new shell | +| PATH addition | `dot_zshrc` → `# --- PATH ---` | new shell | +| zsh plugin | `dot_zsh_plugins.txt` (antidote rebuilds cache on next shell start) | new shell | +| Vim setting / mapping | `dot_vimrc` (matching section) | re-open vim or `:source $MYVIMRC` | +| Vim plugin | `dot_vimrc` inside `call plug#begin … call plug#end`, then `:PlugInstall` | next vim start | +| Tmux setting / binding / colour | `dot_tmux.conf.local` (NEVER `~/.config/tmux/.tmux.conf` — that's the external) | `prefix r` in running tmux | +| iTerm2 preference (simple) | `defaults write com.googlecode.iterm2 ` — iTerm2 reads/writes directly from `/iterm/` via `PrefsCustomFolder` | quit + relaunch iTerm2 | +| iTerm2 preference (complex GUI) | edit via GUI → iTerm2 writes directly to `/iterm/com.googlecode.iterm2.plist` → check `git status` after `chezmoi cd` | quit + relaunch iTerm2 | +| p10k tweak (one-line override) | `dot_p10k.zsh` in-place | new shell | +| p10k major theme change | run `p10k configure` (rewrites `dot_p10k.zsh`) | (done by p10k itself) | +| Brew formula or cask (Mac) / apt/dnf package (Linux) | `.chezmoidata/packages.yaml` under `common.brews` / `darwin.casks` / `linux.{apt,dnf}` | `chezmoi apply` runs install-packages script | +| Claude global rule | `dot_claude/rules/.md` with frontmatter | next Claude session | +| Claude setting / statusline / plugin enable | `.chezmoitemplates/claude-settings-base.json` (curated base; `dot_claude/modify_settings.json.tmpl` merges tool-added keys) | next Claude session | +| Claude custom agent | `dot_claude/agents//...` | next Claude session | +| Claude custom skill | `dot_claude/skills//SKILL.md` | next Claude session | +| Codex behavioural rule | `dot_codex/AGENTS.md` (inline section) | next Codex session | +| Codex setting (model, plugin) | `.chezmoitemplates/codex-config-base.toml` (`dot_codex/modify_config.toml` merges) | next Codex session | +| Codex skill | `dot_claude/skills//SKILL.md` — `~/.codex/skills` is a symlink to `~/.claude/skills`, so both tools see it | next Codex session | +| New file to manage (new `~/.` to track) | `chezmoi add ~/.` (copies live → `dot_` in source) | already applied | +| New install-time hook | `.chezmoiscripts/run__.sh.tmpl` | next `chezmoi apply` | +| New external (vendored archive/repo) | `.chezmoiexternal.toml.tmpl` entry | `chezmoi apply -R` (force refresh) | + +If multiple targets are reasonable (e.g. "make this an alias OR a function?"), +**propose options** with trade-offs. + +## Step 4 — Edit the source + +**Always** edit the chezmoi source, never `~/.claude/` etc. directly. +Two equivalent flows: + +- `chezmoi edit ~/.zshrc` — opens `dot_zshrc` in `$EDITOR` (chezmoi knows the mapping) +- `chezmoi cd` then `$EDITOR dot_zshrc` — open a subshell in the source dir for direct multi-file editing + +### Per-area specifics + +**Claude rules** — frontmatter is required. Universal rule (every session): + +```yaml +--- +name: +description: Universal — +--- +``` + +Conditional rule (loads only on matching file types): + +```yaml +--- +name: +description: +paths: + - "**/*.tsx" + - "**/*.css" +--- +``` + +Ask user: universal or path-conditional? If conditional, which file globs? + +**Codex AGENTS.md additions** — Codex has no `dot_claude/rules/`-style +auto-discovery. Behavioural rules go inline into `dot_codex/AGENTS.md`. + +**Tool-mutated files** (`~/.claude/settings.json`, `~/.codex/config.toml`): +- Edit the **base** file in `.chezmoitemplates/`: + `claude-settings-base.json` / `codex-config-base.toml` +- The `modify_` script loads the base via `includeTemplate` and merges + base + existing-with-tool-keys on each apply +- DO NOT edit `~/.claude/settings.json` directly in `$HOME` — your edit + becomes "tool-added", merged but not authoritative. To make a permanent + change, edit the base. + +**`.chezmoiscripts/*.sh.tmpl`** — **POSIX `sh` only** (templates render to `sh` +scripts; we keep them dash-compatible). No bashisms: no `[[ ]]`, no arrays, +no `${var^^}`, no `<()`, no `local`. OS-branch via `{{ if eq .chezmoi.os "darwin" }}` +TEMPLATE directive. Be idempotent. + +## Step 5 — Apply + +```sh +chezmoi diff # preview +chezmoi apply # commit to $HOME +``` + +For a single target: `chezmoi apply ~/.zshrc`. + +## Step 6 — Reload + +Tell the user the exact command to apply the change now (see Step 3 table). +For most cases this is just opening a new shell; tmux / iTerm2 / etc. need +specific reload commands. + +## Step 7 — Commit + +Show the diff. Suggest a conventional commit message (see repo conventions +in `AGENTS.md` §11 at the source-dir root): + +- `feat:` new feature, `fix:` bug fix, `docs:` docs, `chore:` housekeeping, + `tweak:` small adjustment, plus area prefixes (`zshrc:`, `tmux:`, `iterm:`, + `code:`, `chezmoi:`) when scoped to one area. +- Short, lowercase, imperative, max 72 chars on summary line. +- No AI attribution. +- Body explains WHY when non-obvious. + +Ask before committing — user may want to test the reload first. + +## Don'ts + +- **Don't edit `~/.claude/` or `~/.codex/` directly.** Edit the source + via `chezmoi cd` then `dot_claude/` / `dot_codex/`. Exception: + tool-managed mutations (rtk init writing hook entries, claude plugin + install) — those land in target and our `modify_` scripts preserve them + on apply. +- **Don't edit `~/.config/tmux/`** — it's auto-extracted from the chezmoi + external (gpakosz/.tmux archive). Customize via `dot_tmux.conf.local`. +- **Don't hand-edit `dot_p10k.zsh` for major theme changes.** Run `p10k configure` + and let it regenerate the file. +- **Don't put bashisms in `.chezmoiscripts/*.sh.tmpl` scripts.** They render + to POSIX `sh` (dash-compatible on Ubuntu). +- **Don't put absolute local paths, hostnames, secrets, or per-machine state + in dotfiles.** Those break on other machines. Use the alternatives from + Step 1. +- **Don't write to `~/.claude/settings.local.json`** through chezmoi — + it's per-machine permission state, gitignored. +- **Don't look for the base files in `$HOME`** — they live in source at + `.chezmoitemplates/{claude-settings-base.json,codex-config-base.toml}` + and are automatically excluded from apply (chezmoi's canonical location + for template partials). + +## Test the skill mentally + +**If user says "сохрани, чтобы в zsh всегда был алиас `ll='ls -la'`":** +- **Gate 1**: portable ✓ +- **Gate 2**: cross-platform — `ls -la` works on Mac and Linux ✓ +- **Gate 3**: target = `dot_zshrc` `# --- Aliases ---` section +- `chezmoi cd && $EDITOR dot_zshrc`, add `alias ll='ls -la'` +- `chezmoi apply ~/.zshrc` → reload: `exec zsh` +- Commit: `feat: add ll alias for ls -la` + +**If user says "запомни, что Claude должен всегда писать комменты по-русски":** +- **Gate 1**: portable ✓ +- **Gate 2**: cross-platform ✓ +- **Gate 3**: target = `dot_claude/rules/.md` (new file) + - Ask: universal or path-conditional? Likely universal + - Name: `comments-in-russian` +- Create with universal frontmatter + Rule / Why / How sections +- `chezmoi apply` → reload: next Claude session +- Commit: `feat: add claude rule for Russian comments` + +**If user says "сохрани, что я хочу всегда `brew install ` после клона":** +- **Gate 1**: portable in intent (per-package, no path/host) ✓ +- **Gate 2**: cross-platform via `.chezmoidata/packages.yaml`. If `` exists + on both Mac and Linux, add to `common.brews` (Mac via brew) + `linux.apt` + and `linux.dnf` (Linux distro names may differ — e.g. `fd` → `fd-find`). + If Mac GUI app, add to `darwin.casks`. +- **Gate 3**: target = `.chezmoidata/packages.yaml` under the right subkey. +- Edit, `chezmoi apply` → `run_onchange_after_50-install-packages.sh.tmpl` + re-runs (script content embeds YAML, hash changed) and brew bundle / apt / + dnf install the new package idempotently. +- Commit: `feat(packages): add ` diff --git a/codex/AGENTS.md b/dot_codex/AGENTS.md.tmpl similarity index 69% rename from codex/AGENTS.md rename to dot_codex/AGENTS.md.tmpl index 295f70f..ca72e46 100644 --- a/codex/AGENTS.md +++ b/dot_codex/AGENTS.md.tmpl @@ -4,9 +4,9 @@ Loaded for every Codex session as per-user global instructions (Codex's equivale ## Saving portable settings -When the user asks to save anything globally — a rule, setting, alias, binding, skill, plugin enable, hook — **run the `save-to-dotfiles` skill**. It walks three gates (portability / platform / routing) and edits the right `~/dotfiles/` target. +When the user asks to save anything globally — a rule, setting, alias, binding, skill, plugin enable, hook — **run the `save-to-dotfiles` skill**. It walks three gates (portability / platform / routing) and edits the right target in the chezmoi source dir. -Never write to `~/.claude/` or `~/.codex/` directly — those are symlinks into the dotfiles repo. The full symlink table and per-area guidance live in `~/dotfiles/AGENTS.md`, which Codex auto-loads when cwd is in that repo. +Never write to `~/.claude/` or `~/.codex/` directly — these paths are chezmoi-managed: edits there get overwritten on the next `chezmoi apply` (or surface as drift the user has to clean up). Use `chezmoi cd` to enter the source dir, then edit `dot_claude/` / `dot_codex/`. The full source↔target mapping and per-area guidance live in `AGENTS.md` at the source-dir root, which Codex auto-loads when cwd is anywhere in the repo. --- @@ -32,18 +32,18 @@ Never write to `~/.claude/` or `~/.codex/` directly — those are symlinks ## Working principles by project type -Detailed rule files live under `~/dotfiles/claude/rules/`. They are **agent-agnostic** — the same files are picked up by Claude Code's `~/.claude/rules/` mechanism (auto-loaded with optional `paths:` frontmatter for conditional scope). Codex doesn't have an equivalent `rules/` directory feature, so the TL;DR below is the operative instruction set; the detailed files are reference material you can read on demand if needed. +Detailed rule files live under `dot_claude/rules/` in the chezmoi source dir (`chezmoi cd` to navigate there). They are **agent-agnostic** — the same files are picked up by Claude Code's `~/.claude/rules/` mechanism (auto-loaded with optional `paths:` frontmatter for conditional scope). Codex doesn't have an equivalent `rules/` directory feature, so the TL;DR below is the operative instruction set; the detailed files are reference material you can read on demand if needed. ### Universal (any project) -- **`read-codebase-first`** — Read specs / docs / configs / tests before first fix attempt. Don't hack from assumptions. Sweep specs/docs/configs/tests first. See `~/dotfiles/claude/rules/read-codebase-first.md`. -- **`no-code-without-go`** — Any non-trivial edit (new feature, iteration, visual bug fix) → propose first (chat or spec), wait for user `go`, then code. Exceptions: typo / lint fix / commands the user already directed. See `~/dotfiles/claude/rules/no-code-without-go.md`. -- **`verify-before-fix`** — Don't trust verbal descriptions of bugs. Ask for screenshots / logs / measurements before fixing. See `~/dotfiles/claude/rules/verify-before-fix.md`. +- **`read-codebase-first`** — Read specs / docs / configs / tests before first fix attempt. Don't hack from assumptions. Sweep specs/docs/configs/tests first. See `dot_claude/rules/read-codebase-first.md`. +- **`no-code-without-go`** — Any non-trivial edit (new feature, iteration, visual bug fix) → propose first (chat or spec), wait for user `go`, then code. Exceptions: typo / lint fix / commands the user already directed. See `dot_claude/rules/no-code-without-go.md`. +- **`verify-before-fix`** — Don't trust verbal descriptions of bugs. Ask for screenshots / logs / measurements before fixing. See `dot_claude/rules/verify-before-fix.md`. ### Frontend / visual-design projects (UI the user looks at) -- **`frontend-spec-first-workflow`** — Strict per-block pipeline: SPEC → user approve → IMPL DESKTOP → review → IMPL MOBILE → review → POLISH → COMMIT. No phase advances without explicit `go`. See `~/dotfiles/claude/rules/frontend-spec-first-workflow.md`. -- **`visual-audit-mcp-gotchas`** (relevant when Codex has chrome-devtools MCP enabled) — three-layer viewport conflicts, `resize_page` vs `emulate`, screenshot timeout recipe (pause videos + cancel animations + JPEG), `Cmd+Shift+R` after preview rebuild. See `~/dotfiles/claude/rules/visual-audit-mcp-gotchas.md`. +- **`frontend-spec-first-workflow`** — Strict per-block pipeline: SPEC → user approve → IMPL DESKTOP → review → IMPL MOBILE → review → POLISH → COMMIT. No phase advances without explicit `go`. See `dot_claude/rules/frontend-spec-first-workflow.md`. +- **`visual-audit-mcp-gotchas`** (relevant when Codex has chrome-devtools MCP enabled) — three-layer viewport conflicts, `resize_page` vs `emulate`, screenshot timeout recipe (pause videos + cancel animations + JPEG), `Cmd+Shift+R` after preview rebuild. See `dot_claude/rules/visual-audit-mcp-gotchas.md`. ### Backend / data / library projects @@ -53,3 +53,5 @@ Placeholder for future rule files. Likely candidates: - Migration safety (don't drop columns; deprecate first) - Schema/API changes through explicit review - Logs as evidence (since no visual feedback) + +@{{ .chezmoi.homeDir }}/.codex/RTK.md diff --git a/dot_codex/modify_config.toml b/dot_codex/modify_config.toml new file mode 100755 index 0000000..013ea06 --- /dev/null +++ b/dot_codex/modify_config.toml @@ -0,0 +1,23 @@ +#chezmoi:modify-template +{{- /* + chezmoi modify_ template for ~/.codex/config.toml. + Annotation tells chezmoi to render this as a template; .chezmoi.stdin + contains current target file contents. Template output IS new file content. + + Strategy: + - parse existing TOML + - drop runtime sections that Codex auto-writes + - mergeOverwrite our curated base on top (base wins for managed keys) + - serialize back to TOML + + Replaces the .gitattributes/codex-strip git-clean-filter mechanism. +*/ -}} +{{- $existing := fromToml .chezmoi.stdin -}} +{{- $base := fromToml (includeTemplate "codex-config-base.toml" .) -}} +{{- $existing = unset $existing "projects" -}} +{{- $existing = unset $existing "notice" -}} +{{- $existing = unset $existing "tui" -}} +{{- $existing = unset $existing "tool_suggest" -}} +{{- $existing = unset $existing "windows_wsl_setup_acknowledged" -}} +{{- $merged := mergeOverwrite $existing $base -}} +{{- toToml $merged -}} diff --git a/dot_codex/skills/save-to-dotfiles/symlink_SKILL.md b/dot_codex/skills/save-to-dotfiles/symlink_SKILL.md new file mode 100644 index 0000000..bb03615 --- /dev/null +++ b/dot_codex/skills/save-to-dotfiles/symlink_SKILL.md @@ -0,0 +1 @@ +../../../.claude/skills/save-to-dotfiles/SKILL.md diff --git a/dot_config/git/hooks/executable_pre-push b/dot_config/git/hooks/executable_pre-push new file mode 100644 index 0000000..cd084d6 --- /dev/null +++ b/dot_config/git/hooks/executable_pre-push @@ -0,0 +1,47 @@ +#!/bin/sh +# Pre-push hook: scan outbound commits for leaked secrets via gitleaks. +# Fires from every repo because `git config --global core.hooksPath` +# in dot_gitconfig.tmpl points here. Per-repo opt-out: +# git config --local core.hooksPath '' +# Emergency bypass for a single push: +# git push --no-verify +# +# Fail-open by design: if gitleaks is not on PATH (core profile machines +# without the dev toolchain) the hook exits 0 and the push proceeds. +set -eu + +if ! command -v gitleaks >/dev/null 2>&1; then + exit 0 +fi + +ZERO=0000000000000000000000000000000000000000 +fail=0 + +# stdin: , one line per ref. +while read -r local_ref local_sha remote_ref remote_sha; do + # Branch deletion — nothing to scan. + [ "$local_sha" = "$ZERO" ] && continue + + if [ "$remote_sha" = "$ZERO" ]; then + # New branch — scan from root. + range="$local_sha" + else + range="${remote_sha}..${local_sha}" + fi + + if ! gitleaks git --log-opts="$range" --no-banner --redact; then + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + cat >&2 <<'MSG' + +gitleaks found potential secrets in outbound commits. Push blocked. + +If false positive: add a `.gitleaksignore` entry (commit-sha:file:rule) + or edit the rule in .gitleaks.toml. +Emergency bypass: git push --no-verify (use sparingly) +MSG +fi +exit "$fail" diff --git a/dot_config/mise/config.toml.tmpl b/dot_config/mise/config.toml.tmpl new file mode 100644 index 0000000..b1818d8 --- /dev/null +++ b/dot_config/mise/config.toml.tmpl @@ -0,0 +1,76 @@ +{{- /* + Global mise config — managed by chezmoi (rendered from .tmpl). + Profile-aware: at profile=core, only the dotfile-essential tools are + registered (fzf+zoxide power zshrc bindings). At profile=dev|workstation, + the full dev toolchain registers (languages + 25 aqua tools). + + Per-project pins still live in /.mise.toml or .tool-versions. + Per-machine opt-out: untracked ~/.config/mise/config.local.toml — mise + auto-merges later files over this one. + + Adding a tool: `mise registry | grep -i ` → use the aqua slug shown. +*/ -}} +{{- $profile := .profile | default "core" -}} +{{- $isDev := or (eq $profile "dev") (eq $profile "workstation") -}} +[settings] +# Enable idiomatic version files (.nvmrc, .python-version, .go-version). +# Disabled by default since mise 2025.10.0; we want them on for back-compat +# with existing projects that pre-date our move to .mise.toml. +idiomatic_version_file_enable_tools = ["node", "python", "go"] + +[tools] +# --- Core tier — minimum to make dotfile integrations work -------------- +"aqua:junegunn/fzf" = "latest" # zshrc Ctrl-R, Ctrl-T +"aqua:ajeetdsouza/zoxide" = "latest" # zshrc z, zi + +{{- if $isDev }} + +# --- Languages (core backends) ------------------------------------------ +node = "lts" +go = "latest" +python = "3.12" +rust = "stable" + +# --- CLI utilities ------------------------------------------------------ +"aqua:jqlang/jq" = "latest" # JSON CLI +"aqua:cli/cli" = "latest" # gh (GitHub CLI) +"aqua:dandavison/delta" = "latest" # git pager +"aqua:koalaman/shellcheck" = "latest" # POSIX linter +"aqua:sharkdp/fd" = "latest" # better find +"aqua:gitleaks/gitleaks" = "latest" # pre-push secret scan +"aqua:mikefarah/yq" = "latest" # YAML CLI (Go yq, NOT Python) + +# --- Cloud + Kubernetes ------------------------------------------------- +"aqua:helm/helm" = "latest" +"aqua:kubernetes/kubernetes/kubectl" = "latest" +"aqua:derailed/k9s" = "latest" +"aqua:kubernetes-sigs/kustomize" = "latest" +"aqua:stern/stern" = "latest" +"aqua:argoproj/argo-cd" = "latest" # argocd +"aqua:opentofu/opentofu" = "latest" # tofu +"aqua:aws/aws-cli" = "latest" +"aqua:rclone/rclone" = "latest" +"aqua:cloudflare/cloudflared" = "latest" + +# --- Networking --------------------------------------------------------- +"aqua:vi/websocat" = "latest" + +# --- Go ecosystem (binaries — Go runtime itself is above) --------------- +"aqua:bufbuild/buf" = "latest" +"aqua:golangci/golangci-lint" = "latest" +"aqua:goreleaser/goreleaser" = "latest" +"aqua:gotestyourself/gotestsum" = "latest" +"aqua:protocolbuffers/protobuf-go/protoc-gen-go" = "latest" + +# --- JS / Python pkg managers ------------------------------------------- +"aqua:pnpm/pnpm" = "latest" +"aqua:astral-sh/uv" = "latest" + +# Not in mise: +# - goimports → `go install golang.org/x/tools/cmd/goimports@latest` after Go. +# - ssh-audit → `uv tool install ssh-audit` after uv. +# - rtk → mise registry has wrong project; stays on its own curl-pipe. +# - mongosh → heavy (~80MB) JS bundle; install ad-hoc when needed. +# - htop/tree/nmap/wireguard-tools/telnet/postgresql-client → OS-native +# (apt/dnf on Linux, brew on Mac) for C-library + system-integration reasons. +{{- end }} diff --git a/dot_gitconfig.tmpl b/dot_gitconfig.tmpl new file mode 100644 index 0000000..fd32dea --- /dev/null +++ b/dot_gitconfig.tmpl @@ -0,0 +1,87 @@ +{{- /* + Global git config. Rendered at every chezmoi apply. + - name/email come from prompts in .chezmoi.toml.tmpl (cached in [data] + section of ~/.config/chezmoi/chezmoi.toml after first init). + - delta sections gated on `lookPath "delta"` — first apply on a fresh + box may not have delta yet (it installs in run_onchange_after_50- + install-packages, which runs AFTER chezmoi materializes files). + Second apply re-renders this file with delta pager wired. + - credential.helper INTENTIONALLY omitted — using SSH keys + gh auth, + no HTTPS credential caching needed. +*/ -}} +{{- /* + Skip [user] block when identity values are still chezmoi placeholders + ("Your Name" / "you@example.com" set in .chezmoi.toml.tmpl on a + non-interactive run without --data). Without this guard, every `git + commit` from such a machine would be silently mis-attributed to the + placeholder — caught much later in `git log`. With the guard, git + falls back to system config or errors clearly ("Please tell me who + you are") when a real commit is attempted. +*/ -}} +{{ if and (ne .name "Your Name") (ne .email "you@example.com") -}} +[user] + name = {{ .name | quote }} + email = {{ .email | quote }} +{{ end }} +[core] + editor = vim + excludesFile = ~/.gitignore_global + autocrlf = input + # Global hooks dir (chezmoi-managed). Hooks here fire from ALL repos; + # per-repo override: `git config --local core.hooksPath ''`. + hooksPath = ~/.config/git/hooks +{{ if lookPath "delta" -}} + pager = delta +{{- end }} + +[init] + defaultBranch = master + +[pull] + rebase = true + +[push] + default = current + autoSetupRemote = true + +[merge] + conflictStyle = zdiff3 + +[rebase] + autoSquash = true + autoStash = true + +[diff] + algorithm = histogram + colorMoved = default + submodule = log + +[fetch] + prune = true + pruneTags = true + +[rerere] + enabled = true + +[alias] + co = checkout + br = branch + ci = commit + st = status -sb + last = log -1 HEAD --stat + unstage = reset HEAD -- + amend = commit --amend --no-edit + wip = !git add -A && git commit -m \"WIP\" + undo = reset --soft HEAD^ + lg = log --graph --decorate --abbrev-commit --date=short --pretty=format:'%C(yellow)%h%C(reset) %C(blue)%ad%C(reset) %C(green)%an%C(reset) %s%C(red)%d%C(reset)' + +{{ if lookPath "delta" -}} +[delta] + navigate = true + line-numbers = true + side-by-side = false + syntax-theme = Dracula + +[interactive] + diffFilter = delta --color-only +{{- end }} diff --git a/dot_gitignore_global b/dot_gitignore_global new file mode 100644 index 0000000..b84f027 --- /dev/null +++ b/dot_gitignore_global @@ -0,0 +1,52 @@ +# Global gitignore — patterns ignored across every repo on this machine. +# Wired via `core.excludesFile = ~/.gitignore_global` in dot_gitconfig.tmpl. +# Keep this list to TRULY universal junk (OS detritus, editor backups). +# Language-specific or project-specific ignores belong in the repo's +# own .gitignore. + +# --- macOS --- +.DS_Store +.AppleDouble +.LSOverride +Icon? +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# --- Linux / general POSIX --- +*~ +.nfs* + +# --- Windows --- +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# --- Vim / Neovim --- +*.swp +*.swo +*.swn +.netrwhist +Session.vim + +# --- Emacs --- +\#*\# +.\#* + +# --- VS Code / JetBrains (per-repo prefs that don't belong in shared history) --- +.vscode/ +.idea/ +*.iml + +# --- direnv / per-machine env overrides --- +.envrc.local +.env.local + +# --- Generic local-override convention --- +*.local diff --git a/p10k.zsh b/dot_p10k.zsh similarity index 100% rename from p10k.zsh rename to dot_p10k.zsh diff --git a/tmux/tmux.conf.local b/dot_tmux.conf.local similarity index 94% rename from tmux/tmux.conf.local rename to dot_tmux.conf.local index a868eb3..d0fe5ea 100644 --- a/tmux/tmux.conf.local +++ b/dot_tmux.conf.local @@ -1,5 +1,6 @@ # Customizations on top of gpakosz/.tmux ("oh-my-tmux"). -# Edit this file only — the upstream .tmux.conf is vendored as a submodule. +# Edit this file only — the upstream .tmux.conf is vendored as a chezmoi +# external (pinned in .chezmoiexternal.toml, extracted to ~/.config/tmux/). # --- prefix: C-a only (screen-style) ------------------------------------------ # gpakosz binds C-a as prefix2; make it primary and drop C-b entirely. @@ -51,14 +52,14 @@ tmux_conf_theme_right_separator_sub='' # are 256-color codes p10k itself uses, converted via the ANSI 6x6x6 # cube formula (16 + 36r + 6g + b). Uses gpakosz's canonical colour_N # slots — that way the SSH-tint branch in zshrc can point-override -# colour_15 (right-side bar-default bg) so it follows the red tint -# instead of leaving #080808 islands inside the red bar. +# colour_15 (right-side bar-default bg) so it follows the amber tint +# instead of leaving #080808 islands inside the amber bar. # # Slots not listed here keep their gpakosz defaults, which already match # what we want: # colour_6=#080808 / colour_7=#e4e4e4 → status_left_fg slots match # colour_12=#8a8a8a / colour_14=#080808 → status_right_fg slots 0/2 match -# colour_15=#080808 (bar default bg) → SSH branch overrides to #5f0000 +# colour_15=#080808 (bar default bg) → SSH branch overrides to #5f2f00 tmux_conf_theme_colour_9='#5fafd7' # session bg — p10k light DIR-anchor (74) tmux_conf_theme_colour_10='#005f87' # uptime bg — p10k deep DIR (24) diff --git a/vimrc b/dot_vimrc similarity index 91% rename from vimrc rename to dot_vimrc index 2e638a6..5ea6302 100644 --- a/vimrc +++ b/dot_vimrc @@ -11,6 +11,12 @@ endif call plug#begin('~/.vim/plugged') +" Syntax + filetype detection for chezmoi-managed source files +" (dot_*, *.tmpl, modify_*, .chezmoitemplates/*). Must load before +" plug#end() calls `filetype plugin indent on` so the autocmd registers +" before vim's stock detection runs. +Plug 'alker0/chezmoi.vim' + Plug 'tpope/vim-fugitive' Plug 'tpope/vim-surround' Plug 'dracula/vim', { 'as': 'dracula' } @@ -96,8 +102,6 @@ if has("gui_macvim") noremap :tablast endif -" set guifont=monaco:h18 -" :set number set termguicolors let g:dracula_italic = 0 let g:dracula_colorterm = 0 diff --git a/zsh_plugins.txt b/dot_zsh_plugins.txt similarity index 100% rename from zsh_plugins.txt rename to dot_zsh_plugins.txt diff --git a/zshrc b/dot_zshrc similarity index 70% rename from zshrc rename to dot_zshrc index 16f909d..7d00a77 100644 --- a/zshrc +++ b/dot_zshrc @@ -100,8 +100,28 @@ alias zshconfig="vim ~/.zshrc" # --- Locale --- export LANG=en_US.UTF-8 -# --- Node version manager (fnm) --- -command -v fnm > /dev/null && eval "$(fnm env)" +# --- Default editor --- +# Used by chezmoi edit, git commit (without -m), crontab -e, sudoedit, etc. +# Honour an existing $EDITOR (e.g. preset to nvim/code in a per-machine layer). +export EDITOR="${EDITOR:-vim}" +export VISUAL="$EDITOR" + +# --- Runtime + dev-tool manager (mise) --- +# mise is the primary installer for ~28 dev tools (Go/Python/Node/Rust + +# kubectl/helm/k9s/jq/gh/fzf/yq/delta/... via aqua backend). Activate enables +# auto-version-switch on cd when .mise.toml / .tool-versions / .nvmrc / +# .python-version / .go-version is present. +command -v mise > /dev/null && eval "$(mise activate zsh)" + +# --- fzf shell integration (key-bindings + completion) --- +# fzf is mise-installed (aqua:junegunn/fzf). Source its shell scripts from +# mise's install dir. `mise where fzf` returns the active install path, +# stable across versions (preferred over glob+tail-1 lexical sort). +if command -v mise > /dev/null && _fzf_root="$(mise where fzf 2>/dev/null)" && [[ -n "$_fzf_root" ]]; then + [[ -r "$_fzf_root/shell/key-bindings.zsh" ]] && source "$_fzf_root/shell/key-bindings.zsh" + [[ -r "$_fzf_root/shell/completion.zsh" ]] && source "$_fzf_root/shell/completion.zsh" +fi +unset _fzf_root # --- Smart cd (zoxide) --- command -v zoxide > /dev/null && eval "$(zoxide init zsh)" @@ -114,6 +134,25 @@ export PATH="$PATH:$HOME/go/bin" # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh +# --- OSC 133 semantic prompt sequences --- +# Emit OSC 133 marks (A=prompt-start, B=input-start, C=output-start, +# D;=output-end) so shell-integration-aware terminals can: +# • iTerm2: ⌘↑/⌘↓ jump between previous prompts, exit-code marks in gutter +# • VS Code integrated terminal: command decorations + sticky scroll +# • WezTerm: ScrollToPrompt / status colorization +# • Windows Terminal / Ghostty: command marks + navigation +# Skip if iTerm2's own shell-integration script is already sourced (would +# double-emit and overlap with iTerm2's CurrentDir/RemoteHost extensions). +# Skip if p10k Pro is doing it (POWERLEVEL9K_TERM_SHELL_INTEGRATION=true). +if [[ -z "${ITERM_SHELL_INTEGRATION_INSTALLED:-}" \ + && "${POWERLEVEL9K_TERM_SHELL_INTEGRATION:-false}" != "true" ]]; then + autoload -Uz add-zsh-hook + _osc133_preexec() { print -Pn '\e]133;C\a' } + _osc133_precmd() { print -Pn '\e]133;D;'"$?"'\a\e]133;A\a' } + add-zsh-hook preexec _osc133_preexec + add-zsh-hook precmd _osc133_precmd +fi + # --- SSH visual tints (orange ❯ + amber tmux bar) --- # Defensive nudge so I can't type into the wrong machine by accident: # • p10k `❯` turns orange (208). Not red — that's already taken by @@ -150,3 +189,15 @@ if [[ -n $SSH_CONNECTION ]]; then export tmux_conf_theme_window_status_bg='#5f2f00' export tmux_conf_theme_colour_15='#5f2f00' fi + +# --- Per-machine overrides --- +# Sourced LAST so machine-local edits win over EVERYTHING tracked here +# (aliases, env vars, even POWERLEVEL9K_* tweaks if user re-sources +# ~/.p10k.zsh after their override block, or SSH tmux_conf_theme_* if +# the user wants different cues for their machine). +# +# File seeded as a commented stub by chezmoi (`create_dot_zshrc_local`), +# then never overwritten — edits persist across `chezmoi apply` runs. +# Override the p10k prompt itself via `p10k configure` editing +# `~/.p10k.zsh` directly (Powerlevel10k convention). +[[ ! -f ~/.zshrc_local ]] || source ~/.zshrc_local diff --git a/hooks/ensure-prereqs.sh b/hooks/ensure-prereqs.sh new file mode 100755 index 0000000..0dd2d82 --- /dev/null +++ b/hooks/ensure-prereqs.sh @@ -0,0 +1,125 @@ +#!/bin/sh +# Hook: ensure minimum prereqs are present BEFORE chezmoi reads source state. +# Registered in .chezmoi.toml.tmpl as `hooks.read-source-state.pre.command`. +# +# Per chezmoi docs: hooks are PLAIN shell scripts (not templates). Detect OS +# inline. Idempotent: skips when everything is present, skips with warning if +# missing tools would require interactive sudo on a non-TTY apply. +# +# Installs (per OS): +# - macOS: Xcode Command Line Tools + Homebrew (Apple bundles git/zsh/vim/ +# tmux/curl, so no base-tool install needed) + mise (via brew). +# - Linux: base tools (git/zsh/vim/tmux/curl/ca-certificates) via apt/dnf, +# plus mise via `curl https://mise.run | sh` into ~/.local/bin. +# - No Alpine/Arch support (documented limitation). +set -eu + +# Force apt non-interactive — even from a TTY apply, downstream scripts may +# run from a non-TTY chezmoi child, and consistency matters. +export DEBIAN_FRONTEND=noninteractive +export TZ=Etc/UTC + +is_tty=0 +tty -s && is_tty=1 + +case "$(uname -s)" in + Darwin) + # Homebrew install (if missing) — needs Xcode CLT, which `brew install` + # script handles. Interactive on first run. + if ! command -v brew >/dev/null 2>&1; then + if [ "$is_tty" -eq 0 ]; then + # Fail loudly: skipping brew install silently leads to all + # downstream scripts breaking with "brew: command not found". + # Halt the apply so the user runs the visible install command. + echo "ERROR: Homebrew not installed; chezmoi apply needs an interactive run first." >&2 + echo " Run: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2 + echo " Then re-run: chezmoi apply" >&2 + exit 1 + fi + echo "Installing Homebrew (Xcode CLT will install as a side effect)..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + fi + + # mise — install via brew (primary dev-tool installer). Idempotent. + # Must be on PATH before chezmoi reads source state, since the + # install-packages script runs `mise install` for ~28 tools. + if ! command -v mise >/dev/null 2>&1; then + brew install mise 2>&1 || echo "brew install mise failed" >&2 + fi + ;; + Linux) + # --- 1. Base tools ------------------------------------------------- + # git/zsh/vim/tmux/curl: editor + shell + version control baseline. + # Each is a CLI binary — `command -v` is the right check. + missing="" + for tool in git zsh vim tmux curl; do + command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool" + done + # ca-certificates is a system PACKAGE, not a binary — `command -v` + # always returns false for it. Check via the canonical cert-bundle + # file location instead. Linux distros put the bundle in different + # places; check both Debian-family and RHEL-family paths. + if [ ! -f /etc/ssl/certs/ca-certificates.crt ] \ + && [ ! -f /etc/pki/tls/certs/ca-bundle.crt ]; then + missing="$missing ca-certificates" + fi + + if [ "$(id -u)" -eq 0 ]; then + sudo_cmd="" + elif command -v sudo >/dev/null 2>&1; then + sudo_cmd="sudo -E" + else + sudo_cmd="" + fi + + # Decide whether sudo path is usable (passwordless or TTY). + sudo_usable=1 + if [ -n "$sudo_cmd" ] && [ "$is_tty" -eq 0 ]; then + if ! sudo -n true 2>/dev/null; then + sudo_usable=0 + else + sudo_cmd="sudo -nE" + fi + fi + + if [ -n "$missing" ]; then + if [ -z "$sudo_cmd" ] && [ "$(id -u)" -ne 0 ]; then + echo "WARNING: missing tools:$missing — neither root nor sudo available." >&2 + echo " Install manually as root." >&2 + elif [ "$sudo_usable" -eq 0 ]; then + echo "WARNING: missing tools:$missing — non-interactive apply, sudo prompt would block." >&2 + echo " Install manually:$sudo_cmd apt-get install -y$missing (or dnf install -y$missing)" >&2 + elif command -v apt-get >/dev/null 2>&1; then + # Redirect stdout → /dev/null so the hook stays silent during + # chezmoi diff / verify (their stdout becomes chezmoi's). apt + # errors still surface on stderr and via $?. + $sudo_cmd apt-get update -qq >/dev/null + # shellcheck disable=SC2086 + $sudo_cmd apt-get install -y --no-install-recommends $missing >/dev/null + elif command -v dnf >/dev/null 2>&1; then + # shellcheck disable=SC2086 + $sudo_cmd dnf install -y $missing >/dev/null + else + echo "WARNING: missing tools:$missing — neither apt-get nor dnf found." >&2 + exit 0 + fi + fi + + # --- 2. mise — install via curl-pipe to ~/.local/bin ---------------- + # mise must be on PATH before chezmoi reads source state (the install- + # packages script calls `mise install` for ~28 tools). + # + # Why curl-pipe and not signed apt/dnf repo: simplicity. mise has + # `mise self-update` for ongoing upgrades, so we don't need apt's + # tracking. Trade-off: one curl-pipe = ~5 LOC vs ~35 LOC repo logic. + if ! command -v mise >/dev/null 2>&1; then + curl -fsSL https://mise.run | sh + export PATH="$HOME/.local/bin:$PATH" + hash -r + fi + ;; + *) + echo "WARNING: unsupported OS $(uname -s) — repo supports macOS + Debian/Ubuntu + Fedora only." >&2 + exit 0 + ;; +esac diff --git a/install b/install deleted file mode 100755 index 1eadd3c..0000000 --- a/install +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -set -e - -CONFIG=".install.conf.yaml" -DOTBOT_DIR=".dotbot" - -DOTBOT_BIN="bin/dotbot" -BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -cd "${BASEDIR}" - -# Hard prerequisites — fail fast with an actionable message before anything else. -# git/python3 are required to run this script and dotbot itself. -# zsh/vim/tmux are the shells/editors we configure — without them the -# symlinks land but nothing actually uses them. -missing=() -for tool in git python3 zsh vim tmux; do - command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") -done -if (( ${#missing[@]} )); then - printf 'Missing required tool(s): %s\n' "${missing[*]}" >&2 - case "$(uname -s)" in - Darwin) printf ' Install via Homebrew: brew install %s\n' "${missing[*]}" >&2 ;; - Linux) printf ' Install via your package manager (e.g. apt install %s)\n' "${missing[*]}" >&2 ;; - esac - exit 1 -fi - -git submodule sync --quiet --recursive -git submodule update --init --recursive - -"${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "${BASEDIR}" -c "${CONFIG}" "${@}" - -# Final nudge: zsh installed but not the login shell? Suggest `chsh`. -# Reads $SHELL which on every Unix is populated from the password DB at login. -case "${SHELL:-}" in - */zsh) ;; - *) - printf '\nYour login shell is %s, not zsh. To switch:\n chsh -s %s\n' \ - "${SHELL:-unknown}" "$(command -v zsh)" - ;; -esac diff --git a/lib/install-packages.sh b/lib/install-packages.sh new file mode 100644 index 0000000..52af825 --- /dev/null +++ b/lib/install-packages.sh @@ -0,0 +1,233 @@ +#!/bin/sh +# Library of package-install functions. Pure POSIX shell, no chezmoi +# templating. Sourced by `.chezmoiscripts/run_onchange_after_50-install- +# packages.sh.tmpl` (which renders chezmoi facts into DOTFILES_* env vars +# before sourcing). Sourceable standalone by bats unit tests with mocked +# external commands. +# +# Entry point: `main`. The wrapper sets INSTALL_PACKAGES_INVOKE=1 and +# sources this file; the guard at the bottom calls main only when invoked. +# Bats `setup()` sources without setting the flag → no auto-run. +# +# Required env (set by wrapper): +# DOTFILES_PROFILE core | dev | workstation +# DOTFILES_OS darwin | linux +# DOTFILES_OSID darwin | linux- (e.g. linux-ubuntu) +# DOTFILES_OSRELEASE_IDLIKE comma-list, e.g. "debian" (Linux only) +# DOTFILES_CORE_BREWS space-separated formula list +# DOTFILES_DEV_BREWS space-separated formula list +# DOTFILES_CORE_APT space-separated package list +# DOTFILES_DEV_APT space-separated package list +# DOTFILES_CORE_DNF space-separated package list +# DOTFILES_DEV_DNF space-separated package list +# DOTFILES_GUI_MAC_CASKS space-separated cask list (Mac workstation only) +# DOTFILES_GUI_LINUX_APT space-separated package list (Linux workstation only) +# DOTFILES_GUI_LINUX_DNF space-separated package list (Linux workstation only) +# DOTFILES_DEV_NPM_GLOBAL space-separated npm package list + +# Cascade: dev⊂workstation. workstation gets dev tools too. +is_dev() { + [ "$DOTFILES_PROFILE" = "dev" ] || [ "$DOTFILES_PROFILE" = "workstation" ] +} + +is_workstation() { + [ "$DOTFILES_PROFILE" = "workstation" ] +} + +is_debian_family() { + case "$DOTFILES_OSID" in + linux-debian|linux-ubuntu) return 0 ;; + esac + case ",${DOTFILES_OSRELEASE_IDLIKE:-}," in + *,debian,*) return 0 ;; + esac + return 1 +} + +is_fedora_family() { + [ "$DOTFILES_OSID" = "linux-fedora" ] +} + +_ensure_brew_path() { + if command -v brew >/dev/null 2>&1; then return 0; fi + if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [ -x /usr/local/bin/brew ]; then + eval "$(/usr/local/bin/brew shellenv)" + else + echo "brew not found — ensure-prereqs hook should have installed it." >&2 + return 1 + fi +} + +brew_bundle_install() { + _ensure_brew_path || return 0 + + # Compose Brewfile from env-supplied package lists. + # workstation on Mac → adds casks from packages.gui.mac_casks. + { + for f in $DOTFILES_CORE_BREWS; do echo "brew \"$f\""; done + if is_dev; then + for f in $DOTFILES_DEV_BREWS; do echo "brew \"$f\""; done + fi + if is_workstation; then + for c in $DOTFILES_GUI_MAC_CASKS; do echo "cask \"$c\""; done + fi + } | brew bundle --file=/dev/stdin || { + echo "brew bundle had failures — some packages may need manual install." >&2 + echo "Most common: a cask trying to adopt a pre-existing /Applications/" >&2 + echo " needs sudo (non-interactive apply can't supply password). Fix: remove" >&2 + echo " the app first, run 'chezmoi apply' from a TTY, or 'sudo -v' beforehand." >&2 + } + + # libpq is keg-only (conflicts with `postgresql`). Force-link to expose + # psql, pg_dump, pg_restore, etc. + if brew list libpq >/dev/null 2>&1; then + brew link --force libpq >/dev/null 2>&1 || true + fi +} + +_sudo_cmd() { + if [ "$(id -u)" -eq 0 ]; then + echo "" + return 0 + fi + if command -v sudo >/dev/null 2>&1; then + echo "sudo -E" + return 0 + fi + return 1 +} + +apt_install() { + sudo_cmd=$(_sudo_cmd) || { + echo "Neither root nor sudo available — skipping apt install." >&2 + return 0 + } + + pkgs="$DOTFILES_CORE_APT" + if is_dev; then + pkgs="$pkgs $DOTFILES_DEV_APT" + fi + if is_workstation; then + pkgs="$pkgs $DOTFILES_GUI_LINUX_APT" + fi + # shellcheck disable=SC2086 + $sudo_cmd apt-get update -qq + # shellcheck disable=SC2086 + $sudo_cmd apt-get install -y --no-install-recommends $pkgs || \ + echo "apt-get install had failures — re-run interactively." >&2 +} + +dnf_install() { + sudo_cmd=$(_sudo_cmd) || { + echo "Neither root nor sudo available — skipping dnf install." >&2 + return 0 + } + + pkgs="$DOTFILES_CORE_DNF" + if is_dev; then + pkgs="$pkgs $DOTFILES_DEV_DNF" + fi + if is_workstation; then + pkgs="$pkgs $DOTFILES_GUI_LINUX_DNF" + fi + # shellcheck disable=SC2086 + $sudo_cmd dnf install -y $pkgs || \ + echo "dnf install had failures — re-run interactively." >&2 +} + +linux_pkg_install() { + if is_debian_family; then + apt_install + elif is_fedora_family; then + dnf_install + else + echo "Unsupported Linux distro (osid=$DOTFILES_OSID) — targets Debian/Ubuntu/Fedora only." >&2 + fi +} + +mise_install_tools() { + if ! command -v mise >/dev/null 2>&1; then + echo "mise not on PATH — ensure-prereqs hook should have installed it. Skipping." >&2 + return 0 + fi + mise trust "$HOME/.config/mise/config.toml" >/dev/null 2>&1 || true + mise install --yes || echo "mise install had failures — re-run interactively." >&2 +} + +post_install_goimports() { + if command -v go >/dev/null 2>&1 && ! command -v goimports >/dev/null 2>&1; then + GOBIN="$HOME/.local/bin" go install golang.org/x/tools/cmd/goimports@latest \ + || echo "goimports install failed" >&2 + fi +} + +post_install_ssh_audit() { + if command -v uv >/dev/null 2>&1 && ! command -v ssh-audit >/dev/null 2>&1; then + uv tool install ssh-audit --quiet || echo "ssh-audit install failed" >&2 + fi +} + +_resolve_npm() { + # Prefer mise's npm — avoids stale fnm/nvm shims. + if command -v mise >/dev/null 2>&1; then + mise_node_root="$(mise where node 2>/dev/null || true)" + if [ -n "$mise_node_root" ] && [ -x "$mise_node_root/bin/npm" ]; then + echo "$mise_node_root/bin/npm" + return 0 + fi + fi + command -v npm 2>/dev/null && return 0 + return 1 +} + +npm_install_ai_globals() { + npm_bin=$(_resolve_npm) || return 0 + + # Defang any stale prefix from a decommissioned version manager. + "$npm_bin" config delete prefix --global 2>/dev/null || true + "$npm_bin" config delete prefix 2>/dev/null || true + + if [ "$DOTFILES_OS" = "linux" ]; then + "$npm_bin" config set prefix "$HOME/.local" + fi + for pkg in $DOTFILES_DEV_NPM_GLOBAL; do + "$npm_bin" install -g "$pkg" || echo "npm -g $pkg failed" >&2 + done +} + +linux_fd_symlink_fallback() { + # Distro fd-find → fd symlink. Relevant when mise's fd hasn't installed + # yet (first apply mid-download) or at core profile (no fd in mise.toml). + [ "$DOTFILES_OS" = "linux" ] || return 0 + if command -v fdfind >/dev/null 2>&1 && ! command -v fd >/dev/null 2>&1; then + mkdir -p "$HOME/.local/bin" + ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd" + fi +} + +main() { + echo "[install-packages] profile=$DOTFILES_PROFILE osid=$DOTFILES_OSID" + + case "$DOTFILES_OS" in + darwin) brew_bundle_install ;; + linux) linux_pkg_install ;; + esac + + mise_install_tools + + if is_dev; then + post_install_goimports + post_install_ssh_audit + npm_install_ai_globals + fi + + linux_fd_symlink_fallback +} + +# Auto-run when sourced by the chezmoi wrapper (which sets the flag). +# Bats tests source without setting it → functions defined, main not called. +if [ "${INSTALL_PACKAGES_INVOKE:-0}" = "1" ]; then + main "$@" +fi diff --git a/symlink_dot_tmux.conf b/symlink_dot_tmux.conf new file mode 100644 index 0000000..bee5a59 --- /dev/null +++ b/symlink_dot_tmux.conf @@ -0,0 +1 @@ +.config/tmux/.tmux.conf diff --git a/tests/files/common.bats b/tests/files/common.bats new file mode 100644 index 0000000..5f6e3f2 --- /dev/null +++ b/tests/files/common.bats @@ -0,0 +1,183 @@ +#!/usr/bin/env bats +# Post-apply asserts for `profile=core` on Linux. Runs in CI after +# `chezmoi apply` against a clean devcontainer (Codespaces-style): +# verifies dotfile presence, content sanity, OS-package install, and +# mise-managed core tools (fzf + zoxide). +# +# Profile-aware: stricter asserts (dev/mac tools) would go in +# tests/files/dev.bats / tests/files/mac.bats — out of scope for the +# minimal Phase-1 smoke harness. + +setup() { + export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" + hash -r 2>/dev/null || true +} + +# --- chezmoi config + profile ---------------------------------------------- + +@test "chezmoi config profile is core" { + grep -qE '^\s*profile\s*=\s*"core"' "$HOME/.config/chezmoi/chezmoi.toml" +} + +@test "chezmoi diff is empty post-apply (idempotent)" { + run chezmoi diff + # Print full diagnostic on any failure so CI logs surface the cause. + if [ "$status" -ne 0 ] || [ -n "$output" ]; then + echo "---" >&2 + echo "chezmoi diff exit=$status" >&2 + echo "chezmoi diff output:" >&2 + echo "$output" >&2 + echo "---" >&2 + echo "chezmoi verify:" >&2 + chezmoi verify >&2 2>&1 || true + echo "---" >&2 + echo "chezmoi managed | head -5:" >&2 + chezmoi managed 2>&1 | head -5 >&2 || true + fi + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# --- core dotfiles ---------------------------------------------------------- + +@test "~/.zshrc exists" { + [ -f "$HOME/.zshrc" ] +} + +@test "~/.zshrc activates mise" { + grep -q 'mise activate' "$HOME/.zshrc" +} + +@test "~/.zshrc references mise where fzf" { + grep -q 'mise where fzf' "$HOME/.zshrc" +} + +@test "~/.vimrc exists" { + [ -f "$HOME/.vimrc" ] +} + +@test "~/.tmux.conf.local exists" { + [ -f "$HOME/.tmux.conf.local" ] +} + +@test "~/.p10k.zsh exists" { + [ -f "$HOME/.p10k.zsh" ] +} + +@test "~/.gitconfig exists" { + [ -f "$HOME/.gitconfig" ] +} + +@test "~/.gitignore_global exists (target of core.excludesFile)" { + [ -f "$HOME/.gitignore_global" ] + grep -q '\.DS_Store' "$HOME/.gitignore_global" +} + +@test "~/.zshrc_local seeded by create_dot_zshrc_local (chezmoi-create_, never overwritten)" { + [ -f "$HOME/.zshrc_local" ] +} + +@test "~/.zshrc sources ~/.zshrc_local LAST (after p10k + SSH tints, so local wins)" { + # The source line must come after the SSH tint block that sets + # tmux_conf_theme_*. Grep both anchors; assert line order. + src_line=$(grep -n 'source ~/.zshrc_local' "$HOME/.zshrc" | tail -1 | cut -d: -f1) + ssh_line=$(grep -n 'tmux_conf_theme_status_bg' "$HOME/.zshrc" | tail -1 | cut -d: -f1) + [ -n "$src_line" ] + [ -n "$ssh_line" ] + [ "$src_line" -gt "$ssh_line" ] +} + +# --- AI tooling configs ----------------------------------------------------- + +@test "~/.claude/CLAUDE.md exists" { + [ -f "$HOME/.claude/CLAUDE.md" ] +} + +@test "~/.claude/settings.json exists and is valid JSON" { + [ -f "$HOME/.claude/settings.json" ] + jq -e . "$HOME/.claude/settings.json" >/dev/null +} + +@test "~/.claude/rules/no-code-without-go.md present" { + [ -f "$HOME/.claude/rules/no-code-without-go.md" ] +} + +@test "~/.codex/AGENTS.md exists" { + [ -f "$HOME/.codex/AGENTS.md" ] +} + +@test "~/.codex/config.toml exists" { + [ -f "$HOME/.codex/config.toml" ] +} + +# --- mise config + tools (profile=core: only fzf + zoxide) ----------------- + +@test "~/.config/mise/config.toml exists" { + [ -f "$HOME/.config/mise/config.toml" ] +} + +@test "~/.config/mise/config.toml has core tools" { + grep -q 'aqua:junegunn/fzf' "$HOME/.config/mise/config.toml" + grep -q 'aqua:ajeetdsouza/zoxide' "$HOME/.config/mise/config.toml" +} + +@test "~/.config/mise/config.toml does NOT have dev tools at core profile" { + ! grep -q 'aqua:kubernetes/kubernetes/kubectl' "$HOME/.config/mise/config.toml" + ! grep -q '^node ' "$HOME/.config/mise/config.toml" +} + +@test "mise binary on PATH" { + command -v mise +} + +@test "fzf installed via mise" { + command -v fzf +} + +@test "zoxide installed via mise" { + command -v zoxide +} + +# --- OS-native core packages (Linux apt) ----------------------------------- + +@test "apt: zsh installed" { + command -v zsh +} + +@test "apt: vim installed" { + command -v vim +} + +@test "apt: tmux installed" { + command -v tmux +} + +@test "apt: git installed" { + command -v git +} + +@test "apt: ufw installed" { + command -v ufw +} + +@test "apt: tcpdump installed" { + command -v tcpdump +} + +# --- negatives ------------------------------------------------------------- + +@test "no stale fnm directory" { + [ ! -e "$HOME/.fnm" ] +} + +@test "no stale pyenv directory" { + [ ! -e "$HOME/.pyenv" ] +} + +@test "no rtk binary at core profile (gated to dev|workstation)" { + ! command -v rtk +} + +@test "no claude CLI at core profile (npm globals gated to dev|workstation)" { + ! command -v claude +} diff --git a/tests/unit/install-packages.bats b/tests/unit/install-packages.bats new file mode 100644 index 0000000..6931a75 --- /dev/null +++ b/tests/unit/install-packages.bats @@ -0,0 +1,318 @@ +#!/usr/bin/env bats +# Unit tests for lib/install-packages.sh — sources the library (which +# the POSIX guard skips because INSTALL_PACKAGES_INVOKE is unset) and +# invokes individual functions with mocked external commands. + +setup() { + SOURCE_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + # Mocks recorded here; cleared per-test by bats' tmpdir lifecycle. + export CALLS_LOG="$BATS_TEST_TMPDIR/calls.log" + : >"$CALLS_LOG" + + # Default env (overridden per-test). + export DOTFILES_PROFILE="core" + export DOTFILES_OS="linux" + export DOTFILES_OSID="linux-ubuntu" + export DOTFILES_OSRELEASE_IDLIKE="debian" + export DOTFILES_CORE_BREWS="" + export DOTFILES_DEV_BREWS="" + export DOTFILES_GUI_MAC_CASKS="" + export DOTFILES_GUI_LINUX_APT="" + export DOTFILES_GUI_LINUX_DNF="" + export DOTFILES_CORE_APT="curl git zsh" + export DOTFILES_DEV_APT="" + export DOTFILES_CORE_DNF="curl git zsh" + export DOTFILES_DEV_DNF="" + export DOTFILES_DEV_NPM_GLOBAL="" + + # Wipe INSTALL_PACKAGES_INVOKE so sourcing the lib doesn't auto-run main. + unset INSTALL_PACKAGES_INVOKE + # shellcheck source=../../lib/install-packages.sh + . "$SOURCE_DIR/lib/install-packages.sh" +} + +# --- profile / distro predicates ------------------------------------------ + +@test "is_dev: core profile → false" { + DOTFILES_PROFILE=core + run is_dev + [ "$status" -ne 0 ] +} + +@test "is_dev: dev profile → true" { + DOTFILES_PROFILE=dev + run is_dev + [ "$status" -eq 0 ] +} + +@test "is_dev: workstation profile → true (cascade)" { + DOTFILES_PROFILE=workstation + run is_dev + [ "$status" -eq 0 ] +} + +@test "is_workstation: only workstation profile → true" { + DOTFILES_PROFILE=workstation + run is_workstation + [ "$status" -eq 0 ] + + DOTFILES_PROFILE=dev + run is_workstation + [ "$status" -ne 0 ] + + DOTFILES_PROFILE=core + run is_workstation + [ "$status" -ne 0 ] +} + +@test "is_debian_family: linux-ubuntu → true" { + DOTFILES_OSID=linux-ubuntu + DOTFILES_OSRELEASE_IDLIKE="" + run is_debian_family + [ "$status" -eq 0 ] +} + +@test "is_debian_family: linux-debian → true" { + DOTFILES_OSID=linux-debian + DOTFILES_OSRELEASE_IDLIKE="" + run is_debian_family + [ "$status" -eq 0 ] +} + +@test "is_debian_family: linux-pop with idLike=debian → true (fallback)" { + DOTFILES_OSID=linux-pop + DOTFILES_OSRELEASE_IDLIKE="debian" + run is_debian_family + [ "$status" -eq 0 ] +} + +@test "is_debian_family: linux-fedora → false" { + DOTFILES_OSID=linux-fedora + DOTFILES_OSRELEASE_IDLIKE="" + run is_debian_family + [ "$status" -ne 0 ] +} + +@test "is_fedora_family: linux-fedora → true" { + DOTFILES_OSID=linux-fedora + run is_fedora_family + [ "$status" -eq 0 ] +} + +# --- apt_install ---------------------------------------------------------- + +@test "apt_install: as root → no sudo prefix" { + sudo_called="$BATS_TEST_TMPDIR/sudo.log" + apt_get_called="$BATS_TEST_TMPDIR/apt.log" + id() { echo 0; } + sudo() { echo "sudo $*" >>"$sudo_called"; } + apt-get() { echo "apt-get $*" >>"$apt_get_called"; } + export -f id sudo apt-get 2>/dev/null || true + + DOTFILES_CORE_APT="curl git" + DOTFILES_DEV_APT="" + apt_install + + [ ! -f "$sudo_called" ] + grep -q 'apt-get update -qq' "$apt_get_called" + grep -q 'apt-get install -y --no-install-recommends curl git' "$apt_get_called" +} + +@test "apt_install: dev profile concatenates core + dev lists" { + apt_get_called="$BATS_TEST_TMPDIR/apt.log" + id() { echo 0; } + apt-get() { echo "apt-get $*" >>"$apt_get_called"; } + + DOTFILES_PROFILE=dev + DOTFILES_CORE_APT="zsh git" + DOTFILES_DEV_APT="htop tree" + apt_install + + install_line=$(grep 'apt-get install' "$apt_get_called") + [[ "$install_line" =~ zsh ]] + [[ "$install_line" =~ git ]] + [[ "$install_line" =~ htop ]] + [[ "$install_line" =~ tree ]] +} + +@test "apt_install: core profile excludes dev list" { + apt_get_called="$BATS_TEST_TMPDIR/apt.log" + id() { echo 0; } + apt-get() { echo "apt-get $*" >>"$apt_get_called"; } + + DOTFILES_PROFILE=core + DOTFILES_CORE_APT="zsh git" + DOTFILES_DEV_APT="htop tree" + apt_install + + install_line=$(grep 'apt-get install' "$apt_get_called") + [[ ! "$install_line" =~ htop ]] + [[ ! "$install_line" =~ tree ]] +} + +@test "apt_install: workstation profile adds GUI list (cascade core+dev+gui)" { + apt_get_called="$BATS_TEST_TMPDIR/apt.log" + id() { echo 0; } + apt-get() { echo "apt-get $*" >>"$apt_get_called"; } + + DOTFILES_PROFILE=workstation + DOTFILES_CORE_APT="zsh git" + DOTFILES_DEV_APT="htop tree" + DOTFILES_GUI_LINUX_APT="firefox code" + apt_install + + install_line=$(grep 'apt-get install' "$apt_get_called") + [[ "$install_line" =~ zsh ]] + [[ "$install_line" =~ htop ]] + [[ "$install_line" =~ firefox ]] + [[ "$install_line" =~ code ]] +} + +@test "apt_install: no root + no sudo → skip with warning" { + id() { echo 1000; } + command() { + case "$2" in sudo) return 1 ;; *) builtin command "$@" ;; esac + } + + run apt_install + [ "$status" -eq 0 ] + [[ "$output" =~ "Neither root nor sudo" ]] +} + +# --- linux_pkg_install dispatcher ----------------------------------------- + +@test "linux_pkg_install: linux-ubuntu → apt path" { + apt_install() { echo "APT_CALLED"; } + dnf_install() { echo "DNF_CALLED"; } + DOTFILES_OSID=linux-ubuntu + + run linux_pkg_install + [[ "$output" =~ "APT_CALLED" ]] + [[ ! "$output" =~ "DNF_CALLED" ]] +} + +@test "linux_pkg_install: linux-fedora → dnf path" { + apt_install() { echo "APT_CALLED"; } + dnf_install() { echo "DNF_CALLED"; } + DOTFILES_OSID=linux-fedora + DOTFILES_OSRELEASE_IDLIKE="" + + run linux_pkg_install + [[ "$output" =~ "DNF_CALLED" ]] + [[ ! "$output" =~ "APT_CALLED" ]] +} + +@test "linux_pkg_install: linux-alpine → unsupported warning" { + apt_install() { echo "APT_CALLED"; } + dnf_install() { echo "DNF_CALLED"; } + DOTFILES_OSID=linux-alpine + DOTFILES_OSRELEASE_IDLIKE="" + + run linux_pkg_install + [[ "$output" =~ "Unsupported" ]] + [[ "$output" =~ "linux-alpine" ]] +} + +# --- mise_install_tools ---------------------------------------------------- + +@test "mise_install_tools: mise missing → skip with warning" { + command() { + case "$2" in mise) return 1 ;; *) builtin command "$@" ;; esac + } + + run mise_install_tools + [ "$status" -eq 0 ] + [[ "$output" =~ "mise not on PATH" ]] +} + +@test "mise_install_tools: mise present → trust + install" { + mise_log="$BATS_TEST_TMPDIR/mise.log" + command() { + case "$2" in mise) return 0 ;; *) builtin command "$@" ;; esac + } + mise() { echo "mise $*" >>"$mise_log"; } + + mise_install_tools + grep -q 'mise trust' "$mise_log" + grep -q 'mise install --yes' "$mise_log" +} + +# --- main dispatcher ------------------------------------------------------- + +@test "main: core profile → no post-installs run" { + DOTFILES_PROFILE=core + DOTFILES_OS=linux + + # Stub all called functions. + brew_bundle_install() { :; } + linux_pkg_install() { :; } + mise_install_tools() { :; } + linux_fd_symlink_fallback() { :; } + post_install_goimports() { echo "GO_CALLED"; } + post_install_ssh_audit() { echo "AUDIT_CALLED"; } + npm_install_ai_globals() { echo "NPM_CALLED"; } + + run main + [[ ! "$output" =~ "GO_CALLED" ]] + [[ ! "$output" =~ "AUDIT_CALLED" ]] + [[ ! "$output" =~ "NPM_CALLED" ]] +} + +@test "main: dev profile → all post-installs run" { + DOTFILES_PROFILE=dev + DOTFILES_OS=linux + + brew_bundle_install() { :; } + linux_pkg_install() { :; } + mise_install_tools() { :; } + linux_fd_symlink_fallback() { :; } + post_install_goimports() { echo "GO_CALLED"; } + post_install_ssh_audit() { echo "AUDIT_CALLED"; } + npm_install_ai_globals() { echo "NPM_CALLED"; } + + run main + [[ "$output" =~ "GO_CALLED" ]] + [[ "$output" =~ "AUDIT_CALLED" ]] + [[ "$output" =~ "NPM_CALLED" ]] +} + +@test "main: darwin OS → brew path, not linux" { + DOTFILES_OS=darwin + brew_bundle_install() { echo "BREW_CALLED"; } + linux_pkg_install() { echo "LINUX_CALLED"; } + mise_install_tools() { :; } + linux_fd_symlink_fallback() { :; } + + run main + [[ "$output" =~ "BREW_CALLED" ]] + [[ ! "$output" =~ "LINUX_CALLED" ]] +} + +@test "main: linux OS → linux_pkg_install path, not brew" { + DOTFILES_OS=linux + DOTFILES_OSID=linux-ubuntu + brew_bundle_install() { echo "BREW_CALLED"; } + linux_pkg_install() { echo "LINUX_CALLED"; } + mise_install_tools() { :; } + linux_fd_symlink_fallback() { :; } + + run main + [[ "$output" =~ "LINUX_CALLED" ]] + [[ ! "$output" =~ "BREW_CALLED" ]] +} + +# --- guard sanity ---------------------------------------------------------- + +@test "INSTALL_PACKAGES_INVOKE guard: sourcing without flag does not run main" { + out=$(INSTALL_PACKAGES_INVOKE="" sh -c '. "'"$SOURCE_DIR"'/lib/install-packages.sh"; echo POST_SOURCE') + [[ "$out" =~ "POST_SOURCE" ]] + [[ ! "$out" =~ "install-packages" ]] +} + +@test "INSTALL_PACKAGES_INVOKE=1: sourcing runs main" { + DOTFILES_PROFILE=core DOTFILES_OS=darwin DOTFILES_OSID=darwin \ + DOTFILES_CORE_BREWS="" DOTFILES_DEV_BREWS="" DOTFILES_GUI_MAC_CASKS="" \ + INSTALL_PACKAGES_INVOKE=1 \ + out=$(sh -c '. "'"$SOURCE_DIR"'/lib/install-packages.sh"' 2>&1) || true + [[ "$out" =~ "install-packages" ]] || skip "non-fatal: brew may be missing in test env" +} diff --git a/tmux/oh-my-tmux b/tmux/oh-my-tmux deleted file mode 160000 index af33f07..0000000 --- a/tmux/oh-my-tmux +++ /dev/null @@ -1 +0,0 @@ -Subproject commit af33f07134b76134acca9d01eacbdecca9c9cda6