| // 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); | ||
| } |
+180
| // 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); | ||
| }); |
+21
| 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. |
+1064
| <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> • | ||
| <a href="TUTORIAL.md">Tutorial</a> • | ||
| <a href="#usage">Usage</a> • | ||
| <a href="#how-it-works">How It Works</a> • | ||
| <a href="#troubleshooting">Troubleshooting</a> • | ||
| <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
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
No License Found
LicenseLicense information could not be found.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
0
-100%0
-100%7
250%0
-100%0
-100%1065
Infinity%17
-59.52%3
-82.35%69721
-89.64%693
-96.48%- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed