kb/hardware

(Mac) I replaced 10 yrs of paying for Keyboard Maestro with a single Lua script.

Friendship ended with KBM. Now Rebind is my best friend.

I've been a Keyboard Maestro user for ten years. Paid for every major upgrade. Built up a Global Macro Group with seventeen macros that my hands depend on: browser history on ⌘J/⌘L, tab switching on ⌘S/⌘D, hard refresh, dev tools, window sizing, a terminal toggle on backtick, forward delete, a text expansion. The kind of setup you stop thinking about because it's part of how you type.

Last week I replaced all of it with one Rebind script. I didn't even write the script. RebindGPT did.

Rebind is a free app that remaps your keyboard with Luau scripts. Think Hammerspoon's scripting with Karabiner's input engine, without the config mess.

The before state

If you've maintained a serious Keyboard Maestro setup, you know what it looks like. Every remap is a macro. Every macro is a stack of click-configured conditions: If any of the following are true: Google Chrome is at the front, Chromium is at the front... execute: Type the ⌘] Keystroke. Otherwise: No Action. Multiply that by every browser you use, then by every shortcut, then again for the apps that need a different keystroke for the same action.

My "browser history forward" macro alone was two condition blocks deep, each enumerating applications one dropdown at a time. Seventeen macros of that. Keyboard Maestro is genuinely good software, but the setup is opaque: you can't diff it, you can't grep it, and you rebuild it dropdown by dropdown on every new machine.

The old setup: one of seventeen macros, two condition blocks and four app dropdowns just to remap ⌘L per browser family.

The after state

One Luau file. My entire Global Macro Group, expressed as code I can read:

remap("J", "LeftBrace",  function() return cmd() and CHROMISH[front()] end)   -- Chrome  → ⌘[
remap("J", "Left",       function() return cmd() and FIREFOXISH[front()] end) -- Firefox → ⌘←
remap("L", "RightBrace", function() return cmd() and CHROMISH[front()] end)   -- Chrome  → ⌘]
remap("L", "Right",      function() return cmd() and FIREFOXISH[front()] end) -- Firefox → ⌘→

That's the entire "browser history" pair, the thing that took four condition blocks across two macros in the old setup. App families are defined once as plain tables and reused throughout:

BROWSERS     = set { "Google Chrome", "Chromium", "Brave Browser", "Firefox", "LibreWolf", "Safari" }
ARROW_TABS   = set { "Google Chrome", "Chromium", "Brave Browser", "Firefox", "LibreWolf", "Safari", "Code", "Visual Studio Code", "Cursor" }
BRACKET_TABS = set { "Ghostty", "Terminal", "Hyper", "iTerm2", "iTerm", "MacVim", term }

Add a browser to the table, every remap picks it up. Try doing that across seventeen macros of dropdowns.

(Rebind is a free download if you want to poke at it while you read. The full script is at the bottom.)

I didn't write this script

Here's the part that actually surprised me. I described my Keyboard Maestro setup to RebindGPT: the shortcuts, which apps needed which variants, the terminal toggle behavior. It wrote the whole thing, including the subtle stuff I would have gotten wrong on the first pass, like waiting for physical modifiers to clear before injecting a chord that conflicts with them:

-- Emit `chord` after physical modifiers clear — used when the chord conflicts
-- with held ⌘/⇧ (macOS applies held modifiers to any injected event).
local function injectAfterMods(chord)
  Run(function()
    while cmd() or shift() do Sleep(5) end
    HID.Press(chord)
  end)
end

Held modifiers merging into synthetic events is a real macOS injection pitfall, and the generated script handled it without being asked.

It didn't stop at writing the script, either. When an early version polled System.Window() too aggressively in a timer, I pasted the runtime's warning into the same chat. RebindGPT diagnosed the tick-budget problem and rewrote the hot path: slower timer, expensive check moved behind the keypress that actually needs it.

The replacement: taky-km.luau running in the Rebind editor at 0µs/tick, with RebindGPT explaining its own performance fix.

The full script

Everything below is what runs on my machine today. The two configurable values (terminal app and the ⌘P text expansion) surface as UI controls in the Rebind panel, so the script itself stays generic:

The full script (~180 lines)
-- rebind: name=taky-km
-- rebind: min_sdk=3.2.1
-- rebind: description=Keyboard Maestro parity — global, app-conditional chord remaps, app control, and configurable terminal/hash.

