Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions hammerspoon/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Hammerspoon Configuration

Language: Lua. Runtime: Hammerspoon (macOS automation framework).

## Setup

Config path is `~/.config/hammerspoon/init.lua`, set via:
```
defaults write org.hammerspoon.Hammerspoon MJConfigFile "~/.config/hammerspoon/init.lua"
```

Hammerspoon is a GUI app and does not inherit shell environment variables. Use `launchctl setenv` to propagate env vars.

## Structure

- `init.lua` — Entry point. App-switching hotkeys, config reload, and spoon loading.
- `Spoons/` — Hammerspoon plugin packages (each is a `.spoon` directory with `init.lua`).

## Hotkeys

| Binding | Action |
|---------|--------|
| `cmd+alt+T` | Focus Ghostty |
| `cmd+alt+B` | Focus Arc |
| `cmd+alt+S` | Focus Slack |
| `cmd+alt+M` | Focus Spotify |
| `cmd+alt+Z` | Focus Zoom |
| `cmd+alt+,` | Focus System Settings |
| `alt+R` | Reload Hammerspoon config |
| `alt+C` | Rewrite selected text in place (ClaudeRewriter) |
| `alt+shift+C` | Show rewrite menu on selected text (ClaudeRewriter) |

## ClaudeRewriter Spoon

Path: `Spoons/ClaudeRewriter.spoon/`

Rewrites selected text using the Anthropic Messages API. Reads the API key from `~/.config/gitlab-duo/a-key`.

### Hotkey actions

- `rewrite` — Sends selected text to Claude with the default system prompt, replaces selection with result.
- `clipboard` — Same as rewrite but copies result to clipboard instead of pasting.
- `menu` — Shows a popup context menu with prompt options loaded from disk. Selecting one rewrites the text in place.

### Configurable properties

- `apiKey` — Anthropic API key (required).
- `model` — Model ID (default: `claude-sonnet-4-5`).
- `systemPrompt` — Default system prompt used by the `rewrite` action.
- `promptsPath` — Directory for menu prompts (default: `Spoons/ClaudeRewriter.spoon/prompts/`).
- `alertDuration` — How long alerts display in seconds (default: 1.5).

### Menu prompts

Prompt files live in `Spoons/ClaudeRewriter.spoon/prompts/` as markdown files with YAML front matter. Menu order is determined by filename sort order (use numeric prefixes like `01-`, `02-`).

Format:
```markdown
---
title: Menu Item Title
---
The system prompt text sent to Claude.
```

Required front matter field: `title` (displayed in the menu). The markdown body is the system prompt.

To add a new menu item, create a new `.md` file in the prompts directory.

## Code style

- Tabs for indentation.
- Hammerspoon APIs are available via the global `hs` namespace.
- Spoons follow Hammerspoon conventions: `init()`, `bindHotkeys()`, `start()`, `stop()`.
101 changes: 95 additions & 6 deletions hammerspoon/Spoons/ClaudeRewriter.spoon/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ obj.model = "claude-sonnet-4-5"
obj.systemPrompt =
"Rewrite the following text for clarity and correct grammar. Preserve the original tone, intent, and meaning. Return ONLY the rewritten text with no preamble or explanation. DONT use emdashes. Use emojies to express gratitude, but avoid face emojies, only symbols."
obj.alertDuration = 1.5
obj.promptsPath = nil -- set externally; defaults to spoon's prompts/ dir

local API_URL = "https://api.anthropic.com/v1/messages"

Expand All @@ -30,17 +31,76 @@ local function getSelectedText()
return focusedElement:attributeValue("AXSelectedText")
end

local function parseFrontMatter(content)
local frontMatter, body = content:match("^%-%-%-\n(.-)\n%-%-%-\n(.*)$")
if not frontMatter then
return nil, content
end
local meta = {}
for line in frontMatter:gmatch("[^\n]+") do
local key, value = line:match("^(%w+):%s*(.+)$")
if key then
if tonumber(value) then
meta[key] = tonumber(value)
else
meta[key] = value
end
end
end
return meta, body:gsub("^%s+", ""):gsub("%s+$", "")
end

local function loadPrompts(dir)
local prompts = {}
local files = {}
local iter, dirObj = hs.fs.dir(dir)
if not iter then
return prompts
end
for file in iter, dirObj do
if file:match("%.md$") then
files[#files + 1] = file
end
end
table.sort(files)
for _, file in ipairs(files) do
local path = dir .. "/" .. file
local f = io.open(path, "r")
if f then
local content = f:read("*a")
f:close()
local meta, body = parseFrontMatter(content)
if meta and meta.title and body ~= "" then
prompts[#prompts + 1] = {
title = meta.title,
prompt = body,
}
end
end
end
return prompts
end

function obj:init()
return self
end

function obj:bindHotkeys(mapping)
for action, bind in pairs(mapping) do
local mods, key = bind[1], bind[2]
local paste = action == "rewrite"
hotkeys[#hotkeys + 1] = hs.hotkey.new(mods, key, function()
self:rewrite(paste)
end)
if action == "rewrite" then
hotkeys[#hotkeys + 1] = hs.hotkey.new(mods, key, function()
self:rewrite(true)
end)
elseif action == "clipboard" then
hotkeys[#hotkeys + 1] = hs.hotkey.new(mods, key, function()
self:rewrite(false)
end)
elseif action == "menu" then
hotkeys[#hotkeys + 1] = hs.hotkey.new(mods, key, function()
self:showMenu()
end)
end
end
return self
end
Expand All @@ -59,7 +119,36 @@ function obj:stop()
return self
end

function obj:rewrite(pasteInPlace)
function obj:showMenu()
local selected = getSelectedText()
if not selected or selected == "" then
hs.alert.show("No text selected", self.alertDuration)
return
end

local dir = self.promptsPath or (hs.spoons.scriptPath() .. "/prompts")
local prompts = loadPrompts(dir)
if #prompts == 0 then
hs.alert.show("No prompts found in " .. dir, self.alertDuration)
return
end

local menuItems = {}
for _, p in ipairs(prompts) do
menuItems[#menuItems + 1] = {
title = p.title,
fn = function()
self:rewrite(true, p.prompt)
end,
}
end

local menu = hs.menubar.new(false)
menu:setMenu(menuItems)
menu:popupMenu(hs.mouse.absolutePosition())
end

function obj:rewrite(pasteInPlace, promptOverride)
if rewriting then
hs.alert.show("Rewrite in progress", self.alertDuration)
return
Expand Down Expand Up @@ -89,7 +178,7 @@ function obj:rewrite(pasteInPlace)
local body = hs.json.encode({
model = self.model,
max_tokens = 4096,
system = self.systemPrompt,
system = promptOverride or self.systemPrompt,
messages = {
{ role = "user", content = selected },
},
Expand Down
4 changes: 4 additions & 0 deletions hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/01-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Rewrite
---
Rewrite the following text for clarity and correct grammar. Preserve the original tone, intent, and meaning. Return ONLY the rewritten text with no preamble or explanation. DONT use emdashes. Use emojies to express gratitude, but avoid face emojies, only symbols.
4 changes: 4 additions & 0 deletions hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/02-formal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Make Formal
---
Rewrite the following text in a formal, professional tone. Preserve the original meaning. Return ONLY the rewritten text with no preamble or explanation.
4 changes: 4 additions & 0 deletions hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/03-casual.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Make Casual
---
Rewrite the following text in a casual, friendly tone. Preserve the original meaning. Return ONLY the rewritten text with no preamble or explanation.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Translate to English
---
Translate the following text to English. Return ONLY the translation with no preamble or explanation.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: Summarize
---
Summarize the following text concisely. Return ONLY the summary with no preamble or explanation.
2 changes: 1 addition & 1 deletion hammerspoon/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ end

spoon.ClaudeRewriter:bindHotkeys({
rewrite = { { "alt" }, "c" },
clipboard = { { "alt", "shift" }, "c" },
menu = { { "alt", "shift" }, "c" },
}):start()

hs.alert.show("🔮 Config loaded")
Loading