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, yabai 7.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”:

  1. Start the agent: /opt/homebrew/bin/yabai --start-service
  2. 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.sh makes your current layout reproducible
  • ctrl+space opens fzf window-switcher → jump anywhere
  • ctrl+alt+b snapshots rules → ~/.yabai.d/backups/
  • Login & Dock hooks keep everything consistent

Ship it, and stop dragging windows ever again.