Lightweight macOS keyboard remapper using CGEventTap. No kernel extensions, no DriverKit, no Karabiner dependency.
Switcheroo has two parts:
- The daemon performs the keyboard remapping. Install it with Homebrew and run it as a background service.
- The optional Raycast extension lets you view and edit remaps. It does not install or replace the daemon. Store submission is awaiting review.
You can use Switcheroo without Raycast by editing ~/.config/switcheroo/config.toml directly. It supports modifier remaps, key swaps, tap-hold, conditional remaps, and chords. Modifier mappings are reapplied at startup and after wake; they do not persist across reboot by themselves.
Requires macOS and Homebrew. Homebrew builds the daemon from source, so the first installation may take a few minutes. No Apple Developer account is needed.
Already using a standalone installation? Follow the migration instructions before starting the Homebrew service. Do not run both providers at once; brew services stop does not stop a standalone LaunchAgent.
brew tap mitchelljphayes/switcheroo
brew install switcherooDo not start the service until you have configured it and granted Accessibility permission.
Copy the sample only if you do not already have a config file or symlink:
mkdir -p "$HOME/.config/switcheroo"
config="$HOME/.config/switcheroo/config.toml"
if [ ! -e "$config" ] && [ ! -L "$config" ]; then
cp "$(brew --prefix)/etc/switcheroo/config.toml" "$config"
fi
open -e "$config"Review the mappings before starting the daemon: the sample changes keyboard behavior. See the sample config and config reference. An existing config is left untouched.
-
Print the installed app path:
echo "$(brew --prefix switcheroo)/Switcheroo.app"
-
Open System Settings → Privacy & Security → Accessibility.
-
Click +, use ⌘⇧G to navigate to the printed path, and add
Switcheroo.app. -
Enable its permission. Grant access to Switcheroo, not just Raycast.
The locally built app is ad-hoc signed. After an upgrade or rebuild, you may need to remove and re-add its Accessibility entry if remapping stops working.
brew services start switcheroo
brew services info switcheroo
switcheroo --versionThe service runs in the background and starts at login. Test a mapping from your config. If it does not work, check troubleshooting below.
Homebrew does not install the Raycast extension. See Raycast Extension for its current availability and local installation instructions.
For a Homebrew installation:
brew services info switcheroo # check service status
brew services restart switcheroo # apply manually edited config
brew services stop switcheroo # stop remapping and disable start-at-login
brew services start switcheroo # start again and enable start-at-loginKeep the daemon running while using remaps. A normal shutdown removes Switcheroo-owned modifier mappings and restores the previous mappings where safe.
Store status: the extension is submitted for review, not yet available as a Store install. You can use the daemon now; Raycast is optional.
The extension provides View Remaps, Add Remap, Restart Switcheroo, View Logs, and Edit Config. For a local installation while review is pending, follow the extension guide. The latest Store candidate is on the submission branch, which may differ from the extension source in this repository.
After the initial local build/import, stop npm run dev with Ctrl+C. The built commands remain available; the watcher is only needed while developing. Stopping it does not stop the remapping daemon.
Once the Store version is approved, install it from Raycast and remove any duplicate local development copy in Raycast Settings → Extensions.
brew update
brew upgrade switcheroo
brew services restart switcherooRecheck Accessibility permission if needed. Your active config is separate from Homebrew's installed files; upgrades do not replace it.
- Installed, but keys are unchanged: check
brew services info switcheroo, the active config path, and Accessibility permission for the Homebrew app path printed above. - Changed the TOML manually: restart the service to load it. In the submitted Raycast extension, add/edit/delete actions attempt a restart after saving and report restart failures separately.
- Raycast says “Missing executable”: this is an extension build/import error, not necessarily a missing daemon. Rebuild the local extension using its local-install instructions.
- Two sets of Raycast commands: stop extra
npm run devwatchers and check for duplicate local extension registrations before removing anything. - Old standalone install detected: use the migration guide. Do not run a second daemon or use blanket
hidutilclearing commands.
Daemon logs are under ~/Library/Logs/com.mitchelljphayes.switcheroo/:
tail -f "$HOME/Library/Logs/com.mitchelljphayes.switcheroo/daemon.err"For Homebrew launchd stdout/stderr paths and more service commands, see the tap README.
Stop the service before removing the executable:
brew services stop switcheroo
brew uninstall switcheroo
brew untap mitchelljphayes/switcherooKeep ~/.config/switcheroo/config.toml if you may reinstall. If the daemon was hard-killed or cleanup reports an error, seek recovery guidance before deleting its binary or recovery state; a normal uninstall is not a guarantee of crash recovery.
From the source checkout used to build the current standalone version:
cargo build --release --locked
./uninstall.shThe build supplies the recovery helper required by the uninstaller. It handles the current and legacy standalone labels and preserves your config. Do not use uninstall.sh to remove a Homebrew installation.
Use this instead of Homebrew, not alongside it. Requires Git, Rust via rustup, and the Xcode Command Line Tools. Install the latter with xcode-select --install if needed.
git clone --branch v0.1.1 --depth 1 https://github.com/mitchelljphayes/switcheroo.git
cd switcheroo
./install.shThis will:
- Build the release binary with
cargo - Stage + ad-hoc sign the
.appbundle and atomically swap it into~/.local/bin/Switcheroo.app - Create a sample config at
~/.config/switcheroo/config.tomlif one does not exist - Install and start a LaunchAgent, migrating from the old
com.local.switcheroolabel if present
The standalone installer:
- Stop any existing Switcheroo agent before overwriting the bundle
- Validate
~, paths, and plist ownership/permissions (rejecting hostile symlinks) - Migrate the old
com.local.switcheroolabel safely (only if its plist points at Switcheroo) - Verify the agent is registered after bootstrap, rolling back on failure
Standalone permission path: the installer starts its LaunchAgent; after first install, grant Accessibility access:
- System Settings → Privacy & Security → Accessibility
- Add
~/.local/bin/Switcheroo.app
Bundle-id migration (v0.1.x): The LaunchAgent identity changed from
com.local.switcherootocom.mitchelljphayes.switcheroo. An existing Accessibility grant is tied to the old bundle id and must be re-issued once after upgrading.install.shdetects and cleanly stops the old label;uninstall.shcleans up both. Unrelatedhidutilmappings are preserved across the migration.
The source installer rejects some symlinked installation/config paths. If you manage config through dotfiles symlinks, prefer the Homebrew path rather than removing or overwriting those symlinks to satisfy the installer.
Public releases currently distribute source. Prebuilt app archives produced by CI are validation artifacts, not a supported public install path. Ad-hoc signatures and co-hosted checksums do not authenticate the publisher; signed or attested binary distribution is separate future work.
Kernel-level key remap applied via hidutil on startup, before the event tap processes keys. Mappings are automatically re-applied about two seconds after wake. They are not inherently persistent across reboot: the login service reapplies them. Normal shutdown removes owned mappings and restores prior mappings where safe. If wake reapplication fails, a warning is logged and the daemon keeps running.
| Field | Values |
|---|---|
from |
Any key name (see below) |
to |
Any key name (see below) |
Simple unconditional key remap. Every press of from becomes to, regardless of which modifiers are held. Applied at the CGEventTap level (userspace), so these require Switcheroo to be running.
Use this for straightforward key swaps that aren't modifier-specific.
| Field | Values |
|---|---|
from |
Any key name (see below) |
to |
Any key name (see below) |
Examples:
# Map semicolon to equal (; to =)
[[remap]]
from = "semicolon"
to = "equal"
# Remap Caps Lock to Escape (alternative to modifier_remap if you
# want it handled in userspace rather than at the kernel level)
[[remap]]
from = "caps_lock"
to = "escape"
[[remap]]vs[[modifier_remap]]: Usemodifier_remapfor modifier changes such as Caps Lock → Ctrl at the HID level. Useremapfor unconditional key changes handled by the event tap. Keep Switcheroo running for normal operation; stopping it normally also cleans up its owned HID mappings.
Tap a key for one action, hold it for another.
| Field | Description |
|---|---|
key |
The key to intercept |
tap |
Key to emit on quick press+release |
hold |
Key to emit when held with other keys |
timeout_ms |
Time window in ms (default: 200) |
Remap a key when a modifier is held. The modifier is stripped from the output event.
| Field | Values |
|---|---|
modifier |
ctrl, shift, option/alt, cmd/command |
from |
Any key name (see below) |
to |
Any key name (see below) |
Emit a key when multiple keys are pressed simultaneously.
| Field | Description |
|---|---|
keys |
Array of key names that must be pressed together |
emit |
Key to emit when chord triggers |
window_ms |
Time window in ms for chord detection (default: 100) |
Letters: a-z
Arrows: left_arrow, right_arrow, up_arrow, down_arrow
Modifiers: left_shift, right_shift, left_ctrl, right_ctrl, left_option, right_option, left_cmd, right_cmd, caps_lock
Special: escape, tab, space, return, delete, forward_delete
Function: f1-f12
- Applies
[[modifier_remap]]rules viahidutil(kernel-level, instant) — on startup and on wake from sleep (via an IOKit power notification; debounced ~2 s) - Registers a
CGEventTapatkCGHIDEventTap(earliest interception point in userspace) - Receives
keyDown,keyUp, andflagsChangedevents - Runs them through the remap engine (tap-hold, conditional remaps, chords)
- Returns modified events (or suppresses them)
This is the same mechanism used by macOS accessibility tools, screenshot apps, and remote desktop software. It requires Accessibility permission but no special entitlements, kernel extensions, or virtual HID devices.
Both depend on Karabiner-DriverKit-VirtualHIDDevice, which:
- Requires a DriverKit system extension
- Has recurring permission issues on macOS updates
- Was broken in macOS 26.4 beta (internal keyboard stopped working)
- Apple is pushing developers away from DriverKit virtual HID toward CoreHID
Switcheroo uses CGEventTap, which has been stable since macOS 10.4 (2005) and is Apple's supported userspace event interception API. For kernel-level modifier remaps, it uses hidutil, which has been stable since macOS 10.12.
The Homebrew formula copies the tracked bundle/AppIcon.icns into the app bundle. It does not run iconutil inside Homebrew's build sandbox; that was fixed in v0.1.1.
Maintainers can regenerate the asset from bundle/AppIcon-1024.png with scripts/generate_icns.sh outside the sandbox. The Raycast extension uses the separate 512×512 raycast-extension/assets/command-icon.png asset.
MIT