Hammerspoon LLM script
A Hammerspoon script that calls the OpenAI API using selected text when you press a keyboard shortcut. One of my attempts to reduce the bottleneck in the AI–human interaction loop, even a little.
Differences from macOS “Writing Tools”
macOS “Writing Tools” already has similar functionality, but it has drawbacks. It does not work in WebView-based apps such as Slack, VSCode, and Obsidian, or even native apps that implement their own text controls (such as zed). Nor can I freely customize its features or prompts.
Usage
- Select any text.
- Press a shortcut (configured as desired in Hammerspoon).
- Enter commands.
- The LLM’s response goes to the clipboard.
The available commands are:
ko: Translate the text into Korean.en: Translate the text into English.cli: Write a macOS CLI command that does what the text describes.regex: Express what the text describes as a regular expression.sum: Briefly summarize the text.explain: Explain the content of the text (code, etc.).answer: Answer the content of the text.enhance: Improve the text with minimal changes.pr: Proofread the text.bullet: Convert the text to a bulleted list.dic: Explain the word or phrase.
Multiple commands are chained like a Unix pipeline. For example, summarize bullet ko summarizes the text, converts it into a bulleted list, and then translates it into Korean.
Which bottleneck does it reduce?
It can slightly reduce the bottleneck caused by human reluctance to bother or laziness.
For example, summarizing some text with AI while using the computer, translating the summary into Korean, and copying the result to the clipboard currently takes these steps:
- Select the text.
- Copy the text.
- Press a shortcut to open the ChatGPT input window. (If the ChatGPT app isn’t installed, switch to a browser, open a new tab, and go to the ChatGPT site.)
- Paste the text.
- Enter an additional prompt, such as “Summarize this text and translate the summary to Korean.”
- Copy the result.
With this script, the steps become:
- Select the text.
- Press a shortcut.
- Enter a prompt, such as “sum ko”.
Code
By changing api_key, endpoint, and model_id appropriately, you can use not only OpenAI but various providers compatible with the OpenAI Completion API (OpenRouter, Gemini, etc.). The code below uses Anthropic’s Haiku 4.5.
function obj:buildSystemPrompt(cmds)
local lines = {}
for word in cmds:gmatch("%S+") do
local description = COMMAND_DESCRIPTIONS[word]
if description then
table.insert(lines, "- " .. description)
end
end
return string.format(SYSTEM_PROMPT_BASE, table.concat(lines, "\n"))
end
function obj:init()
return self
end
function obj:setStatus(emoji)
self.statusIcon = hs.menubar.new()
self.statusIcon:setTitle(emoji)
end
function obj:clearStatus()
self.statusIcon:delete()
self.statusIcon = nil
end
function obj:bindHotkeys(mapping)
local spec = {
generateText = hs.fnutils.partial(self.generateText, self),
}
hs.spoons.bindHotkeysToSpec(spec, mapping)
return self
end
function obj:generateText()
hs.eventtap.keyStroke({ "cmd" }, "c")
hs.timer.usleep(0.05)
local focusedApp = hs.window.frontmostWindow():application()
local body, cmds = self:getBodyAndCmds()
hs.timer.doAfter(0.01, function() focusedApp:activate() end)
if not body then
return
end
local header = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. self.api_key,
}
local reqBody = {
model = self.model_id,
messages = {
{ role = "system", content = self:buildSystemPrompt(cmds) },
{ role = "user", content = body .. '\n\n' .. cmds },
},
max_completion_tokens = 10240,
}
self:setStatus("⏳")
hs.http.asyncPost(
self.endpoint,
hs.json.encode(reqBody),
header,
function(http_code, res)
self:clearStatus()
local resBody = hs.json.decode(res)
if http_code ~= 200 then
hs.alert("Failed to get response: " .. resBody.error.message)
return
end
local resText = resBody.choices[1].message.content
hs.pasteboard.setContents(resText)
hs.alert("Updated clipboard")
end
)
end
function obj:getBodyAndCmds()
local body = hs.pasteboard.getContents()
if not body then
hs.alert("Please select the text first")
return nil
end
local button, cmds = hs.dialog.textPrompt("Enter the command", "", "ko summarize", "Ok", "Cancel")
if button == "Cancel" or not cmds or cmds == "" then
return nil
end
for word in cmds:gmatch("%S+") do
if not COMMAND_DESCRIPTIONS[word] then
return nil
end
end
return body, cmds
end
return obj