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:
- Open Automator, create a new Quick Action
- Set “Workflow receives current text in any application”
- Add a Run Shell Script action, pass input as stdin
- 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
- macOS Services only work in native Cocoa text views. iTerm2, Slack (Electron), VS Code won’t show the Service menu. That’s why you need Hammerspoon for global hotkeys.
osascriptcan’t send keystrokes without Accessibility permission. Hammerspoon already has it. Don’t fight withosascript.hs.taskspawns with piped stdin, not a terminal. So[ -t 0 ](check if stdin is a terminal) doesn’t work for mode detection. Use explicit flags like--clipboardinstead.- Notification quoting breaks with special characters. Interpolating translated text directly into
osascript -e "display notification \"$var\""will fail on quotes, newlines, CJK punctuation. Write to a temp file and read it back. - iTerm2 AppleScript needs iTerm2 running. If iTerm2 isn’t open, the
create windowpath handles it. But iTerm2 must be installed and have automation permission (System Settings > Privacy & Security > Automation). - Slack URL format is strict. The pattern expects
https://<workspace>.slack.com/archives/<CHANNEL_ID>/p<TIMESTAMP>. URLs with?thread_ts=query params or other suffixes will be rejected. Strip them before copying, or update the regex.
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.