Two Hammerspoon Hotkeys I Actually Use

2026/04/11

BUILDmacosautomationhammerspoononcallclaude-code🍞

I have two global hotkeys bound in Hammerspoon. They each do one thing, they work in every app, and I use them constantly.

Hotkey What it does Input
βŒƒβŒ₯T Translate selected English text to Chinese, result in clipboard Selected text
βŒƒβŒ₯O Open oncall triage session for a Slack alert URL Clipboard (Slack URL)

Both follow the same pattern: Hammerspoon handles the macOS concerns (global hotkey, clipboard, notifications), then shells out to a script that handles the app-specific work. Clean separation. Each piece independently testable.

βŒƒβŒ₯T β€” Translate to δΈ­ζ–‡

Select English text anywhere, hit βŒƒβŒ₯T, get Chinese in your clipboard.

Architecture

 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  User selects text, hits βŒƒβŒ₯T                               β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  Hammerspoon (global hotkey)                                 β”‚
 β”‚  1. hs.eventtap.keyStroke(⌘C)   ← copies selected text      β”‚
 β”‚  2. wait 0.3s                                                β”‚
 β”‚  3. hs.task.new("translate-to-zh.sh", "--clipboard")         β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  translate-to-zh.sh                                          β”‚
 β”‚  1. pbpaste                                                  β”‚
 β”‚  2. trans -b -t zh-CN   ← Google Translate via CLI           β”‚
 β”‚  3. pbcopy               ← result goes to clipboard          β”‚
 β”‚  4. osascript notification                                   β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Component Role Where
Hammerspoon Global hotkey, simulates ⌘C ~/.hammerspoon/init.lua
translate-to-zh.sh Translation logic ~/.local/bin/
Automator Quick Action Right-click menu for native Cocoa apps ~/Library/Services/

Hammerspoon config

In ~/.hammerspoon/init.lua:

hs.hotkey.bind({"ctrl", "alt"}, "T", function()
    -- Copy selected text
    hs.eventtap.keyStroke({"cmd"}, "C")

    -- Wait for clipboard to update
    hs.timer.doAfter(0.3, function()
        local task = hs.task.new(
            os.getenv("HOME") .. "/.local/bin/translate-to-zh.sh",
            nil,  -- callback
            {"--clipboard"}
        )
        task:start()
    end)
end)

Hammerspoon has Accessibility permission, so it can send ⌘C to any app.

The translate script

~/.local/bin/translate-to-zh.sh:

#!/bin/bash
set -euo pipefail

if [[ "${1:-}" == "--clipboard" ]]; then
    text="$(pbpaste)"
else
    text="$(cat)"  # stdin from Automator
fi

result="$(echo "$text" | trans -b -t zh-CN)"

echo -n "$result" | pbcopy

# Notification via temp file (avoids quoting hell)
tmpfile="$(mktemp)"
echo "$result" > "$tmpfile"
osascript -e "
    set f to POSIX file \"$tmpfile\"
    set r to read f
    display notification r with title \"ηΏ»θ―‘\"
" &

Install the translator: brew install translate-shell. Free, uses Google Translate, no API key.

Automator Quick Action (optional)

For native Cocoa apps (Safari, Notes, TextEdit), you can also add a right-click menu option:

  1. Open Automator, create a new Quick Action
  2. Set “Workflow receives current text in any application
  3. Add a Run Shell Script action, pass input as stdin
  4. Command: ~/.local/bin/translate-to-zh.sh

This shows up under right-click > Services > “Translate to δΈ­ζ–‡”.

βŒƒβŒ₯O β€” Oncall Triage

During CI/CD oncall, I get Slack alerts for failing builds. The triage flow used to be: read the alert, open the GH Actions run, read the logs, check if it’s a known flake, decide what to do. Repetitive. Most of that is pattern matching a human shouldn’t be doing.

Now: copy the Slack alert URL, hit βŒƒβŒ₯O. Hammerspoon validates the clipboard, launches a shell script that opens a new iTerm2 tab and runs claude '/zoncall diagnose <url>'. Claude Code fetches the thread, pulls the GH Actions logs, cross-references known flakes, and gives me a diagnosis. I just read the verdict.

