Select text in any app, press your hotkey, and the answer streams into a floating markdown window. Press ⌘↩ to copy it and close.
Holster talks to any OpenAI-compatible endpoint, so the model behind a command
can be a local Ollama, OpenAI, Google Gemini, OpenRouter, or a CLIProxyAPI
sitting in front of a ChatGPT or Claude subscription you already pay for.
Prompts and commands are plain files under ~/.config/holster/, so you can keep
them in git. Built as a stable replacement for Raycast AI custom commands.
- One global hotkey per command, recorded in Settings with a duplicate check
- Prompts and commands are plain files: git-friendly, hot-reloaded, and editable from the built-in Settings UI
- Streaming markdown rendering, GFM tables included
- Smart copy asks the model which part of the answer you meant to paste — the translation, the rewritten email, the code — and copies that instead of the whole response (one extra request per ⌘↩)
- API keys go to the macOS Keychain, never to
config.yaml - A fallback provider takes over when the primary one dies before any output
- Optional text-to-speech: free Edge voices, an OpenAI-compatible
/audio/speech, or the built-in macOS voice - Holster restores your clipboard after the capture and marks the restore transient, so clipboard managers don't log a duplicate
- Headless CLI mode for scripting and for testing prompts
- Native Swift and SwiftUI. No Electron, one small binary
Holster has no telemetry and no backend. Your selected text goes to the endpoint
you configure and nowhere else. The one exception is text-to-speech. With
tts.provider: edge, the default in the example config, whatever you ask Holster
to speak goes to Microsoft's read-aloud service. Settings → Speak → Source →
System switches to a voice installed on your Mac, which sends nothing anywhere.
Apple Silicon only, macOS 14 or later. There is no Intel build.
brew tap impelcrypto/tap
brew trust impelcrypto/tap # Homebrew refuses casks from taps you have not trusted
brew install --cask holsterOr download Holster-0.2.0.zip from the
latest release, unzip
it, and drag Holster.app into /Applications.
Holster is signed but not notarized by Apple, so macOS blocks it the first time with "Apple could not verify Holster is free of malware". To get past it, open System Settings → Privacy & Security, scroll down to the Security section, and click Open Anyway next to the Holster message. Every launch after that is normal.
Notarization needs a paid Apple Developer account, so until then this step comes with every install route, Homebrew included.
Everything goes through SPM. There is no Xcode project to open, but you do need Xcode installed for Swift 6.
git clone https://github.com/impelcrypto/Holster.git
cd Holster
make app # build Holster.app into ./build.noindex
make install # copy to /Applications and launch
make test # unit testsscripts/bundle.sh signs with your "Apple Development" certificate when you
have one and falls back to ad-hoc signing otherwise. Under ad-hoc signing macOS
forgets the Accessibility grant after every rebuild, so a stable certificate
saves you a trip to System Settings each time.
- Launch the app. A text icon appears in the menu bar and the Settings window opens on General, where System Health shows the Accessibility permission (needed to read the selection via a synthetic ⌘C) and the config status.
~/.config/holster/is created with an example config and a grammar check prompt.- Set up a provider (next section), then select some text anywhere and press ⌘⇧G.
Holster needs one OpenAI-compatible endpoint behind your commands. Pick the one that matches what you already have.
Cloud models run on Ollama's machines, so a 31B model works on a Mac that could never hold it in memory.
brew install ollama # or grab the app from https://ollama.com/download
brew services start ollama # skip this if you installed the app instead
ollama signin # free ollama.com account; cloud models need it
ollama pull gemma4:31b-cloudThe example config already points an ollama provider at
http://127.0.0.1:11434/v1, and it needs no API key. Open Settings, pick your
command, set Provider to ollama and Model to gemma4:31b-cloud. The example
command ships on cliproxy, so it keeps failing until you switch it.
The free tier covers light use, with limits that reset per session and per week
(pricing). To skip the account and the limits, pull
a local model instead: ollama pull gemma4:12b weighs about 7 GB and never
leaves your Mac.
CLIProxyAPI signs in to a subscription you already pay for and serves it on an OpenAI-compatible port. You pay nothing per token.
brew install cliproxyapi
cliproxyapi --codex-login # ChatGPT Plus/Pro, opens a browser
cliproxyapi --claude-login # Claude Pro/Max
brew services start cliproxyapiThe proxy listens on 8317, where the example config's cliproxy provider already
points, and it takes an empty API key.
This setup has more moving parts than the other two, so hand this README and
https://help.router-for.me/ to Claude Code or another coding agent and let it
finish the job. It can install the binary, walk you through the OAuth login, and
write the provider into config.yaml.
Settings ships presets for opencode-go and gemini, both on fixed base URLs.
Paste a key into the command editor and it lands in the Keychain rather than in
config.yaml. Anything else OpenAI-compatible, OpenRouter for instance, goes
under the custom preset with its own base URL. Saving writes the provider into
providers: for you.
Everything lives in ~/.config/holster/. Edit the files directly or use the
Settings window (menu bar icon → Settings…). Both stay in sync because the files
are the single source of truth and the app watches them. Saving from the GUI
re-serializes config.yaml, which drops your YAML comments.
# config.yaml
providers: # any OpenAI-compatible endpoints
cliproxy:
base_url: http://127.0.0.1:8317/v1
api_key: "" # only for CLI/dev; the app uses macOS Keychain
ollama:
base_url: http://127.0.0.1:11434/v1
opencode-go: # https://opencode.ai/docs/go/
base_url: https://opencode.ai/zen/go/v1
api_key: ""
gemini: # Google Gemini API
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key: ""
custom: # e.g. OpenRouter; editable from Settings
base_url: https://openrouter.ai/api/v1
api_key: ""
default_provider: cliproxy
tts: # provider: edge = free Edge voices, sent to Microsoft;
provider: edge # provider: system = macOS voice, nothing sent;
# base_url = OpenAI-compatible /audio/speech
voice: en-US-AvaMultilingualNeural
commands:
- name: Grammar Teacher
hotkey: cmd+shift+g # modifiers: cmd, shift, opt, ctrl
prompt: grammar.md # file under prompts/, {selection} gets replaced
provider: cliproxy
model: gpt-5.6-sol
reasoning: medium # low / medium / high; omit to send no reasoning field
# (on opencode-go an omitted value falls back to low:
# its models think by default, so "none" never helps)
copy_on_select: true # selecting text in the result window copies it
fallback_provider: ollama # used when the provider fails before any output
fallback_model: qwen3:8b # omit to reuse the primary modelPrompt files take two placeholders: {selection} for the captured selection and
{clipboard} for the current clipboard contents.
The packaged app keeps the keys you enter in Settings in the macOS Keychain, not
in config.yaml. The first time it opens an existing config it also migrates any
plaintext provider or TTS keys into the Keychain and strips them from the file. Development builds launched with swift run keep reading
api_key from YAML; the packaged app uses the Keychain in both menu-bar and
headless CLI modes.
Each command gets its own editor. It records the hotkey and warns when another
command already claims the combination, lists the provider's models from
/v1/models (type the ID when the endpoint has none), and checks the API key. A
Test section runs the draft on a sample sentence before you save.
Settings → General → Theme picks System (default), Light, or Dark. It lives in
UserDefaults rather than config.yaml.
| Key | Action |
|---|---|
| ⌘↩ | Smart copy: the model picks the part worth pasting, then close |
| ⇧⌘↩ | Copy the full response |
| ⌘S | Speak the selection, or the whole response when nothing is selected |
| ⌘. | Stop generating (Esc also cancels the stream on close) |
| ⌘R | Retry (after an error) |
| Esc | Close |
⌘↩ costs one extra non-streaming request on the command's own provider and model. The selector only returns line numbers into the response, so it can add nothing of its own to your clipboard; if it fails or you press Esc while it is running, you get the full response. ⇧⌘↩ never calls it.
Holster runs the same pipeline without the GUI or any permissions, which helps when you are iterating on prompts and providers:
Holster --list
Holster --run "Grammar Teacher" --text "It seem wrong."
echo "It seem wrong." | Holster --run "Grammar Teacher"
Holster --help--config <dir> points at an alternative config directory and --no-stream
disables SSE. Each config directory gets its own Keychain namespace, so API keys
never leak between configs.
- Text-to-speech through CLIProxyAPI does not work, because the proxy does not
forward
/v1/audio/speechfor subscription auth. Pointtts.base_urlstraight at a provider with a real API key, or useprovider: edgeor the macOS voice. provider: edgerides Microsoft Edge's free read-aloud voices over a WebSocket. No API key, but it is an unofficial endpoint that may change. On any failure Holster drops back to the built-in macOS voice.- The built-in macOS voice (
provider: system, or no provider and no base_url) honorstts.voice, either a name likeAvaor a full identifier. The standard voices sound flat next to Edge's; Premium ones are a free download, and Apple's instructions cover it. An uninstalled voice falls back to the default en-US one. - A result window that is already open keeps the old theme until the next run.
Issues and pull requests are welcome. Run make test before opening one.
- Bump
VERSION. One line, novprefix. Semver: patch for fixes, minor for new commands or settings, major once an existingconfig.yamlstops loading. - Commit it and merge to
master. - Run
scripts/release.shfrommaster.
The script stops on Intel or on a dirty tree, builds the app, zips it with
ditto so the code signature survives, and prints the sha256. When a
homebrew-tap checkout sits next to this one, it rewrites the cask's version
and sha256 in place. After you confirm, it tags vX.Y.Z, pushes the tag, and
creates the GitHub release with the zip attached.
The cask lives in its own repository, so push that one yourself:
cd ../homebrew-tap
git commit -am "holster X.Y.Z"
git pushVERSION is the only place the number lives. scripts/bundle.sh reads it into
the Info.plist, scripts/release.sh reads it for the tag and the zip name.
Signing stays on the "Apple Development" certificate for now, so every release
needs the "Open Anyway" step described under Install. A paid Apple Developer
account would let notarytool remove that step, at the cost of resetting
everyone's Accessibility grant once when the certificate changes.
