Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ Non-interactive / agent automation:
printf '%s' "$FATHOM_API_KEY" | fathom auth set --stdin
```

The saved config lives at `~/.config/fathom/config.json` with `0600` permissions.
Saved auth is encrypted at rest:

- encrypted payload: `~/.config/fathom/config.enc`
- local encryption key: `~/.config/fathom/.encryption_key`

Permissions are kept strict:

- config dir: `0700`
- encrypted auth file: `0600`
- encryption key file: `0600`

`FATHOM_API_KEY` remains the highest-priority auth source and is still the safest option for ephemeral agent use.

Tip: if you keep the key in a local `.env`, Node 22+ can load it without adding any CLI dependency:

Expand Down
3 changes: 3 additions & 0 deletions docs/CONTRACT_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,15 @@ Derived agent helpers built on top of the official API:
"data": {
"hasApiKey": true,
"source": "env:FATHOM_API_KEY",
"storage": "env",
"apiKeyRedacted": "PbXI…_WTw",
"validation": { "ok": true }
}
}
```

Saved local auth reports `source: "config:encrypted"` and `storage: "encrypted"`.

### `fathom doctor --json`

```json
Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/fathom/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ If `fathom doctor --json` reports missing auth:
- Saved local config: `fathom auth set`
- Non-interactive automation: `printf '%s' "$FATHOM_API_KEY" | fathom auth set --stdin`

Avoid pasting full keys into logs or chat.
Avoid pasting full keys into logs or chat. Prefer ephemeral `FATHOM_API_KEY` for the safest automation path. If you use `fathom auth set`, the CLI stores encrypted auth at `~/.config/fathom/config.enc` and keeps local key material in `~/.config/fathom/.encryption_key`.

Public share URLs are the exception: `fathom meetings get <share_url> --with transcript --json` can resolve through Fathom's public share page even when no API key is configured.

Expand Down
116 changes: 95 additions & 21 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { createRequire } from "node:module";
import { createInterface } from "node:readline/promises";
import { Command } from "commander";
import { clearConfig, readConfig, redactApiKey, resolveApiKey } from "./config.js";
import { clearConfig, ConfigError, getConfigPaths, readStoredConfig, redactApiKey, resolveApiKey } from "./config.js";
import { saveAndValidateApiKey, validateApiKey } from "./auth.js";
import {
collectCursorPages,
Expand Down Expand Up @@ -163,16 +163,56 @@ async function promptForApiKey(): Promise<string> {
terminal: true,
});
try {
process.stderr.write(`Saving to ~/.config/fathom/config.json\n`);
process.stderr.write(`Saving encrypted auth to ~/.config/fathom/config.enc\n`);
return (await rl.question("Fathom API key: ")).trim();
} finally {
rl.close();
}
}

async function resolveAuthState(): Promise<{
apiKey: string | null;
source: "env:FATHOM_API_KEY" | "config:encrypted" | null;
storage: "env" | "encrypted" | "none";
migratedFromLegacy: boolean;
}> {
const env = process.env.FATHOM_API_KEY?.trim();
if (env) {
return {
apiKey: env,
source: "env:FATHOM_API_KEY",
storage: "env",
migratedFromLegacy: false,
};
}

const state = await readStoredConfig();
return {
apiKey: state.config?.apiKey?.trim() || null,
source: state.config?.apiKey ? "config:encrypted" : null,
storage: state.storage,
migratedFromLegacy: state.migratedFromLegacy,
};
}

function emitAuthResolutionError(error: unknown, { json }: CommonJsonOptions): string {
const cliError =
error instanceof ConfigError
? makeError(error, { code: error.code, message: error.message })
: makeError(error, { code: "AUTH_INVALID", message: "Saved auth is unreadable. Run `fathom auth clear` and `fathom auth set` again." });
if (json) printJson(fail(cliError));
else process.stderr.write(`${cliError.message}\n`);
process.exitCode = 2;
return "";
}

async function requireApiKey({ json }: CommonJsonOptions): Promise<string> {
const apiKey = await resolveApiKey();
if (apiKey) return apiKey;
try {
const apiKey = await resolveApiKey();
if (apiKey) return apiKey;
} catch (error) {
return emitAuthResolutionError(error, { json });
}

const error = makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT });
if (json) printJson(fail(error));
Expand Down Expand Up @@ -288,47 +328,80 @@ program
.description("Show API key source (redacted)")
.option("--json", "Print JSON")
.action(async (opts: CommonJsonOptions) => {
const config = await readConfig();
const env = process.env.FATHOM_API_KEY?.trim();
const apiKey = env || config?.apiKey || "";
let state;
try {
state = await resolveAuthState();
} catch (error) {
emitAuthResolutionError(error, opts);
return;
}
const apiKey = state.apiKey || "";
if (!apiKey) {
if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false }));
if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false, source: null, storage: "none" }));
else process.stderr.write(`${AUTH_HELP_TEXT}\n`);
process.exitCode = 2;
return;
}
const source = env ? "env:FATHOM_API_KEY" : "config";
if (opts.json) {
printJson(ok({ hasApiKey: true, source, apiKeyRedacted: redactApiKey(apiKey) }));
printJson(
ok({
hasApiKey: true,
source: state.source,
storage: state.storage,
apiKeyRedacted: redactApiKey(apiKey),
migratedFromLegacy: state.migratedFromLegacy,
}),
);
return;
}
// eslint-disable-next-line no-console
console.log(`${source}: ${redactApiKey(apiKey)}`);
console.log(`${state.source}: ${redactApiKey(apiKey)}`);
}),
)
.addCommand(
new Command("status")
.description("Validate the configured API key")
.option("--json", "Print JSON")
.action(async (opts: CommonJsonOptions) => {
const env = process.env.FATHOM_API_KEY?.trim();
const config = await readConfig();
const apiKey = await resolveApiKey();
const source = env ? "env:FATHOM_API_KEY" : config?.apiKey ? "config" : null;
let state;
try {
state = await resolveAuthState();
} catch (error) {
emitAuthResolutionError(error, opts);
return;
}
const apiKey = state.apiKey;
if (!apiKey) {
if (opts.json) printJson(fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), { hasApiKey: false, source }));
if (opts.json) {
printJson(
fail(makeError(null, { code: "AUTH_MISSING", message: AUTH_HELP_TEXT }), {
hasApiKey: false,
source: state.source,
storage: state.storage,
}),
);
}
else process.stderr.write(`${AUTH_HELP_TEXT}\n`);
process.exitCode = 2;
return;
}

const validation = await validateApiKey(apiKey);
if (opts.json) {
printJson(ok({ hasApiKey: true, source, apiKeyRedacted: redactApiKey(apiKey), validation }));
printJson(
ok({
hasApiKey: true,
source: state.source,
storage: state.storage,
apiKeyRedacted: redactApiKey(apiKey),
validation,
migratedFromLegacy: state.migratedFromLegacy,
}),
);
if (!validation.ok) process.exitCode = 1;
} else if (validation.ok) {
// eslint-disable-next-line no-console
console.log(`API key valid (${source || "unknown source"})`);
console.log(`API key valid (${state.source || "unknown source"})`);
} else {
process.stderr.write(`API key invalid: ${validation.reason}\n`);
process.exitCode = 1;
Expand All @@ -353,11 +426,11 @@ program

const result = await saveAndValidateApiKey(raw);
if (opts.json) {
printJson(ok({ saved: true, apiKeyRedacted: redactApiKey(result.apiKey), validation: result.validation }));
printJson(ok({ saved: true, storage: "encrypted", apiKeyRedacted: redactApiKey(result.apiKey), validation: result.validation }));
if (!result.validation.ok) process.exitCode = 1;
return;
}
process.stderr.write(`Saved Fathom API key (${redactApiKey(result.apiKey)}) to ~/.config/fathom/config.json.\n`);
process.stderr.write(`Saved encrypted Fathom API key (${redactApiKey(result.apiKey)}) to ~/.config/fathom/config.enc.\n`);
if (!result.validation.ok) process.exitCode = 1;
}),
)
Expand All @@ -366,9 +439,10 @@ program
.description("Clear the saved API key from local config")
.option("--json", "Print JSON")
.action(async (opts: CommonJsonOptions) => {
const { encryptedConfigPath, encryptionKeyPath, legacyConfigPath } = getConfigPaths();
await clearConfig();
if (opts.json) {
printJson(ok({ cleared: true }));
printJson(ok({ cleared: true, removed: { encryptedConfigPath, encryptionKeyPath, legacyConfigPath } }));
return;
}
// eslint-disable-next-line no-console
Expand Down
Loading