#!/usr/bin/env python3 """ bootstrap.py — Dotfiles manager for Ben's machines. OVERVIEW -------- This script is the single entry point for setting up and maintaining any of Ben's machines (macOS or Arch Linux). It handles three concerns: 1. Packages — installing formulae/casks (brew) or AUR packages (yay) 2. Tooling — shell plugins, tmux plugins, etc. declared in setup.toml 3. Dotfiles — rsyncing the overlay directory (home//) into $HOME, and optionally /etc on Linux DESIGN DECISIONS ---------------- - OS is the only variation axis. All Macs are identical; all Arch machines are identical. There are no per-hostname overrides. - The repo's overlay directory IS the manifest. Any file placed under home// will be synced to $HOME. Use `add` to track new files. - Backups are always taken before overwriting ($HOME/dotfiles.bak/). - When syncing, files that are NEWER at the destination (system) than in the repo are flagged for per-file review rather than silently overwritten. This protects edits made on a work machine that haven't been pulled yet. - setup.toml holds declarative tooling config (fish/tmux plugins). Packages stay in packages/*.txt so they're easy to grep and diff. USAGE ----- python3 bootstrap.py → interactive TUI python3 bootstrap.py install → full setup (non-interactive) python3 bootstrap.py copy → sync dotfiles only python3 bootstrap.py pull → save local edits back to repo python3 bootstrap.py status → check drift, no changes made python3 bootstrap.py add → start tracking a new file STRUCTURE --------- packages/brew.txt formulae for all Macs packages/brew-casks.txt cask apps for all Macs packages/arch.txt packages for all Arch machines setup.toml fish/tmux plugin declarations home// dotfile overlay (mirrors $HOME structure) etc/ optional /etc overlay (applied with sudo) themes/ colour schemes (catppuccin submodule etc.) """ from __future__ import annotations import os, sys, subprocess, shutil, platform from pathlib import Path # ── dependency bootstrap ────────────────────────────────────────────────────── # This block runs before any third-party imports so the script is self-contained # on a fresh machine. It installs rich, questionary, and (on Python < 3.11) tomli, # then re-execs the current process so the new packages are on sys.path. # We cannot simply importlib.reload after pip because sys.path is already frozen. def _ensure_deps() -> None: """Install TUI/TOML deps if missing, then re-exec so they're importable.""" import importlib.util needed = ["rich", "questionary"] if sys.version_info < (3, 11): # tomllib was added to stdlib in 3.11; older Pythons need the backport needed.append("tomli") missing = [p for p in needed if not importlib.util.find_spec(p)] if not missing: return print(f"[bootstrap] Installing missing deps: {', '.join(missing)} ...") try: # --user avoids needing root; --quiet suppresses pip's noisy output subprocess.check_call( [sys.executable, "-m", "pip", "install", "--quiet", "--user"] + missing, stderr=subprocess.DEVNULL, ) except subprocess.CalledProcessError: # PEP 668 (Arch, Ubuntu 23+) marks the system Python as externally # managed, requiring --break-system-packages for user-level installs subprocess.check_call( [sys.executable, "-m", "pip", "install", "--quiet", "--break-system-packages", "--user"] + missing ) # Re-exec this process with the same args; now deps will be importable os.execv(sys.executable, [sys.executable] + sys.argv) _ensure_deps() # tomllib is stdlib on 3.11+; fall back to the tomli backport below that try: import tomllib except ImportError: import tomli as tomllib # type: ignore from dataclasses import dataclass from typing import Callable from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text import questionary console = Console() # ── paths ───────────────────────────────────────────────────────────────────── DOTFILES_DIR = Path(__file__).parent.resolve() BACKUP_DIR = Path.home() / "dotfiles.bak" # originals go here before overwrite USERNAME = os.environ.get("USER", os.environ.get("LOGNAME", "benk")) # ── setup.toml ──────────────────────────────────────────────────────────────── # setup.toml holds tooling that doesn't belong in a plain text package list: # fish plugins, tmux plugins, and future post-install hooks. # Packages stay in packages/*.txt so they remain easy to diff and grep. def _load_setup() -> dict: """Load setup.toml from the repo root. Returns {} if the file doesn't exist.""" p = DOTFILES_DIR / "setup.toml" if not p.exists(): return {} with open(p, "rb") as f: return tomllib.load(f) SETUP = _load_setup() # ── OS detection ────────────────────────────────────────────────────────────── def detect_os() -> str: """ Detect the current operating system. Returns one of: 'macos', 'arch', 'linux', 'unknown'. """ if platform.system() == "Darwin": return "macos" if Path("/etc/arch-release").exists(): return "arch" if Path("/etc/os-release").exists(): return "linux" return "unknown" OS = detect_os() # ── overlay resolution ──────────────────────────────────────────────────────── # OS is the ONLY variation axis — all Macs are identical, all Arch machines # are identical. We fall back through home// → home/benk/. def overlay_home() -> Path: """ Return the dotfile overlay directory for the current user. Checks home// first, then falls back to home/benk/. Creates home// if neither exists. """ for candidate in (USERNAME, "benk"): p = DOTFILES_DIR / "home" / candidate if p.is_dir(): return p # No overlay found — create one for this user rather than silently using benk/ p = DOTFILES_DIR / "home" / USERNAME p.mkdir(parents=True, exist_ok=True) return p # ── OS-specific path exclusions ─────────────────────────────────────────────── # Some paths in the overlay only make sense on a specific OS. # These are excluded from syncing when we're on a different OS. # # Format: { "owner_os": ["path/to/exclude/"] } # # Current rules: # macOS only → Library/ (iTerm2 prefs, macOS app support) # Linux only → .config/wezterm/ (WezTerm is the Linux terminal; # iTerm2 is used on macOS instead) OS_ONLY: dict[str, list[str]] = { "macos": ["Library/"], "linux": [".config/wezterm/"], "arch": [".config/wezterm/"], } def home_excludes() -> list[str]: """ Build rsync --exclude flags for paths that don't belong on this OS. For each entry in OS_ONLY, if we're NOT on that OS, exclude those paths. """ flags: list[str] = [] for owner_os, paths in OS_ONLY.items(): if OS != owner_os: for p in paths: flags += ["--exclude", p] return flags # ── rsync helpers ───────────────────────────────────────────────────────────── # We use rsync for all file syncing because it handles: # - incremental transfers (only changed files) # - directory trees # - backups (--backup --backup-dir) # - dry-run previews (--dry-run --itemize-changes) # # The --itemize-changes format is: YXcstpogba path # Y = update type: > (transfer to dst), < (transfer from dst) # X = file type: f (file), d (dir), L (symlink), ... # flags = 9 chars; all '+' means new file; any other char means attribute changed # # We parse this output to distinguish new files from overwrites, # and to decide which overwrites need per-file confirmation. def _parse_changes(rsync_output: str) -> list[tuple[str, str]]: """ Parse rsync --itemize-changes output into (kind, path) tuples. Only regular files transferred TO the destination ('>f') are included. kind values: 'new' — file doesn't exist at dst yet (flags are all '+') 'changed' — file exists at dst and will be overwritten """ results = [] for line in rsync_output.splitlines(): # Only care about files being sent to the destination ('>f...') if not line.startswith(">f"): continue parts = line.split(None, 1) if len(parts) != 2: continue flag, path = parts # All-'+' flags = brand new file; any variation = existing file updated kind = "new" if "+++++++++" in flag else "changed" results.append((kind, path)) return results def confirm_rsync(src: str, dst: str, flags: list[str], *, sudo: bool = False, label: str = "") -> bool: """ Smart rsync wrapper that protects against accidentally overwriting newer work. Flow: 1. Dry-run rsync to find what would change 2. For files that already exist at dst: - If dst is NEWER than src → add to per-file confirmation list (unchecked by default, so the user must actively choose to overwrite) - If dst is OLDER than src → overwrite silently (repo wins) 3. Run the real rsync, excluding any files the user declined to overwrite Returns True if the sync ran, False if the user cancelled. """ all_args = flags + [src, dst] # Dry run first — no files are touched dry = subprocess.run( ["rsync", "--dry-run", "--itemize-changes"] + all_args, capture_output=True, text=True, ) changes = _parse_changes(dry.stdout) overwrites = [p for kind, p in changes if kind == "changed"] extra_excludes: list[str] = [] if overwrites: src_p = Path(src.rstrip("/")) dst_p = Path(dst.rstrip("/")) newer_at_dst: list[str] = [] # dst is newer → needs review auto_count = 0 # dst is older → safe to overwrite for rel in overwrites: src_f, dst_f = src_p / rel, dst_p / rel if (dst_f.exists() and src_f.exists() and dst_f.stat().st_mtime > src_f.stat().st_mtime): newer_at_dst.append(rel) else: auto_count += 1 if newer_at_dst: # Inform the user about silent overwrites before showing the checkbox if auto_count: console.print( f" [dim]Auto-overwriting {auto_count} older file(s) — " f"repo is newer, no action needed[/dim]" ) console.print() # Show a checkbox for files where the system has newer edits. # All unchecked by default — the user must opt IN to overwrite. prefix = f"[{label}] " if label else "" selected = questionary.checkbox( f"{prefix}These files are newer on this machine than in the repo.\n" f" Check any you want to overwrite with the repo version:", choices=[ questionary.Choice( title=f"{r} [dim](system is newer)[/dim]", value=r, checked=False, # opt-in, not opt-out ) for r in newer_at_dst ], ).ask() if selected is None: console.print("[yellow] Cancelled.[/yellow]") return False # Exclude files the user chose NOT to overwrite for rel in newer_at_dst: if rel not in selected: extra_excludes += ["--exclude", rel] cmd = (["sudo"] if sudo else []) + ["rsync"] + extra_excludes + all_args subprocess.run(cmd, check=True) return True # ── directory bootstrap ─────────────────────────────────────────────────────── def create_home_dirs() -> None: """ Ensure standard XDG and home directories exist before syncing. rsync will fail if a parent directory doesn't exist. .ssh must be 700 or SSH will refuse to use it. """ for d in [ Path.home() / ".config", Path.home() / ".local" / "bin", Path.home() / ".local" / "share", Path.home() / ".local" / "state", Path.home() / ".ssh", ]: d.mkdir(parents=True, exist_ok=True) (Path.home() / ".ssh").chmod(0o700) # ── package file helpers ────────────────────────────────────────────────────── def _read_packages(path: Path) -> list[str]: """ Read a package list file. Returns an empty list if the file doesn't exist. Lines starting with '#' and blank lines are ignored. """ if not path.exists(): return [] return [ ln.strip() for ln in path.read_text().splitlines() if ln.strip() and not ln.startswith("#") ] def _install_one(cmd: list[str], pkg: str) -> None: """ Run `cmd pkg` and prompt to continue if it fails. Exits the whole script if the user says no — a half-installed machine is worse than an aborted one. """ console.print(f" [dim]{' '.join(cmd)} {pkg}[/dim]") result = subprocess.run(cmd + [pkg], capture_output=True, text=True) if result.returncode != 0: console.print(f" [red][!] Failed to install:[/red] {pkg}") if not questionary.confirm(" Continue anyway?", default=False).ask(): sys.exit(1) # ── package installation ────────────────────────────────────────────────────── def install_packages() -> None: """Dispatch to the correct package manager based on OS.""" if OS == "macos": _install_brew() elif OS == "arch": _install_arch() else: console.print("[yellow]Unknown OS — skipping package install.[/yellow]") console.print("[dim]Install packages manually from packages/*.txt[/dim]") def _install_brew() -> None: """ Install Homebrew if not present, then install all formulae from brew.txt and all casks from brew-casks.txt. brew.txt → `brew install ` (CLI tools, libraries) brew-casks.txt → `brew install --cask ` (GUI apps, fonts) """ if not shutil.which("brew"): console.print(" [bold]Homebrew not found — installing...[/bold]") subprocess.run( ["/bin/bash", "-c", "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"], check=True, ) pkgs = _read_packages(DOTFILES_DIR / "packages" / "brew.txt") console.print(f" Installing [bold]{len(pkgs)}[/bold] formulae...") for pkg in pkgs: _install_one(["brew", "install"], pkg) casks = _read_packages(DOTFILES_DIR / "packages" / "brew-casks.txt") if casks: console.print(f" Installing [bold]{len(casks)}[/bold] casks...") for cask in casks: _install_one(["brew", "install", "--cask"], cask) def _install_arch() -> None: """ Bootstrap yay (AUR helper) if not present, then install all packages from arch.txt using yay (which also handles official repo packages). """ if not shutil.which("yay"): console.print(" [bold]yay not found — bootstrapping from AUR...[/bold]") subprocess.run( ["sudo", "pacman", "-S", "--needed", "--noconfirm", "git", "base-devel"], check=True, ) tmp = Path(subprocess.check_output(["mktemp", "-d"]).decode().strip()) subprocess.run( ["git", "clone", "https://aur.archlinux.org/yay.git", str(tmp / "yay")], check=True, ) subprocess.run(["makepkg", "-si", "--noconfirm"], cwd=tmp / "yay", check=True) shutil.rmtree(tmp) pkgs = _read_packages(DOTFILES_DIR / "packages" / "arch.txt") console.print(f" Installing [bold]{len(pkgs)}[/bold] packages...") for pkg in pkgs: _install_one(["yay", "-S", "--needed", "--noconfirm"], pkg) # ── shell tooling ───────────────────────────────────────────────────────────── # "Tooling" means things that aren't packages but need to be set up once: # tmux plugin manager, oh-my-fish, fisher, and the plugins declared in setup.toml. def setup_shell_tooling() -> None: """Set up tmux and fish tooling in sequence.""" _setup_tmux() _setup_fish() def _setup_tmux() -> None: """ Clone tpm (Tmux Plugin Manager) if not present, then install any tmux plugins listed in setup.toml [tmux] plugins via tpm's headless install script. tpm lives at ~/.tmux/plugins/tpm. Plugins are installed to ~/.tmux/plugins/. The headless install script is at ~/.tmux/plugins/tpm/bin/install_plugins. """ tpm = Path.home() / ".tmux" / "plugins" / "tpm" if not tpm.exists(): console.print(" Installing tmux plugin manager (tpm)...") subprocess.run( ["git", "clone", "https://github.com/tmux-plugins/tpm", str(tpm)], check=True, ) else: console.print(" [green]✓[/green] tmux plugin manager") plugins = SETUP.get("tmux", {}).get("plugins", []) if plugins: install_script = tpm / "bin" / "install_plugins" if install_script.exists(): console.print(f" Installing {len(plugins)} tmux plugin(s) headlessly...") subprocess.run([str(install_script)], capture_output=True) console.print(" [green]✓[/green] tmux plugins") else: # Fallback: manual instruction if the script isn't present console.print(" [yellow]![/yellow] Run [dim]I[/dim] inside tmux to install plugins") def _setup_fish() -> None: """ Set up the fish shell plugin ecosystem: 1. oh-my-fish (OMF) — theming and utility functions 2. fisher — fish plugin manager (manages plugins declared in setup.toml) 3. fish plugins from setup.toml [fish] plugins Skipped entirely if fish is not installed on this machine. Fisher install is idempotent — it only installs plugins not already present. """ if not shutil.which("fish"): console.print(" [dim]fish not installed — skipping fish setup[/dim]") return omf = Path.home() / ".local" / "share" / "omf" if not omf.exists(): console.print(" Installing oh-my-fish...") subprocess.run(["fish", "-c", "curl -L https://get.oh-my.fish | fish"], check=True) else: console.print(" [green]✓[/green] oh-my-fish") # Check if fisher is available as a fish function result = subprocess.run(["fish", "-c", "type -q fisher"], capture_output=True) if result.returncode != 0: console.print(" Installing fisher...") subprocess.run( ["fish", "-c", "curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/" "functions/fisher.fish | source && fisher install jorgebucaran/fisher"], check=True, ) else: console.print(" [green]✓[/green] fisher") # Install any fish plugins declared in setup.toml that aren't already installed plugins = SETUP.get("fish", {}).get("plugins", []) if plugins: installed_r = subprocess.run( ["fish", "-c", "fisher list"], capture_output=True, text=True ) installed = set(installed_r.stdout.strip().splitlines()) to_install = [p for p in plugins if p not in installed] if to_install: console.print(f" Installing {len(to_install)} fish plugin(s)...") subprocess.run( ["fish", "-c", f"fisher install {' '.join(to_install)}"], check=True, ) console.print(" [green]✓[/green] fish plugins") # ── sync: repo → home (copy / install) ─────────────────────────────────────── def sync_to_home() -> None: """ Sync the overlay directory (home//) into $HOME. Original files are backed up to ~/dotfiles.bak/home/ before overwriting. OS-specific paths are excluded via home_excludes(). """ src = overlay_home() dst = Path.home() (BACKUP_DIR / "home").mkdir(parents=True, exist_ok=True) flags = [ "-av", "--itemize-changes", "--backup", f"--backup-dir={BACKUP_DIR / 'home'}", *home_excludes(), ] console.print( f" [bold]Syncing[/bold] [cyan]{src.relative_to(DOTFILES_DIR)}[/cyan]" f" → [cyan]~[/cyan]" ) confirm_rsync(f"{src}/", f"{dst}/", flags, label="repo → home") # ── sync: home → repo (pull) ───────────────────────────────────────────────── def sync_to_repo() -> None: """ Pull live config from $HOME back into the overlay (home//). Only files that already exist in the overlay are candidates — we build an explicit file list from the overlay and pass it via --files-from. Why --files-from instead of --existing: --existing tells rsync "only update files at dst that exist there", but rsync still traverses the ENTIRE source tree to find candidates. On a large home directory this is slow, verbose, and can create ghost directories in the overlay that become "tracked" on the next run. --files-from is an explicit manifest: rsync only touches those paths. Also pulls /etc if an etc/ overlay directory exists in the repo. """ import tempfile src, dst = Path.home(), overlay_home() # Build explicit file list from the overlay (relative to overlay root) tracked = [ str(f.relative_to(dst)) for f in dst.rglob("*") if f.is_file() and not any( str(f.relative_to(dst)).startswith(excl.lstrip("/")) for excl in home_excludes()[1::2] # every other element is the path ) ] if not tracked: console.print(" [dim]No files tracked in overlay — nothing to pull.[/dim]") return console.print( f" [bold]Pulling[/bold] [cyan]~[/cyan]" f" → [cyan]{dst.relative_to(DOTFILES_DIR)}[/cyan]" f" [dim]({len(tracked)} tracked files)[/dim]" ) # Write the manifest to a temp file and pass it to rsync with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as fh: fh.write("\n".join(tracked)) manifest = fh.name try: flags = [ "-av", "--itemize-changes", f"--files-from={manifest}", # only sync these exact paths "--update", # skip files where dst (overlay) is newer than src (home) ] confirm_rsync(f"{src}/", f"{dst}/", flags, label="home → repo") finally: Path(manifest).unlink(missing_ok=True) etc_dir = DOTFILES_DIR / "etc" if etc_dir.is_dir(): etc_tracked = [ str(f.relative_to(etc_dir)) for f in etc_dir.rglob("*") if f.is_file() ] console.print(f" [bold]Pulling[/bold] [cyan]/etc[/cyan] → [cyan]etc/[/cyan]") with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as fh: fh.write("\n".join(etc_tracked)) etc_manifest = fh.name try: confirm_rsync( "/etc/", f"{etc_dir}/", ["-av", "--itemize-changes", f"--files-from={etc_manifest}", "--update"], sudo=True, label="/etc → repo", ) finally: Path(etc_manifest).unlink(missing_ok=True) # ── sync: etc/ → /etc ───────────────────────────────────────────────────────── def sync_etc() -> None: """ Apply the etc/ overlay to /etc on the system (requires sudo). Original /etc files are backed up to ~/dotfiles.bak/etc/ before overwriting. This is exposed as an optional stage in mode_install; it's excluded from mode_copy because /etc changes are usually intentional and should be reviewed. """ etc_dir = DOTFILES_DIR / "etc" files = [f for f in etc_dir.rglob("*") if f.is_file()] console.print(" [bold]System config files to apply:[/bold]") for f in files: console.print(f" [dim]/{f.relative_to(DOTFILES_DIR)}[/dim]") console.print() (BACKUP_DIR / "etc").mkdir(parents=True, exist_ok=True) confirm_rsync( f"{etc_dir}/", "/etc/", ["-av", "--itemize-changes", "--backup", f"--backup-dir={BACKUP_DIR / 'etc'}"], sudo=True, label="etc → /etc", ) def _has_etc() -> bool: """Return True if the repo has an etc/ overlay with at least one file.""" etc_dir = DOTFILES_DIR / "etc" return etc_dir.is_dir() and any(f for f in etc_dir.rglob("*") if f.is_file()) # ── status command ──────────────────────────────────────────────────────────── # Status is read-only — it never modifies any files. # It answers the question: "how does this machine differ from the repo?" def cmd_status() -> None: """ Show a full drift report: packages, dotfiles, and tooling. No files are written or modified. """ console.print(Panel( "[bold]Status[/bold] [dim]comparing this machine against the repo — " "no changes will be made[/dim]", border_style="bright_blue", padding=(0, 1), )) console.rule("[bold]Packages[/bold]", style="bright_blue") _status_packages() console.rule("[bold]Dotfiles[/bold]", style="bright_blue") _status_dotfiles() console.rule("[bold]Tooling[/bold]", style="bright_blue") _status_tooling() console.rule(style="bright_blue") def _status_packages() -> None: """ Compare wanted packages (from packages/*.txt) against what's installed. Shows missing packages individually; installed ones are summarised as a count. """ console.print() if OS == "macos": if not shutil.which("brew"): console.print(" [red]✗[/red] Homebrew is not installed") console.print() return installed_formulae = set( subprocess.check_output(["brew", "list", "--formula"], text=True).splitlines() ) installed_casks = set( subprocess.check_output(["brew", "list", "--cask"], text=True).splitlines() ) _status_pkg_list( "formulae", _read_packages(DOTFILES_DIR / "packages" / "brew.txt"), installed_formulae, ) _status_pkg_list( "casks", _read_packages(DOTFILES_DIR / "packages" / "brew-casks.txt"), installed_casks, ) elif OS in ("arch", "linux"): installed = set(subprocess.check_output(["pacman", "-Qq"], text=True).splitlines()) _status_pkg_list( "packages", _read_packages(DOTFILES_DIR / "packages" / "arch.txt"), installed, ) else: console.print(" [dim]Package status not supported on this OS[/dim]") console.print() def _status_pkg_list(label: str, wanted: list[str], installed: set[str]) -> None: """Print missing packages individually and summarise installed ones as a count.""" missing = [p for p in wanted if p not in installed] ok_count = sum(1 for p in wanted if p in installed) for p in missing: console.print(f" [red]✗[/red] {p} [dim]({label} — not installed)[/dim]") console.print(f" [green]✓[/green] {ok_count}/{len(wanted)} {label} installed") def _status_dotfiles() -> None: """ Dry-run rsync repo→home and classify each changed file as: new — in repo, not yet on this machine (copy will add it) repo ahead — repo is newer (copy will update it) system ahead — system is newer (pull to capture, or leave it) Shows up to 5 examples per category with a count for the rest. """ console.print() src = overlay_home() dst = Path.home() result = subprocess.run( ["rsync", "--dry-run", "--itemize-changes", "-av", *home_excludes(), f"{src}/", f"{dst}/"], capture_output=True, text=True, ) changes = _parse_changes(result.stdout) if not changes: console.print(" [green]✓[/green] All dotfiles are up to date") console.print() return new_files = [p for kind, p in changes if kind == "new"] changed_files = [p for kind, p in changes if kind == "changed"] # For each changed file, decide which side is "ahead" by mtime repo_ahead = [] system_ahead = [] for rel in changed_files: dst_f = dst / rel src_f = src / rel if dst_f.exists() and src_f.exists() and dst_f.stat().st_mtime > src_f.stat().st_mtime: system_ahead.append(rel) else: repo_ahead.append(rel) def _show_files(files: list[str], limit: int = 5) -> None: for p in files[:limit]: console.print(f" [dim]{p}[/dim]") if len(files) > limit: console.print(f" [dim]... and {len(files) - limit} more[/dim]") if new_files: console.print( f" [blue]+[/blue] {len(new_files)} file(s) in repo, " f"not yet on this machine [dim](run Copy to apply)[/dim]" ) _show_files(new_files) if repo_ahead: console.print( f" [cyan]↓[/cyan] {len(repo_ahead)} file(s) where repo is newer " f"[dim](run Copy to apply)[/dim]" ) _show_files(repo_ahead) if system_ahead: console.print( f" [yellow]↑[/yellow] {len(system_ahead)} file(s) where this machine is newer " f"[dim](run Pull to save, or leave as-is)[/dim]" ) _show_files(system_ahead) console.print() def _status_tooling() -> None: """ Check whether tmux tpm, oh-my-fish, fisher, and declared plugins are installed. Cross-references setup.toml for expected plugin lists. """ console.print() # tmux tpm = Path.home() / ".tmux" / "plugins" / "tpm" _status_line("tmux plugin manager (tpm)", tpm.exists()) for plugin in SETUP.get("tmux", {}).get("plugins", []): plugin_dir = Path.home() / ".tmux" / "plugins" / plugin.split("/")[-1] _status_line(f" tmux: {plugin}", plugin_dir.exists()) # fish if shutil.which("fish"): omf = Path.home() / ".local" / "share" / "omf" _status_line("oh-my-fish", omf.exists()) fisher_ok = subprocess.run(["fish", "-c", "type -q fisher"], capture_output=True) _status_line("fisher", fisher_ok.returncode == 0) if fisher_ok.returncode == 0: installed_r = subprocess.run( ["fish", "-c", "fisher list"], capture_output=True, text=True ) installed = set(installed_r.stdout.strip().splitlines()) for plugin in SETUP.get("fish", {}).get("plugins", []): _status_line(f" fish: {plugin}", plugin in installed) else: console.print(" [dim]fish not installed — skipping fish tooling check[/dim]") console.print() def _status_line(label: str, ok: bool) -> None: """Print a single ✓ / ✗ status line.""" icon = "[green]✓[/green]" if ok else "[red]✗[/red]" console.print(f" {icon} {label}") # ── add command ─────────────────────────────────────────────────────────────── def cmd_add(path_str: str) -> None: """ Start tracking a new file by copying it into the dotfile overlay. The file must be under $HOME. Its path relative to $HOME is preserved in the overlay, so the sync will deploy it back to the same location. Example: python3 bootstrap.py add ~/.config/starship.toml → copies to home/benk/.config/starship.toml → `git add` and commit to make it permanent """ path = Path(path_str).expanduser().resolve() if not path.exists(): console.print(f"[red]File not found:[/red] {path}") sys.exit(1) try: rel = path.relative_to(Path.home()) except ValueError: console.print( f"[red]Error:[/red] path must be under $HOME\n" f" Got: {path}\n" f" $HOME: {Path.home()}" ) sys.exit(1) dst = overlay_home() / rel if dst.exists(): console.print(f"[yellow]Already tracked:[/yellow] {rel}") console.print(f" [dim]Currently at: {dst}[/dim]") if not questionary.confirm(" Overwrite the repo copy?", default=False).ask(): sys.exit(0) dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, dst) console.print(f" [green]✓[/green] Tracked: [cyan]~/{rel}[/cyan]") console.print( f" [dim]Repo path: {dst.relative_to(DOTFILES_DIR)}[/dim]\n" f" [dim]Commit: git add {dst.relative_to(DOTFILES_DIR)} && git commit[/dim]" ) # ── stage runner ────────────────────────────────────────────────────────────── # A Stage is a named, describable unit of work within a mode. # run_stages() shows a checkbox so the user can skip stages they don't need, # then runs each selected stage with a clear progress header. @dataclass class Stage: """A discrete unit of work within a mode (e.g. 'Install packages').""" key: str # unique identifier used for checkbox selection title: str # short display name shown in the checkbox and header desc: str # one-line description shown alongside the title fn: Callable[[], None] # the function to call when this stage runs # Set once at import time: True if a CLI argument was passed (non-interactive mode). # run_stages() skips the checkbox and runs all stages when this is True. NON_INTERACTIVE = len(sys.argv) > 1 def run_stages(mode_title: str, mode_desc: str, stages: list[Stage]) -> None: """ Display a mode panel, let the user select which stages to run via checkbox, then execute each selected stage with a numbered progress header. In non-interactive mode (CLI argument provided) all stages run without prompting. The stage rule format is: Stage N/M Title description This makes it easy to see at a glance where you are in a multi-stage run. """ console.print(Panel( f"[bold]{mode_title}[/bold]\n[dim]{mode_desc}[/dim]", border_style="bright_blue", padding=(0, 1), )) console.print() if NON_INTERACTIVE: # Skip the checkbox when called from the command line (scripted / CI use) to_run = stages else: selected_keys = questionary.checkbox( "Select which stages to run " "[dim](space to toggle, enter to confirm, ctrl-c to cancel)[/dim]", choices=[ questionary.Choice( title=f"{s.title} [dim]— {s.desc}[/dim]", value=s.key, checked=True, # all stages selected by default ) for s in stages ], ).ask() if selected_keys is None: # questionary returns None on Ctrl-C console.print("\n[yellow]Cancelled.[/yellow]") sys.exit(0) if not selected_keys: console.print("[yellow]No stages selected — nothing to do.[/yellow]") return to_run = [s for s in stages if s.key in selected_keys] total = len(to_run) for i, stage in enumerate(to_run, 1): console.print() console.rule( f"[bold bright_blue]Stage {i} of {total}[/bold bright_blue]" f" [bold]{stage.title}[/bold] [dim]{stage.desc}[/dim]", style="bright_blue", ) console.print() stage.fn() console.print() console.rule("[bold green]All done[/bold green]", style="green") console.print() # ── modes ───────────────────────────────────────────────────────────────────── # Each mode is a curated list of stages suited to a particular intent. # Modes are presented in the TUI select and can also be called from the CLI. def _stage_sync_dotfiles() -> None: """Ensure home dirs exist then sync dotfiles. Used in both install and copy.""" create_home_dirs() sync_to_home() def mode_install() -> None: """ Full machine setup: packages → tooling → dotfiles → /etc (if present). Intended for bootstrapping a new machine from scratch. All stages are shown in the checkbox; deselect any you want to skip. """ pkg_mgr = {"macos": "brew", "arch": "yay"}.get(OS, "unknown") stages = [ Stage("packages", "Install packages", f"via {pkg_mgr}", install_packages), Stage("tooling", "Shell tooling", "tmux tpm · fish plugins", setup_shell_tooling), Stage("dotfiles", "Sync dotfiles", "repo → home", _stage_sync_dotfiles), ] if _has_etc(): stages.append(Stage("etc", "Sync /etc", "requires sudo", sync_etc)) run_stages( "Set up this machine", "Install packages, shell tooling, and sync dotfiles from the repo", stages, ) def mode_copy() -> None: """ Apply dotfiles from the repo to this machine. Skips packages and shell tooling — use this on a machine that's already set up when you just want to push a config change out. """ run_stages( "Apply dotfiles", "Sync repo → home (skip packages and shell tooling)", [Stage("dotfiles", "Sync dotfiles", "repo → home", _stage_sync_dotfiles)], ) def mode_pull() -> None: """ Pull edits made on this machine back into the repo. Only files already tracked in the overlay are updated (no new files are added). Use `add` to start tracking a new file. Files newer on this machine will be shown for per-file confirmation. """ run_stages( "Save changes", "Pull edits from this machine back into the repo", [Stage("pull", "Pull config", "home + /etc → repo", sync_to_repo)], ) # ── mode registry ───────────────────────────────────────────────────────────── # Modes are keyed by their CLI name. Each entry is (display_title, description, fn). # The TUI select and the CLI dispatcher both read from this dict, # so adding a new mode here is the only change needed. MODES: dict[str, tuple[str, str, Callable[[], None]]] = { "install": ( "Set up this machine", "install packages, shell tooling, and sync dotfiles", mode_install, ), "copy": ( "Apply dotfiles", "push repo changes to this machine (skip packages)", mode_copy, ), "pull": ( "Save changes", "pull edits made on this machine back into the repo", mode_pull, ), "status": ( "Check status", "see what's different between this machine and the repo", cmd_status, ), } # ── entry point ─────────────────────────────────────────────────────────────── def main() -> None: """ Parse CLI arguments or show the interactive TUI. CLI usage (non-interactive, skips all checkboxes): bootstrap.py install | copy | pull | status bootstrap.py add Interactive usage (no arguments): Shows a mode-select menu, then a stage-select checkbox. """ # ── header ── console.print(Panel( Text.assemble( ("dotfiles", "bold cyan"), "\n", (" repo ", "dim"), str(DOTFILES_DIR), "\n", (" user ", "dim"), USERNAME, "\n", (" os ", "dim"), OS, ), border_style="bright_blue", padding=(0, 1), )) console.print() # ── CLI: bootstrap.py add ── if len(sys.argv) > 1 and sys.argv[1] == "add": if len(sys.argv) < 3: console.print( "[red]Usage:[/red] bootstrap.py add [cyan][/cyan]\n" " Example: bootstrap.py add ~/.config/starship.toml" ) sys.exit(1) cmd_add(sys.argv[2]) return # ── CLI: bootstrap.py ── if len(sys.argv) > 1: key = sys.argv[1].lstrip("-") # accept --install as well as install if key in MODES: MODES[key][2]() return console.print( f"[red]Unknown command:[/red] {sys.argv[1]}\n" f" Valid commands: add | {' | '.join(MODES)}" ) sys.exit(1) # ── TUI: interactive mode select ── console.print("[dim]Use arrow keys to navigate, enter to select.[/dim]\n") choice = questionary.select( "What would you like to do?", choices=[ questionary.Choice( title=f"{title} [dim]— {desc}[/dim]", value=key, ) for key, (title, desc, _) in MODES.items() ], ).ask() if choice is None: # Ctrl-C console.print("\n[yellow]Cancelled.[/yellow]") sys.exit(0) MODES[choice][2]() if __name__ == "__main__": main()