-- ═══════════════════════════════════════════════════════════════════════════════
-- Config
-- ═══════════════════════════════════════════════════════════════════════════════

local cfg = UI.Schema({
  terminal = UI.Text("Terminal", {
    label = "Terminal App",
    tooltip = "Process name of your terminal (e.g. Terminal, Ghostty, iTerm2, Kitty).",
    maxLength = 40,
  }),
  hash = UI.Text("your-snippet-here", {
    label = "Hash String",
    tooltip = "Text inserted by ⌘P (skipped in Cursor / MacVim).",
    maxLength = 128,
  }),
})

-- ═══════════════════════════════════════════════════════════════════════════════
-- Helpers
-- ═══════════════════════════════════════════════════════════════════════════════

local function set(t)
  local s = {}
  for _, n in ipairs(t) do s[n] = true end
  return s
end

local function cmd()   return Input.IsDown("LWin") or Input.IsDown("RWin") end
local function shift() return Input.IsDown("LShift") or Input.IsDown("RShift") end
local function ctrl()  return Input.IsDown("LCtrl") or Input.IsDown("RCtrl") end
local function alt()   return Input.IsDown("LAlt") or Input.IsDown("RAlt") end
local function bare()  return not (cmd() or shift() or ctrl() or alt()) end

-- Evaluate the foreground process name on demand (not polled).
local function front()
  local w = System.Window()
  return w and w.process or ""
end

-- Emit `chord` immediately — used when adding modifiers to an already-held ⌘
-- (the physical ⌘ merges in, so we only emit the delta modifiers: ⌘R → "LShift+R").
local function remap(trigger, chord, pred)
  Bind(trigger, function()
    if not pred() then return true end
    HID.Press(chord)
    return false
  end)
end

-- Emit `chord` after physical modifiers clear — used when the chord conflicts
-- with held ⌘/⇧ (macOS applies held modifiers to any injected event).
local function injectAfterMods(chord)
  Run(function()
    while cmd() or shift() do Sleep(5) end
    HID.Press(chord)
  end)
end

-- ═══════════════════════════════════════════════════════════════════════════════
-- App groups (built once at load)
-- ═══════════════════════════════════════════════════════════════════════════════

local BROWSERS, ARROW_TABS, BRACKET_TABS, SHIFT_TABS, TERMINALS
local CHROMISH, FIREFOXISH, HASH_SKIP

function OnStart()
  local term = cfg.terminal

  BROWSERS     = set { "Google Chrome", "Chromium", "Brave Browser", "Firefox", "LibreWolf", "Safari" }
  ARROW_TABS   = set { "Google Chrome", "Chromium", "Brave Browser", "Firefox", "LibreWolf", "Safari", "Code", "Visual Studio Code", "Cursor" }
  BRACKET_TABS = set { "Ghostty", "Terminal", "Hyper", "iTerm2", "iTerm", "MacVim", term }
  SHIFT_TABS   = set { "Neovide" }
  TERMINALS    = set { "Terminal", "Hyper", "iTerm2", "iTerm", "Ghostty", term }
  CHROMISH     = set { "Google Chrome", "Chromium" }
  FIREFOXISH   = set { "Firefox", "Brave Browser", "LibreWolf" }
  HASH_SKIP    = set { "Cursor", "MacVim" }

  Log.Info(string.format("taky-km ready — terminal=%s  front=%s", term, front()))
end

-- ═══════════════════════════════════════════════════════════════════════════════
-- Browser chord remaps (⌘ held, in a browser)
-- ═══════════════════════════════════════════════════════════════════════════════

remap("R", "LShift+R", function() return cmd() and BROWSERS[front()] end)   -- ⌘R  → ⇧⌘R  hard refresh
remap("U", "LAlt+U",   function() return cmd() and BROWSERS[front()] end)   -- ⌘U  → ⌥⌘U  view source
remap("I", "LAlt+I",   function() return cmd() and BROWSERS[front()] end)   -- ⌘I  → ⌥⌘I  dev tools

-- ═══════════════════════════════════════════════════════════════════════════════
-- Tab navigation (⌘S left / ⌘D right), per app family
-- ═══════════════════════════════════════════════════════════════════════════════

