hs

$npx mdskill add megalithic/dotfiles/hs

Provides a comprehensive guide for developing and troubleshooting Hammerspoon configurations in macOS automation.

  • Helps developers set up and debug Hammerspoon for hotkeys, window management, and app integrations.
  • Integrates with Bash, Read, and Edit tools, and references the Hammerspoon API documentation.
  • Uses structured config patterns and decision trees to recommend actions based on performance and error checks.
  • Delivers results through detailed documentation, code examples, and troubleshooting steps in a dotfiles repository.

SKILL.md

.github/skills/hsView on GitHub ↗
---
name: hs
description: Comprehensive guide for Hammerspoon development in this dotfiles repo. Covers config patterns, debugging decision trees, API reference, performance monitoring, and troubleshooting.
tools: Bash, Read, Edit
---

# 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/

More from megalithic/dotfiles

SkillDescription
brave-searchWeb search and content extraction via Brave Search API. Use for searching documentation, facts, or any web content. Lightweight, no browser required.
cli-toolsModern CLI tool usage (fd, rg) for fast file and content searching. Critical for Nix store searches and large codebases. Use when searching files or content, especially in /nix/store.
image-handlingImage handling for Claude API constraints (5MB max, 8000px max dimension). Use when working with images, screenshots, or MCP browser tools.
jjJujutsu (jj) version control workflow, commands, and best practices. Use when working with version control in jj-enabled repos. Covers commits, bookmarks, workspaces, and safe push patterns.
nixExpert help with Nix, nix-darwin, home-manager, flakes, and nixpkgs. Use for dotfiles configuration, package management, module development, hash fetching, debugging evaluation errors, and understanding Nix idioms and patterns.
notesExpert help with the meganote system - cross-tool note capture, daily notes, and obsidian.nvim integration. Covers Hammerspoon, Shade, nvim, and the full capture → daily note linking pipeline.
nvimComprehensive guide for Neovim configuration in this dotfiles repo. Covers plugin management, LSP debugging, treesitter, keymaps, performance, and troubleshooting decision trees.
previewDisplay code, diffs, images, and other content in a tmux pane or popup. Auto-detects nvim/megaterm for floating popups.
shadeExpert help with Shade - the native Swift note capture app. Use for debugging Shade issues, understanding IPC protocols, implementing Hammerspoon integration, nvim RPC, context gathering, and meganote workflows.
smart-ntfySend intelligent notifications via ~/bin/ntfy with context-aware channel selection. Use when completing tasks, asking questions, encountering errors, or reaching milestones.