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.

LayerWhere 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

KeysAction
⌃⌥⌘KSummon or dismiss Keyholdr — works system-wide
⌘NAdd a new key
EscDismiss the add/edit form
just typeSearch 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).

CommandWhat it does
keyholdrInteractive picker (default subcommand)
keyholdr listList every key (never the secrets)
keyholdr get <platform>Print or copy a secret
keyholdr runRun a command with secrets injected as env vars
keyholdr envEmit 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 containsEnv var
githubGITHUB_TOKEN
gitlabGITLAB_TOKEN
huggingfaceHF_TOKEN
slackSLACK_TOKEN
discordDISCORD_TOKEN
telegramTELEGRAM_BOT_TOKEN
vercelVERCEL_TOKEN
netlifyNETLIFY_AUTH_TOKEN
cloudflareCLOUDFLARE_API_TOKEN
twilioTWILIO_AUTH_TOKEN
npmNPM_TOKEN
sentrySENTRY_AUTH_TOKEN
claude / anthropicANTHROPIC_API_KEY
chatgpt / openaiOPENAI_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

KeyAction
typefilter by platform, label, or tag
↑ / ↓move selection
select (single) / confirm (multi)
⇥ or spacemark/unmark an entry (multi-select only)
^Uclear the filter
esc, ^C, ^Dcancel

Exit codes

CodeMeaning
0Success
1Validation error or declined confirmation
130Picker cancelled (esc / ^C / ^D)
otherForwarded 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_secret returns 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.