diff --git a/.gitignore b/.gitignore index fc0f0b4..047f1d1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ *.un~ config/fish/config.local.fish docs/superpowers/ -Brewfile.lock.json diff --git a/Brewfile b/Brewfile deleted file mode 100644 index cae270d..0000000 --- a/Brewfile +++ /dev/null @@ -1,71 +0,0 @@ -# Languages (ruby, node, python, etc.) are managed by mise, not Brew. - -# shell -brew "fish" # friendly interactive shell -brew "starship" # cross-shell prompt -brew "direnv" # per-directory environment variables -brew "mise" # polyglot runtime/tool manager -brew "tmux" # terminal multiplexer -brew "zoxide" # smarter cd with frecency - -# search & navigation -brew "bat" # cat with syntax highlighting -brew "bat-extras" # batdiff, batgrep, batman, etc. -brew "eza" # modern ls replacement -brew "fd" # fast find replacement -brew "fzf" # fuzzy finder -brew "ripgrep" # fast grep replacement -brew "tree" # directory tree viewer - -# git -brew "git" # Homebrew-managed git -brew "gh" # GitHub CLI -brew "difftastic" # structural diff using AST parsing -brew "git-delta" # better git diff pager -brew "git-filter-repo" # rewrite/filter git history - -# data & text -brew "jq" # JSON processor -brew "jsonlint" # JSON validator -brew "par" # paragraph reformatter -brew "yq" # YAML/XML/TOML processor -brew "sd" # simpler sed replacement -brew "wget" # file downloader - -# diagnostics & benchmarking -brew "btop" # interactive system monitor -brew "dust" # intuitive disk usage viewer -brew "entr" # run commands on file changes -brew "fastfetch" # system info display -brew "gping" # ping with a graph -brew "hyperfine" # CLI benchmarking -brew "procs" # modern ps replacement -brew "tokei" # code statistics by language - -# databases -brew "libpq" # PostgreSQL client library -brew "memcached" # in-memory caching -brew "postgresql@18" # PostgreSQL server -brew "redis" # in-memory data store - -# core utilities -brew "coreutils" # GNU core utilities -brew "ctags" # code tag generation -brew "imagemagick" # image manipulation toolkit -brew "watch" # run commands periodically - -# libraries -brew "libyaml" # YAML parsing (ruby dependency) -brew "vips" # fast image processing -brew "watchman" # file watching service - -# networking -brew "kubernetes-cli" # kubectl -brew "nmap" # network scanner -brew "siege" # HTTP load testing - -# fonts -cask "font-jetbrains-mono-nerd-font" - -# casks -cask "ngrok" # expose localhost publicly diff --git a/CLAUDE.md b/CLAUDE.md index a9bdd6b..d79d74d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,27 +4,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -A macOS dotfiles repo. Config files live in `config/`, rc files in `rc/`. The `install.rb` script symlinks everything into place. +A macOS dotfiles repo. Config files live in `config/`, rc files in `rc/`. Machine setup is declarative via `mise bootstrap`, driven entirely by `config/mise/config.toml`. -## Install Script +## Bootstrap + +Run from the cloned repo (relative dotfile sources resolve against the config file's real location, so it can be cloned anywhere): ```bash -ruby install.rb # symlinks + brew bundle + macOS defaults -ruby install.rb -n # dry run — preview what would happen -ruby install.rb --no-brew --no-macos # symlinks only +export MISE_GLOBAL_CONFIG_FILE="$PWD/config/mise/config.toml" +mise bootstrap -n # preview — changes nothing +mise bootstrap # apply +mise bootstrap status # show drift from the declared state ``` -The script does six things: -1. `config/*` → `~/.config/*` (preserving directory structure) -2. `rc/*` → `~/.` (adds dot prefix, e.g. `rc/vimrc` → `~/.vimrc`) -3. `brew bundle` — installs Homebrew packages (auto-detected when `Brewfile` exists, skip with `--no-brew`) -4. `mise install` — installs tools pinned in mise config, e.g. `hk` for git hooks (auto-detected when `mise` present, skip with `--no-mise`) -5. Claude plugins + MCP servers — installs the marketplaces/plugins declared in `claude/settings.json` and registers the rubocop MCP server at user scope (auto-detected when `claude` present, skip with `--no-plugins`). Idempotent: each item is skipped if already present -6. `macos.sh` — applies macOS defaults (auto-detected on macOS, skip with `--no-macos`) +`config/mise/config.toml` declares, in pipeline order: +1. `[bootstrap.hooks.pre-packages]` — installs Homebrew if missing +2. `[bootstrap.packages]` — Homebrew formulae (`brew:`) and casks/fonts (`brew-cask:`) +3. `[dotfiles]` — `symlink-each` for `config/` → `~/.config/`, `claude/` → `~/.claude/`, `bin/` → `~/.local/bin/`, plus per-file `rc/*` → `~/.`. `symlink-each` links each file individually, so machine-local siblings (`config.local`, `config.local.fish`) inside managed dirs are left untouched. +4. `[bootstrap.user].login_shell` — sets fish +5. `[bootstrap.macos.defaults]` — the declarative `defaults write` set; the non-declarative tail (currentHost scope, chflags, PlistBuddy, killall, `$HOME`-expanded screenshot dir) is in `[bootstrap.hooks.post-defaults]` +6. `[tools]` — `mise install` +7. `[tasks.bootstrap]` — depends on `setup-identity`, `setup-vim-dirs`, `setup-claude` +8. `[bootstrap.hooks.final]` — the read-only commit-signing diagnostic + +Everything is idempotent — anything already in its desired state is skipped, so re-running is safe. -Files listed in `SKIP` (currently `config.local.example`) are excluded. Existing non-symlink files are backed up with `.backup` suffix. +### Imperative setup: `bin/dotfiles-setup` -At the end of a run it also does a read-only **commit-signing check** (`check_git_signing`): warns, with the exact fix, if `config.local` is missing, the signing key is absent, or that key isn't trusted in `allowed_signers` / not registered on GitHub. It changes nothing — a new machine just gets told what to wire up. +The parts mise can't express declaratively live in `bin/dotfiles-setup` (a Ruby script). The dotfiles phase deploys it to `~/.local/bin` before the `bootstrap` task runs, and it locates the repo via its own realpath — no hardcoded path. Subcommands, each a mise task and idempotent: +- `identity` (`mise run setup-identity`) — interactive git identity → `~/.config/git/config.local`, ssh config, fish local config +- `vim-dirs` (`mise run setup-vim-dirs`) — vim swap/backup/undo dirs +- `claude` (`mise run setup-claude`) — installs the marketplaces/plugins declared in `claude/settings.json` and registers the rubocop MCP server at user scope +- `signing` (`mise run check-signing`) — read-only diagnostic: warns, with the exact fix, if `config.local` is missing, the signing key is absent, or that key isn't in `allowed_signers` / not registered on GitHub ## Structure @@ -32,8 +43,8 @@ At the end of a run it also does a read-only **commit-signing check** (`check_gi - **`config/git/`** — Git config with SSH signing, histogram diff, zdiff3 merge conflicts, rerere, auto-rebase on pull. Per-machine identity goes in `~/.config/git/config.local` (see `config.local.example`) - **`config/vim/`** — Modular vim config auto-sourced via glob in `rc/vimrc`. Files prefixed with `_` (e.g. `_plug.vim`, `_nvim-defaults.vim`, `_macvim.vim`) are sourced explicitly and excluded from the glob - **`config/starship.toml`** — Single-line Starship prompt with Nerd Font symbols -- **`config/mise/config.toml`** — Global mise settings (legacy version files, experimental features) -- **`claude/`** — Claude Code config symlinked into `~/.claude`: `settings.json` (permissions, status line, `SessionStart` hook, plus the enabled plugins and known marketplaces that drive the install-script plugin step), `statusline.sh` (hostname-led status line), `hooks/machine-context.sh` (a `SessionStart` hook that injects the hostname into the model's context so it knows which machine it's on), and `bin/rubocop-mcp` (a per-repo-adaptive rubocop MCP launcher, registered user-scope by the install script). Note: `settings.json` is a tracked file but Claude also writes to `~/.claude/settings.json` at runtime (theme, plugin state), so machine-local tooling that rewrites it — e.g. peon-ping — is intentionally **not** tracked here; its hooks land in `~/.claude/settings.json.backup` after an install run +- **`config/mise/config.toml`** — mise settings and pinned `[tools]`, plus the whole `mise bootstrap` config (packages, dotfiles, login shell, macOS defaults, setup tasks) +- **`claude/`** — Claude Code config symlinked into `~/.claude`: `settings.json` (permissions, status line, `SessionStart` hook, plus the enabled plugins and known marketplaces that drive the `setup-claude` task), `statusline.sh` (hostname-led status line), `hooks/machine-context.sh` (a `SessionStart` hook that injects the hostname into the model's context so it knows which machine it's on), and `bin/rubocop-mcp` (a per-repo-adaptive rubocop MCP launcher, registered user-scope by the `setup-claude` task). Note: `settings.json` is a tracked file but Claude also writes to `~/.claude/settings.json` at runtime (theme, plugin state), so machine-local tooling that rewrites it — e.g. peon-ping — is intentionally **not** tracked here; its hooks land in `~/.claude/settings.json.backup` after a bootstrap run ## Conventions - Commit messages: **short, imperative, title case** (e.g. `Add Pagination to Query Endpoint`). No bullet-point bodies unless a single sentence of context is genuinely needed. diff --git a/README.md b/README.md index d5dbc5a..e815693 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,59 @@ # Dotfiles -Config files for macOS. Managed with symlinks via `install.rb`. +Config files for macOS. Provisioned declaratively with [`mise bootstrap`](https://mise.jdx.dev/bootstrap.html) — one config (`config/mise/config.toml`) describes the packages, dotfile symlinks, login shell, macOS defaults, and tools for the whole machine. ## New Laptop Setup -### 1. Install Homebrew +### 1. Install mise ```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +curl https://mise.run | sh # or: brew install mise ``` +Everything else — including Homebrew itself — is installed by the bootstrap. + ### 2. Clone This Repo -Clone it anywhere — `install.rb` resolves paths relative to itself: +Clone it anywhere; nothing hardcodes a path (dotfile sources resolve relative to the config file): ```bash -git clone +git clone dotfiles cd dotfiles ``` -### 3. Install Everything +### 3. Bootstrap + +Run bootstrap **from the repo** so the relative dotfile sources resolve correctly: ```bash -ruby install.rb -n # preview what will happen -ruby install.rb # do it +export MISE_GLOBAL_CONFIG_FILE="$PWD/config/mise/config.toml" +mise trust "$MISE_GLOBAL_CONFIG_FILE" +mise bootstrap -n # preview — changes nothing +mise bootstrap # apply ``` -This does three things in order: -1. **Symlinks** config files into place (`config/*` → `~/.config/*`, `rc/*` → `~/.`, `claude/*` → `~/.claude/*`). Existing files are backed up with a `.backup` suffix. -2. **`brew bundle`** — installs packages from the `Brewfile` (auto-detected when the file exists). -3. **`macos.sh`** — applies macOS defaults (auto-detected on macOS). +In order, this: -Each step can be controlled with flags: +1. Installs Homebrew if missing, then the **`[bootstrap.packages]`** (formulae + casks/fonts). +2. Symlinks **dotfiles** (`config/*` → `~/.config/*`, `rc/*` → `~/.`, `claude/*` → `~/.claude/*`, `bin/*` → `~/.local/bin/*`). `symlink-each` links each file individually, so machine-local files like `~/.config/git/config.local` are left untouched. +3. Sets **fish** as the login shell. +4. Writes **macOS defaults**. +5. Installs the pinned **tools** (`ruby`, `node`, …). +6. Runs the **`bootstrap` task** — per-machine setup that isn't declarative (git identity, vim dirs, Claude plugins), then a read-only commit-signing diagnostic. -```bash -ruby install.rb --no-brew # skip brew bundle -ruby install.rb --no-macos # skip macOS defaults -``` +Check for drift any time with `mise bootstrap status`. Re-running is safe: anything already in its desired state is skipped. -### 5. Set Up Git Identity +> Run bootstrap from the cloned repo, not against the deployed `~/.config/mise` symlink — relative sources resolve against the config file's real location. -Create `~/.config/git/config.local` with your per-machine identity (see `config/git/config.local.example`): +### 4. Git Identity -```ini -[user] - email = you@example.com - name = Your Name - signingKey = ~/.ssh/id_ed25519.pub +The bootstrap task seeds `~/.config/git/config.local` interactively. If it couldn't prompt (no TTY during bootstrap), run it directly: + +```bash +mise run setup-identity # or: dotfiles-setup identity ``` -### 6. Set Up an SSH Key for GitHub (Auth + Signing) +### 5. Set Up an SSH Key for GitHub (Auth + Signing) If you ran `gh auth login` and let it generate an SSH key, the key already exists at `~/.ssh/id_ed25519` and is registered with GitHub as an **authentication** key. Otherwise generate one manually: @@ -70,20 +74,13 @@ Then add the public key to `config/git/allowed_signers` so local verification wo echo "$(git config user.email) $(cat ~/.ssh/id_ed25519.pub)" >> config/git/allowed_signers ``` -Confirm everything is wired up with an empty signed commit: +`mise run check-signing` re-runs the diagnostic and prints the exact fix for anything still missing. Confirm everything is wired up with an empty signed commit: ```bash git commit --allow-empty -m "test signing" && git log --show-signature -1 ``` -### 7. Set Fish as Default Shell - -```bash -echo /opt/homebrew/bin/fish | sudo tee -a /etc/shells -chsh -s /opt/homebrew/bin/fish -``` - -### 8. Install Vim Plugins +### 6. Install Vim Plugins Open vim and run: @@ -98,14 +95,24 @@ config/ fish/config.fish — shell: PATH, aliases, abbreviations git/ — git config, global ignore, diff attributes, allowed signers ghostty/config — terminal appearance and keybinds - mise/config.toml — runtime version manager settings + mise/config.toml — tools + the mise bootstrap config (packages, dotfiles, macOS defaults) starship.toml — prompt theme (single-line, Nerd Font icons) vim/ — modular vim config (auto-sourced via glob) rc/ vimrc — vim entry point (sources files from config/vim/) +bin/ + dotfiles-setup — per-machine setup driven by mise tasks (identity, vim dirs, Claude, signing) claude/ - commands/ — custom Claude Code slash commands -Brewfile — Homebrew packages and casks -install.rb — installer: symlinks, brew bundle, macOS defaults -macos.sh — macOS system preferences (run via install.rb or standalone) + settings.json — enabled plugins + marketplaces, hooks, permissions +``` + +## Managing Dotfiles + +Bootstrap is declarative and idempotent, so day-to-day changes are just edits to `config/mise/config.toml` and the files under `config/`, `rc/`, etc. Useful commands: + +```bash +mise bootstrap status # what's drifted from the declared state +mise dotfiles apply -n # preview dotfile symlink changes +mise run setup-identity # (re)seed git identity / ssh / fish-local +mise run check-signing # audit the commit-signing chain ``` diff --git a/bin/dotfiles-setup b/bin/dotfiles-setup new file mode 100755 index 0000000..1cafcaa --- /dev/null +++ b/bin/dotfiles-setup @@ -0,0 +1,206 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Per-machine setup that mise bootstrap's declarative sections can't express: +# interactive git identity, ssh config seeding, fish local config, vim scratch +# dirs, the read-only commit-signing diagnostic, and Claude plugins + rubocop +# MCP. Driven by mise tasks (see config/mise/config.toml); each subcommand is +# idempotent and safe to re-run. +# +# Usage: dotfiles-setup +# +# The repo is located via this script's own realpath, so it works wherever the +# dotfiles are cloned — no hardcoded path. + +require "fileutils" +require "json" +require "pathname" + +REPO = Pathname.new(File.realpath(__FILE__)).dirname.parent +CONFIG_SRC = REPO / "config" +CLAUDE_SRC = REPO / "claude" +HOME = Pathname.new(Dir.home) +CONFIG_DEST = HOME / ".config" +CLAUDE_DEST = HOME / ".claude" + +def ask(question, default = nil) + suffix = default ? " [#{default}]" : "" + print " #{question}#{suffix}: " + answer = $stdin.gets&.strip + answer.nil? || answer.empty? ? default : answer +end + +# Seed ~/.config/git/config.local (per-machine identity), ~/.ssh/config, and +# fish's local config. Skips anything already present; prompts only on a TTY. +def setup_identity + git_local = CONFIG_DEST / "git" / "config.local" + if git_local.exist? + puts " skip #{git_local} (already exists)" + elsif $stdin.tty? + puts "Setting up git identity (#{git_local})..." + name = ask("Git name") + email = ask("Git email") + key = ask("SSH signing key path", "~/.ssh/id_ed25519.pub") + FileUtils.mkdir_p(git_local.dirname) + git_local.write(<<~CONFIG) + [user] + email = #{email} + name = #{name} + signingKey = #{key} + CONFIG + puts " created #{git_local}" + else + warn " WARNING: #{git_local} missing and no TTY; run `mise run setup-identity` interactively." + end + + ssh_config = HOME / ".ssh" / "config" + if ssh_config.exist? + puts " skip #{ssh_config} (already exists)" + else + FileUtils.mkdir_p(ssh_config.dirname, mode: 0o700) + ssh_config.write(<<~CONFIG) + Host * + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/id_ed25519 + CONFIG + FileUtils.chmod(0o600, ssh_config) + puts " created #{ssh_config}" + end + + fish_local = CONFIG_DEST / "fish" / "config.local.fish" + if fish_local.exist? + puts " skip #{fish_local} (already exists)" + else + FileUtils.mkdir_p(fish_local.dirname) + fish_local.write(<<~CONFIG) + # Local fish config — secrets and machine-specific settings + # This file is not checked into git. + CONFIG + puts " created #{fish_local}" + end +end + +# Create vim's swap/backup/undo directories. +def setup_vim_dirs + %w[swap tmp undo].each do |dir| + vim_dir = HOME / ".vim" / dir + if vim_dir.directory? + puts " skip #{vim_dir} (already exists)" + else + FileUtils.mkdir_p(vim_dir) + puts " created #{vim_dir}" + end + end +end + +# Reproduce the Claude plugin + MCP setup declared in claude/settings.json. +# Marketplaces and enabled plugins are read from the committed settings (the +# single source of truth); the rubocop MCP server is registered at user scope. +# Each action is guarded by a presence check, so re-runs are no-ops. +def setup_claude + unless system("command -v claude > /dev/null 2>&1") + puts " skip Claude setup (claude not installed)" + return + end + + settings = JSON.parse((CLAUDE_SRC / "settings.json").read) + puts "Installing Claude plugins and MCP servers..." + + known = `claude plugin marketplace list 2>/dev/null` + settings.fetch("extraKnownMarketplaces", {}).each do |name, spec| + repo = spec.dig("source", "repo") + next unless repo + + if known.include?(name) + puts " skip marketplace #{name} (already known)" + else + system("claude", "plugin", "marketplace", "add", repo) || warn(" WARNING: could not add marketplace #{repo}") + end + end + + installed = `claude plugin list 2>/dev/null` + settings.fetch("enabledPlugins", {}).each do |ref, enabled| + next unless enabled + + if installed.include?(ref) + puts " skip plugin #{ref} (already installed)" + else + system("claude", "plugin", "install", ref) || warn(" WARNING: could not install plugin #{ref}") + end + end + + rubocop_mcp = CLAUDE_DEST / "bin" / "rubocop-mcp" + if system("claude mcp get rubocop > /dev/null 2>&1") + puts " skip mcp rubocop (already registered)" + else + system("claude", "mcp", "add", "--scope", "user", "rubocop", "--", rubocop_mcp.to_s) || + warn(" WARNING: could not register rubocop MCP server") + end +end + +# Read-only check of the commit-signing chain. A new machine typically signs +# with a key that is trusted nowhere — not in allowed_signers, not on GitHub — +# so commits come out "Unverified" and nobody notices for weeks. This surfaces +# each gap and prints the exact fix without changing anything. +def check_signing + puts "Checking git commit signing..." + problems = [] + + config_local = CONFIG_DEST / "git" / "config.local" + unless config_local.file? + problems << "Per-machine identity missing (#{config_local}); commits use an auto-detected name/email.\n" \ + " Fix: run `mise run setup-identity`." + end + + signing_key = `git config --get user.signingkey`.strip + if signing_key.empty? + problems << "user.signingkey is not set." + else + key_path = Pathname.new(signing_key.sub(/\A~/, Dir.home)) + if key_path.file? + pubkey = key_path.read.split[0, 2].join(" ") + email = `git config --get user.email`.strip + + allowed = CONFIG_SRC / "git" / "allowed_signers" + unless allowed.file? && allowed.read.include?(pubkey) + problems << "Signing key not in #{allowed}; your commits verify as untrusted locally.\n" \ + " Fix: add this line to config/git/allowed_signers:\n" \ + " #{email} #{pubkey}" + end + + if system("command -v gh > /dev/null 2>&1") && system("gh auth status > /dev/null 2>&1") + login = `gh api user --jq .login 2>/dev/null`.strip + registered = `gh api users/#{login}/ssh_signing_keys --jq '.[].key' 2>/dev/null` + unless registered.include?(pubkey) + problems << "Signing key not registered on GitHub; pushed commits show \"Unverified\".\n" \ + " Fix: gh ssh-key add #{key_path} --type signing --title \"\"\n" \ + " (first run if needed: gh auth refresh -h github.com -s admin:ssh_signing_key)" + end + end + else + problems << "Signing key #{key_path} does not exist on this machine." + end + end + + if problems.empty? + puts " signing OK" + else + problems.each { |p| warn " WARNING: #{p}" } + end +end + +case ARGV[0] +when "identity" then setup_identity +when "vim-dirs" then setup_vim_dirs +when "claude" then setup_claude +when "signing" then check_signing +when "all", nil + setup_identity + setup_vim_dirs + setup_claude + check_signing +else + warn "Usage: dotfiles-setup " + exit 1 +end diff --git a/config/mise/config.toml b/config/mise/config.toml index 2d9309c..30195c7 100644 --- a/config/mise/config.toml +++ b/config/mise/config.toml @@ -22,3 +22,209 @@ pitchfork = "latest" hk = "latest" aube = "latest" fnox = "latest" + +# ───────────────────────────────────────────────────────────────────────────── +# mise bootstrap — declarative machine setup for these dotfiles. +# Fresh machine (from the cloned repo): +# MISE_GLOBAL_CONFIG_FILE="$PWD/config/mise/config.toml" mise bootstrap +# Preview with `… mise bootstrap -n`; check drift with `… mise bootstrap status`. +# +# Dotfile sources are RELATIVE to this file's directory (config/mise/), so the +# repo can be cloned anywhere — nothing hardcodes a path. Because relative +# sources resolve against the file's real location, always run bootstrap from +# the cloned repo (as above), not against the deployed ~/.config/mise symlink. +# ───────────────────────────────────────────────────────────────────────────── + +# Dotfiles. `symlink-each` mirrors the directory and links each file +# individually, so machine-local siblings mise +# doesn't manage (config/git/config.local, config/fish/config.local.fish) are +# left untouched inside otherwise-managed directories. +[dotfiles] +"~/.config" = { source = "..", mode = "symlink-each" } # config/ → ~/.config/ +"~/.claude" = { source = "../../claude", mode = "symlink-each" } # claude/ → ~/.claude/ +"~/.local/bin" = { source = "../../bin", mode = "symlink-each" } # bin/ → ~/.local/bin/ +# rc/* → ~/.: the dot-prefix rename can't be expressed by symlink-each, +# so these are individual whole-file symlinks (mode defaults to symlink). +"~/.editorconfig" = { source = "../../rc/editorconfig" } +"~/.hushlogin" = { source = "../../rc/hushlogin" } +"~/.psqlrc" = { source = "../../rc/psqlrc" } +"~/.tmux.conf" = { source = "../../rc/tmux.conf" } +"~/.vimrc" = { source = "../../rc/vimrc" } + +# Login shell. +[bootstrap.user] +login_shell = "/opt/homebrew/bin/fish" + +# Homebrew must exist before the packages phase. Install it if missing (a no-op +# when mise itself was installed via brew). +[bootstrap.hooks.pre-packages] +run = 'command -v brew >/dev/null 2>&1 || NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + +# Homebrew packages. Formulae use `brew:`, casks and fonts use `brew-cask:` +# (font cask detection landed in mise 2026.7.0). +# Languages (ruby, node, …) stay in [tools] above, not here. +[bootstrap.packages] +# shell +"brew:fish" = "latest" # friendly interactive shell +"brew:starship" = "latest" # cross-shell prompt +"brew:direnv" = "latest" # per-directory environment variables +"brew:mise" = "latest" # polyglot runtime/tool manager +"brew:tmux" = "latest" # terminal multiplexer +"brew:zoxide" = "latest" # smarter cd with frecency +# search & navigation +"brew:bat" = "latest" # cat with syntax highlighting +"brew:bat-extras" = "latest" # batdiff, batgrep, batman, etc. +"brew:eza" = "latest" # modern ls replacement +"brew:fd" = "latest" # fast find replacement +"brew:fzf" = "latest" # fuzzy finder +"brew:ripgrep" = "latest" # fast grep replacement +"brew:tree" = "latest" # directory tree viewer +# git +"brew:git" = "latest" # Homebrew-managed git +"brew:gh" = "latest" # GitHub CLI +"brew:difftastic" = "latest" # structural diff using AST parsing +"brew:git-delta" = "latest" # better git diff pager +"brew:git-filter-repo" = "latest" # rewrite/filter git history +# data & text +"brew:jq" = "latest" # JSON processor +"brew:jsonlint" = "latest" # JSON validator +"brew:par" = "latest" # paragraph reformatter +"brew:yq" = "latest" # YAML/XML/TOML processor +"brew:sd" = "latest" # simpler sed replacement +"brew:wget" = "latest" # file downloader +# diagnostics & benchmarking +"brew:btop" = "latest" # interactive system monitor +"brew:dust" = "latest" # intuitive disk usage viewer +"brew:entr" = "latest" # run commands on file changes +"brew:fastfetch" = "latest" # system info display +"brew:gping" = "latest" # ping with a graph +"brew:hyperfine" = "latest" # CLI benchmarking +"brew:procs" = "latest" # modern ps replacement +"brew:tokei" = "latest" # code statistics by language +# databases +"brew:libpq" = "latest" # PostgreSQL client library +"brew:memcached" = "latest" # in-memory caching +"brew:postgresql@18" = "latest" # PostgreSQL server +"brew:redis" = "latest" # in-memory data store +# core utilities +"brew:coreutils" = "latest" # GNU core utilities +"brew:ctags" = "latest" # code tag generation +"brew:imagemagick" = "latest" # image manipulation toolkit +"brew:watch" = "latest" # run commands periodically +# libraries +"brew:libyaml" = "latest" # YAML parsing (ruby dependency) +"brew:vips" = "latest" # fast image processing +"brew:watchman" = "latest" # file watching service +# networking +"brew:kubernetes-cli" = "latest" # kubectl +"brew:nmap" = "latest" # network scanner +"brew:siege" = "latest" # HTTP load testing +# fonts & casks +"brew-cask:font-jetbrains-mono-nerd-font" = "latest" +"brew-cask:ngrok" = "latest" # expose localhost publicly + +# ── Imperative per-machine setup, driven by bin/dotfiles-setup. The parts mise +# can't express declaratively. The dotfiles phase deploys the script to +# ~/.local/bin before the bootstrap task runs, and the script finds the repo +# via its own realpath — no hardcoded path. Each subcommand is idempotent. +[tasks.setup-identity] +description = "Seed per-machine git identity, ssh config, and fish local config" +run = '"$HOME/.local/bin/dotfiles-setup" identity' + +[tasks.setup-vim-dirs] +description = "Create vim swap/backup/undo directories" +run = '"$HOME/.local/bin/dotfiles-setup" vim-dirs' + +[tasks.setup-claude] +description = "Install Claude plugins and register the rubocop MCP server" +run = '"$HOME/.local/bin/dotfiles-setup" claude' + +[tasks.check-signing] +description = "Read-only diagnostic of the commit-signing chain" +run = '"$HOME/.local/bin/dotfiles-setup" signing' + +# Auto-run by `mise bootstrap` (step: mise run bootstrap). +[tasks.bootstrap] +depends = ["setup-identity", "setup-vim-dirs", "setup-claude"] + +# The signing diagnostic runs last, once identity and tooling are in place. +[bootstrap.hooks.final] +run = '"$HOME/.local/bin/dotfiles-setup" signing' + +# ── macOS defaults — declarative `defaults write` entries. ─────────────────── +[bootstrap.macos.defaults.NSGlobalDomain] +# General UI +NSNavPanelExpandedStateForSaveMode = true +NSNavPanelExpandedStateForSaveMode2 = true +PMPrintingExpandedStateForPrint = true +PMPrintingExpandedStateForPrint2 = true +NSDocumentSaveNewDocumentsToCloud = false +NSDisableAutomaticTermination = true +# Keyboard & input +KeyRepeat = 2 +InitialKeyRepeat = 15 +NSAutomaticSpellingCorrectionEnabled = false +NSAutomaticDashSubstitutionEnabled = false +NSAutomaticQuoteSubstitutionEnabled = false +NSAutomaticCapitalizationEnabled = false +NSAutomaticPeriodSubstitutionEnabled = false +AppleKeyboardUIMode = 3 +AppleShowAllExtensions = true +"com.apple.mouse.tapBehavior" = 1 + +[bootstrap.macos.defaults."com.apple.driver.AppleBluetoothMultitouch.trackpad"] +Clicking = true + +[bootstrap.macos.defaults."com.apple.finder"] +ShowStatusBar = true +ShowPathbar = true +_FXSortFoldersFirst = true +FXPreferredViewStyle = "Nlsv" +FXDefaultSearchScope = "SCcf" +FXEnableExtensionChangeWarning = false +AppleShowAllFiles = true + +[bootstrap.macos.defaults."com.apple.desktopservices"] +DSDontWriteNetworkStores = true +DSDontWriteUSBStores = true + +[bootstrap.macos.defaults."com.apple.dock"] +mineffect = "scale" +show-recents = false +autohide = true +autohide-delay = 0.0 +autohide-time-modifier = 0.4 +mru-spaces = false +expose-group-apps = true + +[bootstrap.macos.defaults."com.apple.screencapture"] +type = "png" +disable-shadow = true +# location is set in the post-defaults hook — it needs $HOME expansion + mkdir. + +[bootstrap.macos.defaults."com.apple.ActivityMonitor"] +ShowCategory = 0 +SortColumn = "CPUUsage" +SortDirection = 0 + +[bootstrap.macos.defaults."com.apple.TextEdit"] +RichText = 0 +PlainTextEncoding = 4 +PlainTextEncodingForWrite = 4 + +[bootstrap.macos.defaults."com.apple.screensaver"] +askForPassword = 1 +askForPasswordDelay = 0 + +# ── macOS: the non-declarative extras (per-host scope, chflags, a HOME-expanded +# path with a prerequisite mkdir, a nested PlistBuddy edit, and the app +# restarts). Runs after the declarative defaults are written. +[bootstrap.hooks.post-defaults] +run = """ +defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 +chflags nohidden ~/Library +mkdir -p "$HOME/Screenshots" +defaults write com.apple.screencapture location -string "$HOME/Screenshots" +/usr/libexec/PlistBuddy -c "Set :AppleSymbolicHotKeys:64:enabled false" ~/Library/Preferences/com.apple.symbolichotkeys.plist 2>/dev/null || true +killall Finder Dock SystemUIServer 2>/dev/null || true +""" diff --git a/install.rb b/install.rb deleted file mode 100755 index f05f0b3..0000000 --- a/install.rb +++ /dev/null @@ -1,474 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Symlinks config files from this repo into ~/.config, rc files into ~/, -# and bin/ scripts into ~/.local/bin. Also installs Claude Code plugins and -# registers the rubocop MCP server from the setup declared in claude/settings.json. - -require "fileutils" -require "json" -require "optparse" -require "pathname" - -DOTFILES_DIR = Pathname.new(__dir__ || abort("Cannot determine script directory")) -CONFIG_SRC = DOTFILES_DIR / "config" -CONFIG_DEST = Pathname.new(Dir.home) / ".config" -RC_SRC = DOTFILES_DIR / "rc" -CLAUDE_SRC = DOTFILES_DIR / "claude" -CLAUDE_DEST = Pathname.new(Dir.home) / ".claude" -BIN_SRC = DOTFILES_DIR / "bin" -BIN_DEST = Pathname.new(Dir.home) / ".local" / "bin" -HOME = Pathname.new(Dir.home) - -SKIP = %w[config.local.example].freeze - -options = {} -OptionParser.new do |opts| - opts.banner = "Usage: #{$PROGRAM_NAME} [options]" - opts.on("-n", "--dry-run", "Show what would be done without making changes") { options[:dry_run] = true } - opts.on("-y", "--yes", "Answer yes to all prompts") { options[:yes] = true } - opts.on("--[no-]macos", "Run macOS defaults (auto-detected on macOS)") { |v| options[:macos] = v } - opts.on("--[no-]brew", "Run brew bundle (auto-detected when Brewfile present)") { |v| options[:brew] = v } - opts.on("--[no-]mise", "Install mise-managed tools (auto-detected when mise present)") { |v| options[:mise] = v } - opts.on("--[no-]plugins", "Install Claude plugins + MCP servers (auto-detected when claude present)") { |v| options[:plugins] = v } - opts.on("--[no-]fish", "Set fish as default shell") { |v| options[:fish] = v } -end.parse! - -dry_run = options[:dry_run] -# `reported_dangling` is a per-run set of broken parent-symlink paths already -# printed during a dry run, so we don't emit the same "would remove" line for -# every file that lives inside the same dangling directory. Real runs are -# naturally idempotent — the first removal makes the path a real directory, -# so subsequent calls see nothing to clean up. -counts = { linked: 0, skipped: 0, backed_up: 0, reported_dangling: [] } - -def prompt?(question, options) - return true if options[:yes] - print "#{question} [y/N] " - $stdin.gets&.strip&.downcase == "y" -end - -# Read-only check of the commit-signing chain. A new machine typically signs -# with a key that is trusted nowhere — not in allowed_signers, not on GitHub — -# so commits come out "Unverified" and nobody notices for weeks. This surfaces -# each gap during setup and prints the exact fix without changing anything. -def check_git_signing - puts "\nChecking git commit signing..." - problems = [] - - config_local = HOME / ".config" / "git" / "config.local" - unless config_local.file? - problems << "Per-machine identity missing (#{config_local}); commits use an auto-detected name/email.\n" \ - " Fix: cp #{CONFIG_SRC / 'git' / 'config.local.example'} #{config_local} and edit it." - end - - signing_key = `git config --get user.signingkey`.strip - if signing_key.empty? - problems << "user.signingkey is not set." - else - key_path = Pathname.new(signing_key.sub(/\A~/, Dir.home)) - if key_path.file? - pubkey = key_path.read.split[0, 2].join(" ") # "ssh-ed25519 AAAA..." - email = `git config --get user.email`.strip - - allowed = CONFIG_SRC / "git" / "allowed_signers" - unless allowed.file? && allowed.read.include?(pubkey) - problems << "Signing key not in #{allowed}; your commits verify as untrusted locally.\n" \ - " Fix: add this line to config/git/allowed_signers:\n" \ - " #{email} #{pubkey}" - end - - if system("command -v gh > /dev/null 2>&1") && system("gh auth status > /dev/null 2>&1") - login = `gh api user --jq .login 2>/dev/null`.strip - registered = `gh api users/#{login}/ssh_signing_keys --jq '.[].key' 2>/dev/null` - unless registered.include?(pubkey) - problems << "Signing key not registered on GitHub; pushed commits show \"Unverified\".\n" \ - " Fix: gh ssh-key add #{key_path} --type signing --title \"\"\n" \ - " (first run if needed: gh auth refresh -h github.com -s admin:ssh_signing_key)" - end - end - else - problems << "Signing key #{key_path} does not exist on this machine." - end - end - - if problems.empty? - puts " signing OK" - else - problems.each { |p| warn " WARNING: #{p}" } - end -end - -# Reproduces the Claude plugin + MCP setup declared in claude/settings.json so a -# fresh machine matches this one. Marketplaces and enabled plugins are read from -# the committed settings (the single source of truth), and the rubocop MCP server -# is registered at user scope so it's available in every repo. Every action is -# guarded by a presence check, so re-runs are no-ops. -def install_claude_plugins(dry_run:) - settings = JSON.parse((CLAUDE_SRC / "settings.json").read) - - puts "\nInstalling Claude plugins and MCP servers..." - - known = `claude plugin marketplace list 2>/dev/null` - settings.fetch("extraKnownMarketplaces", {}).each do |name, spec| - repo = spec.dig("source", "repo") - next unless repo - - if known.include?(name) - puts " skip marketplace #{name} (already known)" - elsif dry_run - puts " [dry-run] would run: claude plugin marketplace add #{repo}" - else - system("claude", "plugin", "marketplace", "add", repo) || warn(" WARNING: could not add marketplace #{repo}") - end - end - - installed = `claude plugin list 2>/dev/null` - settings.fetch("enabledPlugins", {}).each do |ref, enabled| - next unless enabled - - if installed.include?(ref) - puts " skip plugin #{ref} (already installed)" - elsif dry_run - puts " [dry-run] would run: claude plugin install #{ref}" - else - system("claude", "plugin", "install", ref) || warn(" WARNING: could not install plugin #{ref}") - end - end - - # rubocop MCP server (user scope) — adapts to whichever repo Claude runs in. - rubocop_mcp = CLAUDE_DEST / "bin" / "rubocop-mcp" - if system("claude mcp get rubocop > /dev/null 2>&1") - puts " skip mcp rubocop (already registered)" - elsif dry_run - puts " [dry-run] would run: claude mcp add --scope user rubocop -- #{rubocop_mcp}" - else - system("claude", "mcp", "add", "--scope", "user", "rubocop", "--", rubocop_mcp.to_s) || - warn(" WARNING: could not register rubocop MCP server") - end -end - -# Walks up from `dir` and removes any dangling symlinks in the ancestor -# chain so `FileUtils.mkdir_p(dir)` can create real directories in their -# place. Handles the case where an older version of this script symlinked -# whole directories at paths that are now expected to be real directories -# holding individual file symlinks. -def clear_dangling_parent_symlinks(dir, dry_run:, reported:) - to_remove = [] - current = dir - until current.directory? - break if current.root? || current == current.parent - to_remove << current if current.symlink? - current = current.parent - end - - to_remove.reverse_each do |broken| - if dry_run - next if reported.include?(broken.to_s) - reported << broken.to_s - puts " [dry-run] would remove dangling symlink #{broken} -> #{broken.readlink}" - else - puts " removing dangling symlink #{broken} -> #{broken.readlink}" - broken.delete - end - end -end - -def symlink(src, dest, dry_run:, counts:) - if dest.symlink? - if dest.exist? && dest.realpath == src.realpath - puts " skip #{dest} (already linked)" - counts[:skipped] += 1 - return - else - backup = Pathname.new("#{dest}.backup") - abort "ERROR: #{backup} already exists, not overwriting. Remove it manually and re-run." if backup.exist? - puts " #{dry_run ? "[dry-run] would remove" : "removing"} old symlink #{dest} -> #{dest.readlink}" - dest.delete unless dry_run - end - elsif dest.exist? - backup = Pathname.new("#{dest}.backup") - if backup.exist? - abort "ERROR: #{backup} already exists, not overwriting. Remove it manually and re-run." - end - puts " #{dry_run ? "[dry-run] would back up" : "backing up"} #{dest} -> #{backup}" - FileUtils.mv(dest, backup) unless dry_run - counts[:backed_up] += 1 - end - - clear_dangling_parent_symlinks(dest.dirname, dry_run: dry_run, reported: counts[:reported_dangling]) - - puts " #{dry_run ? "[dry-run] would link" : "linked"} #{dest} -> #{src}" - unless dry_run - FileUtils.mkdir_p(dest.dirname) - FileUtils.ln_s(src, dest) - end - counts[:linked] += 1 -end - -unless CONFIG_SRC.directory? - abort "ERROR: #{CONFIG_SRC} not found. Run this script from the dotfiles directory." -end - -files = Dir.glob("**/*", base: CONFIG_SRC).sort -files.select! { |f| (CONFIG_SRC / f).file? } -files.reject! { |f| SKIP.include?(Pathname.new(f).basename.to_s) } - -if files.empty? - abort "No config files found in #{CONFIG_SRC}." -end - -puts dry_run ? "Dry run — no changes will be made:\n\n" : "Installing config symlinks:\n\n" - -files.each do |relative| - src = CONFIG_SRC / relative - dest = CONFIG_DEST / relative - symlink(src, dest, dry_run: dry_run, counts: counts) -end - -# rc/ files are symlinked into ~/ with a dot prefix (rc/vimrc -> ~/.vimrc) -if RC_SRC.directory? - rc_files = Dir.glob("*", base: RC_SRC).sort - rc_files.select! { |f| (RC_SRC / f).file? } - rc_files.reject! { |f| SKIP.include?(f) } - - unless rc_files.empty? - puts "\n" - rc_files.each do |name| - src = RC_SRC / name - dest = HOME / ".#{name}" - symlink(src, dest, dry_run: dry_run, counts: counts) - end - end -end - -# claude/ files are symlinked into ~/.claude/ -if CLAUDE_SRC.directory? - claude_files = Dir.glob("**/*", base: CLAUDE_SRC).sort - claude_files.select! { |f| (CLAUDE_SRC / f).file? } - claude_files.reject! { |f| SKIP.include?(Pathname.new(f).basename.to_s) } - - unless claude_files.empty? - puts "\n" - claude_files.each do |relative| - src = CLAUDE_SRC / relative - dest = CLAUDE_DEST / relative - symlink(src, dest, dry_run: dry_run, counts: counts) - end - end -end - -# bin/ files are symlinked into ~/.local/bin/ (which is on PATH via config.fish) -if BIN_SRC.directory? - bin_files = Dir.glob("*", base: BIN_SRC).sort - bin_files.select! { |f| (BIN_SRC / f).file? && File.executable?(BIN_SRC / f) } - bin_files.reject! { |f| SKIP.include?(f) } - - unless bin_files.empty? - puts "\n" - bin_files.each do |name| - src = BIN_SRC / name - dest = BIN_DEST / name - symlink(src, dest, dry_run: dry_run, counts: counts) - end - end -end - -# Create vim directories for swap, backup, and undo -%w[swap tmp undo].each do |dir| - vim_dir = HOME / ".vim" / dir - if vim_dir.directory? - puts " skip #{vim_dir} (already exists)" - elsif dry_run - puts " [dry-run] would create #{vim_dir}" - else - FileUtils.mkdir_p(vim_dir) - puts " created #{vim_dir}" - end -end - -# Create local config files if they don't exist -git_local = CONFIG_DEST / "git" / "config.local" -if git_local.exist? - puts "\n skip #{git_local} (already exists)" -else - puts "\nSetting up git identity (#{git_local})..." - if dry_run - puts " [dry-run] would create #{git_local}" - else - print " Git name: " - git_name = $stdin.gets&.strip || "" - print " Git email: " - git_email = $stdin.gets&.strip || "" - print " SSH signing key path [~/.ssh/id_ed25519.pub]: " - git_key = $stdin.gets&.strip - git_key = "~/.ssh/id_ed25519.pub" if git_key.nil? || git_key.empty? - - FileUtils.mkdir_p(git_local.dirname) - git_local.write(<<~CONFIG) - [user] - email = #{git_email} - name = #{git_name} - signingKey = #{git_key} - CONFIG - puts " created #{git_local}" - end -end - -ssh_config = HOME / ".ssh" / "config" -if ssh_config.exist? - puts " skip #{ssh_config} (already exists)" -else - if dry_run - puts " [dry-run] would create #{ssh_config}" - else - FileUtils.mkdir_p(ssh_config.dirname, mode: 0700) - ssh_config.write(<<~CONFIG) - Host * - AddKeysToAgent yes - UseKeychain yes - IdentityFile ~/.ssh/id_ed25519 - CONFIG - FileUtils.chmod(0600, ssh_config) - puts " created #{ssh_config}" - end -end - -fish_local = CONFIG_DEST / "fish" / "config.local.fish" -if fish_local.exist? - puts " skip #{fish_local} (already exists)" -else - if dry_run - puts " [dry-run] would create #{fish_local}" - else - FileUtils.mkdir_p(fish_local.dirname) - fish_local.write(<<~CONFIG) - # Local fish config — secrets and machine-specific settings - # This file is not checked into git. - CONFIG - puts " created #{fish_local}" - end -end - -puts "\nSymlinks: #{counts[:linked]} linked, #{counts[:skipped]} skipped, #{counts[:backed_up]} backed up." - -# Install Homebrew if missing -unless system("command -v brew > /dev/null 2>&1") - if prompt?("Homebrew is not installed. Install it?", options) - puts "\nInstalling Homebrew..." - if dry_run - puts " [dry-run] would install Homebrew" - else - system("bash", "-c", '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)') || warn("WARNING: Homebrew installation had errors") - end - end -end - -# Brew bundle -run_brew = if options.key?(:brew) - options[:brew] -elsif (DOTFILES_DIR / "Brewfile").file? && system("command -v brew > /dev/null 2>&1") - prompt?("Install Homebrew packages?", options) -else - false -end - -if run_brew - brewfile = DOTFILES_DIR / "Brewfile" - puts "\nInstalling Homebrew packages from #{brewfile}..." - if dry_run - puts " [dry-run] would run: brew bundle --file=#{brewfile}" - else - system("brew", "bundle", "--file=#{brewfile}") || warn("WARNING: brew bundle had errors") - end -end - -# Mise install (provision tools pinned in mise config, e.g. hk for git hooks) -run_mise = if options.key?(:mise) - options[:mise] -elsif system("command -v mise > /dev/null 2>&1") - prompt?("Install mise-managed tools?", options) -else - false -end - -if run_mise - puts "\nInstalling mise-managed tools..." - if dry_run - puts " [dry-run] would run: mise install" - else - system("mise", "install") || warn("WARNING: mise install had errors") - end -end - -# Claude plugins + MCP servers (declared in claude/settings.json) -run_plugins = if options.key?(:plugins) - options[:plugins] -elsif system("command -v claude > /dev/null 2>&1") && (CLAUDE_SRC / "settings.json").file? - prompt?("Install Claude plugins and register MCP servers?", options) -else - false -end - -install_claude_plugins(dry_run: dry_run) if run_plugins - -# macOS defaults -run_macos = if options.key?(:macos) - options[:macos] -elsif RUBY_PLATFORM.include?("darwin") - prompt?("Apply macOS defaults?", options) -else - false -end - -if run_macos - macos_script = DOTFILES_DIR / "macos.sh" - if macos_script.file? - puts "\nApplying macOS defaults from #{macos_script}..." - if dry_run - puts " [dry-run] would run: bash #{macos_script}" - else - system("bash", macos_script.to_s) || warn("WARNING: macos.sh had errors") - end - else - puts "\nSkipping macOS defaults (#{macos_script} not found)." - end -end - -# Set fish as default shell -fish_path = "/opt/homebrew/bin/fish" -run_fish = if options.key?(:fish) - options[:fish] -elsif File.executable?(fish_path) && ENV.fetch("SHELL", "") != fish_path - prompt?("Set fish as default shell?", options) -else - false -end - -if run_fish && File.executable?(fish_path) - shells = File.read("/etc/shells") - unless shells.include?(fish_path) - puts "\nAdding #{fish_path} to /etc/shells..." - if dry_run - puts " [dry-run] would add #{fish_path} to /etc/shells" - else - system("sudo", "sh", "-c", "echo '#{fish_path}' >> /etc/shells") || warn("WARNING: could not add fish to /etc/shells") - end - end - - current_shell = ENV.fetch("SHELL", "") - if current_shell != fish_path - puts "\nSetting fish as default shell..." - if dry_run - puts " [dry-run] would run: chsh -s #{fish_path}" - else - system("chsh", "-s", fish_path) || warn("WARNING: could not set default shell") - end - else - puts "\n skip default shell (already fish)" - end -end - -check_git_signing - -puts "\nAll done." diff --git a/macos.sh b/macos.sh deleted file mode 100755 index b00b6d0..0000000 --- a/macos.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# -# Sensible macOS defaults. Run once on a fresh machine, then log out/in. -# Inspired by mathiasbynens/dotfiles. Review before running — tweak to taste. - -set -euo pipefail - -echo "Applying macOS defaults..." - -# Close System Settings to prevent it from overriding -osascript -e 'tell application "System Settings" to quit' 2>/dev/null || true - -# ─────────────────────────────────────────────────── -# General UI -# ─────────────────────────────────────────────────── - -# Expand save panel by default -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode -bool true -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode2 -bool true - -# Expand print panel by default -defaults write NSGlobalDomain PMPrintingExpandedStateForPrint -bool true -defaults write NSGlobalDomain PMPrintingExpandedStateForPrint2 -bool true - -# Save to disk (not iCloud) by default -defaults write NSGlobalDomain NSDocumentSaveNewDocumentsToCloud -bool false - -# Disable automatic termination of inactive apps -defaults write NSGlobalDomain NSDisableAutomaticTermination -bool true - -# ─────────────────────────────────────────────────── -# Keyboard & Input -# ─────────────────────────────────────────────────── - -# Fast key repeat and short delay -defaults write NSGlobalDomain KeyRepeat -int 2 -defaults write NSGlobalDomain InitialKeyRepeat -int 15 - -# Disable auto-correct, smart dashes, smart quotes, and auto-capitalize -defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticDashSubstitutionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticCapitalizationEnabled -bool false -defaults write NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled -bool false - -# Enable full keyboard access for all controls (Tab through dialogs) -defaults write NSGlobalDomain AppleKeyboardUIMode -int 3 - -# ─────────────────────────────────────────────────── -# Trackpad -# ─────────────────────────────────────────────────── - -# Enable tap to click -defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool true -defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 -defaults write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 - -# ─────────────────────────────────────────────────── -# Finder -# ─────────────────────────────────────────────────── - -# Show all filename extensions -defaults write NSGlobalDomain AppleShowAllExtensions -bool true - -# Show status bar and path bar -defaults write com.apple.finder ShowStatusBar -bool true -defaults write com.apple.finder ShowPathbar -bool true - -# Keep folders on top when sorting by name -defaults write com.apple.finder _FXSortFoldersFirst -bool true - -# Default to list view -defaults write com.apple.finder FXPreferredViewStyle -string "Nlsv" - -# Search the current folder by default -defaults write com.apple.finder FXDefaultSearchScope -string "SCcf" - -# Disable warning when changing file extensions -defaults write com.apple.finder FXEnableExtensionChangeWarning -bool false - -# Avoid creating .DS_Store on network and USB volumes -defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true -defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true - -# Show ~/Library -chflags nohidden ~/Library - -# ─────────────────────────────────────────────────── -# Dock -# ─────────────────────────────────────────────────── - -# Minimize using scale effect (faster than genie) -defaults write com.apple.dock mineffect -string "scale" - -# Don't show recent apps -defaults write com.apple.dock show-recents -bool false - -# Auto-hide the Dock -defaults write com.apple.dock autohide -bool true - -# Speed up auto-hide animation -defaults write com.apple.dock autohide-delay -float 0 -defaults write com.apple.dock autohide-time-modifier -float 0.4 - -# ─────────────────────────────────────────────────── -# Mission Control & Spaces -# ─────────────────────────────────────────────────── - -# Don't automatically rearrange Spaces based on most recent use -defaults write com.apple.dock mru-spaces -bool false - -# Group windows by application in Mission Control -defaults write com.apple.dock expose-group-apps -bool true - -# ─────────────────────────────────────────────────── -# Screenshots -# ─────────────────────────────────────────────────── - -# Save screenshots to ~/Screenshots -mkdir -p "${HOME}/Screenshots" -defaults write com.apple.screencapture location -string "${HOME}/Screenshots" - -# Save as PNG -defaults write com.apple.screencapture type -string "png" - -# Disable shadow in screenshots -defaults write com.apple.screencapture disable-shadow -bool true - -# ─────────────────────────────────────────────────── -# Activity Monitor -# ─────────────────────────────────────────────────── - -# Show all processes -defaults write com.apple.ActivityMonitor ShowCategory -int 0 - -# Sort by CPU usage -defaults write com.apple.ActivityMonitor SortColumn -string "CPUUsage" -defaults write com.apple.ActivityMonitor SortDirection -int 0 - -# ─────────────────────────────────────────────────── -# Finder (additional) -# ─────────────────────────────────────────────────── - -# Show hidden files -defaults write com.apple.finder AppleShowAllFiles -bool true - -# ─────────────────────────────────────────────────── -# TextEdit -# ─────────────────────────────────────────────────── - -# Default to plain text -defaults write com.apple.TextEdit RichText -int 0 - -# Open and save as UTF-8 -defaults write com.apple.TextEdit PlainTextEncoding -int 4 -defaults write com.apple.TextEdit PlainTextEncodingForWrite -int 4 - -# ─────────────────────────────────────────────────── -# Security -# ─────────────────────────────────────────────────── - -# Require password immediately after sleep or screensaver -defaults write com.apple.screensaver askForPassword -int 1 -defaults write com.apple.screensaver askForPasswordDelay -int 0 - -# ─────────────────────────────────────────────────── -# Spotlight -# ─────────────────────────────────────────────────── - -# Disable Spotlight keyboard shortcut (Cmd+Space) to free it for Raycast/Alfred -# Note: re-enable in System Settings > Keyboard > Shortcuts if needed -/usr/libexec/PlistBuddy -c "Set :AppleSymbolicHotKeys:64:enabled false" ~/Library/Preferences/com.apple.symbolichotkeys.plist 2>/dev/null || true - -# ─────────────────────────────────────────────────── -# Apply changes -# ─────────────────────────────────────────────────── - -echo "Restarting affected apps..." -for app in "Finder" "Dock" "SystemUIServer"; do - killall "$app" &>/dev/null || true -done - -echo "Done. Some changes require a logout/restart to take effect."