diff --git a/README.md b/README.md index 38a5a53..7ff9c7b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ the only true prerequisites are `zsh`, `git`, and `curl`. All shell wiring lives `home/dot_config/zsh/aliases.zsh` and is `command -v`-guarded, so anything missing degrades to the native command. +On macOS (non-headless), the bootstrap also installs [AeroSpace](https://nikitabobko.github.io/AeroSpace), +an i3-like tiling WM, and manages `~/.aerospace.toml`. It is the repo's only Homebrew **cask** and its +only **tap** (`nikitabobko/tap`, the upstream author's own) — everything else brew installs is +homebrew-core. + ### Aliases | Alias | Expands to | Notes | diff --git a/docs/shell-performance.md b/docs/shell-performance.md index 9728102..936677a 100644 --- a/docs/shell-performance.md +++ b/docs/shell-performance.md @@ -17,6 +17,7 @@ zsh -c 'zmodload zsh/zprof; source ~/.zshrc >/dev/null 2>&1; zprof | head -15' - Don't add `emulate -L zsh` inside `_eval_cached` — `LOCAL_OPTIONS` would revert `setopt`s made by the sourced init (this broke starship once). 3. **compinit is deferred.** `compdef` doesn't exist at rc-load time; the stub in `dot_zshrc.tmpl` queues calls and the zinit turbo block flushes them after `zicompinit` (`-C` flag: trusts the cached dump). Anything that emits `compdef` at rc time (like `op completion`) just works via the queue. 4. New plugins go in `plugins.zsh` under the existing `zinit wait lucid for` blocks, never eager-loaded in `.zshrc`. +5. **Tool inits that aren't mise or starship go in `_deferred_tool_inits`** (defined in `dot_zshrc.tmpl`, invoked from the `atload` of `zsh-autosuggestions` in `plugins.zsh`). fzf, zoxide and op used to run sync and cost ~8ms before the prompt for keybindings and completions nothing can reach until a prompt exists. Piggybacking on an existing turbo plugin's `atload` avoids cloning a `null` carrier just to schedule code. Note that `zsh -i -c exit` never fires turbo, so this work is invisible to the budget check — that's the point, but it also means functional changes here need verifying in a real interactive shell (`zsh/zpty`), not with `-c`. ## Enforcement diff --git a/home/.chezmoiignore b/home/.chezmoiignore index 0ac2613..181c189 100644 --- a/home/.chezmoiignore +++ b/home/.chezmoiignore @@ -46,3 +46,9 @@ Library/** .config/cmux .config/cmux/** {{ end }} + +# AeroSpace is a macOS tiling WM — useless without a GUI, so gate it the same way +# as use-spark. install-aerospace.sh carries the matching guard. +{{ if or (ne .chezmoi.os "darwin") .headless }} +.aerospace.toml +{{ end }} diff --git a/home/dot_aerospace.toml b/home/dot_aerospace.toml new file mode 100644 index 0000000..36f3856 --- /dev/null +++ b/home/dot_aerospace.toml @@ -0,0 +1,206 @@ +config-version = 2 +# Place a copy of this config to ~/.aerospace.toml +# After that, you can edit ~/.aerospace.toml to your liking + +# It's not necessary to copy all keys to your config. +# If the key is missing in your config, "default-config.toml" will serve as a fallback + +# You can use it to add commands that run after login to macOS user session. +# 'start-at-login' needs to be 'true' for 'after-login-command' to work +# Available commands: https://nikitabobko.github.io/AeroSpace/commands +after-login-command = [] + +# You can use it to add commands that run after AeroSpace startup. +# 'after-startup-command' is run after 'after-login-command' +# Available commands : https://nikitabobko.github.io/AeroSpace/commands +# after-startup-command = [ +# 'exec-and-forget borders active_color=0xff56b6c2 inactive_color=0xff494d64 width=3.0' +# ] + +# Start AeroSpace at login +start-at-login = true + +# Normalizations. See: https://nikitabobko.github.io/AeroSpace/guide#normalization +enable-normalization-flatten-containers = false +enable-normalization-opposite-orientation-for-nested-containers = false + +# See: https://nikitabobko.github.io/AeroSpace/guide#layouts +# The 'accordion-padding' specifies the size of accordion padding +# You can set 0 to disable the padding feature +accordion-padding = 30 + +# Possible values: tiles|accordion +default-root-container-layout = 'tiles' + +# Possible values: horizontal|vertical|auto +# 'auto' means: wide monitor (anything wider than high) gets horizontal orientation, +# tall monitor (anything higher than wide) gets vertical orientation +default-root-container-orientation = 'auto' + +# Possible values: (qwerty|dvorak) +# See https://nikitabobko.github.io/AeroSpace/guide#key-mapping +key-mapping.preset = 'qwerty' + +# Mouse follows focus when focused monitor changes +# Drop it from your config, if you don't like this behavior +# See https://nikitabobko.github.io/AeroSpace/guide#on-focus-changed-callbacks +# See https://nikitabobko.github.io/AeroSpace/commands#move-mouse +on-focused-monitor-changed = ['move-mouse monitor-lazy-center'] + +[[on-window-detected]] + if.app-id = 'com.tinyspeck.slackmacgap' + run = 'move-node-to-workspace 3' + +[[on-window-detected]] + if.app-id = 'company.thebrowser.Browser' + run = 'move-node-to-workspace 2' + +[[on-window-detected]] + if.app-id = 'com.exafunction.windsurf' + run = 'move-node-to-workspace 1' + +[[on-window-detected]] + if.app-id = 'com.cmuxterm.app' + run = 'move-node-to-workspace 1' + +[[on-window-detected]] + if.app-id = 'com.readdle.SparkDesktop' + run = 'move-node-to-workspace 0' + +[[on-window-detected]] + if.app-id = 'com.apple.MobileSMS' + run = 'move-node-to-workspace M' + +[[on-window-detected]] + if.app-id = 'com.apple.Music' + run = 'move-node-to-workspace M' + +# Gaps between windows (inner-*) and between monitor edges (outer-*). +# Possible values: +# - Constant: gaps.outer.top = 8 +# - Per monitor: gaps.outer.top = [{ monitor.main = 16 }, { monitor."some-pattern" = 32 }, 24] +# In this example, 24 is a default value when there is no match. +# Monitor pattern is the same as for 'workspace-to-monitor-force-assignment'. +# See: https://nikitabobko.github.io/AeroSpace/guide#assign-workspaces-to-monitors +[gaps] +inner.horizontal = 5 +inner.vertical = 5 +outer.left = 5 +outer.bottom = 5 +outer.top = 5 +outer.right = 5 + +# 'main' binding mode declaration +# See: https://nikitabobko.github.io/AeroSpace/guide#binding-modes +# 'main' binding mode must be always presented +[mode.main.binding] + +# All possible keys: +# - Letters. a, b, c, ..., z +# - Numbers. 0, 1, 2, ..., 9 +# - Keypad numbers. keypad0, keypad1, keypad2, ..., keypad9 +# - F-keys. f1, f2, ..., f20 +# - Special keys. minus, equal, period, comma, slash, backslash, quote, semicolon, backtick, +# leftSquareBracket, rightSquareBracket, space, enter, esc, backspace, tab +# - Keypad special. keypadClear, keypadDecimalMark, keypadDivide, keypadEnter, keypadEqual, +# keypadMinus, keypadMultiply, keypadPlus +# - Arrows. left, down, up, right + +# All possible modifiers: cmd, alt, ctrl, shift + +# All possible commands: https://nikitabobko.github.io/AeroSpace/commands + +# Disable Commands +cmd-h = [] +cmd-alt-h = [] + +# See: https://nikitabobko.github.io/AeroSpace/commands#exec-and-forget +# You can uncomment the following lines to open up terminal with alt + enter shortcut (like in i3) +alt-enter = 'exec-and-forget open -n /Applications/Ghostty.app' + +# See: https://nikitabobko.github.io/AeroSpace/commands#layout +alt-slash = 'layout tiles horizontal vertical' +alt-comma = 'layout accordion horizontal vertical' + +# See: https://nikitabobko.github.io/AeroSpace/commands#focus +alt-h = 'focus --boundaries-action wrap-around-the-workspace left' +alt-j = 'focus --boundaries-action wrap-around-the-workspace down' +alt-k = 'focus --boundaries-action wrap-around-the-workspace up' +alt-l = 'focus --boundaries-action wrap-around-the-workspace right' + +# Consider using 'join-with' command as a 'split' replacement if you want to enable +# normalizations +alt-shift-backslash = 'split horizontal' +alt-shift-minus = 'split vertical' + +alt-f = 'fullscreen' + +alt-s = 'layout v_accordion' # 'layout stacking' in i3 +alt-w = 'layout h_accordion' # 'layout tabbed' in i3 +alt-e = 'layout tiles horizontal vertical' # 'layout toggle split' in i3 + +alt-shift-space = 'layout floating tiling' # 'floating toggle' in i3 + +# See: https://nikitabobko.github.io/AeroSpace/commands#move +alt-shift-h = 'move left' +alt-shift-j = 'move down' +alt-shift-k = 'move up' +alt-shift-l = 'move right' + +# See: https://nikitabobko.github.io/AeroSpace/commands#resize +# alt-shift-minus = 'resize smart -50' +# alt-shift-equal = 'resize smart +50' + +# See: https://nikitabobko.github.io/AeroSpace/commands#workspace +alt-1 = 'workspace 1' +alt-2 = 'workspace 2' +alt-3 = 'workspace 3' +alt-4 = 'workspace 4' +alt-5 = 'workspace 5' +alt-6 = 'workspace 6' +alt-7 = 'workspace 7' +alt-8 = 'workspace 8' +alt-9 = 'workspace 9' +alt-0 = 'workspace 0' +alt-m = 'workspace M' + +# See: https://nikitabobko.github.io/AeroSpace/commands#move-node-to-workspace +alt-shift-1 = 'move-node-to-workspace 1' +alt-shift-2 = 'move-node-to-workspace 2' +alt-shift-3 = 'move-node-to-workspace 3' +alt-shift-4 = 'move-node-to-workspace 4' +alt-shift-5 = 'move-node-to-workspace 5' +alt-shift-6 = 'move-node-to-workspace 6' +alt-shift-7 = 'move-node-to-workspace 7' +alt-shift-8 = 'move-node-to-workspace 8' +alt-shift-9 = 'move-node-to-workspace 9' +alt-shift-0 = 'move-node-to-workspace 0' +alt-shift-m = 'move-node-to-workspace M' + +# See: https://nikitabobko.github.io/AeroSpace/commands#workspace-back-and-forth +alt-tab = 'workspace-back-and-forth' +# See: https://nikitabobko.github.io/AeroSpace/commands#move-workspace-to-monitor +alt-shift-tab = 'move-workspace-to-monitor --wrap-around next' + +# See: https://nikitabobko.github.io/AeroSpace/commands#mode +# alt-shift-semicolon = 'mode service' + +# 'service' binding mode declaration. +# See: https://nikitabobko.github.io/AeroSpace/guide#binding-modes +[mode.service.binding] +# esc = ['reload-config', 'mode main'] +# r = ['flatten-workspace-tree', 'mode main'] # reset layout +# s = ['layout sticky tiling', 'mode main'] # sticky is not yet supported https://github.com/nikitabobko/AeroSpace/issues/2 +# f = ['layout floating tiling', 'mode main'] # Toggle between floating and tiling layout +# backspace = ['close-all-windows-but-current', 'mode main'] +alt-shift-c = 'reload-config' + +alt-r = 'mode resize' + +[mode.resize.binding] +h = 'resize width -50' +j = 'resize height +50' +k = 'resize height -50' +l = 'resize width +50' +enter = 'mode main' +esc = 'mode main' diff --git a/home/dot_config/mise/conf.d/cli-tools.toml.tmpl b/home/dot_config/mise/conf.d/cli-tools.toml.tmpl index 8c77511..afac29f 100644 --- a/home/dot_config/mise/conf.d/cli-tools.toml.tmpl +++ b/home/dot_config/mise/conf.d/cli-tools.toml.tmpl @@ -19,6 +19,7 @@ ripgrep = "latest" node = "22" helix = "latest" bottom = "latest" # `btm` — lighter, narrow-friendly; used by the cmux dock Host panel +zig = "0.15.2" # cmux builds GhosttyKit.xcframework; ghostty pins minimum_zig_version {{- if eq .chezmoi.os "linux" }} "npm:@endevco/aube" = "latest" # aube — our Node package manager (jdx, same author as mise); verified v1.33.1 "github:aristocratos/btop" = "latest" # btop via github backend (aqua asset-match is broken, ubi deprecated); Linux release binaries only diff --git a/home/dot_config/zsh/plugins.zsh b/home/dot_config/zsh/plugins.zsh index 974480b..2623592 100644 --- a/home/dot_config/zsh/plugins.zsh +++ b/home/dot_config/zsh/plugins.zsh @@ -4,7 +4,7 @@ zinit wait lucid for \ atinit"ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay; _flush_deferred_compdefs" \ zdharma-continuum/fast-syntax-highlighting \ - atload"_zsh_autosuggest_start" \ + atload"_zsh_autosuggest_start; _deferred_tool_inits" \ zsh-users/zsh-autosuggestions \ blockf \ zsh-users/zsh-completions diff --git a/home/dot_zshrc.tmpl b/home/dot_zshrc.tmpl index fd294d2..25aad1d 100644 --- a/home/dot_zshrc.tmpl +++ b/home/dot_zshrc.tmpl @@ -69,14 +69,25 @@ _flush_deferred_compdefs() { # ---- starship (sync: prompt paints immediately) ---- _eval_cached starship starship init zsh +# ---- deferred tool inits (invoked from the turbo block in plugins.zsh) ---- +# Rule 1 in docs/shell-performance.md: only mise (tools on PATH) and starship +# (prompt must paint) earn a sync slot. fzf's keybindings, zoxide's `z`, and op's +# completion are all unreachable until there is a prompt to type at, so they load +# ~1ms after it appears rather than ~8ms before it. +_deferred_tool_inits() { + _eval_cached fzf fzf --zsh + _eval_cached zoxide zoxide init zsh + # `op completion zsh` emits `compdef _op op`. Running here rather than at rc + # time means compinit has already restored the real compdef, so it lands + # directly instead of going through the _DEFERRED_COMPDEFS queue. + _eval_cached op op completion zsh + [[ -f "$HOME/.config/op/plugins.sh" ]] && source "$HOME/.config/op/plugins.sh" +} + # ---- turbo plugins + aliases ---- [[ -f "$HOME/.config/zsh/plugins.zsh" ]] && source "$HOME/.config/zsh/plugins.zsh" [[ -f "$HOME/.config/zsh/aliases.zsh" ]] && source "$HOME/.config/zsh/aliases.zsh" -# ---- fzf + zoxide ---- -_eval_cached fzf fzf --zsh -_eval_cached zoxide zoxide init zsh - # ---- history prefix-search on Up/Down (OMZ-style) ---- # Type a prefix, then Up/Down cycles only through history entries starting with it. autoload -Uz up-line-or-beginning-search down-line-or-beginning-search @@ -88,11 +99,9 @@ bindkey '^[[B' down-line-or-beginning-search; bindkey '^[OB' down-line-or-beginn [[ -n "${terminfo[kcud1]}" ]] && bindkey "${terminfo[kcud1]}" down-line-or-beginning-search # ---- vendor integrations (guarded; no-op when tool absent) ---- -# `op completion zsh` already emits its own `compdef _op op` (queued by the stub above). -_eval_cached op op completion zsh -[[ -f "$HOME/.config/op/plugins.sh" ]] && source "$HOME/.config/op/plugins.sh" +# op's completion + shell plugins load from _deferred_tool_inits above; these are +# plain PATH/fpath edits with no subprocess, so they stay sync. [[ -d "$HOME/.grok/bin" ]] && { export PATH="$HOME/.grok/bin:$PATH"; fpath=("$HOME/.grok/completions/zsh" $fpath); } -[[ -f "$HOME/.openclaw/completions/openclaw.zsh" ]] && source "$HOME/.openclaw/completions/openclaw.zsh" [[ -d "$HOME/.composio" ]] && export PATH="$HOME/.composio:$PATH" # ---- chezmoi drift nudge ---- diff --git a/home/run_once_after_install-aerospace.sh.tmpl b/home/run_once_after_install-aerospace.sh.tmpl new file mode 100644 index 0000000..1eae2dd --- /dev/null +++ b/home/run_once_after_install-aerospace.sh.tmpl @@ -0,0 +1,30 @@ +{{ if and (eq .chezmoi.os "darwin") (not .headless) (not .container) -}} +#!/bin/sh +# AeroSpace — i3-like tiling WM for macOS. GUI-only, so this is gated to +# non-headless Macs; ~/.aerospace.toml is gated the same way in .chezmoiignore. +# +# TRUST NOTE: this is the repo's only tap and only cask — everything else brew +# installs (tmux, vim, mise) is homebrew-core. AeroSpace is not in homebrew-cask, +# so it comes from nikitabobko/tap, which is the upstream author's own tap for +# github.com/nikitabobko/AeroSpace — same trust boundary as the app itself, not a +# third party repackaging it. Installed fully qualified rather than via a bare +# `brew tap` so the provenance is explicit at the call site and no extra tap is +# left configured for unrelated formulae to resolve against. +# +# Never fails the apply: same policy as install-system-packages.sh. +set -eu + +command -v brew >/dev/null 2>&1 || { + echo "==> no brew; skipping AeroSpace (install manually: https://nikitabobko.github.io/AeroSpace)" >&2 + exit 0 +} + +if brew list --cask aerospace >/dev/null 2>&1 || [ -d /Applications/AeroSpace.app ]; then + exit 0 +fi + +echo "==> Installing AeroSpace (cask: nikitabobko/tap)" +brew install --cask nikitabobko/tap/aerospace || + echo "==> AeroSpace install failed; install manually: brew install --cask nikitabobko/tap/aerospace" >&2 +exit 0 +{{ end -}} diff --git a/home/run_once_before_install-mise.sh.tmpl b/home/run_once_before_install-mise.sh.tmpl index b66c334..d77449e 100644 --- a/home/run_once_before_install-mise.sh.tmpl +++ b/home/run_once_before_install-mise.sh.tmpl @@ -1,6 +1,48 @@ #!/bin/sh +# Installs mise, and — just as important — makes sure an *existing* mise is new +# enough. cli-tools.toml uses `[settings.ruby] compile` and +# `idiomatic_version_file_enable_tools`; mise from 2024 rejects the former with a +# hard TOML parse error, which fails install-tools.sh and aborts the whole apply. +# A presence-only check let that through: a Mac carrying a stale `brew install +# mise` never reached mise.run and then died at `mise install`. +# +# MISE_MIN is the oldest release verified to parse this repo's config, not a +# researched lower bound — the true minimum is somewhere below it. Erring high +# only costs an occasional no-op upgrade. set -eu -if ! command -v mise >/dev/null 2>&1 && [ ! -x "$HOME/.local/bin/mise" ]; then + +MISE_MIN="2026.8.2" + +MISE="" +if command -v mise >/dev/null 2>&1; then + MISE="$(command -v mise)" +elif [ -x "$HOME/.local/bin/mise" ]; then + MISE="$HOME/.local/bin/mise" +fi + +if [ -z "$MISE" ]; then echo "==> Installing mise" curl -fsSL https://mise.run | sh + exit 0 +fi + +# Field-wise numeric compare. Not `sort -V`: busybox sort (Alpine) is spotty on +# it. Not string compare: that ranks 2026.8.10 below 2026.8.2. +older_than() { + [ "$1" != "$2" ] && + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$1" ] +} + +# "2026.8.2 macos-arm64 (2026-08-04)" -> "2026.8.2" +CURRENT="$("$MISE" --version 2>/dev/null | awk '{print $1}')" + +if [ -n "$CURRENT" ] && older_than "$CURRENT" "$MISE_MIN"; then + echo "==> mise $CURRENT is older than $MISE_MIN; upgrading" + if brew list --versions mise >/dev/null 2>&1; then + # self-update refuses to touch a brew-managed install, and dropping a second + # mise in ~/.local/bin would only shadow it inside scripts that reorder PATH. + brew upgrade mise + elif ! "$MISE" self-update -y; then + curl -fsSL https://mise.run | sh + fi fi