TIL: Hammerspoon as a Karabiner-Elements replacement for Hyper/Meh keys

Karabiner-Elements suddenly stopped working on my MacBook. I rely on the Hyper key (⌃⌥⇧⌘) and Meh key (⌃⇧⌘) all day in Keyboard Maestro, mapped from right-Cmd and right-Option. Rather than fight the kernel extension, I rewrote the remap in Hammerspoon — pure Lua, no extension required.

The trick: swallow flagsChanged events for the physical right-Cmd / right-Option keys, track their state, and stamp the corresponding modifier combo onto every subsequent keyDown/keyUp. The device-specific bits (NX_DEVICERCMDKEYMASK = 0x10, NX_DEVICERALTKEYMASK = 0x40) let me tell which physical key fired, since macOS otherwise treats left and right modifiers as identical.

~/.hammerspoon/init.lua:

-- ~/.hammerspoon/init.lua
-- Right Cmd    -> Hyper (Ctrl+Alt+Shift+Cmd)
-- Right Option -> Ctrl+Shift+Cmd
--
-- Approach: swallow the physical right-Cmd / right-Option flagsChanged events
-- and, while either is "held", stamp the corresponding modifier combo onto
-- every keyDown/keyUp that fires.

local KC_RCMD = 54
local KC_ROPT = 61

-- Device-specific CGEventFlag bits identifying which physical key fired.
-- (NX_DEVICERCMDKEYMASK = 0x10, NX_DEVICERALTKEYMASK = 0x40)
local RAW_RCMD = 0x10
local RAW_ROPT = 0x40

local state = {rcmd = false, ropt = false}

local et = hs.eventtap.event.types

local function stampFlags(event)
    if state.rcmd and state.ropt then
        event:setFlags({cmd = true, alt = true, shift = true})
    elseif state.rcmd then
        event:setFlags({cmd = true, alt = true, shift = true, ctrl = true})
    elseif state.ropt then
        event:setFlags({cmd = true, shift = true, ctrl = true})
    end
end

local watcher = hs.eventtap.new(
    {et.flagsChanged, et.keyDown, et.keyUp},
    function(event)
        if event:getType() == et.flagsChanged then
            local kc = event:getKeyCode()
            if kc == KC_RCMD then
                state.rcmd = (event:rawFlags() & RAW_RCMD) ~= 0
                return true, {}
            elseif kc == KC_ROPT then
                state.ropt = (event:rawFlags() & RAW_ROPT) ~= 0
                return true, {}
            end
        end
        stampFlags(event)
        return false
    end
)
watcher:start()

hs.alert.show("hammerspoon: right-modifier remaps active")

-- Diagnostic probes (delete once verified). Press the listed combo + K:
hs.hotkey.bind({"cmd","alt","shift","ctrl"}, "k", function() hs.alert.show("HYPER+K (rcmd alone)") end)
hs.hotkey.bind({"cmd","shift","ctrl"},        "k", function() hs.alert.show("MEH+K (ropt alone)")  end)
hs.hotkey.bind({"cmd","alt","shift"},         "k", function() hs.alert.show("CHORD+K (rcmd+ropt)") end)
hs.hotkey.bind({"cmd","alt"},                 "k", function() hs.alert.show("cmd+alt+K (no remap)") end)

Gotcha: Keyboard Maestro recognizes the Hyper and Meh keys natively when they come from Karabiner-Elements — there are dedicated trigger options for them. With Hammerspoon, KM sees only the raw modifier chord. So when defining a Hyper+K hotkey in KM, I have to bind it as ⌃⌥⇧⌘+K explicitly, not pick “Hyper” from the dropdown. Same for Meh → ⌃⇧⌘.

Reference: Hammerspoon eventtap docs.


Let's stay connected!

If you like the post, subscribe to my newsletter to get latest updates:

Author

I'm Oliver Nguyen. A software maker working mostly in Go and JavaScript. I enjoy learning and seeing a better version of myself each day. Occasionally spin off new open source projects. Share knowledge and thoughts during my journey.