Last active
March 2, 2025 14:54
-
-
Save amanbolat/7f34ede37ed04223910b7b63c7d14ce0 to your computer and use it in GitHub Desktop.
Hammperspoon Spoon to proofread the selected text
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- === LLMProofreader === | |
--- | |
--- Proofread clipboard contents using Claude | |
--- | |
--- Place this file in the ~/.hammerspoon directory with the following structure | |
--- ── Spoons | |
--- └── LLMProofreader.spoon | |
--- └── init.lua | |
--- | |
--- To use, add the following lines to ~/.hammerspoon/init.lua: | |
--- hs.loadSpoon('LLMProofreader') | |
--- spoon.LLMProofreader:bindHotkeys({ | |
--- proofread = { {"cmd", "alt", "ctrl"}, "P" }, | |
--- }) | |
local obj = {} | |
obj.__index = obj | |
-- Metadata | |
obj.name = 'LLMProofreader' | |
obj.version = '1.1' | |
obj.author = 'Modified from ChatGPT spoon by JC Connell' | |
obj.license = 'MIT - https://opensource.org/licenses/MIT' | |
--- LLMProofreader.api_key | |
--- Variable | |
--- String API key for Claude/Anthropic | |
obj.api_key = 'YOUR-ANTHROPIC-API-KEY-HERE' | |
--- LLMProofreader.model | |
--- Variable | |
--- Claude model to use | |
obj.model = 'claude-3-7-sonnet-latest' | |
--- LLMProofreader.max_tokens | |
--- Variable | |
--- Integer indicating max length of the response | |
obj.max_tokens = 4000 | |
--- LLMProofreader.logger | |
--- Variable | |
--- Logger object used within the Spoon | |
obj.logger = hs.logger.new('LLMProofreader') | |
-- getTextSelection() | |
-- Gets currently selected text using Cmd+C | |
-- Saves and restores the current pasteboard | |
-- Imperfect, perhaps. May not work well on large selections. | |
-- Taken from: https://github.com/Hammerspoon/hammerspoon/issues/634 | |
local function getTextSelection() -- returns text or nil while leaving pasteboard undisturbed. | |
local oldText = hs.pasteboard.getContents() | |
hs.eventtap.keyStroke({"cmd"}, "c") | |
hs.timer.usleep(25000) | |
local text = hs.pasteboard.getContents() -- if nothing is selected this is unchanged | |
hs.pasteboard.setContents(oldText) | |
if text ~= oldText then | |
return text | |
else | |
return "" | |
end | |
end | |
local function readFileContents(filePath) | |
local file, err = io.open(filePath, "r") | |
if not file then | |
hs.alert.show("Error opening file: " .. tostring(err)) | |
return nil | |
end | |
local content = file:read("*a") | |
file:close() | |
return content:match("^%s*(.-)%s*$") -- Trim whitespace | |
end | |
local pathToFile = "/Users/<your_user_name>/.secrets/claude_api_key" | |
--- LLMProofreader:proofreadWithConfig(max_tokens) | |
--- Method | |
--- Sends text to Claude for proofreading | |
--- | |
--- Parameters: | |
--- * max_tokens - Integer | |
function obj:proofreadWithConfig(max_tokens) | |
max_tokens = max_tokens or obj.max_tokens | |
obj.api_key = readFileContents(pathToFile) | |
assert(obj.api_key and obj.api_key ~= '', "API key must be provided") | |
-- local user_text = hs.pasteboard.getContents() | |
local user_text = getTextSelection() | |
assert(user_text and user_text ~= '', "No text selected") | |
obj.logger.df("User text: %s", board) | |
obj.logger.df("Max tokens: %s", tostring(max_tokens)) | |
hs.http.asyncPost( | |
'https://api.anthropic.com/v1/messages', | |
hs.json.encode({ | |
model = obj.model, | |
max_tokens = max_tokens, | |
system = 'Proofread the text written by user. Paraphrase sentences or paragraphs if required. Make it look like it was written by a native English speaker. Keep the balance between colloquial and formal styles. Do not reply with anything, but a rewritten version of the text.', | |
messages = { | |
{ | |
role = 'user', | |
content = user_text | |
} | |
} | |
}), | |
{ | |
["Content-Type"] = "application/json", | |
["x-api-key"] = obj.api_key, | |
["anthropic-version"] = "2023-06-01" | |
}, | |
function(http_code, response) | |
obj.logger.ef("HTTP Code: %s", tostring(http_code)) | |
obj.logger.ef("Response: %s", response) | |
if http_code == 200 then | |
local decoded_data = hs.json.decode(response) | |
-- Update to use the correct response structure | |
local proofread_text = decoded_data.content[1].text or "No content received" | |
hs.pasteboard.setContents(proofread_text) | |
hs.timer.usleep(25000) | |
hs.eventtap.keyStroke({"cmd"}, "v") | |
hs.notify.new({title = 'Proofreading Successful. The response was added to the pasteboard.'}):send() | |
obj.logger.df("Claude response: %s", proofread_text) | |
else | |
local decoded_data = hs.json.decode(response) | |
local error_message = decoded_data.error and decoded_data.error.message or "Unknown error" | |
hs.notify.new({title = 'Proofreading Failed!', informativeText = error_message}):send() | |
obj.logger.ef("Claude response: %s", response) | |
end | |
end | |
) | |
end | |
--- LLMProofreader:proofread() | |
--- Method | |
--- Sends text to Claude for proofreading with default settings | |
function obj:proofread() | |
obj:proofreadWithConfig(4000) | |
end | |
--- LLMProofreader:bindHotkeys(mapping) | |
--- Method | |
--- Binds hotkeys for LLMProofreader | |
--- | |
--- Parameters: | |
--- * mapping - A table containing hotkey modifier/key details for: | |
--- * proofread - proofread text with Claude | |
function obj:bindHotkeys(mapping) | |
local def = {} | |
for action, key in pairs(mapping) do | |
if action == "proofread" then | |
def.proofread = hs.fnutils.partial(self.proofread, self) | |
else | |
self.logger.ef("Invalid hotkey action '%s'", action) | |
end | |
end | |
hs.spoons.bindHotkeysToSpec(def, mapping) | |
obj.mapping = mapping | |
end | |
return obj |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment