🚀. Socket Launch Week Day 3:Socket Firewall Now Blocks Malicious VS Code and Open VSX Extensions.Learn more
Sign In

keypick

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

keypick - npm Package Compare versions

Comparing version
0.1.2
to
0.2.3
+318
bin/installer.mjs
// KeyPick install wizard — Node-compatible, zero deps.
// Run via: npx github:seanrobertwright/KeyPick install
import { homedir, platform, tmpdir } from "node:os";
import path from "node:path";
import fs from "node:fs";
import { spawnSync } from "node:child_process";
import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
banner, box, why, log, confirm, choose, showCommand, done, cancelled, color,
} from "./ui.mjs";
const REPO = process.env.KEYPICK_REPO || "seanrobertwright/KeyPick";
const IS_WIN = platform() === "win32";
const HOME = homedir();
const SHARE_DIR = process.env.KEYPICK_SHARE_DIR ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".local", "share", "keypick")
: path.join(HOME, ".local", "share", "keypick"));
const BIN_DIR = process.env.KEYPICK_BIN_DIR ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".local", "bin")
: path.join(HOME, ".local", "bin"));
const TOTAL_STEPS = 5;
async function main() {
banner();
box("Installer", [
color.bold("Welcome.") + " This wizard installs KeyPick on your machine.",
"",
"The wizard will:",
" " + color.cyan("•") + " Download the latest release from GitHub",
" " + color.cyan("•") + " Place the binary and shim in per-user paths",
" " + color.cyan("•") + " Check your PATH",
" " + color.cyan("•") + " Optionally install the Claude Code skill",
"",
color.dim("Press Ctrl+C at any time to abort."),
]);
if (!(await confirm("Ready to install?", true))) {
cancelled();
process.exit(0);
}
await stepCheckBun();
const tag = await stepFetchRelease();
await stepInstallBundle(tag);
await stepCheckPath();
await stepInstallSkill();
done("KeyPick installed.");
box("Next", [
"Run the interactive setup to create or join a vault:",
"",
color.bold(color.green(" keypick setup")),
"",
color.dim("If `keypick` is not found, open a new shell or add "
+ BIN_DIR + " to your PATH."),
], { color: color.magenta });
showCommand("keypick setup", "Suggested next command");
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 1 — Bun
// ─────────────────────────────────────────────────────────────────────────────
async function stepCheckBun() {
log.step(1, TOTAL_STEPS, "Check for Bun");
why(
"KeyPick is distributed as a single JavaScript bundle that runs on the "
+ "Bun runtime. Bun gives us fast startup and native binary output without "
+ "asking you to install Node + npm just to run a CLI. We need to verify "
+ "it's on your PATH before we download anything.",
);
const found = spawnSync(IS_WIN ? "where" : "which", ["bun"], { stdio: "pipe" });
if (found.status === 0) {
const v = spawnSync("bun", ["--version"], { stdio: "pipe", encoding: "utf8" });
log.ok("Bun found: " + color.bold((v.stdout || "").trim()));
return;
}
log.err("Bun is not installed.");
box("Install Bun first", [
"Pick your platform:",
"",
color.bold("macOS / Linux / WSL:"),
" " + color.green("curl -fsSL https://bun.sh/install | bash"),
"",
color.bold("Windows (PowerShell):"),
" " + color.green("irm bun.sh/install.ps1 | iex"),
"",
"Re-run this installer once Bun is on your PATH.",
], { color: color.yellow });
process.exit(1);
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2 — Fetch release tag
// ─────────────────────────────────────────────────────────────────────────────
async function stepFetchRelease() {
log.step(2, TOTAL_STEPS, "Find the latest KeyPick release");
why(
"The installer pulls a signed release tarball from GitHub rather than "
+ "building from source. This keeps the install fast and reproducible — "
+ "everyone on your team gets the exact same bundle the release was cut from.",
);
log.info("Querying " + color.cyan(`api.github.com/repos/${REPO}/releases/latest`));
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers: { "User-Agent": "keypick-installer" },
});
if (!res.ok) {
log.err(`GitHub API returned ${res.status}. Check your network and try again.`);
process.exit(1);
}
const body = await res.json();
const tag = body.tag_name;
if (!tag) {
log.err("Release has no tag_name — aborting.");
process.exit(1);
}
log.ok("Latest tag: " + color.bold(tag));
return tag;
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 3 — Install bundle + shim
// ─────────────────────────────────────────────────────────────────────────────
async function stepInstallBundle(tag) {
log.step(3, TOTAL_STEPS, "Install the KeyPick bundle");
why(
"KeyPick lives in two places: the bundle (JavaScript) at "
+ color.cyan(SHARE_DIR)
+ " and a small shim at "
+ color.cyan(BIN_DIR)
+ " that puts the `keypick` command on your PATH. Putting them in "
+ "per-user directories means no sudo and no system-wide pollution.",
);
const asset = IS_WIN ? `keypick-${tag}.zip` : `keypick-${tag}.tar.gz`;
const url = `https://github.com/${REPO}/releases/download/${tag}/${asset}`;
const tmp = fs.mkdtempSync(path.join(tmpdir(), "keypick-install-"));
const downloadPath = path.join(tmp, asset);
try {
log.info("Downloading " + color.cyan(url));
await downloadFile(url, downloadPath);
const extractDir = path.join(tmp, "extract");
fs.mkdirSync(extractDir, { recursive: true });
log.info("Extracting archive…");
await extractArchive(downloadPath, extractDir);
const bundleDir = path.join(extractDir, `keypick-${tag}`);
const bundleSrc = path.join(bundleDir, "keypick.js");
const pkgSrc = path.join(bundleDir, "package.json");
if (!fs.existsSync(bundleSrc)) {
log.err(`keypick.js missing from release archive (looked in ${bundleDir}).`);
process.exit(1);
}
fs.mkdirSync(SHARE_DIR, { recursive: true });
fs.mkdirSync(BIN_DIR, { recursive: true });
const bundleDst = path.join(SHARE_DIR, "keypick.js");
fs.copyFileSync(bundleSrc, bundleDst);
try { fs.chmodSync(bundleDst, 0o755); } catch { /* Windows ignores chmod */ }
if (fs.existsSync(pkgSrc)) {
fs.copyFileSync(pkgSrc, path.join(SHARE_DIR, "package.json"));
}
log.ok("Bundle placed at " + color.cyan(bundleDst));
if (IS_WIN) {
const shimPath = path.join(BIN_DIR, "keypick.cmd");
const shim = `@echo off\r\nbun "${bundleDst}" %*\r\n`;
fs.writeFileSync(shimPath, shim, { encoding: "ascii" });
log.ok("Shim written at " + color.cyan(shimPath));
} else {
const shimPath = path.join(BIN_DIR, "keypick");
try { fs.unlinkSync(shimPath); } catch { /* not present */ }
fs.symlinkSync(bundleDst, shimPath);
log.ok("Symlink written at " + color.cyan(shimPath));
}
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
async function downloadFile(url, dest) {
const res = await fetch(url, { headers: { "User-Agent": "keypick-installer" } });
if (!res.ok || !res.body) {
throw new Error(`Download failed: ${res.status} ${res.statusText} for ${url}`);
}
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
}
async function extractArchive(archivePath, destDir) {
if (archivePath.endsWith(".zip")) {
if (IS_WIN) {
const r = spawnSync(
"powershell.exe",
["-NoProfile", "-Command",
`Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`],
{ stdio: "inherit" },
);
if (r.status !== 0) throw new Error("Expand-Archive failed");
} else {
const r = spawnSync("unzip", ["-q", "-o", archivePath, "-d", destDir], { stdio: "inherit" });
if (r.status !== 0) throw new Error("unzip failed (is it installed?)");
}
return;
}
if (archivePath.endsWith(".tar.gz") || archivePath.endsWith(".tgz")) {
const r = spawnSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "inherit" });
if (r.status !== 0) throw new Error("tar failed");
return;
}
throw new Error(`Unknown archive type: ${archivePath}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 4 — PATH
// ─────────────────────────────────────────────────────────────────────────────
async function stepCheckPath() {
log.step(4, TOTAL_STEPS, "Check your PATH");
why(
"The shim at " + color.cyan(BIN_DIR) + " only works if that directory is on "
+ "your PATH. If it isn't, the `keypick` command won't be found and you'll "
+ "need to call it by its full path. We check here and help you fix it.",
);
const parts = (process.env.PATH || "").split(IS_WIN ? ";" : ":");
if (parts.includes(BIN_DIR)) {
log.ok(color.cyan(BIN_DIR) + " is already on your PATH.");
return;
}
log.warn(color.cyan(BIN_DIR) + " is NOT on your PATH.");
if (IS_WIN) {
box("Add to PATH (Windows)", [
"Run the following in PowerShell to append it to your user PATH:",
"",
color.bold(color.green(
`[Environment]::SetEnvironmentVariable('Path', ` +
`[Environment]::GetEnvironmentVariable('Path','User') + ';${BIN_DIR}', 'User')`,
)),
"",
color.dim("Restart your shell afterwards."),
], { color: color.yellow });
showCommand(
`[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path','User') + ';${BIN_DIR}', 'User')`,
"Copy & run in PowerShell",
);
} else {
const line = `export PATH="${BIN_DIR}:$PATH"`;
box("Add to PATH (macOS / Linux)", [
"Add this line to your shell profile",
color.dim("(~/.zshrc, ~/.bashrc, ~/.config/fish/config.fish, etc.)") + ":",
"",
color.bold(color.green(" " + line)),
"",
color.dim("Then source the file or open a new shell."),
], { color: color.yellow });
showCommand(line, "Copy into your shell profile");
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 5 — Skill
// ─────────────────────────────────────────────────────────────────────────────
async function stepInstallSkill() {
log.step(5, TOTAL_STEPS, "Install the Claude Code skill (optional)");
why(
"The KeyPick skill teaches Claude Code how to inject your vault keys "
+ "into commands without asking you to paste secrets into chat. Install "
+ "it globally to use KeyPick in every Claude Code session, install it "
+ "per-project to scope it to one repo, or skip.",
);
const scope = await choose("Where should the skill be installed?", [
{ key: "G", label: "Global — available in every project (" +
color.cyan(path.join(HOME, ".claude", "skills", "keypick")) + ")" },
{ key: "P", label: "Project — this directory only (" +
color.cyan(path.join(process.cwd(), ".claude", "skills", "keypick")) + ")" },
{ key: "S", label: "Skip" },
]);
if (!scope || scope.toUpperCase() === "S") {
log.skip("Skipping skill installation.");
return;
}
const dest = scope.toUpperCase() === "G"
? path.join(HOME, ".claude", "skills", "keypick")
: path.join(process.cwd(), ".claude", "skills", "keypick");
fs.mkdirSync(dest, { recursive: true });
const url = `https://raw.githubusercontent.com/${REPO}/master/skills/keypick/SKILL.md`;
log.info("Fetching " + color.cyan(url));
try {
await downloadFile(url, path.join(dest, "SKILL.md"));
log.ok("Skill installed at " + color.cyan(dest));
} catch (e) {
log.err("Failed to fetch skill: " + e.message);
log.warn("You can install it manually later by copying skills/keypick/SKILL.md from the repo.");
}
}
main().catch((e) => {
log.err(e.stack || e.message || String(e));
process.exit(1);
});
#!/usr/bin/env node
// KeyPick dispatcher (Node-compatible).
//
// Entry point for `npx github:seanrobertwright/KeyPick <command>`.
// Dispatches:
// install — run the install wizard
// uninstall — run the uninstall wizard
// (anything else) — forward to the installed Bun-built keypick, if present
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { homedir, platform } from "node:os";
import fs from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const arg = process.argv[2];
function importSibling(file) {
return import(pathToFileURL(path.join(__dirname, file)).href);
}
if (arg === "install") {
await importSibling("installer.mjs");
} else if (arg === "uninstall") {
await importSibling("uninstaller.mjs");
} else {
forwardToInstalled();
}
function forwardToInstalled() {
const IS_WIN = platform() === "win32";
const HOME = homedir();
const shareDir = process.env.KEYPICK_SHARE_DIR ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".local", "share", "keypick")
: path.join(HOME, ".local", "share", "keypick"));
const bundle = path.join(shareDir, "keypick.js");
if (!fs.existsSync(bundle)) {
process.stderr.write(
"KeyPick is not installed yet.\n\n" +
"Run: npx github:seanrobertwright/KeyPick install\n"
);
process.exit(1);
}
const bunCheck = spawnSync(IS_WIN ? "where" : "which", ["bun"], { stdio: "pipe" });
if (bunCheck.status !== 0) {
process.stderr.write(
"KeyPick needs Bun at runtime: https://bun.sh\n"
);
process.exit(1);
}
const result = spawnSync("bun", [bundle, ...process.argv.slice(2)], { stdio: "inherit" });
process.exit(result.status ?? 1);
}
// Shared UI primitives for the KeyPick installer wizards.
// Node-compatible, zero runtime deps, inline ANSI.
import { stdin, stdout } from "node:process";
import readline from "node:readline/promises";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const FG = {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m",
brightCyan: "\x1b[96m",
brightMagenta: "\x1b[95m",
};
export const color = {
reset: RESET,
bold: (s) => BOLD + s + RESET,
dim: (s) => DIM + s + RESET,
red: (s) => FG.red + s + RESET,
green: (s) => FG.green + s + RESET,
yellow: (s) => FG.yellow + s + RESET,
blue: (s) => FG.blue + s + RESET,
magenta: (s) => FG.magenta + s + RESET,
cyan: (s) => FG.cyan + s + RESET,
gray: (s) => FG.gray + s + RESET,
brightCyan: (s) => FG.brightCyan + s + RESET,
brightMagenta: (s) => FG.brightMagenta + s + RESET,
};
export function stripAnsi(s) {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
const BOX_WIDTH = 72;
function padVisual(s, width) {
const visible = stripAnsi(s).length;
return s + " ".repeat(Math.max(0, width - visible));
}
// Draws a bordered box with a title tab and content lines.
export function box(title, lines, opts = {}) {
const colorize = opts.color ?? color.cyan;
const width = opts.width ?? BOX_WIDTH;
const titleTab = ` ${title} `;
const fillDash = "─".repeat(Math.max(0, width - 2 - stripAnsi(titleTab).length));
const top = colorize("╭─" + titleTab + fillDash + "╮");
const bottom = colorize("╰" + "─".repeat(width - 2) + "╯");
const bar = colorize("│");
stdout.write(top + "\n");
for (const line of lines) {
const body = " " + line;
stdout.write(bar + padVisual(body, width - 2) + bar + "\n");
}
stdout.write(bottom + "\n");
}
function wrap(text, width) {
const words = text.split(/\s+/);
const lines = [];
let current = "";
for (const w of words) {
if (stripAnsi(current + " " + w).trim().length > width) {
if (current) lines.push(current);
current = w;
} else {
current = current ? current + " " + w : w;
}
}
if (current) lines.push(current);
return lines;
}
// Boxed rationale shown before each step.
export function why(text) {
const lines = wrap(text, BOX_WIDTH - 4);
box(color.bold("Why"), lines, { color: color.blue });
}
export const log = {
step: (n, total, title) =>
stdout.write(
"\n" + color.brightMagenta(`▸ Step ${n}/${total}`) + " " + color.bold(title) + "\n",
),
info: (msg) => stdout.write(color.cyan(" ==> ") + msg + "\n"),
ok: (msg) => stdout.write(color.green(" ✓ ") + msg + "\n"),
warn: (msg) => stdout.write(color.yellow(" ! ") + msg + "\n"),
err: (msg) => stdout.write(color.red(" ✗ ") + msg + "\n"),
skip: (msg) => stdout.write(color.gray(" - ") + msg + "\n"),
blank: () => stdout.write("\n"),
};
export function banner() {
const art = [
" ██╗ ██╗███████╗██╗ ██╗ ██████╗ ██╗ ██████╗██╗ ██╗",
" ██║ ██╔╝██╔════╝╚██╗ ██╔╝ ██╔══██╗██║██╔════╝██║ ██╔╝",
" █████╔╝ █████╗ ╚████╔╝ ██████╔╝██║██║ █████╔╝",
" ██╔═██╗ ██╔══╝ ╚██╔╝ ██╔═══╝ ██║██║ ██╔═██╗",
" ██║ ██╗███████╗ ██║ ██║ ██║╚██████╗██║ ██╗",
" ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝",
];
stdout.write("\n");
for (const line of art) stdout.write(color.brightCyan(line) + "\n");
stdout.write(
"\n " + color.bold(color.brightCyan("KeyPick")) +
color.dim(" — Secure Cross-Platform API Key Vault") + "\n\n",
);
}
async function withReadline(fn) {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
return await fn(rl);
} finally {
rl.close();
}
}
export async function ask(question, defaultValue = "") {
return withReadline(async (rl) => {
const hint = defaultValue ? color.dim(` [${defaultValue}]`) : "";
const ans = await rl.question(color.bold("? ") + question + hint + " ");
return ans.trim() || defaultValue;
});
}
export async function confirm(question, defaultYes = false) {
const hint = defaultYes ? "Y/n" : "y/N";
const ans = await ask(question + " " + color.dim(`(${hint})`), defaultYes ? "y" : "n");
return /^y/i.test(ans);
}
export async function choose(question, choices) {
stdout.write(color.bold("? ") + question + "\n");
for (const c of choices) {
stdout.write(" " + color.brightCyan(c.key) + ") " + c.label + "\n");
}
const keys = choices.map((c) => c.key).join(", ");
const ans = await ask(color.dim(`Enter ${keys}:`));
const matched = choices.find((c) => c.key.toLowerCase() === ans.toLowerCase());
return matched?.key ?? null;
}
// OSC 52 — writes text to the system clipboard. Modern terminals honor this;
// older ones silently drop the escape.
export function osc52Copy(text) {
const b64 = Buffer.from(text, "utf8").toString("base64");
stdout.write(`\x1b]52;c;${b64}\x07`);
}
// Shows a runnable command in a highlighted box and copies it to the clipboard.
export function showCommand(cmd, label = "Copy & run") {
const line = color.bold(color.green("$ ")) + cmd;
box(label, [line], { color: color.green });
osc52Copy(cmd);
stdout.write(color.gray(" clipboard ← ") + color.dim("copied") + "\n");
}
export function rule() {
stdout.write(color.gray("─".repeat(BOX_WIDTH)) + "\n");
}
export function done(message) {
stdout.write("\n" + color.green("━".repeat(BOX_WIDTH)) + "\n");
stdout.write(" " + color.bold(color.green("✓ " + message)) + "\n");
stdout.write(color.green("━".repeat(BOX_WIDTH)) + "\n\n");
}
export function cancelled() {
stdout.write("\n" + color.yellow("Cancelled.") + "\n");
}
// KeyPick uninstall wizard — Node-compatible, zero deps.
// Run via: npx github:seanrobertwright/KeyPick uninstall
import { homedir, platform } from "node:os";
import path from "node:path";
import fs from "node:fs";
import { spawnSync } from "node:child_process";
import {
banner, box, why, log, confirm, done, cancelled, color,
} from "./ui.mjs";
const IS_WIN = platform() === "win32";
const HOME = homedir();
const SHARE_DIR = process.env.KEYPICK_SHARE_DIR ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".local", "share", "keypick")
: path.join(HOME, ".local", "share", "keypick"));
const BIN_DIR = process.env.KEYPICK_BIN_DIR ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".local", "bin")
: path.join(HOME, ".local", "bin"));
const CONFIG_DIR = process.env.KEYPICK_HOME ||
(IS_WIN
? path.join(process.env.USERPROFILE || HOME, ".keypick")
: path.join(HOME, ".keypick"));
const AGE_KEYS = IS_WIN
? path.join(process.env.APPDATA || "", "sops", "age", "keys.txt")
: path.join(HOME, ".config", "sops", "age", "keys.txt");
const SKILL_DIR = path.join(HOME, ".claude", "skills", "keypick");
const TOTAL_STEPS = 5;
function rm(target, label) {
try {
const stat = fs.lstatSync(target);
fs.rmSync(target, { recursive: true, force: true });
log.ok(`Removed ${label}: ${color.cyan(target)}`);
return true;
} catch (e) {
if (e.code === "ENOENT") {
log.skip(`${label} not present: ${color.gray(target)}`);
} else {
log.warn(`Could not remove ${label} (${target}): ${e.message}`);
}
return false;
}
}
async function main() {
banner();
box("Uninstaller", [
color.bold("Heads up.") + " This wizard removes KeyPick from your machine.",
"",
"It will offer to remove: the bundle, shim, legacy installs, config,",
"cloned vaults, the Claude Code skill, and your age private key.",
"",
color.yellow(" ! Your remote git repo — and vaults on other machines — are NOT affected."),
color.dim("Press Ctrl+C at any time to abort."),
], { color: color.yellow });
if (!(await confirm("Proceed with uninstall?", false))) {
cancelled();
process.exit(0);
}
stepRemoveCurrent();
stepRemoveLegacy();
await stepRemoveConfig();
await stepRemoveSkill();
await stepRemoveAgeKey();
done("Uninstall complete.");
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 1 — Current install (bundle + shim)
// ─────────────────────────────────────────────────────────────────────────────
function stepRemoveCurrent() {
log.step(1, TOTAL_STEPS, "Remove bundle and shim");
why(
"This removes the files KeyPick placed when you installed it: the "
+ "bundle at " + color.cyan(SHARE_DIR) + " and the shim in "
+ color.cyan(BIN_DIR) + ". Safe to remove — none of your vault data "
+ "lives in these directories.",
);
rm(SHARE_DIR, "bundle");
rm(path.join(BIN_DIR, IS_WIN ? "keypick.cmd" : "keypick"), "shim");
if (IS_WIN) rm(path.join(BIN_DIR, "keypick"), "shim (nix-style)");
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2 — Legacy installs
// ─────────────────────────────────────────────────────────────────────────────
function stepRemoveLegacy() {
log.step(2, TOTAL_STEPS, "Remove legacy installations");
why(
"Earlier versions of KeyPick shipped via `bun install -g` or a Rust "
+ "cargo build. Those leave artifacts in different directories that the "
+ "normal uninstall doesn't cover. We sweep them up here.",
);
// Legacy: bun global install
const bunCheck = spawnSync(IS_WIN ? "where" : "which", ["bun"], { stdio: "pipe" });
if (bunCheck.status === 0) {
const lst = spawnSync("bun", ["pm", "ls", "-g"], { stdio: "pipe", encoding: "utf8" });
if ((lst.stdout || "").toLowerCase().includes("keypick")) {
log.info("Removing legacy bun global install…");
spawnSync("bun", ["remove", "-g", "keypick"], { stdio: "inherit" });
}
}
const bunBase = path.join(HOME, ".bun", "bin");
for (const f of ["keypick", "keypick.exe", "keypick.bunx"]) {
rm(path.join(bunBase, f), "legacy bun shim");
}
rm(path.join(HOME, ".bun", "install", "global", "node_modules", "keypick"), "legacy bun package dir");
// Legacy: cargo install
const cargoCheck = spawnSync(IS_WIN ? "where" : "which", ["cargo"], { stdio: "pipe" });
if (cargoCheck.status === 0) {
const lst = spawnSync("cargo", ["install", "--list"], { stdio: "pipe", encoding: "utf8" });
if (/^keypick/m.test(lst.stdout || "")) {
log.info("Removing legacy cargo install…");
spawnSync("cargo", ["uninstall", "keypick"], { stdio: "inherit" });
}
}
const cargoBase = path.join(HOME, ".cargo", "bin");
for (const f of ["keypick", "keypick.exe", "keypick2.exe"]) {
rm(path.join(cargoBase, f), "legacy cargo artifact");
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 3 — Config + cloned vaults
// ─────────────────────────────────────────────────────────────────────────────
async function stepRemoveConfig() {
log.step(3, TOTAL_STEPS, "Remove config and cloned vaults");
why(
"KeyPick clones your vault repositories into " + color.cyan(CONFIG_DIR) + ". "
+ "Removing this directory deletes the LOCAL copies only — your git remotes "
+ "(e.g. on GitHub) and your vaults on other machines are untouched. You can "
+ "always re-clone later with `keypick setup`.",
);
if (!fs.existsSync(CONFIG_DIR)) {
log.skip(`No config dir at ${color.gray(CONFIG_DIR)}`);
return;
}
log.warn(`Contents of ${color.cyan(CONFIG_DIR)}:`);
try {
const entries = fs.readdirSync(CONFIG_DIR).slice(0, 10);
for (const e of entries) log.info(" " + e);
} catch {
/* best-effort listing */
}
if (await confirm(`Remove ${CONFIG_DIR}?`, true)) {
rm(CONFIG_DIR, "config dir");
} else {
log.skip(`Kept ${color.gray(CONFIG_DIR)}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 4 — Claude Code skill
// ─────────────────────────────────────────────────────────────────────────────
async function stepRemoveSkill() {
log.step(4, TOTAL_STEPS, "Remove the Claude Code skill");
why(
"The global skill lives at " + color.cyan(SKILL_DIR) + ". We only remove "
+ "the GLOBAL installation here. Project-scope installations "
+ "(./.claude/skills/keypick in individual repos) are left alone — remove "
+ "them per-project if needed.",
);
if (!fs.existsSync(SKILL_DIR)) {
log.skip(`No skill at ${color.gray(SKILL_DIR)}`);
return;
}
if (await confirm(`Remove the KeyPick skill at ${SKILL_DIR}?`, true)) {
rm(SKILL_DIR, "skill");
} else {
log.skip(`Kept ${color.gray(SKILL_DIR)}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 5 — Age private key
// ─────────────────────────────────────────────────────────────────────────────
async function stepRemoveAgeKey() {
log.step(5, TOTAL_STEPS, "Remove the age private key");
why(
"This is the ONLY key that can decrypt your vault on THIS machine. "
+ "Without a recovery key, another enrolled machine, or a backup, deleting "
+ "it makes this machine's copy of the vault permanently unreadable. "
+ "Default is NO for a reason.",
);
if (!fs.existsSync(AGE_KEYS)) {
log.skip(`No age private key at ${color.gray(AGE_KEYS)}`);
return;
}
box("DANGER", [
color.bold(color.red("Irreversible.")),
"",
"Age private key location: " + color.cyan(AGE_KEYS),
"",
"If you delete this AND don't have a recovery key AND no other machine",
"is enrolled AND no backup exists — your vault contents on this machine",
"are permanently inaccessible.",
], { color: color.red });
if (await confirm("Delete age private key anyway?", false)) {
rm(AGE_KEYS, "age private key");
} else {
log.skip("Kept age private key");
}
}
main().catch((e) => {
log.err(e.stack || e.message || String(e));
process.exit(1);
});
MIT License
Copyright (c) 2026 KeyPick Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<p align="center">
<img src="KeyPick.png" alt="KeyPick - In-House Secure API Key Manager" width="800"/>
</p>
<h1 align="center">KeyPick</h1>
<p align="center">
<strong>A cross-platform, biometric-secured CLI for managing reusable API keys across multiple machines.</strong><br>
Built on <strong>SOPS + age encryption</strong> with a <strong>private Git repo</strong> as the sync backbone.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> &bull;
<a href="TUTORIAL.md">Tutorial</a> &bull;
<a href="#usage">Usage</a> &bull;
<a href="#how-it-works">How It Works</a> &bull;
<a href="#troubleshooting">Troubleshooting</a> &bull;
<a href="#contributing">Contributing</a>
</p>
---
## Overview
New to KeyPick? Start with [TUTORIAL.md](TUTORIAL.md). It walks through installation, setup, multi-machine vaults, project workflows, `direnv`, recovery strategy, and advanced usage patterns.
KeyPick is a terminal-based secrets manager designed for developers who work across multiple machines. Instead of copying `.env` files around, texting yourself API keys, or storing them in plaintext notes, KeyPick gives you:
- **One encrypted vault** synced via a private Git repo
- **Biometric authentication** (Windows Hello, Touch ID, Linux polkit, WSL→Hello via interop) before any secret is decrypted
- **Per-machine age keys** so compromising one machine doesn't compromise them all
- **A guided setup wizard** that installs prerequisites, generates keys, and configures everything for you
- **Shell integration** via direnv for automatic environment variable injection
```
keypick setup # One-command setup wizard
keypick add # Store secrets in encrypted groups
keypick extract # Export to .env files
keypick copy # Copy a single key to clipboard
keypick auto # Non-interactive export for direnv/CI
keypick vault # Manage vault selection
```
---
## Why KeyPick?
**The problem:** Every developer accumulates API keys, database credentials, and service tokens across projects. The common "solutions" are all terrible:
| Approach | Why It Fails |
|----------|-------------|
| `.env` files on each machine | Out of sync, easy to accidentally commit |
| Cloud password managers | Not designed for developer workflows, no CLI integration |
| Environment variables in shell profiles | Plaintext, no grouping, no sync |
| Shared team vaults (1Password, etc.) | Overkill for personal keys, subscription cost |
| Copy-pasting from Slack/email | Insecure, no audit trail, keys get lost |
**KeyPick's approach:** Encrypt everything with [age](https://github.com/FiloSottile/age), store it in a private Git repo you control, and gate decryption behind your fingerprint. Each machine gets its own encryption key. Syncing is just `git pull`.
---
## How It Works
```mermaid
graph TB
subgraph repo["Private Git Repository"]
vault["vault.yaml<br/><i>SOPS-encrypted</i>"]
sops_cfg[".sops.yaml<br/><i>public keys only</i>"]
ci[".github/workflows/vault-sync.yml<br/><i>auto re-encrypt CI</i>"]
end
repo <-->|"git pull / push"| d1
repo <-->|"git pull / push"| d2
repo <-->|"git pull / push"| laptop
subgraph machines["Your Machines"]
direction LR
subgraph d1["Desktop 1"]
k1["age key #1"]
end
subgraph d2["Desktop 2"]
k2["age key #2"]
end
subgraph laptop["Laptop"]
k3["age key #3"]
end
end
d1 & d2 & laptop --> kp
subgraph kp["keypick"]
direction LR
bio["Biometric Gate<br/><i>Windows Hello / Touch ID / polkit</i>"]
bio --> decrypt["SOPS Decrypt<br/><i>age private key</i>"]
decrypt --> menu["Interactive Menu<br/><i>add / extract / list / copy</i>"]
end
subgraph outputs["Outputs"]
direction LR
env[".env file"]
clip["Clipboard"]
shell["Shell exports<br/><i>direnv auto-inject</i>"]
end
menu --> env & clip & shell
subgraph security["Security Layers"]
direction LR
s1["1. GitHub Auth<br/><i>repo access control</i>"]
s2["2. age Encryption<br/><i>per-machine keys</i>"]
s3["3. Biometric Gate<br/><i>fingerprint / face</i>"]
end
style repo fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px,color:#fff
style vault fill:#0d7377,stroke:#14ffec,color:#fff
style sops_cfg fill:#0d7377,stroke:#14ffec,color:#fff
style ci fill:#0d7377,stroke:#14ffec,color:#fff
style machines fill:#1a1a2e,stroke:#e94560,stroke-width:2px,color:#fff
style d1 fill:#16213e,stroke:#e94560,color:#fff
style d2 fill:#16213e,stroke:#e94560,color:#fff
style laptop fill:#16213e,stroke:#e94560,color:#fff
style k1 fill:#533483,stroke:#e94560,color:#fff
style k2 fill:#533483,stroke:#e94560,color:#fff
style k3 fill:#533483,stroke:#e94560,color:#fff
style kp fill:#1a1a2e,stroke:#0f3460,stroke-width:2px,color:#fff
style bio fill:#e94560,stroke:#e94560,color:#fff
style decrypt fill:#0d7377,stroke:#14ffec,color:#fff
style menu fill:#533483,stroke:#9b59b6,color:#fff
style outputs fill:#1a1a2e,stroke:#00d4ff,stroke-width:2px,color:#fff
style env fill:#16213e,stroke:#14ffec,color:#fff
style clip fill:#16213e,stroke:#14ffec,color:#fff
style shell fill:#16213e,stroke:#14ffec,color:#fff
style security fill:#0f3460,stroke:#00d4ff,stroke-width:2px,color:#fff
style s1 fill:#e94560,stroke:#e94560,color:#fff
style s2 fill:#0d7377,stroke:#14ffec,color:#fff
style s3 fill:#533483,stroke:#9b59b6,color:#fff
```
**Three layers of security:**
1. **GitHub authentication** controls who can access the encrypted file
2. **age encryption** controls who can decrypt it (each machine has a unique private key)
3. **Biometric gate** (Windows Hello / Touch ID / polkit) protects every interactive decryption
Secrets are only ever unencrypted in memory during a `keypick` session. They are never written to disk in plaintext (except when you explicitly export a `.env` file).
---
## Tech Stack
| Component | Technology | Purpose |
|-----------|-----------|---------|
| **Runtime** | [Bun](https://bun.sh) ≥ 1.1 | TypeScript runtime |
| **Encryption** | [age](https://github.com/FiloSottile/age) | Modern, audited, no-config file encryption |
| **Secret management** | [SOPS](https://github.com/getsops/sops) | Encrypted file editing with multiple recipients |
| **Sync backbone** | Git + GitHub | Encrypted vault synced across machines |
| **CI automation** | GitHub Actions | Auto re-encryption when recipients change |
| **Biometrics** | PowerShell + WinRT (Windows / WSL), Swift + LocalAuthentication (macOS), pkexec (Linux) |
| **CLI framework** | [commander](https://www.npmjs.com/package/commander) |
| **Interactive prompts** | [@inquirer/prompts](https://www.npmjs.com/package/@inquirer/prompts) |
| **Progress indicators** | [ora](https://www.npmjs.com/package/ora) |
| **YAML** | [yaml](https://www.npmjs.com/package/yaml) |
| **Clipboard** | [clipboardy](https://www.npmjs.com/package/clipboardy) |
---
## Quick Start
### 1. Install
Installs `keypick` onto your PATH via an interactive, cross-platform wizard. Requires [Bun](https://bun.sh) and Node 18+ (`npx` ships with Node).
```bash
# macOS / Linux / Windows / WSL
npx github:seanrobertwright/KeyPick install
```
Shell-script alternatives (no Node/`npx` required):
```bash
# macOS / Linux / WSL
curl -fsSL https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/install.sh | sh
```
```powershell
# Windows PowerShell
irm https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/install.ps1 | iex
```
### 2. Run the setup wizard
```bash
keypick setup
```
The wizard will:
- Check for `age` and `sops`, downloading them automatically if missing
- Generate (or detect) your machine's age encryption key
- Ask whether this is your first machine or you're joining an existing vault
- Create or clone your encrypted vault repository under `~/.keypick/vaults/` by default
- Optionally configure GitHub Actions auto-sync and a recovery key
For a guided experience with detailed explanations of every step, use walkthrough mode:
```bash
keypick setup --walkthrough
```
---
## Setup Walkthrough
This section mirrors what `keypick setup --walkthrough` shows in your terminal, explaining each step of the setup process so you understand what is happening and why.
### Phase 1: Prerequisites (age + sops)
**What are these tools?**
KeyPick doesn't implement its own cryptography. Instead, it uses two well-audited open-source tools:
| Tool | What It Does | Why KeyPick Needs It |
|------|-------------|---------------------|
| **[age](https://github.com/FiloSottile/age)** | Modern file encryption (like GPG, but simpler) | Each machine gets its own age keypair. The private key decrypts your vault; the public key lets others encrypt *for* this machine. |
| **[sops](https://github.com/getsops/sops)** | Encrypts individual values inside structured files (YAML/JSON) | Handles multi-recipient encryption so one vault can be decrypted by many machines. Key *names* stay visible; only *values* are encrypted. |
**What happens:** The wizard checks if `age` and `sops` are on your PATH. If either is missing, it downloads the correct binary for your OS and architecture from the official GitHub releases and places it in `~/.local/bin/` (or next to the `keypick` binary on Windows).
```
[1/4] Checking prerequisites...
✓ age already installed (v1.2.0)
✓ sops already installed (v3.9.4)
```
If they need to be downloaded, you'll see a progress bar for each.
---
### Phase 2: Machine Identity (age keypair)
**Why does each machine need its own key?**
This is a core security property. Each machine has a unique age keypair:
- **Private key** — stored locally at the platform-specific path below, never shared, never committed
- **Public key** — shared with your vault (listed in `.sops.yaml`) so secrets can be encrypted *for* this machine
If a machine is compromised, you revoke just that machine's key from `.sops.yaml` without affecting any others.
| Platform | Private key location |
|----------|---------------------|
| Windows | `%APPDATA%\sops\age\keys.txt` |
| macOS | `~/.config/sops/age/keys.txt` |
| Linux | `~/.config/sops/age/keys.txt` |
**What happens:** The wizard checks if an age key already exists. If so, you can reuse it (recommended) or generate a new one (the old one is backed up with a `.bak` extension). If no key exists, `age-keygen` generates a fresh keypair.
```
[2/4] Machine identity...
✓ Key generated: age1abc123def456...
Saved to: C:\Users\you\AppData\Roaming\sops\age\keys.txt
```
> **Important:** Never share, commit, or copy your private key (`keys.txt`). If this machine is lost or compromised, remove its public key from `.sops.yaml` to revoke access.
---
### Phase 3: Vault Repository
**What is the vault?**
Your vault is a Git repository containing two key files:
| File | Contents | Safe to commit? |
|------|----------|----------------|
| `vault.yaml` | Your secrets, encrypted by sops+age | Yes (values are encrypted) |
| `.sops.yaml` | List of public keys that can decrypt the vault | Yes (public keys only) |
The repo should be **private** — even though values are encrypted, key *names* are visible in the YAML structure.
**What happens:** You choose one of two paths:
#### Path A: New vault (first machine)
Choose this if you've never used KeyPick before.
1. **Name your vault repo** (default: `my-keys`)
2. **Create the repo** — if GitHub CLI (`gh`) is installed and authenticated, KeyPick creates a private GitHub repo and clones it locally under `~/.keypick/vaults/`. Otherwise, it creates a local Git repo there.
3. **Create `.sops.yaml`** — this file tells sops which public keys can decrypt the vault. Initially, only this machine's key is listed:
```yaml
creation_rules:
- path_regex: vault\.yaml$
age: >-
age1your_public_key_here
```
4. **Create and encrypt `vault.yaml`** — an empty vault (`services: {}`) is created and encrypted in-place with `sops -e -i vault.yaml`
5. **Commit and push** — both files are committed to Git and pushed to the remote (if configured)
```
[3/4] Vault repository...
? Is this your first machine, or joining an existing vault? New vault (first machine)
? Vault repo name? my-keys
? Create a private GitHub repo automatically? Yes
✓ Created and cloned my-keys
✓ Created .sops.yaml
✓ Created and encrypted vault.yaml
✓ Initial commit created
✓ Pushed to remote
Vault directory: C:\Users\you\.keypick\vaults\my-keys
```
#### Path B: Join existing vault (additional machine)
Choose this if you already set up KeyPick on another machine.
1. **Clone (or locate) your vault repo** — provide a GitHub `owner/repo` slug, a git clone URL, or a local path. New clones go into `~/.keypick/vaults/` by default.
2. **Verify `.sops.yaml` exists** — confirms this is a valid vault repo
3. **Check recipients** — shows all public keys currently in `.sops.yaml`
4. **Register this machine** — if your public key isn't already listed:
- Adds your key to `.sops.yaml`
- Runs `sops updatekeys -y vault.yaml` to re-encrypt the vault for all recipients (including this new machine)
- Commits and pushes so other machines see the change
```
[3/4] Vault repository...
? Is this your first machine, or joining an existing vault? Join existing vault
? GitHub repo to clone? yourusername/my-keys
✓ Cloned yourusername/my-keys
Current recipients:
- age1abc123def456ghi789...
✓ Added key age1xyz987wvu654... to recipients
✓ Vault re-encrypted
✓ Changes committed
✓ Pushed to remote
Vault directory: C:\Users\you\.keypick\vaults\my-keys
```
---
### Phase 4: Optional Enhancements
After the core setup, the wizard offers two optional features:
#### GitHub Actions Auto-Sync
**The problem it solves:** When you add a new machine, you update `.sops.yaml` with its public key. But `vault.yaml` is still encrypted for the *old* set of recipients — the new machine can't decrypt it until someone with existing access runs `sops updatekeys`.
**The solution:** A GitHub Actions workflow watches for changes to `.sops.yaml`. When it detects a change, it automatically:
1. Downloads age and sops
2. Imports a dedicated CI age key from GitHub Secrets
3. Runs `sops updatekeys -y vault.yaml` to re-encrypt for all current recipients
4. Commits and pushes the re-encrypted vault
**Setup steps:**
1. **Generate a CI age keypair** — separate from your machine keys, used only by GitHub Actions
2. **Add the CI public key to `.sops.yaml`** — so the workflow can decrypt during re-encryption
3. **Store the CI private key as a GitHub Secret** (`SOPS_AGE_KEY`) — piped to `gh secret set`, encrypted by GitHub with libsodium
4. **Install the workflow file** — `.github/workflows/vault-sync.yml` is created in your repo
5. **Commit and push** — activates the workflow
```
? Set up GitHub Actions auto-sync? Yes
✓ Generated Actions key: age1actionskey123...
✓ Added Actions key to .sops.yaml
✓ Set SOPS_AGE_KEY secret on GitHub
✓ Installed .github/workflows/vault-sync.yml
✓ Pushed to remote
```
#### Recovery Key
**The problem it solves:** If you lose access to *all* your machines (laptop stolen, desktop dies), you lose access to your vault forever. A recovery key is your safety net.
**How it works:**
1. **Generate a recovery age keypair** — not tied to any machine
2. **You choose a strong passphrase** — this protects the recovery key itself
3. **Encrypt the private key with your passphrase** — saved as `recovery_key.age`
4. **Add the recovery public key to `.sops.yaml`** — so the recovery key can decrypt the vault
5. **Re-encrypt the vault** — includes the recovery key as a recipient
**Storage rules (two-factor recovery):**
| What | Where | Why |
|------|-------|-----|
| `recovery_key.age` (encrypted file) | Cloud storage (Google Drive, iCloud, Dropbox) | Accessible from anywhere, but useless without the passphrase |
| Passphrase | Paper in a safe or lockbox | Physical security, but useless without the file |
Store the file and passphrase in **separate physical locations**. An attacker would need to compromise *both* to access your secrets.
**To use the recovery key later:**
```bash
# 1. Download recovery_key.age from cloud storage
# 2. Decrypt it with your passphrase
age -d recovery_key.age > temp_key.txt
# 3. Use it to access your vault
SOPS_AGE_KEY_FILE=temp_key.txt keypick list
# 4. Delete the temporary plaintext key immediately
rm temp_key.txt
```
```
? Create a recovery key? Yes
✓ Generated recovery key: age1recoverykey123...
? Enter a strong passphrase for the recovery key: ********
? Confirm passphrase: ********
✓ Encrypted recovery key saved to recovery_key.age
✓ Added recovery key to .sops.yaml recipients
✓ Vault re-encrypted with recovery key
✓ Changes committed and pushed
```
---
### After Setup
Once setup completes, you're ready to use KeyPick:
```bash
# Store your first secrets
keypick add
# Inspect or change the active vault
keypick vault list
keypick vault current
keypick vault select
# In a project directory, export secrets to .env
cd ~/projects/my-app
keypick extract
# On another machine, join the vault
keypick setup # Choose "Join existing vault"
```
Your secrets are encrypted at rest and protected by biometric authentication. They are only ever decrypted in memory during a `keypick` session.
---
## Installation
KeyPick is a TypeScript CLI that runs on [Bun](https://bun.sh). The recommended installer is a cross-platform interactive wizard; shell-script installers are available for systems without Node.
### `npx` installer (recommended)
One command on every platform — macOS, Linux, Windows, WSL:
```bash
npx github:seanrobertwright/KeyPick install
```
The wizard walks you through each step with a short explanation of *why*, fetches the latest release from GitHub, drops the bundle at `~/.local/share/keypick` and a shim at `~/.local/bin/keypick`, checks your PATH, and optionally installs the Claude Code skill (global or project scope). Requires **Node 18+** (`npx` ships with Node) and **Bun** on your PATH.
To uninstall:
```bash
npx github:seanrobertwright/KeyPick uninstall
```
### Shell-script installers (no Node required)
**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/install.sh | sh
```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/install.ps1 | iex
```
### Direct install
If you already have Bun installed:
```bash
bun install -g keypick
```
### WSL (Windows Subsystem for Linux)
KeyPick runs in WSL. WSL looks like Linux to the process, but:
- **Biometric auth** routes through Windows Hello on the host via `powershell.exe` (exposed by WSL interop). You get the same fingerprint/PIN prompt as native Windows — no polkit required.
- **`age` and `sops`** install as Linux binaries inside WSL. Your age keypair lives at `~/.config/sops/age/keys.txt` in the WSL distro, *not* the Windows host. Machines sharing the same vault still need distinct keys.
- **Clipboard** works through WSL interop via `clip.exe`.
Install via the Linux one-liner from inside your WSL shell:
```bash
curl -fsSL https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/install.sh | sh
```
### Prerequisites
KeyPick needs **Git** for vault syncing and **Bun** as its runtime. The first-run `keypick setup` wizard installs `age` and `sops` automatically; if you'd rather do it by hand:
| Tool | Windows | macOS | Linux |
|------|---------|-------|-------|
| **age** | [Download .zip](https://github.com/FiloSottile/age/releases) | `brew install age` | `apt install age` |
| **sops** | [Download .exe](https://github.com/getsops/sops/releases) | `brew install sops` | `apt install sops` |
### Uninstalling
Cross-platform `npx` uninstaller:
```bash
npx github:seanrobertwright/KeyPick uninstall
```
Shell-script alternatives:
```bash
# macOS / Linux / WSL
curl -fsSL https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/uninstall.sh | sh
```
```powershell
# Windows (PowerShell)
irm https://raw.githubusercontent.com/seanrobertwright/KeyPick/master/uninstall.ps1 | iex
```
All of them remove the KeyPick bundle, shim, any legacy installs (Bun global or cargo-era Rust), and — after confirmation — your config dir (`~/.keypick/`, including cloned vault repos). Each prompts separately before deleting your age private key; if you have no recovery key or other machines, deleting it makes your vault contents permanently inaccessible.
---
## Usage
### First-Time Setup
```bash
keypick setup
```
The interactive wizard walks you through everything. On your first machine it will:
1. Install `age` and `sops` (if missing)
2. Generate your machine's age encryption key
3. Create a private vault repository under `~/.keypick/vaults/`
4. Optionally set up GitHub Actions and a recovery key
On additional machines, choose "Join existing vault" to clone your repo and register the new machine's key.
### Setup Subcommands
```bash
keypick setup # Full wizard
keypick setup --walkthrough # Full wizard with step-by-step explanations
keypick setup actions # Just the GitHub Actions configuration
keypick setup recovery # Just the recovery key generation
```
### Vault Commands
```bash
keypick vault list # Show known vault repositories
keypick vault current # Print the active vault repository
keypick vault select # Interactively choose the active vault repository
```
KeyPick resolves a vault in this order:
1. `KEYPICK_VAULT_DIR`
2. The current directory if you are already inside a vault repo
3. The remembered active vault
4. Vaults under `~/.keypick/vaults/`
5. An interactive selector if multiple candidates are available
If your machine blocks writes to `~/.keypick`, set `KEYPICK_HOME` to a writable directory. On Windows, a good default is:
```powershell
setx KEYPICK_HOME "$env:USERPROFILE\OneDrive\Documents\KeyPick"
```
Then open a new shell and run:
```powershell
keypick vault select
keypick vault current
```
---
### Interactive Menu
```bash
keypick
```
When run without arguments, KeyPick shows a menu after biometric verification:
```
? What would you like to do?
> Extract keys to .env
Add / Update a key group
List vault contents
Copy a key to clipboard
Exit
```
---
### Add Secrets
```bash
keypick add
```
Secrets are organized into **groups** (e.g., `Supabase_Prod`, `Google_AI`, `Stripe_Test`).
**Example session:**
```
? Select a group:
> [ + New Group ]
Supabase_Prod
Google_AI
? Service/Group name: Stripe_Test
Adding keys to group: Stripe_Test
? Key Name: STRIPE_SECRET_KEY
? Value for STRIPE_SECRET_KEY: sk_test_xxxxxxxxxxxxx
+ Added: STRIPE_SECRET_KEY
? Add another key to this group? Yes
? Key Name: STRIPE_PUBLISHABLE_KEY
? Value for STRIPE_PUBLISHABLE_KEY: pk_test_xxxxxxxxxxxxx
+ Added: STRIPE_PUBLISHABLE_KEY
? Add another key to this group? No
Vault updated successfully.
Remember to sync: cd ~/.keypick/vaults/my-keys && git add vault.yaml && git commit -m "Add Stripe_Test" && git push
```
---
### Extract to .env File
```bash
cd my-project
keypick extract
```
Select one or more groups to export:
```
? Select the groups to extract (Space to toggle, Enter to confirm):
> [x] Supabase_Prod
[x] Stripe_Test
[ ] Google_AI
5 keys from 2 group(s) written to .env
WARNING: Add .env to your .gitignore so secrets are never committed.
```
The generated `.env`:
```env
# --- Supabase_Prod ---
DB_HOST=db.xxxxx.supabase.co
DB_PASSWORD=secret_value
SUPABASE_SECRET=service_role_key_abc
# --- Stripe_Test ---
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxx
```
---
### List Vault Contents
```bash
keypick list
```
Shows all groups and key names with values hidden:
```
Vault Contents (values hidden):
Google_AI
- API_KEY
- PROJECT_ID
Stripe_Test
- STRIPE_PUBLISHABLE_KEY
- STRIPE_SECRET_KEY
Supabase_Prod
- DB_HOST
- DB_PASSWORD
- SUPABASE_SECRET
3 group(s), 7 key(s) total.
```
---
### Copy to Clipboard
```bash
keypick copy
```
Select a group, then a key. The value is copied to your clipboard without ever being written to disk.
```
? Select a group: Supabase_Prod
? Select a key: DB_PASSWORD
Copied DB_PASSWORD to clipboard.
```
---
### Automatic Shell Injection (direnv)
```bash
keypick auto Supabase_Prod Google_AI
```
Outputs `export KEY='VALUE'` statements to stdout. Designed for use with [direnv](https://direnv.net/):
**Create a `.envrc` in your project:**
```bash
# my-project/.envrc
eval $(keypick auto Supabase_Prod Google_AI)
```
**Allow it once:**
```bash
direnv allow
```
Now every time you `cd` into the project, your keys are automatically loaded as environment variables. When you `cd` out, they're removed.
> **Note:** `keypick auto` skips the biometric gate for non-interactive use. Your secrets are still protected by Git authentication and age encryption at rest.
**direnv installation:**
```bash
# Windows
winget install direnv.direnv
# macOS
brew install direnv
# Linux
apt install direnv
```
Add the hook to your shell profile:
```bash
# bash (~/.bashrc)
eval "$(direnv hook bash)"
# zsh (~/.zshrc)
eval "$(direnv hook zsh)"
# PowerShell ($PROFILE)
Invoke-Expression "$(direnv hook pwsh)"
```
---
### Syncing Between Machines
```bash
# Pull latest keys on any machine
cd ~/.keypick/vaults/my-keys
git pull
# After adding or updating keys, push
git add vault.yaml
git commit -m "Add Stripe_Test keys"
git push
```
Adding a new machine is straightforward:
1. Run `keypick setup` and choose "Join existing vault"
2. The wizard clones your repo, registers the machine's key, and pushes
If you've set up GitHub Actions, the vault is automatically re-encrypted for the new recipient.
---
### Recovery Key
If you lose access to all your machines, a recovery key lets you restore access.
**Create one during setup** (or run later):
```bash
keypick setup recovery
```
The wizard:
1. Generates a recovery keypair
2. Encrypts it with a passphrase you choose
3. Adds the recovery public key to your vault recipients
4. Saves `recovery_key.age` for you to upload to cloud storage
**Storage rules:**
| What | Where |
|------|-------|
| `recovery_key.age` (encrypted file) | Google Drive, iCloud, etc. |
| Passphrase | Written on paper, in a safe/lockbox |
Store the file and passphrase in **separate physical locations**. Both are required to recover.
**Using the recovery key:**
```bash
# Decrypt the recovery key
age -d recovery_key.age > temp_key.txt
# Use it to access the vault
SOPS_AGE_KEY_FILE=temp_key.txt keypick list
# Delete the temp key immediately after
rm temp_key.txt
```
---
## Command Reference
| Command | Description |
|---------|-------------|
| `keypick` | Interactive menu (biometric required) |
| `keypick add` | Add or update keys in a group |
| `keypick extract` | Export groups to a `.env` file |
| `keypick list` | List all groups and key names (values hidden) |
| `keypick copy` | Copy a single key to clipboard |
| `keypick auto <groups...>` | Non-interactive export for direnv/shell eval |
| `keypick vault list` | Show known vault repositories |
| `keypick vault current` | Print the active vault repository |
| `keypick vault select` | Interactively choose the active vault repository |
| `keypick setup` | Full setup wizard |
| `keypick setup --walkthrough` | Setup wizard with detailed explanations of each step |
| `keypick setup actions` | Configure GitHub Actions auto-sync |
| `keypick setup actions --walkthrough` | Actions setup with detailed explanations |
| `keypick setup recovery` | Generate a recovery key |
| `keypick setup recovery --walkthrough` | Recovery key setup with detailed explanations |
---
## Project Structure
```
KeyPick/
├── .sops.yaml.example # Template for secrets repos
├── .github/
│ └── workflows/
│ └── vault-sync.yml # Template: auto re-encryption CI
└── ts/
├── package.json
└── src/
├── main.ts # Entry point, CLI routing
├── lib/
│ ├── auth.ts # Biometric authentication
│ ├── vault.ts # SOPS encrypt/decrypt, data model
│ ├── terminal.ts # Terminal state + cleanup
│ └── wsl.ts # WSL detection + Hello bridge
└── commands/
├── add.ts # keypick add
├── extract.ts # keypick extract
├── list.ts # keypick list
├── copy.ts # keypick copy
├── auto_export.ts # keypick auto
├── interactive.ts # No-argument menu mode
├── vaults.ts # keypick vault
├── env/ # keypick env push/pull/status
└── setup/
├── index.ts # Setup wizard orchestrator
├── utils.ts # Shared helpers (spinners, downloads, platform)
├── prerequisites.ts # age/sops auto-installer
├── keygen.ts # Age key generation
├── init.ts # First machine flow
├── join.ts # Additional machine flow
├── actions.ts # GitHub Actions wizard
└── recovery.ts # Recovery key wizard
```
---
## Troubleshooting
### `sops` or `age` not found after setup
The setup wizard installs binaries to `~/.local/bin/` or next to the `keypick` executable. If your shell can't find them:
```bash
# Check where they were installed
which age sops # macOS/Linux
where age sops # Windows
# Add to PATH if needed (bash/zsh)
export PATH="$HOME/.local/bin:$PATH"
# Add to PATH (PowerShell - add to $PROFILE for persistence)
$env:PATH += ";$env:USERPROFILE\.local\bin"
```
### SOPS decryption failed
```
SOPS decryption failed: ...
```
This means your machine's age private key can't decrypt the vault. Common causes:
- **Wrong vault selected:** Run `keypick vault current` and `keypick vault list`, or set `KEYPICK_VAULT_DIR` explicitly
- **KeyPick state directory is not writable:** Set `KEYPICK_HOME` to a writable location, then re-run `keypick vault select`
- **Missing key file:** Verify your age key exists:
```bash
# Windows
cat "$env:APPDATA\sops\age\keys.txt"
# macOS/Linux
cat ~/.config/sops/age/keys.txt
```
- **Machine not registered:** Your public key might not be in `.sops.yaml`. Run `keypick setup` and choose "Join existing vault"
### Authentication failed
```
Authentication failed: ...
```
The biometric prompt was cancelled or failed. Possible causes:
- **Windows Hello not configured:** Set up a PIN/fingerprint in Settings > Accounts > Sign-in options
- **Touch ID not enabled:** Enable in System Preferences > Touch ID
- **Linux polkit missing:** Install `policykit-1` or your distro's equivalent
- **Remote/SSH session:** Biometrics require a local display. Use `keypick auto` for non-interactive access
### GitHub Actions workflow not triggering
- Verify the `SOPS_AGE_KEY` secret is set in your repo's Settings > Secrets > Actions
- Check that `.github/workflows/vault-sync.yml` exists in your secrets repo
- The workflow only triggers on pushes to `.sops.yaml` or `vault.yaml`
- Run `keypick setup actions` to reconfigure
### `gh` CLI not authenticated
If the setup wizard can't create repos or set secrets:
```bash
gh auth login
gh auth status # Verify
```
### Vault shows as empty after cloning on a new machine
You need to register this machine's key first. The vault is encrypted for specific recipients:
```bash
keypick setup # Choose "Join existing vault"
```
### Multiple vaults are available
```bash
keypick vault list
keypick vault select
```
Or override a single command explicitly:
```bash
KEYPICK_VAULT_DIR=/path/to/vault keypick list
```
If KeyPick cannot save the selected vault because its state directory is not writable, set `KEYPICK_HOME` first:
```powershell
setx KEYPICK_HOME "$env:USERPROFILE\OneDrive\Documents\KeyPick"
```
---
## Security Model
| Layer | Protection |
|-------|-----------|
| **Git repo (private)** | Controls who can access the encrypted file |
| **age encryption** | Each machine has a unique keypair; only authorized machines can decrypt |
| **Biometric gate** | Windows Hello / Touch ID required before any decryption |
| **SOPS** | Manages multi-recipient encryption; individual values encrypted in YAML |
| **GitHub Actions key** | Separate keypair for CI, stored only in GitHub Secrets |
| **Recovery key** | Passphrase-protected, stored offline in separate physical locations |
**What is safe to commit:** `vault.yaml` (encrypted), `.sops.yaml` (public keys only), workflow files.
**What must never be committed:** `.env` files, `keys.txt`, `recovery_key.age` plaintext, any file containing private keys.
---
## Contributing
Contributions are welcome! Here's how to get started:
### Repo layout
```
KeyPick/
├── bin/ # Cross-platform installer wizard (Node, zero deps)
│ ├── keypick.mjs # dispatcher — bin entry for `npx github:…`
│ ├── installer.mjs # install wizard
│ ├── uninstaller.mjs # uninstall wizard
│ └── ui.mjs # colors, boxes, prompts, OSC-52 clipboard
├── package.json # Root package.json — makes `npx github:…` work
├── ts/ # TypeScript keypick CLI (Bun project)
│ ├── src/
│ │ ├── lib/ # vault, auth, terminal, wsl
│ │ ├── commands/
│ │ └── main.ts
│ └── package.json
├── install.sh # Unix shell-script fallback installer
├── install.ps1 # Windows PowerShell fallback installer
├── uninstall.sh
├── uninstall.ps1
└── .github/workflows/
├── release.yml # Tag-triggered release build
└── vault-sync.yml # Auto re-encryption for vault repos
```
### Development Setup
```bash
git clone https://github.com/seanrobertwright/KeyPick.git
cd KeyPick/ts
bun install
bun run typecheck
bun run src/main.ts --help
```
### Guidelines
1. **Fork the repo** and create your branch from `master`
2. **Write clear commit messages** following [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`)
3. **Test your changes** — make sure `bun run typecheck` (in `ts/`) succeeds with zero warnings
4. **Keep it simple** — KeyPick values simplicity over feature count
5. **Security first** — never log, print, or write secrets to disk unless the user explicitly requests it
### Submitting Changes
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make your changes and commit them
4. Push to your fork: `git push origin feature/my-feature`
5. Open a Pull Request with a clear description of what and why
### Reporting Issues
- Use [GitHub Issues](https://github.com/seanrobertwright/KeyPick/issues) to report bugs or request features
- Include your OS and the version (`keypick --version`)
---
## License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
+9
-24
{
"name": "keypick",
"version": "0.1.2",
"description": "Cross-platform, biometric-secured API key vault manager powered by SOPS + age",
"version": "0.2.3",
"description": "KeyPick installer — run via `npx github:seanrobertwright/KeyPick install`",
"type": "module",
"bin": {
"keypick": "./dist/keypick.js"
"keypick": "./bin/keypick.mjs"
},
"files": [
"dist",
"bin",
"README.md",

@@ -15,24 +15,9 @@ "LICENSE"

"engines": {
"bun": ">=1.1.0"
"node": ">=18"
},
"scripts": {
"dev": "bun run src/main.ts",
"start": "bun run src/main.ts",
"typecheck": "tsc --noEmit",
"build": "bun build src/main.ts --target bun --outfile dist/keypick.js",
"prepublishOnly": "bun run typecheck && bun run build"
"repository": {
"type": "git",
"url": "https://github.com/seanrobertwright/KeyPick.git"
},
"dependencies": {
"@inquirer/prompts": "^7.3.0",
"chalk": "^5.4.1",
"clipboardy": "^4.0.0",
"commander": "^13.1.0",
"ora": "^8.2.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^22.10.0",
"typescript": "^5.7.2"
}
"license": "MIT"
}

Sorry, the diff of this file is too big to display