@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 +51 -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 +24 -0
- package/lib/envfile.d.mts +10 -0
- package/lib/envfile.mjs +104 -0
- package/lib/health.d.mts +17 -0
- package/lib/health.mjs +98 -0
- package/lib/launchd.d.mts +1 -0
- package/lib/launchd.mjs +28 -0
- package/lib/mcp.d.mts +25 -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 +150 -0
- package/lib/paths.d.mts +4 -0
- package/lib/paths.mjs +36 -0
- package/lib/platform.d.mts +5 -0
- package/lib/platform.mjs +38 -0
- package/lib/protect.d.mts +6 -0
- package/lib/protect.mjs +63 -0
- package/lib/schedule.d.mts +11 -0
- package/lib/schedule.mjs +97 -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 +22 -0
- package/lib/setup.mjs +226 -0
- package/lib/testcall.d.mts +11 -0
- package/lib/testcall.mjs +103 -0
- package/lib/vault.d.mts +26 -0
- package/lib/vault.mjs +89 -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,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @getfootgun/keys
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
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,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 }>;
|
package/lib/envfile.mjs
ADDED
|
@@ -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
|
+
}
|
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,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;
|
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,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>;
|