Documentation
Everything Keyholdr does,
in one place.
How secrets are stored, how to install the app and CLI, and the
full keyholdr command reference — including
multi-select run/env and env var naming
conventions.
Overview
Keyholdr is a native macOS menu bar vault for API keys, plus a terminal companion that reads the same vault. The app stores non-secret metadata (platform, label, tags) in a local JSON file and writes the actual secrets straight into the OS's hardware-backed Keychain — they're never on disk in cleartext and every read requires Touch ID or Apple Watch.
There's no dock icon, no server, no sync, and no analytics. Everything happens locally, gated by biometrics.
Architecture
Metadata and secrets never travel together. Names, labels, and tags live in a local file; the secrets themselves live in the operating system's secure vault and are only pulled out — after biometric verification — at the moment you copy them.
| Layer | Where it lives |
|---|---|
| Metadata | ~/Library/Application Support/com.olixstudios.Keyholdr/keys.json — platform, label, tags, and age only |
| Secrets | macOS Keychain Services (Security API) |
| Unlock | Touch ID / Apple Watch (LocalAuthentication) |
Self-healing
Every save also mirrors the (non-secret) metadata into a Keychain
item. If keys.json is ever deleted or corrupted, the
app silently restores it from that mirror — and since secrets
already live in the Keychain, deleting the app or its files loses
nothing.
Vault export
Export the whole vault to a single passphrase-encrypted file (PBKDF2 + AES-256-GCM) and import it on another machine. No cloud account required.
Install
The easy way, via Homebrew:
brew install --cask olixignacious/tap/keyholdr
Or grab the latest release
directly — unzip Keyholdr-macOS-*.zip and move
Keyholdr.app to /Applications. It's
signed and notarized, so it opens without any Gatekeeper prompt.
Requires Apple Silicon.
Or install from the Mac App Store — search for "Keyholdr". Sandboxing means the App Store build can't link the CLI onto your PATH by itself; see below.
CLI link
Homebrew links keyholdr onto your PATH
automatically. For a direct download, link it once:
mkdir -p ~/.local/bin
ln -sf /Applications/Keyholdr.app/Contents/MacOS/keyholdr-cli ~/.local/bin/keyholdr
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
Restart Terminal (or run source ~/.zshrc) afterward.
/usr/local/bin isn't user-writable on modern macOS by
default, which is why ~/.local/bin is used instead —
the in-app CLI installer (direct-download build only) does the
same thing automatically via a footer button.
CLI link (Mac App Store build)
The App Store build is sandboxed, so it can't write to
~/.local/bin or edit your shell profile on its own —
use the same command as above, but point it at the App Store
app's embedded binary instead:
mkdir -p ~/.local/bin
ln -sf /Applications/Keyholdr.app/Contents/MacOS/keyholdr-cli ~/.local/bin/keyholdr
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
You can also open this same snippet from inside the app: ⋯ menu → Terminal Setup….
The first read of each key shows a one-time macOS Keychain consent dialog — choose Always Allow and it won't ask again.
Shortcuts
| Keys | Action |
|---|---|
| ⌃⌥⌘K | Summon or dismiss Keyholdr — works system-wide |
| ⌘N | Add a new key |
| Esc | Dismiss the add/edit form |
| just type | Search is focused by default |
CLI reference
The terminal companion reads the same vault as the app — the same key list and the same macOS Keychain entries. Every secret access requires Touch ID (or your account password as a fallback).
| Command | What it does |
|---|---|
| keyholdr | Interactive picker (default subcommand) |
| keyholdr list | List every key (never the secrets) |
| keyholdr get <platform> | Print or copy a secret |
| keyholdr run | Run a command with secrets injected as env vars |
| keyholdr env | Emit export / .env lines for eval |
| keyholdr add <platform> | Add a new key |
| keyholdr rm [platform] | Delete one or more keys |
Run keyholdr <command> --help for full flag details.
keyholdr / keyholdr pick [filter]
Opens an inline picker: type to filter by platform, label, or tag, ↑↓ to move, ⏎ to select. After Touch ID, the secret is copied to the clipboard.
# browse everything
keyholdr
# start pre-filtered to "aws"
keyholdr aws
Needs a real terminal (stdin and stderr must both be a tty). In
scripts or pipes, use list, get,
run, or env instead.
keyholdr list
Prints every key — platform, label, tags, and age — without ever touching the Keychain or prompting for Touch ID.
keyholdr list
# PLATFORM LABEL TAGS AGE
# github work dev,ci 14d
# aws default 3mo ⚠
A ⚠ next to the age means the key is old enough that
Keyholdr suggests rotating it.
keyholdr get <platform>
Resolves <platform> (case-insensitive,
substring match — keyholdr get git finds
github), prompts for Touch ID, then prints the secret
to stdout.
# prints to stdout
keyholdr get aws
# disambiguate when one platform has multiple keys
keyholdr get github --label work
# clipboard instead of stdout
keyholdr get github --label work --copy
If a platform has more than one matching key and
--label isn't given, the picker opens to choose — but
only in an interactive terminal. In scripts, ambiguity is a hard
error listing the --label values to disambiguate
with, so automation never blocks on hidden interactivity.
keyholdr run
Runs a command with secrets injected as environment variables in the child process only — they never touch stdout, files, or shell history.
Explicit mappings, one or more -e ENV_VAR=platform[/label]:
keyholdr run -e AWS_ACCESS_KEY_ID=aws -e GITHUB_TOKEN=github/work -- npm start
Multi-select, with no -e flags, in an interactive terminal:
keyholdr run -- npm start
This opens the multi-select picker (⇥ or
space to mark, ⏎ to confirm) and
derives conventional env var names automatically — e.g.
GITHUB_TOKEN, AWS_ACCESS_KEY_ID (see
env var naming below).
keyholdr env
Selects keys and prints export NAME='value' lines (or
NAME=value with --dotenv) designed to be
eval'd into your current shell — nothing
lands in files or history.
# multi-select picker
eval "$(keyholdr env)"
# name keys directly
eval "$(keyholdr env aws github/work)"
# .env-style NAME=value
eval "$(keyholdr env --dotenv aws)"
# dry run: shows the name mapping only,
# no Touch ID, nothing exported
keyholdr env --names
eval is required — without it, the
export lines just print to your terminal and don't
affect your shell's environment. --names is a
preview: it never reads secrets or requires Touch ID, and on its
own doesn't export anything.
keyholdr add <platform>
Adds a key. The secret is never an argument
(arguments are visible to every process via ps) —
it's read from a hidden prompt, or piped on stdin.
# hidden prompt
keyholdr add github --label work --tags dev,ci
# piped from the clipboard
pbpaste | keyholdr add aws
--label defaults to default. An
identical platform + label pair is rejected — use a different
--label to keep keys addressable.
keyholdr rm [platform]
Deletes one or more keys and their Keychain secrets.
# confirms, then deletes
keyholdr rm github --label work
# skip the confirmation prompt (scripts)
keyholdr rm github --force
# multi-select: ⇥/space to mark, ⏎ deletes
keyholdr rm
With no platform, opens the multi-select picker
(interactive terminals only). Without --force,
deletion always asks for confirmation — and refuses outright in
non-interactive contexts so scripts can't silently wipe keys.
When several keys match (keyholdr get aws with a work
and a personal key), the same picker opens to choose — in scripts
and pipes it stays a hard error with --label hints
instead, so automation never blocks. The app refuses to create two
keys with an identical platform + label, so every key stays
addressable.
Env var naming
keyholdr run (multi-select) and
keyholdr env derive a conventional environment
variable name per platform:
| Platform contains | Env var |
|---|---|
| github | GITHUB_TOKEN |
| gitlab | GITLAB_TOKEN |
| huggingface | HF_TOKEN |
| slack | SLACK_TOKEN |
| discord | DISCORD_TOKEN |
| telegram | TELEGRAM_BOT_TOKEN |
| vercel | VERCEL_TOKEN |
| netlify | NETLIFY_AUTH_TOKEN |
| cloudflare | CLOUDFLARE_API_TOKEN |
| twilio | TWILIO_AUTH_TOKEN |
| npm | NPM_TOKEN |
| sentry | SENTRY_AUTH_TOKEN |
| claude / anthropic | ANTHROPIC_API_KEY |
| chatgpt / openai | OPENAI_API_KEY |
| anything else | <PLATFORM>_API_KEY |
If selecting multiple keys would produce the same env var name,
the label is appended (then a numeric counter) to keep names
unique — e.g. GITHUB_TOKEN and
GITHUB_TOKEN_WORK.
Picker controls
| Key | Action |
|---|---|
| type | filter by platform, label, or tag |
| ↑ / ↓ | move selection |
| ⏎ | select (single) / confirm (multi) |
| ⇥ or space | mark/unmark an entry (multi-select only) |
| ^U | clear the filter |
| esc, ^C, ^D | cancel |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Validation error or declined confirmation |
| 130 | Picker cancelled (esc / ^C / ^D) |
| other | Forwarded from the child process for keyholdr run |
MCP bridge
MCP (Model Context Protocol) is how tools like Claude Desktop and Claude Code call out to local tools. A small bridge script lets an MCP client ask Keyholdr for a secret by name instead of you pasting it into a config file or a chat, and every request still goes through the same Touch ID prompt as using the app directly.
The bridge is just a thin wrapper: it shells out to the
keyholdr CLI and hands back stdout. It never touches
the Keychain itself and can't skip the biometric check.
1. Install the SDK
npm install @modelcontextprotocol/sdk
2. Save the bridge script
Save this as keyholdr-mcp-bridge.mjs:
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const KEYHOLDR = process.env.KEYHOLDR_BIN || "keyholdr";
const server = new Server(
{ name: "keyholdr-bridge", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_keys",
description: "List available key names in the vault (never the secret values).",
inputSchema: { type: "object", properties: {} },
},
{
name: "get_secret",
description:
"Retrieve one secret from the local Keyholdr vault. Triggers a Touch ID prompt on the Mac running this bridge.",
inputSchema: {
type: "object",
properties: {
platform: { type: "string", description: "Platform name, e.g. github or aws" },
label: { type: "string", description: "Optional label, e.g. work or personal" },
},
required: ["platform"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
if (name === "list_keys") {
const { stdout } = await run(KEYHOLDR, ["list"]);
return { content: [{ type: "text", text: stdout }] };
}
if (name === "get_secret") {
const cliArgs = ["get", args.platform];
if (args.label) cliArgs.push("--label", args.label);
const { stdout } = await run(KEYHOLDR, cliArgs);
return { content: [{ type: "text", text: stdout.trim() }] };
}
throw new Error(`Unknown tool: ${name}`);
});
await server.connect(new StdioServerTransport());
3. Point an MCP client at it
For Claude Desktop, add it to
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"keyholdr": {
"command": "node",
"args": ["/absolute/path/to/keyholdr-mcp-bridge.mjs"]
}
}
}
Restart the client, and it'll have list_keys and
get_secret tools backed by your real vault.
Keep in mind
- Anything
get_secretreturns becomes part of the model's context for that conversation, same exposure as pasting a key into chat yourself, so only wire up secrets you're comfortable with an agent seeing transiently. - The Touch ID prompt appears on the Mac running the bridge, not remotely. If you run the bridge over SSH on a headless Mac, the CLI has no biometric prompt to show and will fail rather than silently skip it.
- This bridge isn't shipped by Keyholdr. It's a pattern you run yourself; audit and adapt it before pointing it at anything sensitive.
Build from source
macOS needs Xcode Command Line Tools
(xcode-select --install):
# release build → app bundle → launches in your menu bar
./build.sh
Tagged releases (v*) automatically build the macOS
app on CI and attach it to the GitHub release.
The Windows app (C# 12, WPF, .NET 8) lives in its own repo: keyholdr-windows.
Questions about building it yourself? Get in touch.