agentlytics
Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravi…
Simple Hotkey Daemon for macOS, ported from skhd by koekeishiya
git clone https://github.com/jackielii/skhd.zig.gitjackielii/skhd.zigSimple Hotkey Daemon for macOS, ported from skhd to Zig.
This implementation is fully compatible with the original skhd configuration format - your existing .skhdrc files will work without modification. Additionally, it includes new features like process groups and command definitions (.define) for cleaner configs, key forwarding/remapping, and improved error reporting.
Bind a run of chords to one action, fired only when the chords arrive in order within a time budget (default 300ms, set with .sequence_timeout):
# Two quick Cmd-Q presses to quit Chrome; a single press does nothing.
cmd - q, cmd - q [
"Google Chrome" | cmd - q
]
A shorter binding can also be the prefix of a longer sequence and fires when the sequence doesn't complete — like Vim's timeoutlen, but the wait only happens in apps where a longer match applies. That lets a global binding and an app's native shortcut share one chord:
lcmd - k : yabai -m window --focus north # focus north — everywhere, instantly
cmd - k, cmd - k [
"Google Chrome" | cmd - k # …but double-tap sends Chrome its own Cmd-K
]
Every action form works on a sequence (commands, forwarding, unbound/passthrough, mode activation, process lists). See SYNTAX.md for the grammar and the v0.2.0 CHANGELOG entry.
skhd.zig now ships a system-level grabber daemon that enables remapping the user-session event tap can't reach. New directives:
.remap caps_lock [device <alias>] : escape — instant 1:1 swap, applied via hidutil (no daemon)..remap key [device <alias>] { tap: …, hold: …, … } — tap vs. hold on the same key (e.g. caps_lock tapped = escape, held = control). Routed through skhd-grabber (root LaunchDaemon) which seizes the keyboard at the IOKit/HID level via Karabiner DriverKit. Required for caps-lock-class rules and modifier-as-hold rules that hidutil silently drops.hold can target a skhd mode instead of a key, so holding the source key activates the layer for the duration of the hold (e.g. hold space to enter fn_layer, where fn_layer < 1 | f1 rebinds the number row to F-keys)..device <alias> { vendor: 0x…, product: 0x… } — scope rules to a specific keyboard, so one config does the right thing on each machine.Install via Homebrew, or download the .app bundle from GitHub releases:
brew install jackielii/tap/skhd-zig skhd --install-service
Or, manual install:
gh release download v0.1.0 --repo jackielii/skhd.zig --pattern '*-arm64-macos.tar.gz' tar -xzf skhd-arm64-macos.tar.gz -C /tmp sudo mv /tmp/skhd.app /Applications/ /Applications/skhd.app/Contents/MacOS/skhd --install-service
--install-service registers the agent, auto-installs the Karabiner DriverKit pkg if your config has tap-hold rules, prompts for the grabber install via sudo, and pops Accessibility + Input Monitoring dialogs in sequence — one click each.
Try it out — drop this into ~/.config/skhd/skhdrc (replace the vendor/product IDs with your own — find them via skhd --grabber-status once installed, or System Information → USB):
# 1. Declare the keyboard you want to remap.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# 2. caps_lock acts like ctrl when held, escape when tapped.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
}
# 3. Hold space to enter a "function layer", release to exit.
:: fn_layer @
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
retro_tap : on
}
# 4. While the layer is held, number row → F-row.
fn_layer < 1 | f1
fn_layer < 2 | f2
fn_layer < 3 | f3
# … etc
Save, then skhd --restart-service. Tap caps_lock → escape. Hold caps_lock + c → ctrl-c. Hold space then press 1 → F1.
Verify with skhd --status (single command shows the agent, grabber, dext, and TCC state) or skhd --grabber-status (drills into the grabber-side dependency chain). Roll back with skhd --uninstall-service (it prints follow-up sudo skhd --uninstall-grabber instructions if anything's still on disk).
Larger real-world example — my own builtin.skhdrc, which makes a MacBook built-in keyboard behave like a Keebio Convolution running custom QMK firmware (caps_lock as ctrl/escape, space as fn_layer, fn_layer < hjkl as arrows, number row → F-row under layer, etc.):
See skhd-grabber below for the full architecture, SYNTAX.md for the new directive grammar, and the v0.1.0 CHANGELOG entry for everything that changed.
The easiest way to install skhd.zig:
brew install jackielii/tap/skhd-zig
Upgrading from 0.0.17 or earlier? See docs/UPGRADING.md. The 0.0.18 release switches to an
.appbundle to keep accessibility permissions working on macOS Tahoe; one-time re-grant is required.
Both architectures are published (Intel is cross-compiled from the arm64 runner):
skhd-arm64-macos.tar.gz (Apple Silicon)skhd-x86_64-macos.tar.gz (Intel)Extract and install (substitute the tarball for your arch):
tar -xzf skhd-arm64-macos.tar.gz # or skhd-x86_64-macos.tar.gz on Intel mv skhd.app /Applications/ # Optional: expose the CLI on your PATH sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
Then grant accessibility (see Granting Accessibility below).
If you want a main-branch build at a specific optimization level (Debug, ReleaseSafe, ReleaseFast, ReleaseSmall), you can download one directly from GitHub Actions. These are signed skhd.app bundles with skhd-grabber included — same layout as the release tarballs, just built from main instead of a tag. Apple Silicon only.
main.skhd-Debug - Debug build with full debugging symbolsskhd-ReleaseSafe - Release build with runtime safety checksskhd-ReleaseFast - Optimized for performance (recommended for daily use)skhd-ReleaseSmall - Optimized for binary sizeGitHub wraps each artifact in a .zip. Inside is skhd-arm64-macos.tar.gz; extract and install the same way as a release tarball:
unzip skhd-ReleaseFast.zip tar -xzf skhd-arm64-macos.tar.gz mv skhd.app /Applications/ sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
Then grant accessibility (see Granting Accessibility below).
# Clone the repository git clone https://github.com/jackielii/skhd.zig cd skhd.zig # Build the .app bundle and code-sign it # (required for Accessibility to persist on macOS Tahoe / Sequoia) zig build sign-app -Doptimize=ReleaseFast # Install: symlink the bundle into /Applications, expose the CLI ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app sudo ln -sfn /Applications/skhd.app/Contents/MacOS/skhd /usr/local/bin/skhd
For quick dev iteration without the bundle wrapper, zig build still produces a bare binary at zig-out/bin/skhd. The .app is only needed for the System Settings → Accessibility picker.
The first run of zig build sign-app creates a self-signed skhd-cert certificate in your login keychain. See docs/CODE_SIGNING.md for details and troubleshooting.
skhd captures keyboard events via macOS Core Graphics, which requires Accessibility permission. On first launch, macOS pops up the Accessibility dialog with a deep-link to System Settings:
skhd --restart-service (or skhd --start-service if not yet running)You only need to do this once. The bundle's stable identifier (com.jackielii.skhd) means TCC entries persist across rebuilds and brew upgrade.
If your config uses .remap or .taphold rules, macOS will additionally
prompt you for Input Monitoring the first time the grabber starts.
The grabber binary lives inside the same skhd.app bundle, so the same
bundle identifier covers it — one click in System Settings → Privacy
& Security → Input Monitoring approves both processes.
After installation, run skhd as a service for automatic startup:
# Install and start the service skhd --install-service skhd --start-service # Check if skhd is running properly skhd --status # Restart service (useful for restarting after giving accessibility permissions) skhd --restart-service # Stop service skhd --stop-service # Uninstall service skhd --uninstall-service
The service will:
~/Library/Logs/skhd.log~/.config/skhd/skhdrc or ~/.skhdrchidutil and tap-vs-hold rules via the optional skhd-grabber daemon. See Device-aware remapping..alias $hyper cmd + alt + ctrl + shift, .alias $grave 0x32)mouse1–mouse5 (e.g. cmd - mouse1 : ...); use -> for passthrough so the click still reaches the appcmd - w ; window : echo "Window mode").device + .remap (v0.1.0): per-device HID-layer remapping, both colon (1:1) and block (tap/hold) forms..remap hold: target can be a skhd mode, so holding a key activates a layer for the duration of the hold.--version / -v - Display version information--help - Show usage information-c / --config - Specify config file location-o / --observe - Observe mode (echo keycodes and modifiers)-V / --verbose - Debug output with detailed logging-k / --key - Synthesize keypress for testing-t / --text - Synthesize text input-r / --reload - Signal reload to running instance-h / --no-hotload - Disable hotloading-P / --profile - Profile event handling (Debug and ReleaseSafe builds only)--install-service - Install launchd service (also auto-installs the DriverKit dext + grabber if your config uses .remap/.taphold)--uninstall-service - Remove launchd service--start-service - Start as service--restart-service - Restart service--stop-service - Stop service--status - Combined health: agent PID, event tap, grabber, dext, TCC/tmp/skhd_$USER.pid)~/Library/Logs/skhd.log)--install-grabber - Install skhd-grabber LaunchDaemon (sudo)--uninstall-grabber - Remove skhd-grabber LaunchDaemon (sudo)--install-dext - Install the pinned Karabiner DriverKit VirtualHIDDevice (and its launchd plist)--grabber-status - Drill into the grabber dependency chain (socket, dext version, IOKit match)/var/log/skhd-grabber.log).load directive.remap for instant 1:1 swaps, block-form for tap-vs-hold semanticshold: can target a skhd mode instead of a key, so holding the source key activates a layerskhd-grabber + Karabiner DriverKit, sidestepping limits of the user-session event tap# Build the project (creates executable in zig-out/bin/) zig build # Build in release mode with optimizations zig build -Doptimize=ReleaseFast # Run the application zig build run # Run with arguments zig build run -- -V -c ~/.config/skhd/skhdrc # Run tests zig build test
skhd.zig looks for configuration files in the following order:
-c flag~/.config/skhd/skhdrc~/.skhdrcThe configuration syntax is fully compatible with the original skhd. See SYNTAX.md for the complete syntax reference and grammar.
# Use custom shell (skips interactive shell overhead)
.shell "/bin/dash"
# Blacklist applications (skip hotkey processing)
.blacklist [
"dota2"
"Microsoft Remote Desktop"
"VMware Fusion"
]
# Load additional config files
.load "~/.config/skhd/extra.skhdrc"
# Define aliases (New in skhd.zig!)
.alias $hyper cmd + alt + ctrl + shift # modifier alias
.alias $super cmd + alt
.alias $grave 0x32 # key alias (UK keyboard backtick)
# Define process groups for reuse (New in skhd.zig!)
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define native_apps ["kitty", "wezterm", "chrome", "whatsapp"]
.define browser_apps ["chrome", "safari", "firefox", "edge"]
# Define reusable commands with placeholders (New in skhd.zig!)
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define yabai_swap : yabai -m window --swap {{1}} || (yabai -m window --display {{1}} && yabai -m display --focus {{1}})
.define toggle_app : open -a "{{1}}" || osascript -e 'tell app "{{1}}" to quit'
.define resize_window : yabai -m window --resize {{1}}:{{2}}:{{3}}
.define toggle_scratchpad : yabai -m window --toggle {{1}} || open -a "{{2}}"
# Declare a keyboard by VendorID/ProductID (v0.1.0)
# See "Device-aware remapping" below for full details.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# Per-device HID remap — colon form (1:1 swap, applied via hidutil)
.remap caps_lock [device builtin] : escape
# Per-device tap-hold (routed through skhd-grabber)
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
}
# Basic format: modifier - key : command cmd - a : echo "Command+A pressed" # Multiple modifiers cmd + shift - t : open -a Terminal # Different modifier combinations ctrl - h : echo "Control+H" alt - space : echo "Alt+Space" shift - f1 : echo "Shift+F1"
A sequence is just a hotkey whose trigger has more than one comma-separated
chord — every action form (commands, forwarding, process lists, modes, ->,
~) works on it exactly as it does on a single-chord hotkey. Each step must
follow the previous one within 300ms, or the pending chord expires and is
dropped (never replayed).
# Require two Cmd-Q presses to quit a protected app. Everywhere else the # first Cmd-Q has no skhd binding, so it passes through and macOS quits # normally. The second press forwards a real Cmd-Q. cmd - q, cmd - q [ "Protected App" | cmd - q ] # Complete modifiers are written on every chord. cmd - k, cmd - c : echo "sequence completed"
-> and ~ apply only to the final chord — earlier chords are always
consumed, since completion isn't known until the sequence finishes. See
SYNTAX.md for the full grammar and the
uniqueness rule that governs when a sequence may share a prefix with another
hotkey.
# Basic modifiers cmd # Command key ctrl # Control key alt # Alt/Option key shift # Shift key fn # Function key # Left/right specific modifiers lcmd, rcmd # Left/right Command lctrl, rctrl # Left/right Control lalt, ralt # Left/right Alt lshift, rshift # Left/right Shift # Special modifier combinations hyper # cmd + shift + alt + ctrl meh # shift + alt + ctrl
# Navigation keys cmd - left : echo "Left arrow" cmd - right : echo "Right arrow" cmd - up : echo "Up arrow" cmd - down : echo "Down arrow" # Special keys cmd - space : echo "Space" cmd - return : echo "Return/Enter" cmd - tab : echo "Tab" cmd - escape : echo "Escape" cmd - delete : echo "Delete/Backspace" cmd - home : echo "Home" cmd - end : echo "End" cmd - pageup : echo "Page Up" cmd - pagedown : echo "Page Down" # Function keys cmd - f1 : echo "F1" cmd - f12 : echo "F12" # Media keys sound_up : echo "Volume Up" sound_down : echo "Volume Down" mute : echo "Mute" brightness_up : echo "Brightness Up" brightness_down : echo "Brightness Down"
# Different commands for different applications
cmd - n [
"terminal" : echo "New terminal window"
"safari" : echo "New safari window"
"finder" : echo "New finder window"
* : echo "New window in other apps"
]
# Keyboard layout fixes 0xa | 0x32 # UK keyboard § to ` shift - 0xa | shift - 0x32 # shift - § to ~ # Function key navigation (for laptop keyboards) fn - j | down fn - k | up fn - h | left fn - l | right # When you have cmd - number for yabai spaces, # and you still want the cmd - number to work in applications ctrl - 1 | cmd - 1 ctrl - 2 | cmd - 2 ctrl - 3 | cmd - 3
# Execute command but still send keypress to application cmd - p -> : echo "This runs but Cmd+P still goes to app"
# Window management mode with anybar visual indicator # Install anybar: brew install --cask anybar # Define window management mode for warp/stack operations # Use anybar to indicate the mode: https://github.com/tonsky/AnyBar :: winmode @ : echo -n "red" | nc -4u -w0 localhost 1738 :: default : echo -n "hollow" | nc -4u -w0 localhost 1738 # Enter window mode with meh + m (shift + alt + ctrl + m) meh - w ; winmode winmode < escape ; default winmode < meh - w ; default # Alternative: Enter window mode AND show notification (New in skhd.zig!) # This executes the command when switching to the mode # It allows for different commands to execute and switch to another mode meh - w ; winmode : osascript -e 'display notification "Window mode active" with title "skhd"' winmode < escape ; default : osascript -e 'display notification "Normal mode" with title "skhd"' # Focus operations - basic hjkl for focus winmode < h : yabai -m window --focus west || yabai -m display --focus west winmode < j : yabai -m window --focus south || yabai -m display --focus south winmode < k : yabai -m window --focus north || yabai -m display --focus north winmode < l : yabai -m window --focus east || yabai -m display --focus east # Move operations - shift + hjkl for moving winmode < shift - h : yabai -m window --move rel:-80:0 winmode < shift - j : yabai -m window --move rel:0:80 winmode < shift - k : yabai -m window --move rel:0:-80 winmode < shift - l : yabai -m window --move rel:80:0 # Warp operations - alt + shift + hjkl for warping winmode < alt + shift - h : yabai -m window --warp west winmode < alt + shift - j : yabai -m window --warp south winmode < alt + shift - k : yabai -m window --warp north winmode < alt + shift - l : yabai -m window --warp east # Stack operations - ctrl + shift + hjkl for stacking winmode < ctrl + shift - h : yabai -m window --stack west winmode < ctrl + shift - j : yabai -m window --stack south winmode < ctrl + shift - k : yabai -m window --stack north winmode < ctrl + shift - l : yabai -m window --stack east # Stack management shortcuts winmode < s : yabai -m window --insert stack # Toggle stack mode winmode < u : yabai -m window --toggle float; yabai -m window --toggle float # Unstack window winmode < n : yabai -m window --focus stack.next # Navigate stack next winmode < p : yabai -m window --focus stack.prev # Navigate stack prev # Resize submode winmode < r ; resize :: resize @ : echo -n "orange" | nc -4u -w0 localhost 1738 resize < h : yabai -m window --resize left:-20:0 resize < j : yabai -m window --resize bottom:0:20 resize < k : yabai -m window --resize top:0:-20 resize < l : yabai -m window --resize right:20:0 resize < escape ; winmode
# Focus windows using command definitions (New in skhd.zig!)
cmd - h : @yabai_focus("west")
cmd - j : @yabai_focus("south")
cmd - k : @yabai_focus("north")
cmd - l : @yabai_focus("east")
# Move/swap windows using command definitions
cmd + shift - h : @yabai_swap("west")
cmd + shift - j : @yabai_swap("south")
cmd + shift - k : @yabai_swap("north")
cmd + shift - l : @yabai_swap("east")
# Resize windows using command definitions
cmd + ctrl - h : @resize_window("left", "-20", "0")
cmd + ctrl - l : @resize_window("right", "20", "0")
# Switch spaces
cmd - 1 : yabai -m space --focus 1
cmd - 2 : yabai -m space --focus 2
# Quick app launching (traditional way)
alt - return : open -a Terminal
alt - b : open -a Safari
# Toggle apps using command definitions (New in skhd.zig!)
alt - f : @toggle_app("Finder")
alt - c : @toggle_app("Visual Studio Code")
# Scratchpad apps with yabai (New in skhd.zig!)
# In yabairc: yabai -m rule --add app="^YouTube Music$" scratchpad=music grid=11:11:1:1:9:9
alt - m : @toggle_scratchpad("music", "YouTube Music")
alt - n : @toggle_scratchpad("notes", "Notes")
# Linux-style word navigation and deletion
ctrl - backspace [
@native_apps ~ # Terminal apps handle natively
* | alt - backspace # Other apps: delete word
]
ctrl - left [
@native_apps ~ # Terminal apps handle natively
* | alt - left # Other apps: move word left
]
ctrl - right [
@native_apps ~ # Terminal apps handle natively
* | alt - right # Other apps: move word right
]
# Home/End key behavior (with shift for selection)
home [
@native_apps ~ # Terminal apps handle natively
* | cmd - left # Other apps: line start
]
shift - home [
@native_apps ~ # Terminal apps handle natively
* | cmd + shift - left # Other apps: select to line start
]
# Ctrl+Home/End for document navigation
ctrl - home [
@native_apps ~ # Terminal apps handle natively
* | cmd - up # Other apps: document start
]
ctrl - end [
@native_apps ~ # Terminal apps handle natively
* | cmd - down # Other apps: document end
]
.device + .remap).remap rewrites keys at the HID layer per device. Two forms:
Colon form — instant 1:1 swap, applied via hidutil (no daemon needed):
# Declare the device once, by VendorID/ProductID.
.device builtin { vendor: 0x05AC, product: 0x0342 }
# UK ISO MacBook: make § (top-left) act as the ISO grave key, so it types `.
.remap non_us_backslash [device builtin] : grave
Block form — tap-hold semantics (caps_lock → tap=escape / hold=ctrl,
space → fn_layer, etc.). Goes through skhd-grabber (see below) because
hidutil can't do tap vs. hold.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
retro_tap : off
}
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
permissive_hold : on
retro_tap : on
}
Source/destination names use HID-standard physical-position naming
(layout-independent) — different from the macOS virtual-keycode names
shown by skhd -o. Run skhd --grabber-status or check
src/HidKeyMap.zig for the full list. Common: a-z, 0-9, caps_lock,
escape, space, return, tab, backspace, lctrl, lshift, lalt,
lcmd (and r* variants), f1..f20, minus, equal, lbracket,
rbracket, backslash, semicolon, quote, grave, comma,
period, slash, non_us_backslash.
macOS's user-level event tap can't see caps_lock or rewrite it cleanly
without LED toggle artifacts. Block-form .remap rules go through a
small system daemon (skhd-grabber) that runs as root, seizes the
matched keyboard via IOHIDManager, and injects through the Karabiner
DriverKit virtual HID device.
# Single command — installs the per-user agent and, if your config has # caps_lock-class rules with a connected target device, prompts (Y/n) # to install the system grabber via sudo. The same prompt also auto- # downloads + installs the Karabiner DriverKit .pkg if it's missing # and writes the launchd plist for its userland daemon (the .pkg's # postinstall is a no-op `killall`, so we wire up launchd ourselves — # but we skip it cleanly when Karabiner-Elements is already managing # that label via SMAppService). skhd --install-service
After the grabber install succeeds the agent triggers the Input
Monitoring approval dialog. Granting it to skhd.app covers the
grabber too: both binaries are signed with the same bundle ID and
the grabber runs from inside skhd.app/Contents/MacOS/, so TCC
bundle-keys the grant — one click, both processes covered. No
manual "add /usr/local/libexec/skhd-grabber to Input Monitoring"
step.
If the prompt didn't fire (e.g. you added .remap rules later) or
you want to install the grabber separately:
sudo skhd --install-grabber
Diagnostic walk-through of every prerequisite (dext, VHIDD daemon, grabber plist + process, IPC socket):
skhd --grabber-status
skhd-grabber requires the Karabiner DriverKit VirtualHIDDevice
extension to inject HID events. The agent's install flow auto-installs
the pinned version (currently v6.14.0; sha-256 verified) the first time
you run --install-service, so most users never touch this directly.
If you'd rather install it ahead of time:
skhd --install-dext # downloads + installs the pkg, writes the
# VHIDD daemon launchd plist (or skips if
# Karabiner-Elements is already handling it)
Upstream releases: https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice
After install, macOS will prompt you to approve the system extension in System Settings → General → Login Items & Extensions → Driver Extensions.
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber + the VHIDD
# daemon launchd plist we wrote
--uninstall-service prints any follow-up commands (the grabber
isn't auto-removed because it's a separate sudo step). For the
Karabiner DriverKit pkg files and the kernel-loaded dext (pqrs's
domain), run their uninstall scripts under
/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice/scripts/uninstall/,
or toggle the dext off via System Settings → Login Items &
Extensions → Driver Extensions.
If you share one config across a laptop and a desktop, .remap
block-form rules targeting the laptop's built-in keyboard simply
don't fire on the desktop — --install-service and the agent both
detect that the target device isn't connected and skip the grabber
entirely on that machine. No need to install the grabber or the dext
on a machine that doesn't need them.
HIDKeyboardCapsLockDelayOverride requires an
Apple Developer ID signature. Unsigned builds fall back to a reactive
workaround: the grabber reads the OS caps_lock state via
CGEventSourceFlagsState and, when Apple's firmware-level toggle
fires, injects a vhidd caps_lock toggle to flip it back. Works
cleanly in practice (no LED flash); see src/grabber/HidSeize.zig
for the rationale.org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon)
via SMAppService.daemon, so we share that piece — --install-dext
detects KE's registration and skips writing our own plist. But the
grabber layer (karabiner_grabber for KE, skhd-grabber for us)
still wants exclusive seize on the same keyboard. If you're running
Karabiner-Elements, disable its grabber
(sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber)
before starting skhd-grabber. skhd --status and
--install-grabber flag this conflict when detected.Because the grabber holds the keyboard exclusively, a rare failure (typically around sleep/wake or power transitions) can leave every key dead — presses are swallowed and nothing reaches macOS, so you can't type your way out. The grabber auto-recovers from every failure mode we've identified (device re-enumeration, sleep-stale seize, too-early wake seize, dead injection socket), but if you ever do get stuck, work down this ladder — no second machine required:
skhd --restart-serviceRestarting the agent makes the grabber drop and re-acquire the keyboard seize (no sudo needed). If that's not enough — the injection channel itself may be wedged — restart the grabber, which rebuilds everything:
sudo launchctl kickstart -k system/com.jackielii.skhd.grabberOr, to stop remapping entirely and get the native keyboard back:
sudo launchctl bootout system/com.jackielii.skhd.grabber
If it happens, /var/log/skhd-grabber.log records the full event
sequence (power transitions, seize lifecycle, recovery attempts) —
please attach the relevant window to a GitHub issue.
This whole feature exists because of Karabiner-Elements by Takayama Fumihiko (pqrs.org). The architecture, the idea, and the runtime dependency all come from there — skhd.zig is reusing pqrs's lower-level work to plug "QMK-style remapping" into a config format more skhd users already know.
Karabiner-Elements is the comprehensive keyboard customizer for macOS: a polished GUI, complex modifications, simultaneous keys, parameterized rules, an event viewer, profiles, a giant community library of rule presets, and years of refinement. If you want a turnkey keyboard remapper with a UI, install Karabiner-Elements — it's the right tool. skhd.zig's grabber path is intentionally narrower:
.skhdrc plus a few new directives. No JSON, no GUI, no separate rule-set system.skhd --status and skhd --grabber-status.If you need anything more sophisticated than the four directives in this README, Karabiner-Elements is unequivocally the better choice.
From Karabiner — architecture and runtime infrastructure:
skhd --install-dext, share the launchd registration if Karabiner-Elements is already there, and never modify their code. Apache-2.0 licensed; the .pkg ships under pqrs's signing identity.caps_lock, non_us_backslash, lctrl, etc. (layout-independent physical positions, distinct from the macOS virtual-keycode names skhd uses elsewhere) — Karabiner uses the same identifiers, so cross-referencing their docs for which name maps to which physical key is direct.From QMK — tap-hold parameters and defaults:
We deliberately do not use Karabiner's complex modifications JSON dialect (verbose, camelCase, to_if_alone / to_if_held_down / etc.). skhd users come from a .skhdrc background and want a config that reads like the rest of skhd. We follow QMK firmware's tap-hold model instead: snake_case keywords, the same parameter set you'd put in a QMK config.h, and the same defaults.
timeout ↔ QMK TAPPING_TERM (default 200ms).permissive_hold ↔ QMK PERMISSIVE_HOLD.hold_on_other_key_press ↔ QMK HOLD_ON_OTHER_KEY_PRESS.retro_tap ↔ QMK RETRO_TAPPING.If you've configured a custom keyboard in QMK, you already know how every knob in .taphold behaves — they're direct ports. QMK's docs/tap_hold.md is the canonical long-form reference for why each parameter exists and what edge cases it solves; our parser implements the same semantics rule-for-rule.
skhd-grabber and Karabiner-Elements both want exclusive HID seize on the same keyboard, so running both grabbers at once doesn't work. We coexist at the DriverKit daemon layer (we share the same org.pqrs.service.daemon.Karabiner-VirtualHIDDevice-Daemon registration; if Karabiner-Elements is installed, our --install-dext detects its SMAppService registration and skips writing our own plist), but you have to pick one grabber at a time. See the Coexistence caveat above for the disable command.
If you're switching from Karabiner-Elements to skhd.zig: keep the dext, keep the daemon, just disable karabiner_grabber. If you want to go back the other way: sudo skhd --uninstall-grabber and re-enable Karabiner-Elements.
Thanks again to pqrs and the Karabiner-Elements community. None of this is novel work on our side — we're just packaging a narrow slice in a different config format.
When something isn't working, start with these:
skhd --status # one-line summary: agent, grabber, dext, TCC
skhd --grabber-status # drills into the grabber dependency chain
# (socket, dext version, IOKit match, …)
--status is the fastest way to spot a missing piece (e.g. agent running
but grabber not installed, or dext loaded but not enabled).
--grabber-status is what to run when a .remap/.taphold rule isn't
firing.
Two processes, two log files:
# Agent (user-session skhd) — config parsing, event tap, hotkey dispatch. tail -f ~/Library/Logs/skhd.log # skhd-grabber (root LaunchDaemon) — only present if you installed the # grabber. Captures HID seize, tap-hold timing, layer pushes, IPC traffic. sudo tail -f /var/log/skhd-grabber.log
For unified-logging captures across both processes:
log show --last 5m --predicate 'process == "skhd" OR process == "skhd-grabber"'
| Build | -V shows |
-P profiling |
|---|---|---|
| ReleaseFast (Homebrew default) | errors + warnings only | disabled (compiled out) |
| ReleaseSafe | + info | available |
Debug (zig build) |
+ debug | available with full traces |
Homebrew installs are ReleaseFast, so -V against the installed binary is
intentionally quiet. To dig into a hotkey misbehaviour, run a Debug or
ReleaseSafe build directly:
zig build run -- -V # debug logs
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -V # info logs, prod-shaped binary
Verbose mode also preserves child-command stdout/stderr (useful for
diagnosing why a : command is silent), at a small per-event cost.
skhd -o # echo every keycode + modifier the tap sees skhd -k "cmd + shift - t" # synthesize a keypress skhd -t "hello world" # synthesize text skhd -r # reload running instance's config
skhd -o prints macOS virtual keycodes (e.g. 0x35 for escape).
.remap/.taphold rules use HID-standard names (e.g. escape) — see
Device-aware remapping.
zig build && ./zig-out/bin/skhd -P # Debug
zig build -Doptimize=ReleaseSafe && \
./zig-out/bin/skhd -P # ReleaseSafe — closer to prod
Ctrl-C prints the trace summary.
zig build alloc -- -V
The event loop is allocation-free in release builds, so any allocation in the hot path during interactive use is a regression.
| Symptom | Start here |
|---|---|
| Hotkey isn't firing | skhd --status — agent running? Accessibility granted? |
| TCC granted but keys silently swallowed | tccutil reset ListenEvent com.jackielii.skhd && tccutil reset Accessibility com.jackielii.skhd, then skhd --restart-service and re-grant |
.remap / .taphold does nothing |
skhd --grabber-status — dext enabled? grabber running? device matched? |
| Caps lock LED toggles weirdly | /var/log/skhd-grabber.log; search for HIDKeyboardCapsLockDelayOverride |
| Karabiner-Elements also installed | sudo launchctl bootout system/org.pqrs.service.daemon.karabiner_grabber |
skhd --uninstall-service # removes the LaunchAgent
sudo skhd --uninstall-grabber # removes skhd-grabber
# (Karabiner DriverKit dext stays — see Uninstall above)
systemextensionsctl list # inspect dext activation/enabled state
zig build testThis project maintains compatibility with the original skhd license.
more like this
Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravi…
Free open-source Cloudflare R2 desktop client and S3 GUI for macOS, Windows, and Linux. Manage Cloudflare R2, AWS S3, M…
meine 🌒 - A CLI file manager and system utility built with Textual. It combines intuitive command parsing with rich t…
search projects, people, and tags