Hammerspoon LLM script

  • 2025-04-17 (modified: 2025-04-19)

텍스트를 선택하고 단축키를 누르면 해당 텍스트를 이용하여 OpenAI API를 호출하는 Hammerspoon 스크립트. AI-인간 상호작용 루프에서의 병목을 조금이라도 줄여보려는 시도 중 하나.

macOS “Writing Tools”와의 차이

이미 macOS의 “Writing Tools”에 유사한 기능이 있긴 하지만, Slack, VSCode, Obsidian 등 WebView 기반의 앱 또는 네이티브 앱이라도 텍스트 컨트롤을 직접 구현한 앱(예: zed)에서는 작동하지 않는 문제, 내 마음대로 기능이나 프롬프트를 다듬을 수 없는 문제 등 단점이 있다.

사용법

  1. 아무 텍스트나 선택한다.
  2. 단축키(Hammerspoon에서 임의로 설정)를 누른다.
  3. 명령을 입력한다. (복사한 텍스트의 마지막 줄에 이미 명령이 담겨 있으면 이 단계는 생략된다)
  4. 클립보드에 LLM의 응답이 담긴다.

쓸 수 있는 명령들은 다음과 같다.

  • ko: 텍스트를 한국어로 번역한다.
  • en: 텍스트를 영어로 번역한다.
  • js: 텍스트에 적힌 내용을 자바스크립트 코드로 작성한다.
  • py: 텍스트에 적힌 내용을 파이썬 코드로 작성한다.
  • cli: 텍스트에 적힌 내용을 실행하는 맥OS CLI 커멘드를 작성한다.
  • regex: 텍스트에 적힌 내용을 정규표현식으로 작성한다.
  • summarize or sum: 텍스트를 짧게 요약한다.
  • explain: 텍스트에 담긴 내용(코드 등)을 설명한다.
  • enhance: 텍스트를 최소한으로 수정하여 개선한다.
  • proofread or pr: 텍스트를 교정한다.
  • expand: 텍스트를 길게 확장한다.
  • bullet: 텍스트를 총알 목록으로 변환한다.
  • dic: 해당 단어나 구를 설명한다.

명령을 여러 개 입력하면 유닉스 파이프라인처럼 연결된다. 예를 들어 summarize bullet ko라고 입력하면 요약한 뒤 불릿 리스트로 변환한 다음에 한국어로 번역한다.

추가 기능

명령이 $로 시작하면 LLM을 호출하지 않고 쉘을 실행한다. 예를 들어 $grep "X" | sort | uniq을 하면 현재 선택한 텍스트에서 “X”가 담긴 행만 찾은 뒤에 정렬하고 겹치는 행을 제거한 다음 그 결과를 클립보드에 담는다. 엑셀 등에서도 사용 가능.

LLM CLI를 설치했다면 파이프에 LLM 호출을 끼워 넣을 수 있다. 예: $head -n 2 | llm "Explain"

(여기까지 해놓고 생각해보니 그냥 LLM CLI를 쓰면 굳이 Lua에서 LLM을 호출할 필요가 없겠다 싶어서 Hammerspoon CLI script를 만들었다. 타이핑은 좀 더 많이 해야하지만 Lua 코드가 더 간단해진다.)

어떤 병목을 줄이나

인간의 귀찮음 또는 게으름으로 인한 병목을 조금 줄여줄 수 있다.

예를 들어 지금은 컴퓨터 사용 중 AI를 써서 어떤 텍스트를 요약한 뒤 한국어로 번역하고 그 결과를 클립보드에 복사하려면 다음 단계가 필요하다.

  1. 텍스트를 선택한다.
  2. 텍스트를 복사한다.
  3. 단축키를 눌러서 ChatGPT 입력창을 연다. (ChatGPT 앱을 설치하지 않았다면 브라우저로 전환한 뒤 새 탭을 열고 ChatGPT에 사이트에 접속한다)
  4. 텍스트를 붙여넣는다.
  5. 추가로 프롬프트를 입력한다. 예: “Summarize this text and translate the summary to Korean.”
  6. 결과를 복사한다.

이 스크립트를 쓰면 아래와 같이 단순해진다.

  1. 텍스트를 선택한다.
  2. 단축키를 누른다.
  3. 프롬프트를 입력한다. 예: “sum ko”

코드

api_key, endpoint, model_id를 적절히 바꾸면 OpenAI 뿐 아니라 OpenAI Completion API와 호환되는 다양한 제공자(OpenRouter, Gemini 등)를 쓸 수 있다. 아래 코드는 Google Gemini 2.5 Flash를 사용한다. 2025년 4월 18일 현재 매일 500회 무료로 호출할 수 있다.

local os = require("os")

local obj = {}
obj.api_key = "YOUR_API_KEY"
-- obj.endpoint = "https://api.openai.com/v1/chat/completions"
obj.endpoint = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
-- obj.model_id = "gpt-4.1-mini"
obj.model_id = "gemini-2.5-flash-preview-04-17"

