@getfootgun/keys 0.0.0-stage → 0.1.0
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 +40 -2
- package/bin/footgun-keys.mjs +4 -0
- package/lib/check.d.mts +3 -0
- package/lib/check.mjs +14 -0
- package/lib/dispatch.d.mts +2 -0
- package/lib/dispatch.mjs +23 -0
- package/lib/envfile.d.mts +6 -0
- package/lib/envfile.mjs +101 -0
- package/lib/health.d.mts +17 -0
- package/lib/health.mjs +100 -0
- package/lib/launchd.d.mts +1 -0
- package/lib/launchd.mjs +28 -0
- package/lib/mcp.d.mts +24 -0
- package/lib/mcp.mjs +271 -0
- package/lib/native-host.mjs +7 -0
- package/lib/native.d.mts +15 -0
- package/lib/native.mjs +144 -0
- package/lib/paths.d.mts +4 -0
- package/lib/paths.mjs +31 -0
- package/lib/protect.d.mts +6 -0
- package/lib/protect.mjs +63 -0
- package/lib/server.d.mts +24 -0
- package/lib/server.mjs +105 -0
- package/lib/sessions.d.mts +19 -0
- package/lib/sessions.mjs +37 -0
- package/lib/setup.d.mts +19 -0
- package/lib/setup.mjs +185 -0
- package/lib/testcall.d.mts +11 -0
- package/lib/testcall.mjs +103 -0
- package/lib/vault.d.mts +25 -0
- package/lib/vault.mjs +77 -0
- package/package.json +1 -6
- package/shared/contract.d.mts +36 -0
- package/shared/contract.mjs +49 -0
- package/shared/extension-id.json +5 -0
- package/shared/extension-ids.d.mts +1 -0
- package/shared/extension-ids.mjs +13 -0
- package/shared/types.d.ts +60 -0
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @getfootgun/keys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Footgun Keys: guided API key setup for your Mac. Keys go to your Keychain, and only to an env file you ask for.
|
|
4
|
+
|
|
5
|
+
Mac only. 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`, 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 that launchd runs at 9:30. 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
|
+
- 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.
|
|
26
|
+
- 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.
|
|
27
|
+
- Chrome and launchd: a native host manifest in Chrome's NativeMessagingHosts folder, and a launch agent at `~/Library/LaunchAgents/com.getfootgun.keys.health.plist`.
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
## Release checklist
|
|
31
|
+
|
|
32
|
+
The Claude Code plugin does not start this helper until all of these are done, in order:
|
|
33
|
+
|
|
34
|
+
1. Publish the Footgun Keys extension to the Chrome Web Store.
|
|
35
|
+
2. Add the store extension id to `shared/extension-id.json` (`{ "ids": ["<store id>"] }`).
|
|
36
|
+
3. `npm publish` this package as `@getfootgun/keys`.
|
|
37
|
+
4. Add the footgun-keys stdio entry to `plugin/footgun/.mcp.json` under `mcpServers`:
|
|
38
|
+
|
|
39
|
+
"footgun-keys": { "command": "npx", "args": ["-y", "@getfootgun/keys", "mcp"] }
|
|
40
|
+
|
|
41
|
+
Then bump `version` in `plugin/footgun/.claude-plugin/plugin.json`.
|
package/lib/check.d.mts
ADDED
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
|
+
}
|
package/lib/dispatch.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } 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.
|
|
10
|
+
export async function dispatch(argv, libDir = LIB) {
|
|
11
|
+
const [cmd, ...args] = argv;
|
|
12
|
+
if (!COMMANDS.includes(cmd)) {
|
|
13
|
+
console.error(`Usage: footgun-keys <${COMMANDS.join("|")}> [args]`);
|
|
14
|
+
return 2;
|
|
15
|
+
}
|
|
16
|
+
const file = join(libDir, `${cmd}.mjs`);
|
|
17
|
+
if (!existsSync(file)) {
|
|
18
|
+
console.error("not built yet");
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const mod = await import(file);
|
|
22
|
+
return (await mod.run(args)) ?? 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function envFileFor(projectPath: string): Promise<string>;
|
|
2
|
+
export function writeEnv(
|
|
3
|
+
projectPath: string,
|
|
4
|
+
vars: { name: string; value: string }[],
|
|
5
|
+
opts?: { git?: (args: string[]) => Promise<string> },
|
|
6
|
+
): Promise<{ file: string; addedGitignore: boolean }>;
|
package/lib/envfile.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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 } = {}) {
|
|
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) await chmod(full, 0o600);
|
|
100
|
+
return { file, addedGitignore };
|
|
101
|
+
}
|
package/lib/health.d.mts
ADDED
|
@@ -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,100 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { keychainStore, openVault } from "./vault.mjs";
|
|
3
|
+
import { makeServer } from "./server.mjs";
|
|
4
|
+
import { runTest } from "./testcall.mjs";
|
|
5
|
+
import { groupByProviderLabel, healthRecord } from "./check.mjs";
|
|
6
|
+
|
|
7
|
+
const TITLE = "Footgun Keys";
|
|
8
|
+
// Title and body travel as argv, never inside the script text.
|
|
9
|
+
const SCRIPT = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run";
|
|
10
|
+
|
|
11
|
+
export function macNotify(title, body, exec = execFile) {
|
|
12
|
+
return new Promise((resolve, reject) => exec("osascript", ["-e", SCRIPT, title, body], (e) => (e ? reject(e) : resolve())));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The env var a failed test is about: the one the test call authenticates with.
|
|
16
|
+
function testedVar(guide, fallback) {
|
|
17
|
+
const a = guide.test?.auth;
|
|
18
|
+
return a?.envVar ?? a?.userEnvVar ?? fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Test each provider and label group once. Returns the test results without recording them.
|
|
22
|
+
async function testGroups(groups, vault, server, fetchFn) {
|
|
23
|
+
const results = [];
|
|
24
|
+
const unavailable = [];
|
|
25
|
+
for (const list of groups) {
|
|
26
|
+
const { provider, label } = list[0];
|
|
27
|
+
const g = await server.guide(provider);
|
|
28
|
+
if (!g.ok) { unavailable.push(provider); continue; }
|
|
29
|
+
const values = {};
|
|
30
|
+
for (const c of g.guide.captures) {
|
|
31
|
+
const v = await vault.get(provider, label, c.envVar);
|
|
32
|
+
if (v !== null) values[c.envVar] = v;
|
|
33
|
+
}
|
|
34
|
+
results.push({ list, guide: g.guide, t: await runTest(g.guide, values, { fetch: fetchFn }) });
|
|
35
|
+
}
|
|
36
|
+
return { results, unavailable };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const allUnreachable = (results) => results.length > 0 && results.every((r) => r.t.reason === "unreachable");
|
|
40
|
+
|
|
41
|
+
// Test every stored key once per provider and label. Silence must always mean healthy, so a check that
|
|
42
|
+
// cannot run notifies too. Notification text carries env var names and reasons only.
|
|
43
|
+
export async function runHealth({ vault, server, notify, fetch: fetchFn, now = () => new Date(), retryDelayMs = 60000 }) {
|
|
44
|
+
try {
|
|
45
|
+
const ent = await server.entitlement();
|
|
46
|
+
if (ent === null) {
|
|
47
|
+
// Never set up: nothing to check. A key that exists but gets no entitlement must not go quiet.
|
|
48
|
+
if (!(await server.apiKey())) return { checked: 0, failing: [] };
|
|
49
|
+
throw new Error("could not confirm your Footgun plan (offline, or the Footgun API key was not accepted)");
|
|
50
|
+
}
|
|
51
|
+
if (ent.health !== true) return { checked: 0, failing: [] };
|
|
52
|
+
|
|
53
|
+
const groups = groupByProviderLabel(await vault.list());
|
|
54
|
+
|
|
55
|
+
// Every test unreachable means this Mac is offline, not that every key broke: wait, retry once,
|
|
56
|
+
// and if still offline keep the old health records.
|
|
57
|
+
let run = await testGroups(groups, vault, server, fetchFn);
|
|
58
|
+
if (allUnreachable(run.results)) {
|
|
59
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
60
|
+
run = await testGroups(groups, vault, server, fetchFn);
|
|
61
|
+
if (allUnreachable(run.results)) throw new Error("no internet connection.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let checked = 0;
|
|
65
|
+
const failures = [];
|
|
66
|
+
for (const { list, guide, t } of run.results) {
|
|
67
|
+
const health = healthRecord(t, now);
|
|
68
|
+
for (const e of list) await vault.setHealth(e.provider, e.label, e.envVar, health);
|
|
69
|
+
checked += list.length;
|
|
70
|
+
if (!t.ok) failures.push({ name: testedVar(guide, list[0].envVar), reason: health.reason, provider: guide.name });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (failures.length === 1) {
|
|
74
|
+
const f = failures[0];
|
|
75
|
+
await notify(TITLE, `${f.name} stopped working (${f.reason}). Run the ${f.provider} guide again.`);
|
|
76
|
+
} else if (failures.length > 1) {
|
|
77
|
+
await notify(TITLE, `${failures.length} keys stopped working: ${failures.map((f) => f.name).join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
if (run.unavailable.length > 0) throw new Error(`could not load the guide for ${run.unavailable.join(", ")}`);
|
|
80
|
+
return { checked, failing: failures.map((f) => f.name) };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const msg = e instanceof Error && e.message ? e.message : "unknown error";
|
|
83
|
+
try {
|
|
84
|
+
await notify(TITLE, `Footgun Keys could not run today's key check: ${msg}`);
|
|
85
|
+
} catch {
|
|
86
|
+
// Nothing more can be done if notifying itself fails; the original error still surfaces.
|
|
87
|
+
}
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function run() {
|
|
93
|
+
const store = keychainStore();
|
|
94
|
+
try {
|
|
95
|
+
await runHealth({ vault: openVault({ store }), server: makeServer({ store }), notify: macNotify });
|
|
96
|
+
return 0;
|
|
97
|
+
} catch {
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function launchdPlist(launcherPath: string): string;
|
package/lib/launchd.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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,24 @@
|
|
|
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 INSTRUCTIONS: string;
|
|
6
|
+
export const SPECS: Record<"keys_list" | "keys_guides" | "keys_write_env" | "keys_request" | "keys_check", { description: string; inputSchema: import("zod").ZodType }>;
|
|
7
|
+
export function buildTools(deps: {
|
|
8
|
+
vault: Vault;
|
|
9
|
+
server: Server;
|
|
10
|
+
openUrl: (url: string) => Promise<void>;
|
|
11
|
+
home?: string;
|
|
12
|
+
now?: () => Date;
|
|
13
|
+
waitMs?: number;
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}): {
|
|
16
|
+
keys_list(): Promise<ToolResult>;
|
|
17
|
+
keys_guides(): Promise<ToolResult>;
|
|
18
|
+
keys_write_env(args: { project_path: string; env_vars: string[] }): Promise<ToolResult>;
|
|
19
|
+
keys_request(args: { provider: string; project_path: string; env_vars?: string[]; label?: string; session?: string }): Promise<ToolResult>;
|
|
20
|
+
keys_check(args?: { env_var?: string }): Promise<ToolResult>;
|
|
21
|
+
};
|
|
22
|
+
export function toolsFor(platform: string, makeDeps: () => Parameters<typeof buildTools>[0]): ReturnType<typeof buildTools>;
|
|
23
|
+
export function runMcp(): Promise<void>;
|
|
24
|
+
export function run(): Promise<number>;
|
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
5
|
+
import { keychainStore, openVault } from "./vault.mjs";
|
|
6
|
+
import { makeServer } from "./server.mjs";
|
|
7
|
+
import { writeEnv } from "./envfile.mjs";
|
|
8
|
+
import { runTest } from "./testcall.mjs";
|
|
9
|
+
import { createSession, readSession, isExpired } from "./sessions.mjs";
|
|
10
|
+
import { SITE } from "./paths.mjs";
|
|
11
|
+
import { groupByProviderLabel, healthRecord } from "./check.mjs";
|
|
12
|
+
import { isProtected } from "./protect.mjs";
|
|
13
|
+
import { LABEL_RE, FREE_GUIDES } from "../shared/contract.mjs";
|
|
14
|
+
|
|
15
|
+
// Every result here carries names, statuses and messages. A key value is never read into a result.
|
|
16
|
+
export const INSTRUCTIONS =
|
|
17
|
+
"When a project needs a third-party API key, call keys_request instead of asking the user to paste it. Tool results never contain key values.";
|
|
18
|
+
|
|
19
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
|
+
const PROVIDER_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; // the server's guide slug rule
|
|
21
|
+
const BAD_PROVIDER = "That is not a valid guide name. Use a slug such as stripe or openai.";
|
|
22
|
+
const BAD_LABEL = "A label can use letters, numbers, spaces, dots, dashes and underscores, up to 64 characters.";
|
|
23
|
+
const PROTECT_TIP = 'Run "npx @getfootgun/keys protect" in this project so Claude Code\'s Read tool will not open it.';
|
|
24
|
+
|
|
25
|
+
const reply = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
|
|
26
|
+
const proText = () => `This needs Footgun Pro. ${SITE()}/#pricing`;
|
|
27
|
+
const SETUP_TEXT = "Run npx @getfootgun/keys setup to connect your Footgun account (it is free).";
|
|
28
|
+
const UNCONFIRMED_TEXT = "Footgun Keys could not confirm your Footgun plan (offline, or your API key was not accepted). Check your connection or run npx @getfootgun/keys setup again.";
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
// After an env file is written: say when .gitignore changed, and show the protect tip until the project denies reads.
|
|
31
|
+
async function writeNotes(wrote, projectPath) {
|
|
32
|
+
if (!wrote) return [];
|
|
33
|
+
const lines = wrote.addedGitignore ? [`Added ${wrote.file} to .gitignore.`] : [];
|
|
34
|
+
if (!(await isProtected(projectPath))) lines.push(PROTECT_TIP);
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildTools({ vault, server, openUrl, home, now = () => new Date(), waitMs = 120000, fetch: fetchFn }) {
|
|
39
|
+
// Why a paid tool cannot run, or null when it can. Only a confirmed plan that lacks it gets the Pro message.
|
|
40
|
+
async function planBlock(tool, slug) {
|
|
41
|
+
const ent = await server.entitlement();
|
|
42
|
+
if (ent === null) return (await server.apiKey()) === null ? SETUP_TEXT : UNCONFIRMED_TEXT;
|
|
43
|
+
if (!ent.tools.includes(tool)) return proText();
|
|
44
|
+
if (slug !== undefined && !FREE_GUIDES.includes(slug) && ent.allGuides !== true) return proText();
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
async function guideAllowed(slug) {
|
|
48
|
+
if (FREE_GUIDES.includes(slug)) return true;
|
|
49
|
+
return (await server.entitlement())?.allGuides === true;
|
|
50
|
+
}
|
|
51
|
+
// Test with every stored value the guide captures; the values stay inside this function.
|
|
52
|
+
async function testGuide(guide, provider, label) {
|
|
53
|
+
const values = {};
|
|
54
|
+
for (const c of guide.captures) {
|
|
55
|
+
const v = await vault.get(provider, label, c.envVar);
|
|
56
|
+
if (v !== null) values[c.envVar] = v;
|
|
57
|
+
}
|
|
58
|
+
return runTest(guide, values, { fetch: fetchFn });
|
|
59
|
+
}
|
|
60
|
+
const testLine = (t) => (!t ? "Test call: skipped." : t.ok ? "Test call: ok." : `Test call: failed (${t.reason ?? "unexpected"}).`);
|
|
61
|
+
|
|
62
|
+
async function savedText(r, projectPath) {
|
|
63
|
+
const lines = [`Saved ${r.saved.join(", ")}.`];
|
|
64
|
+
if (r.wrote) lines.push(`Wrote to ${r.wrote.file} in the project.`);
|
|
65
|
+
lines.push(testLine(r.test));
|
|
66
|
+
lines.push(...(await writeNotes(r.wrote, projectPath)));
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tools = {
|
|
71
|
+
async keys_list() {
|
|
72
|
+
const entries = await vault.list();
|
|
73
|
+
if (entries.length === 0) return reply("No keys saved yet.");
|
|
74
|
+
return reply(entries.map((e) => {
|
|
75
|
+
const status = e.health === null ? "not checked" : e.health.ok ? "ok" : `failing: ${e.health.reason ?? "unknown"}`;
|
|
76
|
+
return `${e.envVar} (${e.provider}, ${e.label}): ${status}; used in ${e.projects.length} projects`;
|
|
77
|
+
}).join("\n"));
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async keys_guides() {
|
|
81
|
+
const r = await server.guides();
|
|
82
|
+
if (!r.ok) return reply(`Could not load the guide list: ${r.error}`, true);
|
|
83
|
+
return reply(r.guides.map((g) => `${g.name} (${g.slug}): ${g.tier === "pro" ? "Pro" : "Free"}, ${g.status}`).join("\n"));
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async keys_write_env({ project_path, env_vars }) {
|
|
87
|
+
const bad = env_vars.find((n) => !NAME_RE.test(n));
|
|
88
|
+
if (bad !== undefined) return reply("One of the names is not a valid env var name.", true);
|
|
89
|
+
const entries = await vault.list();
|
|
90
|
+
const found = [];
|
|
91
|
+
const missing = [];
|
|
92
|
+
for (const name of env_vars) {
|
|
93
|
+
const hits = entries.filter((e) => e.envVar === name).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
94
|
+
const value = hits[0] ? await vault.get(hits[0].provider, hits[0].label, name) : null;
|
|
95
|
+
if (value === null) missing.push(name);
|
|
96
|
+
else found.push({ name, value, entry: hits[0] });
|
|
97
|
+
}
|
|
98
|
+
const lines = [];
|
|
99
|
+
let wrote = null;
|
|
100
|
+
if (found.length > 0) {
|
|
101
|
+
try {
|
|
102
|
+
wrote = await writeEnv(project_path, found.map(({ name, value }) => ({ name, value })));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return reply(e instanceof Error && e.message ? e.message : "Could not write the env file.", true);
|
|
105
|
+
}
|
|
106
|
+
for (const f of found) await vault.addProject(f.entry.provider, f.entry.label, f.name, project_path);
|
|
107
|
+
lines.push(`Wrote ${found.map((f) => f.name).join(", ")} to ${wrote.file}.`);
|
|
108
|
+
}
|
|
109
|
+
for (const m of missing) lines.push(`${m}: missing (nothing written)`);
|
|
110
|
+
lines.push(...(await writeNotes(wrote, project_path)));
|
|
111
|
+
return reply(lines.join("\n"));
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async keys_request({ provider, project_path, env_vars, label = "default", session }) {
|
|
115
|
+
if (typeof provider !== "string" || !PROVIDER_RE.test(provider)) return reply(BAD_PROVIDER, true);
|
|
116
|
+
if (typeof label !== "string" || !LABEL_RE.test(label)) return reply(BAD_LABEL, true);
|
|
117
|
+
const blocked = await planBlock("keys_request", provider);
|
|
118
|
+
if (blocked) return reply(blocked);
|
|
119
|
+
if (env_vars?.some((n) => !NAME_RE.test(n))) return reply("One of the names is not a valid env var name.", true);
|
|
120
|
+
|
|
121
|
+
if (session !== undefined) {
|
|
122
|
+
const s = await readSession(session, home);
|
|
123
|
+
if (!s) return reply("That guide session was not found. Call keys_request without a session to start again.", true);
|
|
124
|
+
if (isExpired(s, now())) return reply("That guide session expired.");
|
|
125
|
+
return waitFor(s.id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (env_vars?.length === 0) return reply("Name at least one env var, or leave env_vars out to get everything the guide captures.", true);
|
|
129
|
+
const g = await server.guide(provider);
|
|
130
|
+
if (!g.ok && !env_vars) return reply(`Could not load the ${provider} guide: ${g.error}`, true);
|
|
131
|
+
if (g.ok && env_vars) {
|
|
132
|
+
const captured = g.guide.captures.map((c) => c.envVar);
|
|
133
|
+
const extra = env_vars.filter((n) => !captured.includes(n));
|
|
134
|
+
if (extra.length) return reply(`The ${provider} guide does not capture ${extra.join(", ")}. It captures ${captured.join(", ")}.`, true);
|
|
135
|
+
}
|
|
136
|
+
const needed = env_vars ?? g.guide.captures.map((c) => c.envVar);
|
|
137
|
+
|
|
138
|
+
const values = [];
|
|
139
|
+
for (const name of needed) {
|
|
140
|
+
const v = await vault.get(provider, label, name);
|
|
141
|
+
if (v === null) break;
|
|
142
|
+
values.push({ name, value: v });
|
|
143
|
+
}
|
|
144
|
+
if (values.length === needed.length && needed.length > 0) {
|
|
145
|
+
let wrote;
|
|
146
|
+
try {
|
|
147
|
+
wrote = await writeEnv(project_path, values);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return reply(e instanceof Error && e.message ? e.message : "Could not write the env file.", true);
|
|
150
|
+
}
|
|
151
|
+
for (const name of needed) await vault.addProject(provider, label, name, project_path);
|
|
152
|
+
const test = g.ok ? await testGuide(g.guide, provider, label) : undefined;
|
|
153
|
+
return reply(await savedText({ saved: needed, wrote, test }, project_path));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const s = await createSession({ provider, envVars: needed, projectPath: project_path, label, home });
|
|
157
|
+
const url = `${SITE()}/keys/go/${provider}?s=${s.id}`;
|
|
158
|
+
try {
|
|
159
|
+
await openUrl(url);
|
|
160
|
+
} catch {
|
|
161
|
+
return reply(`Could not open Chrome. Open ${url} yourself, then call keys_request again with session "${s.id}".`, true);
|
|
162
|
+
}
|
|
163
|
+
return waitFor(s.id);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async keys_check({ env_var } = {}) {
|
|
167
|
+
const blocked = await planBlock("keys_check");
|
|
168
|
+
if (blocked) return reply(blocked);
|
|
169
|
+
const entries = (await vault.list()).filter((e) => env_var === undefined || e.envVar === env_var);
|
|
170
|
+
if (entries.length === 0) return reply(env_var ? `${env_var}: missing` : "No keys saved yet.");
|
|
171
|
+
const groups = groupByProviderLabel(entries);
|
|
172
|
+
const lines = [];
|
|
173
|
+
for (const list of groups) {
|
|
174
|
+
const { provider, label } = list[0];
|
|
175
|
+
const mark = (e, s) => `${e.envVar} (${e.provider}, ${e.label}): ${s}`;
|
|
176
|
+
if (!(await guideAllowed(provider))) { lines.push(...list.map((e) => mark(e, "needs Footgun Pro"))); continue; }
|
|
177
|
+
const g = await server.guide(provider);
|
|
178
|
+
if (!g.ok) { lines.push(...list.map((e) => mark(e, "not checked (guide unavailable)"))); continue; }
|
|
179
|
+
const t = await testGuide(g.guide, provider, label);
|
|
180
|
+
const health = healthRecord(t, now);
|
|
181
|
+
for (const e of list) {
|
|
182
|
+
await vault.setHealth(e.provider, e.label, e.envVar, health);
|
|
183
|
+
lines.push(mark(e, t.ok ? "ok" : `failing: ${health.reason}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return reply(lines.join("\n"));
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Poll the session file once a second, up to waitMs.
|
|
191
|
+
async function waitFor(id) {
|
|
192
|
+
const deadline = Date.now() + waitMs;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const s = await readSession(id, home);
|
|
195
|
+
if (!s) return reply("That guide session was not found. Call keys_request without a session to start again.", true);
|
|
196
|
+
if (isExpired(s, now())) return reply("That guide session expired.");
|
|
197
|
+
if (s.status === "done" && s.result) return reply(await savedText(s.result, s.projectPath));
|
|
198
|
+
if (s.status === "failed") return reply(`The guide could not finish: ${s.result?.error ?? "unknown error"}`, true);
|
|
199
|
+
const left = deadline - Date.now();
|
|
200
|
+
if (left <= 0) break;
|
|
201
|
+
await sleep(Math.min(1000, left));
|
|
202
|
+
}
|
|
203
|
+
return reply(`Still waiting for the guide in Chrome. Call keys_request again with session "${id}".`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return Object.fromEntries(Object.entries(tools).map(([name, fn]) => [name, async (args) => {
|
|
207
|
+
try {
|
|
208
|
+
return await fn(args ?? {});
|
|
209
|
+
} catch {
|
|
210
|
+
return reply("Something went wrong in Footgun Keys.", true);
|
|
211
|
+
}
|
|
212
|
+
}]));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const names = z.array(z.string().regex(NAME_RE, "Use a plain env var name like STRIPE_SECRET_KEY."));
|
|
216
|
+
|
|
217
|
+
export const SPECS = {
|
|
218
|
+
keys_list: { description: "List the saved keys by env var name, provider, label and health. Never shows values.", inputSchema: z.object({}) },
|
|
219
|
+
keys_guides: { description: "List the available setup guides and whether each is Free or Pro.", inputSchema: z.object({}) },
|
|
220
|
+
keys_write_env: {
|
|
221
|
+
description: "Write already-saved keys into the project's env file (git-ignored). Names missing from the vault are reported, not written.",
|
|
222
|
+
inputSchema: z.object({ project_path: z.string().describe("Absolute path of the project folder."), env_vars: names.describe("Env var names to write.") }),
|
|
223
|
+
},
|
|
224
|
+
keys_request: {
|
|
225
|
+
description: "Get a provider's API key into a project. Writes it from the Keychain if saved, otherwise opens a guided setup in Chrome and waits. If it returns a session, call it again with that session.",
|
|
226
|
+
inputSchema: z.object({
|
|
227
|
+
provider: z.string().regex(PROVIDER_RE, BAD_PROVIDER).describe("Guide slug, such as stripe or openai."),
|
|
228
|
+
project_path: z.string().describe("Absolute path of the project folder."),
|
|
229
|
+
env_vars: names.optional().describe("Env var names needed. Defaults to all the guide captures."),
|
|
230
|
+
label: z.string().regex(LABEL_RE, BAD_LABEL).optional().describe("Name for this key set. Defaults to default."),
|
|
231
|
+
session: z.string().optional().describe("Session id from an earlier keys_request that was still waiting."),
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
keys_check: {
|
|
235
|
+
description: "Test saved keys against their provider and record whether each still works.",
|
|
236
|
+
inputSchema: z.object({ env_var: z.string().optional().describe("Check just this env var. Defaults to all saved keys.") }),
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function openUrl(url) {
|
|
241
|
+
return new Promise((resolve, reject) => execFile("open", [url], (e) => (e ? reject(e) : resolve())));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Off macOS the server still answers initialize and tools/list (so a plugin that starts it does not
|
|
245
|
+
// crash), but every tool call says Footgun Keys is Mac only.
|
|
246
|
+
export function toolsFor(platform, makeDeps) {
|
|
247
|
+
if (platform !== "darwin") {
|
|
248
|
+
return Object.fromEntries(Object.keys(SPECS).map((name) => [name, async () => reply("Footgun Keys runs on macOS for now.")]));
|
|
249
|
+
}
|
|
250
|
+
return buildTools(makeDeps());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function runMcp() {
|
|
254
|
+
const tools = toolsFor(process.platform, () => {
|
|
255
|
+
const store = keychainStore();
|
|
256
|
+
return { vault: openVault({ store }), server: makeServer({ store }), openUrl };
|
|
257
|
+
});
|
|
258
|
+
const mcp = new McpServer({ name: "footgun-keys", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
259
|
+
for (const [name, spec] of Object.entries(SPECS)) {
|
|
260
|
+
mcp.registerTool(name, { title: name, ...spec }, async (args) => tools[name](args));
|
|
261
|
+
}
|
|
262
|
+
const transport = new StdioServerTransport();
|
|
263
|
+
const closed = new Promise((resolve) => { transport.onclose = resolve; });
|
|
264
|
+
await mcp.connect(transport);
|
|
265
|
+
await closed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function run() {
|
|
269
|
+
await runMcp();
|
|
270
|
+
return 0;
|
|
271
|
+
}
|