@getfootgun/keys 0.0.0-stage → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @getfootgun/keys
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Footgun Keys: guided API key setup for your Mac or Windows PC. Keys go to your Mac Keychain or Windows Credential Manager, and only to an env file you ask for.
4
+
5
+ Works on macOS and Windows. Needs Node 20 or newer.
6
+
7
+ ## Install
8
+
9
+ npx @getfootgun/keys setup
10
+
11
+ Setup installs a stable copy of the helper, registers it with Chrome and with a daily check, and asks for your Footgun API key. Create one free at https://getfootgun.com/account.
12
+
13
+ ## Commands
14
+
15
+ footgun-keys <setup|mcp|native-host|health|protect>
16
+
17
+ - `setup`: one-time install and registration, described above.
18
+ - `mcp`: runs the MCP server that Claude Code and Cursor talk to. Connect it with `claude mcp add footgun-keys -- npx -y @getfootgun/keys mcp` (on Windows: `claude mcp add footgun-keys -- cmd /c npx -y @getfootgun/keys mcp`), or add the same command under MCP servers in Cursor.
19
+ - `native-host`: the bridge the Chrome extension uses to hand a key to the helper. Chrome starts it; you do not run it yourself.
20
+ - `health`: the daily key check at 9:30, run by launchd on Mac and by Task Scheduler on Windows. It tells you when a saved key stops working.
21
+ - `protect`: adds a Claude Code rule to the current project so Claude Code's Read tool will not open your env files (shell commands still can). It asks first.
22
+
23
+ ## What is stored where
24
+
25
+ On a Mac:
26
+
27
+ - Your Mac Keychain, service `footgun-keys`: every key you capture, and your Footgun API key. Key values live nowhere else except an env file you ask for.
28
+ - The support folder, `~/Library/Application Support/Footgun Keys/`: the installed helper, the two launcher scripts, and session and guide files. It holds names and statuses only, never key values.
29
+ - Chrome and launchd: a native host manifest in Chrome's NativeMessagingHosts folder, and a launch agent at `~/Library/LaunchAgents/com.getfootgun.keys.health.plist`.
30
+
31
+ On Windows:
32
+
33
+ - Windows Credential Manager, in entries whose names include `footgun-keys`: every key you capture, and your Footgun API key. Key values live nowhere else except an env file you ask for.
34
+ - The support folder, `%LOCALAPPDATA%\Footgun Keys`: the installed helper, the two launcher scripts (`footgun-keys.cmd` and `native-host.cmd`), and session and guide files. It holds names and statuses only, never key values.
35
+ - Chrome and Task Scheduler: a native host manifest in the support folder, registered under `HKCU\Software\Google\Chrome\NativeMessagingHosts\com.getfootgun.keys`, and a Task Scheduler task named "Footgun Keys health" (defined in `health-task.xml` in the support folder). The task runs without a window, on battery too, and catches up after a missed 9:30. Both are per user and need no admin rights.
36
+
37
+ On both:
38
+
39
+ - Never on Footgun's servers: your keys are never sent to Footgun. The only thing the helper sends is your Footgun API key, to check your plan.
40
+
41
+ ## Release checklist
42
+
43
+ The Claude Code plugin does not start this helper until all of these are done, in order:
44
+
45
+ 1. Publish the Footgun Keys extension to the Chrome Web Store.
46
+ 2. Add the store extension id to `shared/extension-id.json` (`{ "ids": ["<store id>"] }`).
47
+ 3. `npm publish` this package as `@getfootgun/keys`.
48
+ 4. Add the footgun-keys stdio entry to `plugin/footgun/.mcp.json` under `mcpServers`:
49
+
50
+ "footgun-keys": { "command": "npx", "args": ["-y", "@getfootgun/keys", "mcp"] }
51
+
52
+ Then bump `version` in `plugin/footgun/.claude-plugin/plugin.json`.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { dispatch } from "../lib/dispatch.mjs";
3
+
4
+ process.exit(await dispatch(process.argv.slice(2)));
@@ -0,0 +1,3 @@
1
+ import type { IndexEntry } from "./vault.mjs";
2
+ export function groupByProviderLabel(entries: IndexEntry[]): IndexEntry[][];
3
+ export function healthRecord(t: { ok: boolean; reason?: string }, now: () => Date): NonNullable<IndexEntry["health"]>;
package/lib/check.mjs ADDED
@@ -0,0 +1,14 @@
1
+ // Shared by keys_check and the daily health check.
2
+ export function groupByProviderLabel(entries) {
3
+ const groups = new Map();
4
+ for (const e of entries) {
5
+ const k = `${e.provider}\u0000${e.label}`;
6
+ if (!groups.has(k)) groups.set(k, []);
7
+ groups.get(k).push(e);
8
+ }
9
+ return [...groups.values()];
10
+ }
11
+
12
+ export function healthRecord(t, now) {
13
+ return { ok: t.ok, ...(t.ok ? {} : { reason: t.reason ?? "unexpected" }), checkedAt: now().toISOString() };
14
+ }
@@ -0,0 +1,2 @@
1
+ export const COMMANDS: string[];
2
+ export function dispatch(argv: string[], libDir?: string, importModule?: (specifier: string) => Promise<{ run(args: string[]): unknown }>): Promise<number>;
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ export const COMMANDS = ["setup", "mcp", "native-host", "health", "protect"];
6
+ const LIB = fileURLToPath(new URL(".", import.meta.url));
7
+
8
+ // "Not built yet" means the subcommand's own module file is absent. Any error thrown while loading
9
+ // an existing module (such as a missing dependency) propagates. The module is imported by file: URL,
10
+ // since Windows rejects a plain absolute path (C:\...) as an ESM specifier.
11
+ export async function dispatch(argv, libDir = LIB, importModule = (specifier) => import(specifier)) {
12
+ const [cmd, ...args] = argv;
13
+ if (!COMMANDS.includes(cmd)) {
14
+ console.error(`Usage: footgun-keys <${COMMANDS.join("|")}> [args]`);
15
+ return 2;
16
+ }
17
+ const file = join(libDir, `${cmd}.mjs`);
18
+ if (!existsSync(file)) {
19
+ console.error("not built yet");
20
+ return 2;
21
+ }
22
+ const mod = await importModule(pathToFileURL(file).href);
23
+ return (await mod.run(args)) ?? 0;
24
+ }
@@ -0,0 +1,10 @@
1
+ export function envFileFor(projectPath: string): Promise<string>;
2
+ export function writeEnv(
3
+ projectPath: string,
4
+ vars: { name: string; value: string }[],
5
+ opts?: {
6
+ git?: (args: string[]) => Promise<string>;
7
+ platform?: NodeJS.Platform;
8
+ chmodFn?: (path: string, mode: number) => Promise<void>;
9
+ },
10
+ ): Promise<{ file: string; addedGitignore: boolean }>;
@@ -0,0 +1,104 @@
1
+ import { readFile, writeFile, chmod, stat } from "node:fs/promises";
2
+ import { join, dirname, resolve } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const realGit = async (args) => (await execFileAsync("git", args)).stdout;
8
+
9
+ async function readOrNull(file) {
10
+ try { return await readFile(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
11
+ }
12
+
13
+ export async function envFileFor(projectPath) {
14
+ const text = await readOrNull(join(projectPath, "package.json"));
15
+ if (text === null) return ".env";
16
+ let pkg;
17
+ try { pkg = JSON.parse(text); } catch { return ".env"; }
18
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
19
+ return "next" in deps || "vite" in deps ? ".env.local" : ".env";
20
+ }
21
+
22
+ // Bare when only [A-Za-z0-9_./:+-]; otherwise single-quoted with no escaping (dotenv reads
23
+ // single-quoted values literally and dotenv-expand does not expand them). Values with a single
24
+ // quote or a newline cannot be represented, so they are refused (the value is never echoed).
25
+ function formatValue(value) {
26
+ if (/^[A-Za-z0-9_./:+-]*$/.test(value)) return value;
27
+ if (/[\r\n]/.test(value)) throw new Error("A key value cannot contain a newline.");
28
+ if (value.includes("'")) throw new Error("A key value cannot contain a single quote.");
29
+ return `'${value}'`;
30
+ }
31
+
32
+ // Every line that assigns name (indented, or with "export ") is replaced: the first keeps its place
33
+ // (and its "export " with the indent before it), later ones are dropped with their line ending.
34
+ // All other bytes stay as they were.
35
+ function upsert(text, name, value) {
36
+ const line = `${name}=${formatValue(value)}`;
37
+ const assigns = new RegExp(`^([ \\t]*export[ \\t]+)?[ \\t]*${name}[ \\t]*=`);
38
+ const parts = text.split(/(\r?\n)/); // content, eol, content, eol, ... (last is content)
39
+ const lines = [];
40
+ for (let i = 0; i < parts.length; i += 2) lines.push({ content: parts[i], eol: parts[i + 1] ?? "" });
41
+ let found = false;
42
+ const out = [];
43
+ for (const l of lines) {
44
+ if (!assigns.test(l.content)) { out.push(l); continue; }
45
+ if (!found) { out.push({ content: (assigns.exec(l.content)[1] ?? "") + line, eol: l.eol }); found = true; continue; }
46
+ // A dropped last line takes the line ending before it, so no stray newline is left.
47
+ if (l.eol === "" && out.length > 0) out[out.length - 1].eol = "";
48
+ }
49
+ if (found) return out.map((l) => l.content + l.eol).join("");
50
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
51
+ if (text !== "" && !/\n$/.test(text)) text += eol;
52
+ return `${text}${line}${eol}`;
53
+ }
54
+
55
+ const IGNORING = (file) => new Set([file, `/${file}`, ".env*", ".env.local*", "/.env*", "/.env.local*",
56
+ ...(file.endsWith(".local") ? [".env*.local", "*.local"] : [])]);
57
+
58
+ // True when projectPath or a folder above it has a .git entry (a folder, or a file for a worktree).
59
+ async function underGit(projectPath) {
60
+ for (let dir = resolve(projectPath); ; dir = dirname(dir)) {
61
+ try { await stat(join(dir, ".git")); return true; } catch { /* keep walking */ }
62
+ if (dirname(dir) === dir) return false;
63
+ }
64
+ }
65
+
66
+ // Inside a git work tree git itself decides (negations, parent folders, nested rules); outside one,
67
+ // a plain pattern match on the project's .gitignore. Git is not run at all without a .git above.
68
+ async function isIgnored(projectPath, file, text, git) {
69
+ let inside = false;
70
+ if (await underGit(projectPath)) try { inside = (await git(["-C", projectPath, "rev-parse", "--is-inside-work-tree"])).trim() === "true"; } catch { /* not a repo, or no git */ }
71
+ if (inside) {
72
+ try { await git(["-C", projectPath, "check-ignore", "-q", file]); return true; } catch { return false; }
73
+ }
74
+ const ok = IGNORING(file);
75
+ return text.split(/\r?\n/).some((l) => ok.has(l.trim()));
76
+ }
77
+
78
+ async function ensureIgnored(projectPath, file, git) {
79
+ const gi = join(projectPath, ".gitignore");
80
+ const text = (await readOrNull(gi)) ?? "";
81
+ if (await isIgnored(projectPath, file, text, git)) return false;
82
+ const add = `\n# Footgun Keys\n${file}\n`;
83
+ await writeFile(gi, text === "" ? add.slice(1) : text + add);
84
+ return true;
85
+ }
86
+
87
+ export async function writeEnv(projectPath, vars, { git = realGit, platform = process.platform, chmodFn = chmod } = {}) {
88
+ for (const { value } of vars) formatValue(value); // refuse a bad value before touching any file
89
+ const file = await envFileFor(projectPath);
90
+ // Ignore first, so a key never sits in a file git could pick up.
91
+ const addedGitignore = await ensureIgnored(projectPath, file, git);
92
+ const full = join(projectPath, file);
93
+ const prior = await readOrNull(full);
94
+ const existed = prior !== null;
95
+ let text = prior ?? "";
96
+ for (const { name, value } of vars) text = upsert(text, name, value);
97
+ // writeFile on an existing file keeps its mode; a new file gets 0600 (umask can only tighten it).
98
+ await writeFile(full, text, { mode: 0o600 });
99
+ if (!existed) {
100
+ // Windows has no POSIX modes, so a chmod failure there is harmless; elsewhere it must surface.
101
+ try { await chmodFn(full, 0o600); } catch (e) { if (platform !== "win32") throw e; }
102
+ }
103
+ return { file, addedGitignore };
104
+ }
@@ -0,0 +1,17 @@
1
+ import type { Vault } from "./vault.mjs";
2
+ import type { Server } from "./server.mjs";
3
+
4
+ export function macNotify(
5
+ title: string,
6
+ body: string,
7
+ exec?: (file: string, args: string[], cb: (e: Error | null) => void) => unknown,
8
+ ): Promise<void>;
9
+ export function runHealth(deps: {
10
+ vault: Vault;
11
+ server: Server;
12
+ notify: (title: string, body: string) => Promise<void>;
13
+ fetch?: typeof fetch;
14
+ now?: () => Date;
15
+ retryDelayMs?: number;
16
+ }): Promise<{ checked: number; failing: string[] }>;
17
+ export function run(): Promise<number>;
package/lib/health.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import { keychainStore, openVault } from "./vault.mjs";
2
+ import { makeServer } from "./server.mjs";
3
+ import { runTest } from "./testcall.mjs";
4
+ import { groupByProviderLabel, healthRecord } from "./check.mjs";
5
+ import { notify } from "./platform.mjs";
6
+
7
+ const TITLE = "Footgun Keys";
8
+ // Kept for existing callers: the Mac notification, with an optional exec.
9
+ export function macNotify(title, body, exec) {
10
+ return notify(title, body, { platform: "darwin", exec });
11
+ }
12
+
13
+ // The env var a failed test is about: the one the test call authenticates with.
14
+ function testedVar(guide, fallback) {
15
+ const a = guide.test?.auth;
16
+ return a?.envVar ?? a?.userEnvVar ?? fallback;
17
+ }
18
+
19
+ // Test each provider and label group once. Returns the test results without recording them.
20
+ async function testGroups(groups, vault, server, fetchFn) {
21
+ const results = [];
22
+ const unavailable = [];
23
+ for (const list of groups) {
24
+ const { provider, label } = list[0];
25
+ const g = await server.guide(provider);
26
+ if (!g.ok) { unavailable.push(provider); continue; }
27
+ const values = {};
28
+ for (const c of g.guide.captures) {
29
+ const v = await vault.get(provider, label, c.envVar);
30
+ if (v !== null) values[c.envVar] = v;
31
+ }
32
+ results.push({ list, guide: g.guide, t: await runTest(g.guide, values, { fetch: fetchFn }) });
33
+ }
34
+ return { results, unavailable };
35
+ }
36
+
37
+ const allUnreachable = (results) => results.length > 0 && results.every((r) => r.t.reason === "unreachable");
38
+
39
+ // Test every stored key once per provider and label. Silence must always mean healthy, so a check that
40
+ // cannot run notifies too. Notification text carries env var names and reasons only.
41
+ export async function runHealth({ vault, server, notify, fetch: fetchFn, now = () => new Date(), retryDelayMs = 60000 }) {
42
+ try {
43
+ const ent = await server.entitlement();
44
+ if (ent === null) {
45
+ // Never set up: nothing to check. A key that exists but gets no entitlement must not go quiet.
46
+ if (!(await server.apiKey())) return { checked: 0, failing: [] };
47
+ throw new Error("could not confirm your Footgun plan (offline, or the Footgun API key was not accepted)");
48
+ }
49
+ if (ent.health !== true) return { checked: 0, failing: [] };
50
+
51
+ const groups = groupByProviderLabel(await vault.list());
52
+
53
+ // Every test unreachable means this Mac is offline, not that every key broke: wait, retry once,
54
+ // and if still offline keep the old health records.
55
+ let run = await testGroups(groups, vault, server, fetchFn);
56
+ if (allUnreachable(run.results)) {
57
+ await new Promise((r) => setTimeout(r, retryDelayMs));
58
+ run = await testGroups(groups, vault, server, fetchFn);
59
+ if (allUnreachable(run.results)) throw new Error("no internet connection.");
60
+ }
61
+
62
+ let checked = 0;
63
+ const failures = [];
64
+ for (const { list, guide, t } of run.results) {
65
+ const health = healthRecord(t, now);
66
+ for (const e of list) await vault.setHealth(e.provider, e.label, e.envVar, health);
67
+ checked += list.length;
68
+ if (!t.ok) failures.push({ name: testedVar(guide, list[0].envVar), reason: health.reason, provider: guide.name });
69
+ }
70
+
71
+ if (failures.length === 1) {
72
+ const f = failures[0];
73
+ await notify(TITLE, `${f.name} stopped working (${f.reason}). Run the ${f.provider} guide again.`);
74
+ } else if (failures.length > 1) {
75
+ await notify(TITLE, `${failures.length} keys stopped working: ${failures.map((f) => f.name).join(", ")}`);
76
+ }
77
+ if (run.unavailable.length > 0) throw new Error(`could not load the guide for ${run.unavailable.join(", ")}`);
78
+ return { checked, failing: failures.map((f) => f.name) };
79
+ } catch (e) {
80
+ const msg = e instanceof Error && e.message ? e.message : "unknown error";
81
+ try {
82
+ await notify(TITLE, `Footgun Keys could not run today's key check: ${msg}`);
83
+ } catch {
84
+ // Nothing more can be done if notifying itself fails; the original error still surfaces.
85
+ }
86
+ throw e;
87
+ }
88
+ }
89
+
90
+ export async function run() {
91
+ const store = keychainStore();
92
+ try {
93
+ await runHealth({ vault: openVault({ store }), server: makeServer({ store }), notify });
94
+ return 0;
95
+ } catch {
96
+ return 1;
97
+ }
98
+ }
@@ -0,0 +1 @@
1
+ export function launchdPlist(launcherPath: string): string;
@@ -0,0 +1,28 @@
1
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2
+
3
+ // Daily 9:30 run of "footgun-keys health". Writing and loading this file is the caller's job.
4
+ export function launchdPlist(launcherPath) {
5
+ return `<?xml version="1.0" encoding="UTF-8"?>
6
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
7
+ <plist version="1.0">
8
+ <dict>
9
+ <key>Label</key>
10
+ <string>com.getfootgun.keys.health</string>
11
+ <key>ProgramArguments</key>
12
+ <array>
13
+ <string>${esc(launcherPath)}</string>
14
+ <string>health</string>
15
+ </array>
16
+ <key>StartCalendarInterval</key>
17
+ <dict>
18
+ <key>Hour</key>
19
+ <integer>9</integer>
20
+ <key>Minute</key>
21
+ <integer>30</integer>
22
+ </dict>
23
+ <key>RunAtLoad</key>
24
+ <false/>
25
+ </dict>
26
+ </plist>
27
+ `;
28
+ }
package/lib/mcp.d.mts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Vault } from "./vault.mjs";
2
+ import type { Server } from "./server.mjs";
3
+
4
+ export type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean };
5
+ export const SERVER_INFO: { name: string; version: string };
6
+ export const INSTRUCTIONS: string;
7
+ export const SPECS: Record<"keys_list" | "keys_guides" | "keys_write_env" | "keys_request" | "keys_check", { description: string; inputSchema: import("zod").ZodType }>;
8
+ export function buildTools(deps: {
9
+ vault: Vault;
10
+ server: Server;
11
+ openUrl: (url: string) => Promise<void>;
12
+ home?: string;
13
+ now?: () => Date;
14
+ waitMs?: number;
15
+ fetch?: typeof fetch;
16
+ }): {
17
+ keys_list(): Promise<ToolResult>;
18
+ keys_guides(): Promise<ToolResult>;
19
+ keys_write_env(args: { project_path: string; env_vars: string[] }): Promise<ToolResult>;
20
+ keys_request(args: { provider: string; project_path: string; env_vars?: string[]; label?: string; session?: string }): Promise<ToolResult>;
21
+ keys_check(args?: { env_var?: string }): Promise<ToolResult>;
22
+ };
23
+ export function toolsFor(platform: string, makeDeps: () => Parameters<typeof buildTools>[0]): ReturnType<typeof buildTools>;
24
+ export function runMcp(): Promise<void>;
25
+ export function run(): Promise<number>;