LLM_COMMANDS = {'ko', 'en', 'js', 'py', 'cli', 'regex', 'summarize', 'sum', 'explain', 'enhance', 'proofread', 'pr', 'expand', 'bullet', 'dic'}

SYSTEM_PROMPT = [[
You are a helpful assistant. The user will provide you with a text. The last line of the text is the command. You will perform the command and return the resulting text.require

Here are the commands:

- `ko`: Translate the text into Korean.
- `en`: Translate the text into English.
- `js`: Write JS function that does the text.
- `py`: Write Python function that does the text.
- `cli`: Write single line CLI command that does the text for macOS, not GNU/Linux. Note that some commands like `grep` have different syntax in macOS.
- `regex`: Write regex pattern.
- `summarize` or `sum`: Summarize the text in a single paragraph.
- `explain`: Explain the text.
- `enhance`: Enhance the text while preserving the text as much as possible.
- `proofread` or `pr`: Proofread the text.
- `expand`: Expand the text.
- `bullet`: Write markdown bullet points. Maximum depth is 2. The second level should be indented by TAB character.
- `dic`: Explain the meaning of a word or a phrase in a dictionary style, with examples.

The command can be combined with other commands, e.g. `ko summarize`. You will perform all the commands in the order of the commands like a Unix pipeline. Note that your output will be directly copied to the clipboard. DO NOT write triple backticks or anything like that.
]]

function obj:init()
    self.statusIcon = hs.menubar.new()

    return self
end

function obj:setStatus(emoji)
    self.statusIcon:setTitle(emoji)
end

function obj:clearStatus()
    self.statusIcon:setTitle("")
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()
    -- Copy the text from the clipboard
    hs.eventtap.keyStroke({ "cmd" }, "c")
    hs.timer.usleep(0.001)

    -- Get the text from the clipboard
    local type, body, cmds = self:getBodyAndCmds()
    if type == nil then
        return
    end

    if type == "llm" then
        -- If the type is "llm", call the LLM
        local header = {
            ["Content-Type"] = "application/json",
            ["Authorization"] = "Bearer " .. self.api_key,
        }
        local reqBody = {
            model = self.model_id,
            messages = {
                {role = "system", content = SYSTEM_PROMPT},
                {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 from OpenAI: " .. resBody.error.message)
                    return
                end

                local resText = resBody.choices[1].message.content
                hs.pasteboard.setContents(resText)

                hs.alert("Updated clipboard")
            end
        )
    elseif type == "shell" then
        -- If the type is "shell", run the command
        self:setStatus("")

        local shell = os.getenv("SHELL")
        hs.task.new(shell, function(code, out, err)
            if code ~= 0 then
                hs.dialog.blockAlert("Error", err, "Ok")
            else
                hs.alert("Updated clipboard")
            end
            self:clearStatus()
        end, {
            "-i", "-c", "pbpaste | " .. cmds .. " | pbcopy"
        }):start()
    end
end

function obj:getBodyAndCmds()
    local clipboardText = hs.pasteboard.getContents()

    -- If the clipboard is empty, return nil
    if clipboardText == nil then
        hs.alert("Please select the text first")
        return nil, nil, nil
    end

    -- Split the text into two parts: body and cmds
    clipboardText = clipboardText:match("^%s*(.-)%s*$")
    local body, cmds = clipboardText:match("^(.-)\n([^\n]*)\n?$")

    -- If there's no new line, it means the last line is just a body and there's no command
    if body == nil then
        body = clipboardText
        cmds = nil
    end

    -- If the cmds is empty or unknown, show a input dialog to get the command
    if not self:is_shell_cmds(cmds) and not self:is_llm_cmds(cmds) then
        -- Get the focused application to activate it after the dialog is closed
        local focusedApp = hs.window.frontmostWindow():application()

        -- Show the input dialog
        local button, userInput = hs.dialog.textPrompt("Enter the command", "", "ko summarize", "Ok", "Cancel")

        -- Restore the focused application
        if focusedApp then
            hs.timer.doAfter(0.01, function()
              focusedApp:activate()
            end)
        end

        -- If the user cancels the dialog, return nil
        if button == "Cancel" then
            return nil, nil, nil
        end
        cmds = userInput
    end

    -- Now we have the body and cmds
    if self:is_shell_cmds(cmds) then
        ---@diagnostic disable-next-line: need-check-nil
        cmds = cmds:sub(2)
        return "shell", body, cmds
    elseif self:is_llm_cmds(cmds) then
        return "llm", body, cmds
    else
        return nil, body, cmds
    end
end

function obj:is_shell_cmds(cmds)
    return cmds ~= nil and cmds ~= "" and cmds:sub(1, 1) == "$"
end

function obj:is_llm_cmds(cmds)
    if cmds == nil or cmds == "" then
        return false
    end

    for word in cmds:gmatch("%S+") do
      if not self:is_known_llm_cmd(word) then
        return false
      end
    end

    return true
end

function obj:is_known_llm_cmd(cmd)
    for _, c in ipairs(LLM_COMMANDS) do
      if c == cmd then return true end
    end
    return false
end

return obj

2025 © ak