remap("S", "LAlt+Left",         function() return cmd() and ARROW_TABS[front()] end)
remap("S", "LShift+LeftBrace",  function() return cmd() and BRACKET_TABS[front()] end)
remap("S", "LShift+S",          function() return cmd() and SHIFT_TABS[front()] end)
remap("D", "LAlt+Right",        function() return cmd() and ARROW_TABS[front()] end)
remap("D", "LShift+RightBrace", function() return cmd() and BRACKET_TABS[front()] end)
remap("D", "LShift+D",          function() return cmd() and SHIFT_TABS[front()] end)

-- ═══════════════════════════════════════════════════════════════════════════════
-- Browser history (⌘J back / ⌘L forward)
-- ═══════════════════════════════════════════════════════════════════════════════

remap("J", "LeftBrace",  function() return cmd() and CHROMISH[front()] end)   -- Chrome  → ⌘[
remap("J", "Left",       function() return cmd() and FIREFOXISH[front()] end) -- Firefox → ⌘←
remap("L", "RightBrace", function() return cmd() and CHROMISH[front()] end)   -- Chrome  → ⌘]
remap("L", "Right",      function() return cmd() and FIREFOXISH[front()] end) -- Firefox → ⌘→

-- ═══════════════════════════════════════════════════════════════════════════════
-- App-specific single-key overrides
-- ═══════════════════════════════════════════════════════════════════════════════

remap("F", "LAlt+F", function() return cmd() and front() == "Mail" end)          -- ⌘F → ⌥⌘F find in Mail
remap("E", "M",     cmd)                                                         -- ⌘E → ⌘M minimize

-- ⌘⌫ → ^C in terminals, else ⌘Delete (forward delete)
Bind("Backspace", function()
  if not cmd() then return true end
  if TERMINALS[front()] then
    injectAfterMods("LCtrl+C")
  else
    HID.Press("Delete")
  end
  return false
end)

-- ⌘P → configured snippet (skipped in Cursor / MacVim where ⌘P passes through)
Bind("P", function()
  if not cmd() or HASH_SKIP[front()] then return true end
  Run(function()
    while cmd() or shift() do Sleep(5) end
    HID.Type(cfg.hash)
  end)
  return false
end)

-- ⇧⌘' → literal backtick (drops the held ⇧⌘)
Bind("Apostrophe", function()
  if not (cmd() and shift()) then return true end
  injectAfterMods("Grave")
  return false
end)

-- ═══════════════════════════════════════════════════════════════════════════════
-- Window sizing
-- ═══════════════════════════════════════════════════════════════════════════════

-- ⌘Enter → fullscreen-ish with margin; ⇧⌘Enter → center-to-left (85% width)
Bind("Enter", function()
  if not cmd() then return true end
  local sw, sh = System.Screen()
  if shift() then
    Window.Move(0, 0, 0, math.floor(sw * 0.85), math.floor((sh - 22) * 0.90))
  else
    Window.Move(0, 0, 25, sw, sh - 25)
  end
  return false
end)

-- ═══════════════════════════════════════════════════════════════════════════════
-- Terminal toggle: bare backtick activates/hides the configured terminal
-- ═══════════════════════════════════════════════════════════════════════════════

Bind("Grave", function()
  if not bare() or Process.Exists("Hyper") then return true end
  if front() == cfg.terminal then
    HID.Press("LWin+H")
  else
    Window.Focus(cfg.terminal)
  end
  return false
end)

Bind.Remap("CapsLock", "Escape")

What carried over, one for one

  • Browser history. ⌘J back, ⌘L forward, correct native chord per browser family
  • Tab navigation. ⌘S and ⌘D across app families
  • Hard refresh, view source, dev tools. ⌘R, ⌘U, ⌘I in every listed browser
  • Window management. ⌘Enter near-fullscreen, ⇧⌘Enter 85%-width layout
  • Terminal toggle. Bare backtick summons or hides my terminal
  • Forward delete, minimize, Mail find, text expansion, backtick literal, CapsLock to Escape. All the small ones that add up

Why this beats the old setup

It's text. The whole configuration diffs in git, greps, and copies to a new machine as one file.

It's one namespace. Modifier state, the front app, window size, app control, typing. One SDK instead of conditions, actions, and helper macros wired together in a GUI.

It's conversational to extend. Yesterday I wanted ⌘F remapped only in Mail. One sentence to RebindGPT, one pasted line.

Ten years of license fees and seventeen macros, now one file an AI wrote in one sitting. If you keep your Keyboard Maestro setup out of habit like I did, it's worth an afternoon. Grab Rebind, describe your macros to RebindGPT, and see what comes back.