skills/dev-test-hammerspoon/SKILL.md
This skill should be used when the user asks to "debug macOS app", "test native app", "automate macOS workflow", "test native macOS application", or needs desktop automation for testing macOS applications with Hammerspoon. Use for application launch/control, window management, keyboard/mouse simulation, and visual verification.
npx skillsauth add edwinhu/workflows dev-test-hammerspoonInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Announce: "I'm using dev-test-hammerspoon for macOS desktop automation."
<EXTREMELY-IMPORTANT> ## Gate ReminderBefore taking screenshots or running E2E tests, you MUST complete all 6 gates from dev-tdd:
GATE 1: BUILD
GATE 2: LAUNCH (with file-based logging)
GATE 3: WAIT
GATE 4: CHECK PROCESS
GATE 5: READ LOGS ← MANDATORY, CANNOT SKIP
GATE 6: VERIFY LOGS
THEN: E2E tests/screenshots
You loaded dev-tdd earlier. Follow the gates now. </EXTREMELY-IMPORTANT>
Verify Hammerspoon is installed before proceeding.
# Check Hammerspoon installation (both CLI and app)
which hs || echo "MISSING: hs CLI"
ls /Applications/Hammerspoon.app 2>/dev/null || echo "MISSING: Hammerspoon.app"
If missing:
STOP: Cannot proceed with macOS automation.
Missing tool: Hammerspoon (required for macOS E2E testing)
Install with:
# Install via nix-darwin, or verify: hammerspoon -c "print('ok')"
After installing:
1. Open Hammerspoon.app
2. Grant Accessibility permissions in System Preferences
3. In Hammerspoon console, run: hs.ipc.cliInstall()
4. Add to ~/.hammerspoon/init.lua: require("hs.ipc")
Reply when installed and I'll continue testing.
This gate is non-negotiable. Missing tools = full stop. </EXTREMELY-IMPORTANT>
<EXTREMELY-IMPORTANT> ## When to Use HammerspoonUse Hammerspoon for:
Do not use Hammerspoon for:
For web testing, discover and read the relevant skill: Related skills:
${CLAUDE_SKILL_DIR}/../../skills/dev-test-chrome/SKILL.md and follow its instructions.${CLAUDE_SKILL_DIR}/../../skills/dev-test-playwright/SKILL.md and follow its instructions.One-time setup in ~/.hammerspoon/init.lua:
require("hs.ipc") -- Enables CLI
Reload config after changes:
hs -c 'hs.reload()' # Reload Hammerspoon configuration
-- Type text (simulates keystrokes)
hs.eventtap.keyStrokes("hello world")
-- Key press with modifiers
hs.eventtap.keyStroke({"cmd"}, "c") -- Cmd+C
hs.eventtap.keyStroke({"cmd", "shift"}, "s") -- Cmd+Shift+S
hs.eventtap.keyStroke({"ctrl", "alt"}, "t") -- Ctrl+Alt+T
hs.eventtap.keyStroke({}, "return") -- Enter key
hs.eventtap.keyStroke({}, "escape") -- Escape key
-- Function keys
hs.eventtap.keyStroke({}, "f1")
hs.eventtap.keyStroke({"cmd"}, "f5")
-- Mouse clicks
hs.eventtap.leftClick({x=100, y=200})
hs.eventtap.rightClick({x=100, y=200})
hs.eventtap.middleClick({x=100, y=200})
hs.eventtap.doubleClick({x=100, y=200})
-- Mouse movement
hs.mouse.absolutePosition({x=500, y=300})
-- Scroll
hs.eventtap.scrollWheel({0, -5}, {}) -- Scroll down
hs.eventtap.scrollWheel({0, 5}, {}) -- Scroll up
# Execute Lua code directly
hs -c 'hs.eventtap.keyStroke({"cmd"}, "c")' # Run inline Lua code via CLI
# Execute a script file
hs /path/to/test_script.lua # Run Hammerspoon script from file
# Pipe script via stdin
echo 'hs.eventtap.keyStrokes("test")' | hs -s # Run script piped through stdin
-- Launch or focus app by name
local app = hs.application.launchOrFocus("Safari")
-- Launch app by bundle ID
hs.application.launchOrFocusByBundleID("com.apple.Safari")
-- Get running app
local app = hs.application.get("Safari")
if app then
app:activate() -- Bring to front
app:hide() -- Hide
app:unhide() -- Unhide
app:kill() -- Terminate gracefully
app:kill9() -- Force kill
end
-- Get frontmost app
local front = hs.application.frontmostApplication()
print(front:name())
print(front:bundleID())
-- List all running apps
for _, app in ipairs(hs.application.runningApplications()) do
print(app:name())
end
-- Wait for app to launch
hs.timer.waitUntil(
function() return hs.application.get("MyApp") ~= nil end,
function() print("App launched") end,
0.5 -- Check every 0.5 seconds
)
-- Click menu item
local app = hs.application.get("Safari")
app:selectMenuItem({"File", "New Window"})
app:selectMenuItem({"Edit", "Paste"})
-- Check if menu item exists
local menuItem = app:findMenuItem({"File", "Save"})
if menuItem then
print("Save is available, enabled:", menuItem.enabled)
end
-- Get focused window
local win = hs.window.focusedWindow()
print(win:title())
print(win:frame()) -- {x, y, w, h}
-- Get app's windows
local app = hs.application.get("Safari")
local wins = app:allWindows()
for _, win in ipairs(wins) do
print(win:title())
end
-- Get window by title (partial match)
local win = hs.window.get("My Document")
-- Window actions
win:focus() -- Focus window
win:maximize() -- Maximize
win:minimize() -- Minimize to dock
win:close() -- Close window
-- Move/resize
win:setFrame({x=100, y=100, w=800, h=600})
win:move({100, 0}) -- Move relative
win:setSize({800, 600})
win:centerOnScreen()
-- Get window position and size
local frame = win:frame()
print("Position:", frame.x, frame.y)
print("Size:", frame.w, frame.h)
Every E2E test MUST include screenshot evidence.
After completing a workflow, capture a screenshot to prove success. </EXTREMELY-IMPORTANT>
# Full screen (all displays)
screencapture /tmp/screenshot.png # Capture entire screen to file
# Main screen only
screencapture -m /tmp/main_screen.png # Capture primary screen only
# Specific window (interactive - click to select)
screencapture -w /tmp/window.png # Interactively select window to capture
# Specific region
screencapture -R 100,200,800,600 /tmp/region.png # Capture rectangular region (x,y,w,h)
# Without window shadow
screencapture -o /tmp/no_shadow.png # Capture without window shadows
# Silent (no camera sound)
screencapture -x /tmp/silent.png # Capture silently without shutter sound
# To clipboard instead of file
screencapture -c # Capture to clipboard
# Combined: silent, no shadow, specific region
screencapture -x -o -R 0,0,1920,1080 /tmp/clean.png # Capture region silently without shadows
-- Capture focused window
local win = hs.window.focusedWindow()
if win then
local img = win:snapshot()
img:saveToFile("/tmp/window.png")
end
-- Capture entire screen
local screen = hs.screen.mainScreen()
local img = screen:snapshot()
img:saveToFile("/tmp/screen.png")
-- Capture specific region
local img = hs.screen.mainScreen():snapshot({x=0, y=0, w=800, h=600})
img:saveToFile("/tmp/region.png")
Every Hammerspoon E2E test MUST:
-- test_workflow.lua
-- Run with: hs /path/to/test_workflow.lua
local function test_app_workflow()
-- 1. Launch app
print("Launching app...")
hs.application.launchOrFocus("TextEdit")
hs.timer.usleep(1000000) -- Wait 1 second
-- 2. Verify app launched
local app = hs.application.get("TextEdit")
assert(app, "FAIL: TextEdit did not launch")
print("App launched: " .. app:name())
-- 3. Create new document
hs.eventtap.keyStroke({"cmd"}, "n")
hs.timer.usleep(500000)
-- 4. Type content
hs.eventtap.keyStrokes("Hello, this is an automated test!")
hs.timer.usleep(300000)
-- 5. Select all and copy
hs.eventtap.keyStroke({"cmd"}, "a")
hs.timer.usleep(100000)
hs.eventtap.keyStroke({"cmd"}, "c")
-- 6. Verify clipboard
local clipboard = hs.pasteboard.getContents()
assert(clipboard:find("automated test"), "FAIL: Clipboard doesn't match")
print("Clipboard verified: " .. clipboard)
-- 7. Take screenshot
local win = hs.window.focusedWindow()
local img = win:snapshot()
img:saveToFile("/tmp/test_result.png")
print("Screenshot saved to /tmp/test_result.png")
-- 8. Close without saving
hs.eventtap.keyStroke({"cmd"}, "w")
hs.timer.usleep(500000)
hs.eventtap.keyStroke({}, "d") -- "Don't Save" button
print("PASS: Workflow completed successfully")
end
-- Run the test
local status, err = pcall(test_app_workflow)
if not status then
print("FAIL: " .. tostring(err))
os.exit(1)
end
os.exit(0)
Run from CLI:
hs /path/to/test_workflow.lua && echo "TEST PASSED" || echo "TEST FAILED" # Execute test script and report result
For simpler needs, cliclick provides CLI-based mouse/keyboard control:
# Install cliclick tool
# Install cliclick via nix-darwin, or verify: cliclick -V
# Mouse click at coordinates
cliclick c:100,200 # Left-click at coordinates
cliclick rc:100,200 # Right-click at coordinates
cliclick dc:100,200 # Double-click at coordinates
# Move mouse
cliclick m:500,300 # Move mouse to coordinates
# Type text
cliclick t:"Hello world" # Type text at current cursor position
# Key press
cliclick kp:return # Press return key
cliclick kp:escape # Press escape key
cliclick kd:cmd kp:c ku:cmd # Press Cmd+C (key down, press, key up)
# Wait (milliseconds)
cliclick w:500 # Wait for 500 milliseconds
cliclick is useful for simple scripts but lacks app control - prefer Hammerspoon for complex E2E tests.
Every test run MUST be documented in LEARNINGS.md:
## macOS E2E Test: [Description]
**Tool:** Hammerspoon
**Script:**
```bash
hs /path/to/test_script.lua
Output:
Launching app...
App launched: TextEdit
Clipboard verified: Hello, this is an automated test!
Screenshot saved to /tmp/test_result.png
PASS: Workflow completed successfully
Result: PASS
Screenshot: /tmp/test_result.png
## Integration
This skill is referenced by `dev-test` for macOS desktop automation.
Read `${CLAUDE_SKILL_DIR}/../../skills/dev-tdd/SKILL.md` and follow its instructions.
development
Build the meeting-level proxy-voting × ownership panel on the WRDS SGE grid — ISS N-PX fund votes reduced to (item × block) direction cells, joined to institutional and mutual-fund ownership. Use when working with risk.voteanalysis_npx, N-PX fund-level votes, ISS→CRSP fund linking, index/passive/active voting blocks, or a proxy-voting panel that needs ownership attached.
development
Use when "CRSP CIZ", "CRSP v2", "CRSP flat file format 2.0", "crsp.dsf_v2 / msf_v2", "StkDlySecurityData", "StkMthSecurityData", "StkSecurityInfoHist", "stocknames_v2", "DlyRet / MthRet / DlyPrc / MthPrc", "SHRCD or EXCHCD equivalent in new CRSP", "SIZ to CIZ migration", "CRSP data after 2024", "CRSP delisting returns", "CRSP cumulative adjustment factors", "CRSP index INDNO / INDFAM", or any CRSP stock/index query where the legacy SIZ column names no longer exist.
development
Use when linking or deduping datasets by entity name rather than a shared key — 'fuzzy match', 'fuzzy name matching', 'entity resolution', 'record linkage', 'match company/person names', 'dedupe entity names', 'name-based join', 'bridge identifiers' (CIK ↔ permno ↔ gvkey ↔ wficn ↔ EIN ↔ personid), or any use of char n-gram TF-IDF, cosine similarity on names, `sparse_dot_topn`, or RapidFuzz at scale.
development
Use when building a publication-quality table in Python — 'regression table', 'results table', 'summary statistics table', 'etable', 'coefplot', 'great_tables', 'GT', 'gt table', 'format a table for the paper', 'export table to LaTeX/HTML', significance stars, spanners, or column formatting for a table headed into a paper, slide deck, or notebook.