10x Mac windowing with yabai + skhd + fzf (deterministic Spaces, fuzzy jumping, one-key backups)
9/9/2025
If you use multiple desktops (Spaces) and keep reopening the same projects/tools daily, macOS will eventually dump everything into one Space after a reboot. This post fixes that decisively:
- Deterministic layout across Spaces via rules autogenerated from your current arrangement
- Spotlight-style fuzzy switcher to jump to any window in ~200ms
- One-key backup & daily auto-backup of your window rules
- Login automation to re-export + re-apply after Dock restarts
This is not a r/unixporn setup—just a minimal, production-grade workflow that gets out of your way.
Requirements: Homebrew,
yabai7.x,skhd,jq,fzf. For cross-Space moves/focus (window --space,space --focus) load yabai’s scripting addition (SA). On recent macOS, SA requires partially relaxing SIP; if you skip SA you still get everything except cross-Space moves during--apply(the fuzzy switcher will at least raise the app).
0) Mission Control settings that stop macOS from fighting you#
- Turn OFF: Desktop & Dock → Mission Control → “Automatically rearrange Spaces based on most recent use”
- (Multi-display) Turn ON: “Displays have separate Spaces”
- Stage Manager: OFF
1) Exporter: snapshot current layout → rules (numeric Space indices)#
The exporter queries every visible window, maps it to the Space index it’s in, and emits stable rules.
JetBrains projects are matched by project name (file ignored). Browser titles are tolerant to “271 new items” turning into 272, etc.
Create ~/bin/yabai-export-rules.sh:
#!/usr/bin/env bash
# Snapshot current windows -> ~/.yabai.d/generated-rules.sh
# - JetBrains: project-only matches (file ignored)
# - Browsers: tolerate changing counts; ignore app suffix in title
# Requires: /opt/homebrew/bin/{yabai,jq}
set -euo pipefail
YABAI="/opt/homebrew/bin/yabai"
JQ="/opt/homebrew/bin/jq"
OUT="${1:-$HOME/.yabai.d/generated-rules.sh}"
TMPDIR="$(/usr/bin/mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
mkdir -p "$(dirname "$OUT")"
# Wait for yabai to be ready
i=1; while [ $i -le 40 ]; do
if "$YABAI" -m query --spaces >/dev/null 2>&1; then break; fi
[ $i -eq 40 ] && { echo "ERROR: yabai not running / missing perms"; exit 1; }
sleep 0.25; i=$((i+1))
done
# Spaces snapshot (only to know indices; labels optional)
spaces_json="$("$YABAI" -m query --spaces)"
# Windows snapshot; fallback per-space if global is empty
windows_json="$("$YABAI" -m query --windows || true)"
windows_tsv="$TMPDIR/windows.tsv"
to_tsv() { # app \t title \t spaceIndex
"$JQ" -r '.[] | select(.app and .title and .space) | "\(.app)\t\(.title)\t\(.space)"'
}
len() { "$JQ" -c 'length'; }
if [ -n "$windows_json" ] && [ "$windows_json" != "null" ] \
&& [ "$(printf '%s' "$windows_json" | len)" != "0" ]; then
printf '%s' "$windows_json" | to_tsv > "$windows_tsv"
else
: > "$windows_tsv"
echo "$spaces_json" | "$JQ" -r '.[].index' | while read idx; do
w="$("$YABAI" -m query --windows --space "$idx" || true)"
if [ -n "$w" ] && [ "$w" != "null" ] && [ "$(printf '%s' "$w" | len)" != "0" ]; then
printf '%s' "$w" | to_tsv >> "$windows_tsv"
fi
done
fi
if [ ! -s "$windows_tsv" ]; then
echo "ERROR: No windows detected (check Accessibility & Screen Recording perms)." >&2
: > "$OUT"; exit 2
fi
# Emit rules (numeric Space index). JetBrains = project-only; Browsers = relaxed.
> "$OUT"
awk -F'\t' '
function trim(s){ sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s); return s }
function esc_rx(s){ gsub(/\\/,"\\\\",s); gsub(/\[/,"\\[",s); gsub(/\]/,"\\]",s);
gsub(/\(/,"\\(",s); gsub(/\)/,"\\)",s); gsub(/\{/,"\\{",s); gsub(/\}/,"\\}",s);
gsub(/\^/,"\\^",s); gsub(/\$/,"\\$",s); gsub(/\|/,"\\|",s); gsub(/\*/,"\\*",s);
gsub(/\+/,"\\+",s); gsub(/\?/,"\\?",s); gsub(/\./,"\\.",s); gsub(/"/,"\\\"",s); return s }
function isJB(a){ return (a=="WebStorm"||a=="PyCharm"||a=="IntelliJ IDEA"||a=="GoLand"||a=="CLion"||a=="PhpStorm"||a=="RubyMine"||a=="Android Studio") }
function isBrowser(a){ return (a=="Brave Browser"||a=="Google Chrome"||a=="Safari"||a=="Firefox"||a=="LibreWolf"||a=="Microsoft Edge") }
{
app=$1; raw=$2; idx=$3; patt="^" esc_rx(raw) "$";
if (isJB(app)) {
# Title often like: "project [~/project] – file.ext" (we only care about 'project')
proj=raw;
n=split(raw, a, " – "); if (n<2) n=split(raw, a, " - ");
if (n>=2) proj=a[1]; # flip to a[2] if your IDE shows "file – project"
sub(/[[:space:]]*\[[^]]+\][[:space:]]*$/,"",proj); proj=trim(proj);
if (proj=="") proj=raw;
p=esc_rx(proj);
patt=".*\\b" p "\\b.*";
} else if (isBrowser(app)) {
t=esc_rx(raw);
gsub(/[0-9]+/,"[0-9]+",t); # tolerate changing counts (Slack, etc.)
gsub(/(\\s*[–-]\\s*(Brave|Google\\ Chrome|Safari|Firefox|LibreWolf|Microsoft\\ Edge))$/,".*",t);
patt="^" t "$";
}
key=app "|" patt "|" idx;
if (!seen[key]++) printf "yabai -m rule --add app=\"^%s$\" title=\"%s\" space=%s manage=on\n", app, patt, idx;
}' "$windows_tsv" | sort -u > "$OUT"
echo "-> wrote $OUT"
echo "Reload: yabai -m rule --remove all 2>/dev/null || true; source \"$OUT\"; yabai -m rule --apply"
Usage:
chmod +x ~/bin/yabai-export-rules.sh
~/bin/yabai-export-rules.sh
yabai -m rule --remove all 2>/dev/null || true
source ~/.yabai.d/generated-rules.sh
yabai -m rule --list | jq length
yabai -m rule --apply # moves existing windows (needs SA for cross-Space)
2) Fuzzy “Spotlight-style” window jumper (yabai + fzf)#
~/.local/bin/win-jump.sh
#!/usr/bin/env bash
set -euo pipefail
PATH="/opt/homebrew/bin:$PATH"
YABAI=${YABAI:-/opt/homebrew/bin/yabai}
JQ=${JQ:-/opt/homebrew/bin/jq}
json="$($YABAI -m query --windows 2>/dev/null || echo '[]')"
printf '%s\n' "$json" | "$JQ" -r '
sort_by(.app, .space, .title)
| .[] | select(.app and .title and .space)
| [.id, .space, .app, .title] | @tsv' \
| fzf --height 60% --layout=reverse --with-nth=3,4 --delimiter=$'\t' --prompt='win> ' --query="${*:-}" \
| while IFS=$'\t' read -r id space app title; do
$YABAI -m space --focus "$space" >/dev/null 2>&1 || osascript -e "tell application \"$app\" to activate"
$YABAI -m window --focus "$id" >/dev/null 2>&1 || true
done
fzf needs a terminal. Use this tiny launcher to spawn iTerm/Terminal:
~/.local/bin/run-win-jump-in-iterm.sh
#!/usr/bin/env bash
set -euo pipefail
osascript >/dev/null <<'APPLESCRIPT'
set cmd to "~/.local/bin/win-jump.sh"
try
tell application "iTerm2"
create window with default profile command cmd
activate
end tell
on error
tell application "Terminal"
do script cmd
activate
end tell
end try
APPLESCRIPT
Bind a hotkey in skhd:
~/.skhdrc
# fuzzy window switcher
ctrl - space : /bin/bash -lc '~/.local/bin/run-win-jump-in-iterm.sh'
# reload skhd
alt + ctrl - r : skhd --reload
Free Ctrl+Space first: System Settings → Keyboard Shortcuts → Input Sources → disable “Select previous input source”.
Start/reload:
/opt/homebrew/bin/skhd --start-service # first time
skhd --reload
3) One-key backup (export + archive + rotate)#
~/.local/bin/yabai-backup-rules.sh
#!/usr/bin/env bash
set -euo pipefail
EXPORTER="$HOME/bin/yabai-export-rules.sh"
OUT="$HOME/.yabai.d/generated-rules.sh"
DESTDIR="$HOME/.yabai.d/backups"
mkdir -p "$DESTDIR"
"$EXPORTER"
ts="$(date +%Y%m%d-%H%M%S)"; host="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
dst="$DESTDIR/rules-$ts-$host.sh"
cp -f "$OUT" "$dst"; chmod 0644 "$dst"
ln -sf "$dst" "$DESTDIR/latest.sh"
# keep last 30
( ls -1t "$DESTDIR"/rules-*.sh 2>/dev/null || true ) | awk 'NR>30' | while read -r f; do rm -f "$f"; done
osascript -e "display notification \"Saved $(basename "$dst")\" with title \"yabai backup\""
echo "OK -> $dst"
Hotkey:
# ~/.skhdrc
ctrl + alt - b : /bin/bash -lc '~/.local/bin/yabai-backup-rules.sh'
4) Auto-export + apply at login (and after Dock restarts)#
Helper:
~/.local/bin/yabai-export-and-apply.sh
#!/usr/bin/env bash
set -euo pipefail
YABAI="/opt/homebrew/bin/yabai"
sleep 12 # let apps restore
~/bin/yabai-export-rules.sh
$YABAI -m rule --remove all 2>/dev/null || true
# shellcheck disable=SC1090
source ~/.yabai.d/generated-rules.sh
$YABAI -m rule --apply
LaunchAgent:
~/Library/LaunchAgents/com.user.yabai-export.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.user.yabai-export</string>
<key>RunAtLoad</key><true/>
<key>ProgramArguments</key>
<array><string>/bin/bash</string><string>-lc</string><string>~/.local/bin/yabai-export-and-apply.sh</string></array>
<key>EnvironmentVariables</key><dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StandardOutPath</key><string>/tmp/yabai-export.log</string>
<key>StandardErrorPath</key><string>/tmp/yabai-export.err</string>
</dict></plist>
Load once:
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.user.yabai-export.plist
launchctl enable gui/$UID/com.user.yabai-export
launchctl kickstart -k gui/$UID/com.user.yabai-export
Also re-run after Dock restarts (append to ~/.yabairc):
yabai -m signal --add event=dock_did_restart action="~/.local/bin/yabai-export-and-apply.sh"
5) Sanity probes (copy/paste)#
# yabai agent healthy?
launchctl list | grep -i koekeishiya.yabai
yabai --version; which yabai
# see spaces
yabai -m query --spaces | jq -r '.[] | "index=\(.index) id=\(.id) label=\(.label // "-") display=\(.display)"'
# windows detected?
yabai -m query --windows | jq 'length'
yabai -m query --windows | jq '.[0]'
# rules loaded?
yabai -m rule --list | jq length
# cross-space focus/move (needs scripting addition)
yabai -m space --focus 2
yabai -m window --space 3; yabai -m space --focus 3
6) Why this works (and why it’s robust)#
- Rules are generated from the truth (your current layout), not hand-authored guesses.
- JetBrains by project → titles survive file changes.
- Browsers tolerate counters (“271 new items”) and app suffixes (“– Brave/Chrome”).
- Numeric Space indices keep rule parsing compatible across yabai versions.
- Login + Dock signals keep the layout consistent after restarts.
7) Optional: scripting-addition (for cross-Space actions)#
Some actions require SA (e.g., moving/focusing across Spaces). If space --focus N or rule --apply complains about “scripting-addition”:
- Start the agent:
/opt/homebrew/bin/yabai --start-service - Load SA with full path (sudo’s PATH won’t have Homebrew):
sudo /opt/homebrew/bin/yabai --load-sa
killall Dock
If macOS blocks loading, you haven’t allowed SA (requires partially relaxing SIP from Recovery; do this only if you understand the trade-offs). Even without SA, 90% of this workflow still works; you just won’t auto-move windows across Spaces on apply.
8) My defaults (steal them)#
- Space 1: Browser / Comms
- Space 2: WebStorm
project-A - Space 3: PyCharm
project-B - Space 4: iTerm (infra, logs)
- Space 5: Docs / Notes
Add or delete Spaces as you see fit—the exporter just reads what’s live and emits rules accordingly.
TL;DR#
~/bin/yabai-export-rules.shmakes your current layout reproduciblectrl+spaceopensfzfwindow-switcher → jump anywherectrl+alt+bsnapshots rules →~/.yabai.d/backups/- Login & Dock hooks keep everything consistent
Ship it, and stop dragging windows ever again.