Architecture

 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  Copy Slack alert URL, hit βŒƒβŒ₯O                             β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  Hammerspoon (global hotkey)                                 β”‚
 β”‚  1. hs.pasteboard.getContents()   ← read clipboard           β”‚
 β”‚  2. validate: is it a Slack message URL?                     β”‚
 β”‚  3. hs.task.new("oncall-triage.sh", url)                     β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  oncall-triage.sh                                            β”‚
 β”‚  1. validate URL again (defense in depth)                    β”‚
 β”‚  2. AppleScript β†’ open new iTerm2 tab                        β”‚
 β”‚  3. cd to oncall workspace                                   β”‚
 β”‚  4. run: claude '/zoncall diagnose <url>'                    β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Same pattern as the translate hotkey. Hammerspoon owns the macOS layer (hotkey, clipboard, notification). The shell script owns the app-specific layer (iTerm2 AppleScript, Claude Code invocation). Neither knows about the other’s internals.

Hammerspoon config

Added to ~/.hammerspoon/init.lua:

hs.hotkey.bind({"ctrl", "alt"}, "O", function()
    local url = hs.pasteboard.getContents()
    if not url then
        hs.notify.new({title="Oncall Triage", informativeText="Clipboard is empty", soundName="Basso"}):send()
        return
    end
    url = url:match("^%s*(.-)%s*$")
    if not url:match("^https://[%w_.-]+%.slack%.com/archives/[A-Z0-9]+/p%d+$") then
        hs.notify.new({title="Oncall Triage", informativeText="Not a Slack message URL", soundName="Basso"}):send()
        return
    end
    local script = os.getenv("HOME") .. "/zcode/zclaude/scripts/oncall-triage.sh"
    hs.task.new("/bin/bash", nil, {script, url}):start()
    hs.notify.new({title="Oncall Triage", informativeText="Launching Claude Code..."}):send()
end)

The URL validation uses a Lua pattern that matches Slack message URLs exactly: workspace domain, /archives/, channel ID (uppercase alphanumeric), /p followed by digits (the timestamp). Anything else gets rejected with a notification sound.

The triage script

~/zcode/zclaude/scripts/oncall-triage.sh:

#!/bin/bash
set -euo pipefail
ONCALL_DIR="$HOME/zcode/zclaude/zz/workspaces/oncall"
URL="${1:-}"

if [[ -z "$URL" ]]; then
    osascript -e 'display notification "No URL provided" with title "Oncall Triage" sound name "Basso"'
    exit 1
fi

if ! [[ "$URL" =~ ^https://[a-zA-Z0-9._-]+\.slack\.com/archives/[A-Z0-9]+/p[0-9]+$ ]]; then
    osascript -e 'display notification "Not a valid Slack message URL" with title "Oncall Triage" sound name "Basso"'
    exit 1
fi

osascript <<EOF
tell application "iTerm2"
    activate
    if (count of windows) = 0 then
        set newWindow to (create window with default profile)
        set targetSession to current session of newWindow
    else
        tell current window
            set newTab to (create tab with default profile)
            set targetSession to current session of newTab
        end tell
    end if
    tell targetSession
        write text "cd ${ONCALL_DIR} && claude '/zoncall diagnose ${URL}'"
    end tell
end tell
EOF

The double validation (Hammerspoon + shell script) is intentional. The shell script might get called from somewhere else eventually. It shouldn’t trust its caller.

The iTerm2 AppleScript creates a new tab (or window if none exists), cd’s to the oncall workspace, and runs Claude Code with the /zoncall diagnose command. That command is a custom Claude Code skill that knows how to parse Slack threads, fetch GH Actions logs, and produce a triage verdict.

Gotchas

What you need

For the translate hotkey:

brew install translate-shell

For the oncall hotkey:

# Claude Code CLI
# iTerm2 (brew install --cask iterm2)
# The /zoncall skill in your Claude Code config

For both:

brew install --cask hammerspoon

Grant Hammerspoon Accessibility permission in System Settings > Privacy & Security. Grant iTerm2 Automation permission if using the oncall hotkey.

Total setup time: ~10 minutes per hotkey. Both work in every app.