diff --git a/hammerspoon/AGENTS.md b/hammerspoon/AGENTS.md new file mode 100644 index 0000000..edcb0a6 --- /dev/null +++ b/hammerspoon/AGENTS.md @@ -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()`. diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/init.lua b/hammerspoon/Spoons/ClaudeRewriter.spoon/init.lua index 59c9901..0a7d437 100644 --- a/hammerspoon/Spoons/ClaudeRewriter.spoon/init.lua +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/init.lua @@ -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" @@ -30,6 +31,56 @@ 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 @@ -37,10 +88,19 @@ 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 @@ -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 @@ -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 }, }, diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/01-rewrite.md b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/01-rewrite.md new file mode 100644 index 0000000..67029aa --- /dev/null +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/01-rewrite.md @@ -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. diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/02-formal.md b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/02-formal.md new file mode 100644 index 0000000..1847526 --- /dev/null +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/02-formal.md @@ -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. diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/03-casual.md b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/03-casual.md new file mode 100644 index 0000000..f80f959 --- /dev/null +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/03-casual.md @@ -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. diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/04-translate.md b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/04-translate.md new file mode 100644 index 0000000..5ddd3d9 --- /dev/null +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/04-translate.md @@ -0,0 +1,4 @@ +--- +title: Translate to English +--- +Translate the following text to English. Return ONLY the translation with no preamble or explanation. diff --git a/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/05-summarize.md b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/05-summarize.md new file mode 100644 index 0000000..b081d0d --- /dev/null +++ b/hammerspoon/Spoons/ClaudeRewriter.spoon/prompts/05-summarize.md @@ -0,0 +1,4 @@ +--- +title: Summarize +--- +Summarize the following text concisely. Return ONLY the summary with no preamble or explanation. diff --git a/hammerspoon/init.lua b/hammerspoon/init.lua index 0668768..4d19cce 100644 --- a/hammerspoon/init.lua +++ b/hammerspoon/init.lua @@ -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")