hs
Comprehensive guide for Hammerspoon development in this dotfiles repo. Covers config patterns, debugging decision trees, API reference, performance monitoring, and troubleshooting.
Best use case
hs is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Comprehensive guide for Hammerspoon development in this dotfiles repo. Covers config patterns, debugging decision trees, API reference, performance monitoring, and troubleshooting.
Teams using hs should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/hs/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How hs Compares
| Feature / Agent | hs | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Comprehensive guide for Hammerspoon development in this dotfiles repo. Covers config patterns, debugging decision trees, API reference, performance monitoring, and troubleshooting.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
Related Guides
SKILL.md Source
# Hammerspoon Development Guide
## Overview
Hammerspoon is the macOS automation backbone. It handles hotkeys, window management, notifications, app integrations (Shade, browser, nvim), and system event watchers.
**CRITICAL**: Before making changes:
1. Verify NO LSP/diagnostic errors in the file
2. Research APIs at https://www.hammerspoon.org/docs/
3. Test in console before committing
4. Performance matters - CPU/memory efficiency is critical
## Configuration Structure
```
config/hammerspoon/
├── init.lua # Entry point - loads modules in order
├── preflight.lua # Early setup (IPC, globals, logging)
├── overrides.lua # Monkey-patches for hs.* modules
├── config.lua # C.* configuration table (SOURCE OF TRUTH)
├── utils.lua # U.* utilities (logging, helpers)
├── bindings.lua # Hotkey definitions
├── hyper.lua # Hyper key (F19) modal system
├── hypemode.lua # Hype mode (double-tap) triggers
├── chain.lua # Window chaining operations
├── clipper.lua # Clipboard manager
├── contexts/ # Per-app behavior customizations
│ ├── init.lua # Context loader
│ └── com.*.lua # App-specific context files
├── watchers/ # Event observers
│ ├── notification.lua # NC notification capture
│ ├── screen.lua # Display change watcher
│ └── ...
└── lib/ # Reusable modules
├── state.lua # S.* centralized state (S.notification.*, etc.)
├── canvas.lua # Canvas drawing utilities
├── db.lua # SQLite database wrapper
├── notifications/ # Notification system (N.*)
│ ├── init.lua # Main entry (N.send, N.init)
│ ├── send.lua # N.send() implementation
│ ├── notifier.lua # Canvas notification rendering
│ └── processor.lua # Rule-based routing
├── interop/ # External app integrations
│ ├── shade.lua # Shade.app IPC
│ ├── browser.lua # Browser JXA bridge
│ ├── nvim.lua # Neovim RPC
│ └── selection.lua # Text selection helpers
└── meeting/ # Meeting detection
```
## Global References
| Global | Purpose | Source |
|--------|---------|--------|
| `C` | Config table | `config.lua` |
| `U` | Utilities (`U.log.i()`, `U.log.e()`, `U.log.d()`) | `utils.lua` |
| `N` | Notification system | `lib/notifications/init.lua` |
| `S` | State management (`S.notification.*`, `S.watcher.*`) | `lib/state.lua` |
| `I` | Inspect alias (`I(obj)` = `hs.inspect(obj)`) | `init.lua` |
| `P` | Debug print with location | `init.lua` |
| `TERMINAL` | Terminal bundle ID | `config.lua` (Ghostty) |
| `BROWSER` | Browser bundle ID | `config.lua` (Brave Nightly) |
| `HYPER` | Hyper key | `config.lua` (F19) |
## Decision Tree: What to Check When Things Break
### "Hotkey doesn't work"
```
1. Is Hammerspoon running?
└─ pgrep Hammerspoon || open -a Hammerspoon
2. Is hotkey defined?
└─ rg "the-key" config/hammerspoon/bindings.lua config/hammerspoon/hyper.lua
3. Is it in a modal that's not active?
└─ Check if it's in hyper.lua (needs F19 held) or hypemode.lua (needs double-tap)
4. Is the handler erroring?
└─ hs -c "hs.openConsole()" → check for red errors
└─ Or: log stream --predicate 'subsystem == "org.hammerspoon.Hammerspoon"'
5. Is accessibility permission granted?
└─ System Settings → Privacy & Security → Accessibility → Hammerspoon ✓
```
### "Window management doesn't work"
```
1. Is the app excluded from window management?
└─ rg "bundleID" config/hammerspoon/config.lua (check C.layouts)
2. Does the window have a valid frame?
└─ hs -c "print(I(hs.window.focusedWindow():frame()))"
3. Is it a special window (floating, panel, etc.)?
└─ hs -c "print(hs.window.focusedWindow():subrole())"
└─ Check if subrole is AXFloatingWindow, AXSystemDialog, etc.
4. Is the screen/display configured?
└─ Check C.displays in config.lua matches actual display names
└─ hs -c "print(I(hs.screen.allScreens()))"
```
### "Notification not showing"
```
1. Is notification system initialized?
└─ hs -c "print(N ~= nil)"
2. Is canvas rendering working?
└─ hs -c "N.send({title='Test', message='Test', urgency='normal'})"
3. Is the rule suppressing it?
└─ Check C.notificationRules in config.lua
└─ Maybe a rule is matching and dismissing
4. Is it a focus mode issue?
└─ Check C.overrideFocusModes settings
5. Check the notification database:
└─ sqlite3 ~/.local/share/hammerspoon/hammerspoon.db \
"SELECT * FROM notifications ORDER BY timestamp DESC LIMIT 5"
```
### "Performance is bad / Hammerspoon laggy"
```
1. Check CPU usage:
└─ top -pid $(pgrep Hammerspoon) -l 1
2. Check memory:
└─ ps -o rss,vsz -p $(pgrep Hammerspoon)
3. Is a watcher running too often?
└─ Add logging to watcher callbacks
└─ U.log.d("watcher fired", ...)
4. Is there an infinite loop?
└─ Check for circular requires or recursive callbacks
5. Canvas leak?
└─ hs -c "print(#hs.canvas.list())"
└─ Should be small (<10 typically)
6. Timer leak?
└─ Check S.notification.timers or other state for accumulated timers
```
## Reloading Hammerspoon
**CRITICAL**: Never use `hs -c "hs.reload()"` directly — it destroys the Lua
interpreter and crashes the IPC connection. Also avoid calling `hs.reload()`
from timers or callbacks inside `hs -c` commands.
### Safe reload pattern
```bash
# Use the hs-reload script (clicks menu, waits for "hammerspork loaded")
hs-reload
```
**How it works**: The `hs-reload` script uses AppleScript to click "Reload Config"
in the Hammerspoon menu bar, then watches the console for "hammerspork loaded"
to confirm completion. This avoids the IPC crash.
### Check for errors after reload
```bash
hs -c '
local c = hs.console.getConsole()
for line in c:gmatch("[^\n]+") do
if line:match("ERROR") or line:match("attempt to") then print(line) end
end
'
```
### If Hammerspoon is crashed/not running
```bash
open -a Hammerspoon
sleep 3 # Wait for init
hs -c 'print("ok")' && echo "✓ Started"
```
### Quick verification commands
```bash
# Test if alive
hs -c 'print("ok")'
# Check IPC is responding
hs -c "return hs.processInfo.processID"
# Check specific module loaded
hs -c 'print(HUD ~= nil and "HUD loaded" or "HUD missing")'
```
## Common API Patterns
### Window Management
```lua
-- Get focused window
local win = hs.window.focusedWindow()
if not win then return end -- Always check!
-- Get/set frame
local frame = win:frame()
win:setFrame(frame)
-- Get screen
local screen = win:screen()
local screenFrame = screen:frame()
-- Move to position
win:setTopLeft(hs.geometry.point(100, 100))
-- Resize
win:setSize(hs.geometry.size(800, 600))
-- Move to another screen
win:moveToScreen(hs.screen.find("LG UltraFine"))
-- Center on screen
win:centerOnScreen()
-- Full screen toggle
win:setFullscreen(not win:isFullscreen())
```
### Application Management
```lua
-- Find by name or bundle ID
local app = hs.application.find("com.brave.Browser.nightly")
local app = hs.application.get("Brave Browser Nightly")
-- Frontmost app
local frontApp = hs.application.frontmostApplication()
-- Launch or focus
hs.application.launchOrFocusByBundleID("com.brave.Browser.nightly")
-- All windows
local windows = app:allWindows()
-- Main window
local mainWin = app:mainWindow()
-- Activate (bring to front)
app:activate()
-- Hide
app:hide()
```
### Accessibility (AX)
```lua
local ax = hs.axuielement
-- Get element for app
local appElement = ax.applicationElement(app)
-- Get attribute
local children = appElement:attributeValue("AXChildren")
local role = appElement:attributeValue("AXRole")
local title = appElement:attributeValue("AXTitle")
-- Common attributes
-- AXRole, AXSubrole, AXTitle, AXDescription, AXValue
-- AXFocused, AXEnabled, AXPosition, AXSize
-- AXChildren, AXParent, AXWindows, AXFocusedWindow
-- Perform action
appElement:performAction("AXPress")
appElement:performAction("AXRaise")
-- Build a tree
local function printAXTree(el, depth)
depth = depth or 0
if depth > 3 then return end
local role = el:attributeValue("AXRole") or "?"
local title = el:attributeValue("AXTitle") or ""
print(string.rep(" ", depth) .. role .. ": " .. title)
for _, child in ipairs(el:attributeValue("AXChildren") or {}) do
printAXTree(child, depth + 1)
end
end
```
### Notifications
```lua
-- Using the notification system (N.send)
N.send({
title = "Title",
message = "Message body",
urgency = "normal", -- "low"|"normal"|"high"|"critical"
})
-- With phone notification
N.send({
title = "Alert",
message = "Something important",
urgency = "critical", -- Auto-sends to phone
phone = true, -- Or explicit
})
-- Native hs.notify (goes to NC only)
hs.notify.new({
title = "Title",
informativeText = "Body",
}):send()
```
### Distributed Notifications (IPC)
```lua
-- Post to external apps (e.g., Shade)
hs.distributednotifications.post("io.shade.toggle", nil, nil)
-- Listen from external apps
local watcher = hs.distributednotifications.new(function(name, object, info)
U.log.i("Received:", name, object, info)
end, "notification.name")
watcher:start()
```
### Timers
```lua
-- One-shot timer (after delay)
hs.timer.doAfter(2, function()
-- Runs once after 2 seconds
end)
-- Repeating timer
local timer = hs.timer.new(5, function()
-- Runs every 5 seconds
end)
timer:start()
timer:stop() -- Don't forget to stop!
-- Delayed timer (common pattern)
hs.timer.delayed.new(0.5, function()
-- Runs 0.5s after last trigger
end):start()
```
### Canvas (Drawing)
```lua
local canvas = hs.canvas.new({ x = 100, y = 100, w = 200, h = 100 })
canvas:appendElements({
{
type = "rectangle",
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.8 },
roundedRectRadii = { xRadius = 10, yRadius = 10 },
},
{
type = "text",
text = "Hello",
textColor = { white = 1 },
textAlignment = "center",
frame = { x = 0, y = 35, w = 200, h = 30 },
},
})
canvas:show()
-- IMPORTANT: Always clean up canvases
canvas:delete() -- or canvas:hide() then delete later
```
## Debugging Commands
```bash
# Open Hammerspoon console
hs -c "hs.openConsole()"
# Check if running
pgrep Hammerspoon
# View logs
log stream --predicate 'subsystem == "org.hammerspoon.Hammerspoon"' --level debug
# Test a module
hs -c "print(I(require('lib.interop.shade')))"
# Check state
hs -c "print(I(S.notification))"
# Check config
hs -c "print(I(C.displays))"
# List loaded modules
hs -c "for k,v in pairs(package.loaded) do print(k) end"
# Memory usage (canvas count is a good indicator)
hs -c "print('Canvases:', #hs.canvas.list())"
# Check notification rules
hs -c "print(I(C.notificationRules))"
# Query notification database
sqlite3 ~/.local/share/hammerspoon/hammerspoon.db \
"SELECT datetime(timestamp,'unixepoch','localtime') as time, sender, title, message FROM notifications ORDER BY timestamp DESC LIMIT 10"
```
## Performance Monitoring
```lua
-- Memory tracking
local function logMemory(label)
collectgarbage("collect")
local mem = collectgarbage("count")
U.log.i(label .. " memory:", string.format("%.2f KB", mem))
end
-- Timer audit
local function auditTimers()
-- Check S.* for timer accumulation
local count = 0
for k, v in pairs(S.notification.timers or {}) do
count = count + 1
end
U.log.i("Active notification timers:", count)
end
-- Canvas audit
local function auditCanvases()
local canvases = hs.canvas.list()
U.log.i("Active canvases:", #canvases)
for i, c in ipairs(canvases) do
U.log.d(" Canvas", i, c:frame())
end
end
```
## Common Issues and Fixes
### "Error: attempt to index nil value"
**Cause**: Trying to access property on nil object (window closed, app quit, etc.)
**Fix**: Always check for nil before accessing:
```lua
local win = hs.window.focusedWindow()
if not win then return end
```
### "Hammerspoon uses too much CPU"
**Cause**: Watcher firing too often, infinite loop, or timer leak
**Fix**:
1. Add debouncing to watchers
2. Check for recursive callbacks
3. Ensure timers are stopped when not needed
### "Canvas not appearing"
**Cause**: Canvas behind other windows, wrong coordinates, or not shown
**Fix**:
```lua
canvas:level(hs.canvas.windowLevels.overlay) -- Above everything
canvas:behavior(hs.canvas.windowBehaviors.canJoinAllSpaces)
canvas:show()
```
### "Accessibility not working"
**Cause**: Permission not granted or app not accessibility-enabled
**Fix**:
1. System Settings → Privacy & Security → Accessibility → Hammerspoon ✓
2. Some apps (like browsers) need extra permissions
3. Try `hs.accessibilityState()` to check
### "Module not found after reload"
**Cause**: Cached require not cleared
**Fix**:
```lua
package.loaded["module.name"] = nil
require("module.name")
```
## Files to Check First
| Symptom | Check This File |
|---------|-----------------|
| Hotkey not working | `bindings.lua`, `hyper.lua` |
| Window behavior | `config.lua` (C.layouts), `chain.lua` |
| Notification issue | `lib/notifications/*.lua`, `config.lua` (C.notificationRules) |
| App-specific behavior | `contexts/com.bundleid.lua` |
| IPC with Shade | `lib/interop/shade.lua` |
| Browser automation | `lib/interop/browser.lua` |
| State/globals | `lib/state.lua`, `init.lua` |
## Discovering Hammerspoon Capabilities
### List All Available Modules
```lua
-- In Hammerspoon console: list all hs.* modules
for k, v in pairs(hs) do
if type(v) == "table" then
print("hs." .. k)
end
end
-- Check what a module provides
print(I(hs.window)) -- See all methods/properties
-- Get help for a function
help("hs.window.focusedWindow")
```
### Key hs.* Modules Reference
| Module | Purpose |
|--------|---------|
| `hs.window` | Window manipulation |
| `hs.application` | Application control |
| `hs.screen` | Display/screen info |
| `hs.hotkey` | Keyboard shortcuts |
| `hs.eventtap` | Low-level input events |
| `hs.canvas` | Custom drawing |
| `hs.notify` | Native notifications |
| `hs.distributednotifications` | IPC with other apps |
| `hs.axuielement` | Accessibility API |
| `hs.osascript` | AppleScript/JXA |
| `hs.timer` | Timers and delays |
| `hs.task` | Run shell commands |
| `hs.socket` | Network sockets |
| `hs.http` | HTTP requests |
| `hs.json` | JSON encode/decode |
| `hs.fs` | File system operations |
| `hs.audiodevice` | Audio input/output |
| `hs.battery` | Battery status |
| `hs.caffeinate` | Prevent sleep |
| `hs.pasteboard` | Clipboard |
| `hs.chooser` | Selection UI |
| `hs.alert` | Simple alerts |
| `hs.menubar` | Menu bar icons |
| `hs.pathwatcher` | File change watcher |
| `hs.usb` | USB device events |
| `hs.wifi` | WiFi info |
| `hs.location` | GPS location |
| `hs.speech` | Text-to-speech |
### Reading Hammerspoon Source
The Hammerspoon codebase reveals capabilities not always in docs:
```bash
# Clone Hammerspoon source for deep reference
git clone https://github.com/Hammerspoon/hammerspoon.git /tmp/hs-source
# Search for specific functionality
rg "AX" /tmp/hs-source/extensions --type objc
# Check how a module is implemented
cat /tmp/hs-source/extensions/window/window.lua
# Find undocumented features
rg "@objc func" /tmp/hs-source/Hammerspoon --type swift
```
### Checking Documentation Gaps
```bash
# Hammerspoon docs source
git clone https://github.com/Hammerspoon/hammerspoon.github.io.git /tmp/hs-docs
# Compare extension implementations vs docs
ls /tmp/hs-source/extensions/
# vs
ls /tmp/hs-docs/docs/
```
## Known Limitations and Issues
### Platform Limitations (macOS)
| Limitation | Reason | Workaround |
|------------|--------|------------|
| Cannot interact with full-screen apps in other Spaces | macOS security | None - OS limitation |
| AX may not work with some apps | App doesn't implement AX | Use JXA/AppleScript |
| Cannot capture global hotkeys used by system | SIP protection | Remap in System Settings |
| Window manipulation slow for some apps | App uses non-standard windows | Use hs.timer delays |
| Cannot read passwords/secure text fields | macOS security | None - by design |
### Common GitHub Issues
Check these resources for known bugs:
```bash
# Current open issues
open "https://github.com/Hammerspoon/hammerspoon/issues"
# Search for specific problem
open "https://github.com/Hammerspoon/hammerspoon/issues?q=is%3Aissue+canvas+leak"
# Check if issue is fixed in newer version
open "https://github.com/Hammerspoon/hammerspoon/releases"
```
**Notable recurring issues:**
- Canvas memory leaks (always delete canvases)
- hs.reload() hanging (use timeout pattern)
- Accessibility permission revoked after macOS updates
- Window filters not catching all events
- Some apps not responding to AX actions
### Version Compatibility
```lua
-- Check Hammerspoon version
print(hs.processInfo.version)
-- Check if feature exists (defensive coding)
if hs.canvas then
-- Use canvas
else
hs.alert("Canvas not available in this version")
end
-- Check macOS version for compatibility
local osVersion = hs.host.operatingSystemVersion()
print(osVersion.major, osVersion.minor, osVersion.patch)
```
### Undocumented But Useful
```lua
-- hs.inspect (aliased as I in this config)
print(hs.inspect(someTable, { depth = 2 }))
-- hs.printf (like printf)
hs.printf("Value: %s", someValue)
-- hs.fnutils (functional programming)
local mapped = hs.fnutils.map(list, function(x) return x * 2 end)
local filtered = hs.fnutils.filter(list, function(x) return x > 5 end)
-- hs.geometry helpers
local rect = hs.geometry.rect(0, 0, 100, 100)
rect:move(10, 20)
rect:scale(1.5)
-- hs.settings (persistent storage)
hs.settings.set("myKey", "myValue")
local value = hs.settings.get("myKey")
-- hs.ipc (command line communication)
-- This is how `hs -c "..."` works
```
### Extensions Not in Default Build
Some extensions require manual compilation or are experimental:
```lua
-- Check if extension exists
if hs.razer then print("Razer support available") end
if hs.streamdeck then print("Stream Deck support available") end
if hs.tangent then print("Tangent panel support available") end
```
## Self-Discovery Pattern
When you don't know if Hammerspoon can do something:
```
1. Check if module exists:
└─ hs -c "print(hs.modulename ~= nil)"
2. List module contents:
└─ hs -c "for k,v in pairs(hs.modulename) do print(k, type(v)) end"
3. Check docs:
└─ open "https://www.hammerspoon.org/docs/hs.modulename.html"
4. Search GitHub issues:
└─ open "https://github.com/Hammerspoon/hammerspoon/issues?q=modulename"
5. Search source code:
└─ rg "modulename" /path/to/hammerspoon/source
6. Ask in community:
└─ https://github.com/Hammerspoon/hammerspoon/discussions
└─ IRC: #hammerspoon on Libera.Chat
```
## Related Resources
- **Hammerspoon Expert Agent**: Spawn for deep debugging/exploration tasks
- **Shade Skill**: For Shade.app integration
- **Smart-ntfy Skill**: For notification system details
- **Hammerspoon Docs**: https://www.hammerspoon.org/docs/
- **Hammerspoon GitHub**: https://github.com/Hammerspoon/hammerspoon
- **Hammerspoon Wiki**: https://github.com/Hammerspoon/hammerspoon/wiki
- **Notification DB**: `~/.local/share/hammerspoon/hammerspoon.db`
- **Spoons (plugins)**: https://www.hammerspoon.org/Spoons/Related Skills
writing-clearly-and-concisely
Apply Strunk's timeless writing rules to ANY prose humans will read - documentation, commit messages, error messages, explanations, reports, or UI text. Makes your writing clearer, stronger, and more professional.
web-search
Web search using DuckDuckGo (free, unlimited). Falls back to pi-web-access extension for content extraction.
web-browser
Interact with web pages using agent-browser CLI. MUST run 'browser connect 9222' FIRST to use existing browser with authenticated sessions.
tmux
Remote control tmux sessions for interactive CLIs (python, gdb, etc.) by sending keystrokes and scraping pane output.
ticket-worker
Work on a single tk ticket end-to-end. Use when the user says 'work on ticket X' or when spawned by work-tickets.sh.
ticket-creator
Create and refine tickets for the tk ticket system. Use when the user says 'create tickets for X', 'refine ticket X', 'break this into tickets', 'seed tickets from plan', or anything about creating or refining tk tickets.
tell
Delegate tasks to other agents - pi sessions or external agents (claude, opencode, aider). Non-blocking with task tracking and completion notifications.
task-pipeline
Structured workflow for research → plan → tickets → work. Use when starting or continuing a task with /task, /plan, or /tickets commands.
preview
Display code, diffs, images, and other content in a tmux pane or popup. Auto-detects nvim/megaterm for floating popups.
mcpctl
Manage MCP server configurations — add, remove, list, inspect, troubleshoot. Use when asked to "add mcp server", "remove mcp", "list mcp servers", "mcp status", "configure mcp", "troubleshoot mcp", or any MCP server management task.
handoff
Save session state for later pickup. Use /handoff when context is degrading, /pickup to resume in a new session.
github
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.