fathom-mcp 0.5.7 → 0.5.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -160,7 +160,7 @@ resolve_agent_cmd() {
160
160
  case "$AGENT" in
161
161
  claude-code)
162
162
  if [[ "$USE_TMUX" == false ]]; then
163
- echo "claude -p --permission-mode bypassPermissions --no-user-prompt --output-format stream-json"
163
+ echo "claude -p --permission-mode bypassPermissions --output-format stream-json"
164
164
  else
165
165
  echo "claude --model opus --permission-mode bypassPermissions"
166
166
  fi
@@ -169,7 +169,7 @@ resolve_agent_cmd() {
169
169
  if [[ "$USE_TMUX" == true ]]; then
170
170
  echo "claude --model opus --permission-mode bypassPermissions"
171
171
  else
172
- echo "claude -p --permission-mode bypassPermissions --no-user-prompt --output-format stream-json"
172
+ echo "claude -p --permission-mode bypassPermissions --output-format stream-json"
173
173
  fi
174
174
  ;;
175
175
  codex)
@@ -287,6 +287,8 @@ do_start() {
287
287
 
288
288
  cd "$PROJECT_DIR"
289
289
  unset CLAUDECODE 2>/dev/null || true
290
+ # Ensure common binary locations are on PATH for backgrounded processes
291
+ export PATH="$HOME/.local/bin:$HOME/.claude/local/bin:$PATH"
290
292
  mkdir -p .fathom
291
293
 
292
294
  local pipe_file="$PROJECT_DIR/.fathom/agent.pipe"
package/src/agents.js ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Central agent registry — ~/.config/fathom/agents.json
3
+ *
4
+ * Single source of truth for all agent definitions. The CLI uses this
5
+ * for list/start/stop/restart/add/remove/config commands.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import { execSync, execFileSync, spawn } from "child_process";
11
+
12
+ const CONFIG_DIR = path.join(process.env.HOME || "/tmp", ".config", "fathom");
13
+ const AGENTS_FILE = path.join(CONFIG_DIR, "agents.json");
14
+
15
+ const EMPTY_CONFIG = { version: 1, agents: {} };
16
+
17
+ // ── Config I/O ──────────────────────────────────────────────────────────────
18
+
19
+ export function loadAgentsConfig() {
20
+ try {
21
+ const raw = fs.readFileSync(AGENTS_FILE, "utf-8");
22
+ const parsed = JSON.parse(raw);
23
+ if (!parsed.agents || typeof parsed.agents !== "object") {
24
+ return { ...EMPTY_CONFIG };
25
+ }
26
+ return parsed;
27
+ } catch {
28
+ return { ...EMPTY_CONFIG };
29
+ }
30
+ }
31
+
32
+ export function saveAgentsConfig(config) {
33
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
34
+ fs.writeFileSync(AGENTS_FILE, JSON.stringify(config, null, 2) + "\n");
35
+ }
36
+
37
+ // ── CRUD ────────────────────────────────────────────────────────────────────
38
+
39
+ export function getAgent(name) {
40
+ const config = loadAgentsConfig();
41
+ return config.agents[name] || null;
42
+ }
43
+
44
+ export function listAgents() {
45
+ const config = loadAgentsConfig();
46
+ return config.agents;
47
+ }
48
+
49
+ export function addAgent(name, entry) {
50
+ const config = loadAgentsConfig();
51
+ config.agents[name] = entry;
52
+ saveAgentsConfig(config);
53
+ }
54
+
55
+ export function removeAgent(name) {
56
+ const config = loadAgentsConfig();
57
+ if (!config.agents[name]) return false;
58
+ delete config.agents[name];
59
+ saveAgentsConfig(config);
60
+ return true;
61
+ }
62
+
63
+ // ── Status ──────────────────────────────────────────────────────────────────
64
+
65
+ export function isAgentRunning(name, entry) {
66
+ if (entry.ssh) return "ssh";
67
+
68
+ const mode = entry.executionMode || "tmux";
69
+
70
+ if (mode === "tmux") {
71
+ const session = `${name}_fathom-session`;
72
+ try {
73
+ execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, {
74
+ stdio: "pipe",
75
+ });
76
+ return "running";
77
+ } catch {
78
+ return "stopped";
79
+ }
80
+ }
81
+
82
+ // headless — check PID file
83
+ const pidFile = path.join(entry.projectDir, ".fathom", "agent.pid");
84
+ try {
85
+ const pid = fs.readFileSync(pidFile, "utf-8").trim();
86
+ if (pid) {
87
+ process.kill(parseInt(pid, 10), 0); // signal 0 = existence check
88
+ return "running";
89
+ }
90
+ } catch {
91
+ // PID file missing or process gone
92
+ }
93
+ return "stopped";
94
+ }
95
+
96
+ // ── Start / Stop ────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Default command per agent type + execution mode.
100
+ */
101
+ export function defaultCommand(agentType, executionMode) {
102
+ const cmds = {
103
+ "claude-code": {
104
+ tmux: "claude --model opus --permission-mode bypassPermissions",
105
+ headless:
106
+ "claude -p --permission-mode bypassPermissions --output-format stream-json",
107
+ },
108
+ "claude-sdk": {
109
+ tmux: "claude --model opus --permission-mode bypassPermissions",
110
+ headless:
111
+ "claude -p --permission-mode bypassPermissions --output-format stream-json",
112
+ },
113
+ codex: { tmux: "codex", headless: "codex" },
114
+ gemini: { tmux: "gemini", headless: "gemini" },
115
+ opencode: { tmux: "opencode", headless: "opencode" },
116
+ };
117
+ const forType = cmds[agentType] || cmds["claude-code"];
118
+ return forType[executionMode] || forType.tmux;
119
+ }
120
+
121
+ export function startAgent(name, entry) {
122
+ const mode = entry.executionMode || "tmux";
123
+ const command = entry.command || defaultCommand(entry.agentType || "claude-code", mode);
124
+ const session = `${name}_fathom-session`;
125
+
126
+ // Build env
127
+ const env = {
128
+ ...process.env,
129
+ PATH: `${process.env.HOME}/.local/bin:${process.env.HOME}/.claude/local/bin:${process.env.PATH}`,
130
+ ...(entry.env || {}),
131
+ };
132
+ // Remove CLAUDECODE to avoid nested session detection
133
+ delete env.CLAUDECODE;
134
+
135
+ if (entry.ssh) {
136
+ return startAgentSSH(name, entry, command, env);
137
+ }
138
+
139
+ if (mode === "headless") {
140
+ return startAgentHeadless(name, entry, command, env);
141
+ }
142
+
143
+ return startAgentTmux(name, entry, command, session, env);
144
+ }
145
+
146
+ function startAgentTmux(name, entry, command, session, env) {
147
+ // Check if already running
148
+ try {
149
+ execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, {
150
+ stdio: "pipe",
151
+ });
152
+ // Save pane ID in case it wasn't saved
153
+ savePaneId(name, session);
154
+ return { ok: true, message: `Session already running: ${session}`, alreadyRunning: true };
155
+ } catch {
156
+ // Not running — continue
157
+ }
158
+
159
+ try {
160
+ execSync(
161
+ `tmux new-session -d -s ${JSON.stringify(session)} -c ${JSON.stringify(entry.projectDir)} ${command}`,
162
+ { stdio: "pipe", env },
163
+ );
164
+ } catch (e) {
165
+ return { ok: false, message: `Failed to start tmux session: ${e.message}` };
166
+ }
167
+
168
+ // Brief wait for session to stabilize, then save pane ID
169
+ try {
170
+ execSync("sleep 1", { stdio: "pipe" });
171
+ } catch {
172
+ // ignore
173
+ }
174
+ savePaneId(name, session);
175
+
176
+ return { ok: true, message: `Started: ${session}` };
177
+ }
178
+
179
+ function savePaneId(name, session) {
180
+ try {
181
+ const paneId = execSync(
182
+ `tmux list-panes -t ${JSON.stringify(session)} -F '#{pane_id}' 2>/dev/null | head -1`,
183
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
184
+ ).trim();
185
+ if (paneId) {
186
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
187
+ fs.writeFileSync(path.join(CONFIG_DIR, `${name}-pane-id`), paneId + "\n");
188
+ }
189
+ } catch {
190
+ // Non-critical
191
+ }
192
+ }
193
+
194
+ function startAgentHeadless(name, entry, command, env) {
195
+ const fathomDir = path.join(entry.projectDir, ".fathom");
196
+ const pidFile = path.join(fathomDir, "agent.pid");
197
+ const keeperPidFile = path.join(fathomDir, "agent-keeper.pid");
198
+ const pipeFile = path.join(fathomDir, "agent.pipe");
199
+ const logFile = path.join(fathomDir, "agent.log");
200
+
201
+ // Check if already running
202
+ try {
203
+ const pid = fs.readFileSync(pidFile, "utf-8").trim();
204
+ if (pid) {
205
+ process.kill(parseInt(pid, 10), 0);
206
+ return { ok: true, message: `Headless agent already running (PID ${pid})`, alreadyRunning: true };
207
+ }
208
+ } catch {
209
+ // Not running
210
+ }
211
+
212
+ fs.mkdirSync(fathomDir, { recursive: true });
213
+
214
+ // Clean up stale pipe
215
+ try {
216
+ fs.unlinkSync(pipeFile);
217
+ } catch {
218
+ // ignore
219
+ }
220
+
221
+ // Create FIFO, start keeper, start agent
222
+ try {
223
+ execSync(`mkfifo ${JSON.stringify(pipeFile)}`, { stdio: "pipe" });
224
+
225
+ // Keeper: holds the pipe open so agent doesn't get EOF
226
+ const keeper = spawn("sleep", ["infinity"], {
227
+ stdio: ["ignore", fs.openSync(pipeFile, "w"), "ignore"],
228
+ detached: true,
229
+ env,
230
+ });
231
+ keeper.unref();
232
+ fs.writeFileSync(keeperPidFile, String(keeper.pid) + "\n");
233
+
234
+ // Agent: reads from pipe, logs to file
235
+ const logFd = fs.openSync(logFile, "a");
236
+ const pipeFd = fs.openSync(pipeFile, "r");
237
+ const agent = spawn("bash", ["-c", command], {
238
+ cwd: entry.projectDir,
239
+ stdio: [pipeFd, logFd, logFd],
240
+ detached: true,
241
+ env,
242
+ });
243
+ agent.unref();
244
+ fs.closeSync(pipeFd);
245
+ fs.closeSync(logFd);
246
+
247
+ fs.writeFileSync(pidFile, String(agent.pid) + "\n");
248
+
249
+ return { ok: true, message: `Started headless (PID ${agent.pid}). Log: ${logFile}` };
250
+ } catch (e) {
251
+ return { ok: false, message: `Failed to start headless agent: ${e.message}` };
252
+ }
253
+ }
254
+
255
+ function startAgentSSH(name, entry, command, env) {
256
+ const { host, user, key } = entry.ssh;
257
+ const sshArgs = [];
258
+ if (key) sshArgs.push("-i", key);
259
+ const target = user ? `${user}@${host}` : host;
260
+ const remoteCmd = `cd ${JSON.stringify(entry.projectDir)} && ${command}`;
261
+
262
+ try {
263
+ execFileSync("ssh", [...sshArgs, target, remoteCmd], {
264
+ stdio: "inherit",
265
+ env,
266
+ });
267
+ return { ok: true, message: `SSH agent started on ${target}` };
268
+ } catch (e) {
269
+ return { ok: false, message: `SSH start failed: ${e.message}` };
270
+ }
271
+ }
272
+
273
+ export function stopAgent(name, entry) {
274
+ const session = `${name}_fathom-session`;
275
+ const messages = [];
276
+ let stopped = false;
277
+
278
+ if (entry.ssh) {
279
+ return { ok: false, message: "Cannot stop SSH agents remotely — connect to the host directly." };
280
+ }
281
+
282
+ // Kill tmux session
283
+ try {
284
+ execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`, { stdio: "pipe" });
285
+ execSync(`tmux kill-session -t ${JSON.stringify(session)}`, { stdio: "pipe" });
286
+ // Remove pane ID file
287
+ try {
288
+ fs.unlinkSync(path.join(CONFIG_DIR, `${name}-pane-id`));
289
+ } catch {
290
+ // ignore
291
+ }
292
+ messages.push(`Killed tmux session: ${session}`);
293
+ stopped = true;
294
+ } catch {
295
+ // No tmux session
296
+ }
297
+
298
+ // Kill headless process
299
+ const fathomDir = path.join(entry.projectDir, ".fathom");
300
+ const pidFile = path.join(fathomDir, "agent.pid");
301
+ const keeperPidFile = path.join(fathomDir, "agent-keeper.pid");
302
+ const pipeFile = path.join(fathomDir, "agent.pipe");
303
+
304
+ try {
305
+ const pid = fs.readFileSync(pidFile, "utf-8").trim();
306
+ if (pid) {
307
+ process.kill(parseInt(pid, 10));
308
+ messages.push(`Killed headless process: PID ${pid}`);
309
+ stopped = true;
310
+ }
311
+ } catch {
312
+ // Not running or file missing
313
+ }
314
+ try { fs.unlinkSync(pidFile); } catch { /* */ }
315
+
316
+ // Keeper cleanup
317
+ try {
318
+ const keeperPid = fs.readFileSync(keeperPidFile, "utf-8").trim();
319
+ if (keeperPid) process.kill(parseInt(keeperPid, 10));
320
+ } catch {
321
+ // ignore
322
+ }
323
+ try { fs.unlinkSync(keeperPidFile); } catch { /* */ }
324
+ try { fs.unlinkSync(pipeFile); } catch { /* */ }
325
+
326
+ if (!stopped) {
327
+ return { ok: false, message: `No running session found for: ${name}` };
328
+ }
329
+
330
+ return { ok: true, message: messages.join("\n") };
331
+ }
332
+
333
+ // ── Helpers ─────────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Build a default agent entry from a .fathom.json config in a directory.
337
+ */
338
+ export function buildEntryFromConfig(projectDir, fathomConfig) {
339
+ const agentType = fathomConfig.agents?.[0] || "claude-code";
340
+ const isHeadless = agentType === "claude-sdk";
341
+ const executionMode = isHeadless ? "headless" : "tmux";
342
+ return {
343
+ projectDir,
344
+ agentType,
345
+ executionMode,
346
+ command: defaultCommand(agentType, executionMode),
347
+ server: fathomConfig.server || "http://localhost:4243",
348
+ apiKey: fathomConfig.apiKey || "",
349
+ vault: fathomConfig.vault || "vault",
350
+ vaultMode: fathomConfig.vaultMode || "local",
351
+ description: fathomConfig.description || "",
352
+ hooks: fathomConfig.hooks || {},
353
+ ssh: null,
354
+ env: {},
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Look up an agent entry by workspace name from the central registry.
360
+ * Returns null if no match found.
361
+ */
362
+ export function findAgentByWorkspace(workspace) {
363
+ const config = loadAgentsConfig();
364
+ // Direct name match first
365
+ if (config.agents[workspace]) {
366
+ return { name: workspace, entry: config.agents[workspace] };
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /**
372
+ * Look up an agent entry by projectDir from the central registry.
373
+ * Walks up from startDir matching against registered projectDirs.
374
+ * Returns { name, entry } or null.
375
+ */
376
+ export function findAgentByDir(startDir) {
377
+ const config = loadAgentsConfig();
378
+ let dir = path.resolve(startDir);
379
+ const root = path.parse(dir).root;
380
+
381
+ while (true) {
382
+ for (const [name, entry] of Object.entries(config.agents)) {
383
+ if (entry.projectDir === dir) {
384
+ return { name, entry };
385
+ }
386
+ }
387
+ const parent = path.dirname(dir);
388
+ if (parent === dir || dir === root) break;
389
+ dir = parent;
390
+ }
391
+ return null;
392
+ }
393
+
394
+ export { AGENTS_FILE, CONFIG_DIR };
package/src/cli.js CHANGED
@@ -4,10 +4,17 @@
4
4
  * fathom-mcp CLI
5
5
  *
6
6
  * Usage:
7
- * npx fathom-mcp — Start MCP server (stdio, for .mcp.json)
8
- * npx fathom-mcp init — Interactive setup wizard
9
- * npx fathom-mcp status — Check server connection + workspace status
10
- * npx fathom-mcp update — Update hook scripts + version file
7
+ * npx fathom-mcp — Start MCP server (stdio, for .mcp.json)
8
+ * npx fathom-mcp init — Interactive setup wizard
9
+ * npx fathom-mcp status — Check server connection + workspace status
10
+ * npx fathom-mcp update — Update hook scripts + version file
11
+ * npx fathom-mcp list — List all agents + running status
12
+ * npx fathom-mcp start [name] — Start agent by name or legacy cwd-walk
13
+ * npx fathom-mcp stop <name> — Stop an agent
14
+ * npx fathom-mcp restart <name> — Restart an agent
15
+ * npx fathom-mcp add [name] — Add agent to registry (reads .fathom.json defaults)
16
+ * npx fathom-mcp remove <name>— Remove agent from registry
17
+ * npx fathom-mcp config <name>— Print agent config JSON
11
18
  */
12
19
 
13
20
  import fs from "fs";
@@ -18,6 +25,17 @@ import { fileURLToPath } from "url";
18
25
 
19
26
  import { findConfigFile, resolveConfig, writeConfig } from "./config.js";
20
27
  import { createClient } from "./server-client.js";
28
+ import {
29
+ listAgents,
30
+ getAgent,
31
+ addAgent as registryAddAgent,
32
+ removeAgent as registryRemoveAgent,
33
+ isAgentRunning,
34
+ startAgent,
35
+ stopAgent,
36
+ defaultCommand,
37
+ buildEntryFromConfig,
38
+ } from "./agents.js";
21
39
 
22
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
41
  const SCRIPTS_DIR = path.join(__dirname, "..", "scripts");
@@ -372,10 +390,7 @@ async function runInit(flags = {}) {
372
390
  ? (flagWorkspace || defaultName)
373
391
  : await ask(rl, " Workspace name", defaultName);
374
392
 
375
- // 2. Description (optional)
376
- const description = nonInteractive ? "" : await ask(rl, " Workspace description (optional)", "");
377
-
378
- // 4. Agent selection — auto-detect and let user choose
393
+ // 2. Agent selection — auto-detect and let user choose
379
394
  const agentKeys = Object.keys(AGENTS);
380
395
  const detected = agentKeys.filter((key) => AGENTS[key].detect(cwd));
381
396
 
@@ -518,7 +533,6 @@ async function runInit(flags = {}) {
518
533
  vault,
519
534
  server: serverUrl,
520
535
  apiKey,
521
- description,
522
536
  agents: selectedAgents,
523
537
  hooks: {
524
538
  "vault-recall": { enabled: enableRecallHook },
@@ -528,21 +542,13 @@ async function runInit(flags = {}) {
528
542
  const configPath = writeConfig(cwd, configData);
529
543
  console.log(` ✓ ${path.relative(cwd, configPath)}`);
530
544
 
531
- // .fathom/scripts/
532
- const scriptsDir = path.join(cwd, ".fathom", "scripts");
533
- const copiedScripts = copyScripts(scriptsDir);
545
+ // ~/.config/fathom/scripts/ (central, shared across all workspaces)
546
+ const centralScriptsDir = path.join(process.env.HOME, ".config", "fathom", "scripts");
547
+ const copiedScripts = copyScripts(centralScriptsDir);
534
548
  if (copiedScripts.length > 0) {
535
- console.log(` ✓ .fathom/scripts/ (${copiedScripts.length} scripts)`);
549
+ console.log(` ✓ ~/.config/fathom/scripts/ (${copiedScripts.length} scripts)`);
536
550
  }
537
551
 
538
- // .fathom/version
539
- const pkgJsonPath = path.join(__dirname, "..", "package.json");
540
- const pkg = readJsonFile(pkgJsonPath);
541
- const packageVersion = pkg?.version || "unknown";
542
- const fathomDir = path.join(cwd, ".fathom");
543
- fs.writeFileSync(path.join(fathomDir, "version"), packageVersion + "\n");
544
- console.log(` ✓ .fathom/version (${packageVersion})`);
545
-
546
552
  // vault/ directory — only create for synced/local modes
547
553
  if (needsLocalVault) {
548
554
  const vaultDir = path.join(cwd, vault);
@@ -554,18 +560,18 @@ async function runInit(flags = {}) {
554
560
  }
555
561
  }
556
562
 
557
- // fathom-agents.md — boilerplate agent instructions
563
+ // fathom-agents.md — boilerplate agent instructions (central)
558
564
  const agentMdSrc = path.join(__dirname, "..", "fathom-agents.md");
559
- const agentMdDest = path.join(cwd, ".fathom", "fathom-agents.md");
565
+ const agentMdDest = path.join(process.env.HOME, ".config", "fathom", "fathom-agents.md");
560
566
  try {
561
567
  let template = fs.readFileSync(agentMdSrc, "utf-8");
562
568
  template = template
563
569
  .replace(/\{\{WORKSPACE_NAME\}\}/g, workspace)
564
570
  .replace(/\{\{VAULT_DIR\}\}/g, vault)
565
- .replace(/\{\{DESCRIPTION\}\}/g, description || `${workspace} workspace`);
571
+ .replace(/\{\{DESCRIPTION\}\}/g, `${workspace} workspace`);
566
572
  fs.mkdirSync(path.dirname(agentMdDest), { recursive: true });
567
573
  fs.writeFileSync(agentMdDest, template);
568
- console.log(" ✓ .fathom/fathom-agents.md");
574
+ console.log(" ✓ ~/.config/fathom/fathom-agents.md");
569
575
  } catch { /* template not found — skip silently */ }
570
576
 
571
577
  // Per-agent config files
@@ -575,10 +581,10 @@ async function runInit(flags = {}) {
575
581
  console.log(` ✓ ${result}`);
576
582
  }
577
583
 
578
- // Hook scripts (shared across agents)
579
- const sessionStartCmd = "bash .fathom/scripts/fathom-sessionstart.sh";
580
- const recallCmd = "bash .fathom/scripts/fathom-recall.sh";
581
- const precompactCmd = "bash .fathom/scripts/fathom-precompact.sh";
584
+ // Hook scripts (central location, shared across agents)
585
+ const sessionStartCmd = "bash ~/.config/fathom/scripts/fathom-sessionstart.sh";
586
+ const recallCmd = "bash ~/.config/fathom/scripts/fathom-recall.sh";
587
+ const precompactCmd = "bash ~/.config/fathom/scripts/fathom-precompact.sh";
582
588
 
583
589
  // Claude Code hooks
584
590
  if (hasClaude) {
@@ -607,14 +613,13 @@ async function runInit(flags = {}) {
607
613
  }
608
614
 
609
615
  // .gitignore
610
- appendToGitignore(cwd, [".fathom.json", ".fathom/scripts/"]);
616
+ appendToGitignore(cwd, [".fathom.json"]);
611
617
  console.log(" ✓ .gitignore");
612
618
 
613
619
  // Register with server
614
620
  if (serverReachable) {
615
621
  const regResult = await regClient.registerWorkspace(workspace, cwd, {
616
622
  vault,
617
- description,
618
623
  agents: selectedAgents,
619
624
  type: selectedAgents[0] || "local",
620
625
  });
@@ -659,7 +664,7 @@ async function runInit(flags = {}) {
659
664
  }
660
665
 
661
666
  // Auto-integrate agent instructions
662
- const agentMdPath = path.join(cwd, ".fathom", "fathom-agents.md");
667
+ const agentMdPath = agentMdDest;
663
668
  let instructionsBlob = "";
664
669
  try {
665
670
  instructionsBlob = fs.readFileSync(agentMdPath, "utf-8");
@@ -788,25 +793,20 @@ async function runUpdate() {
788
793
  }
789
794
 
790
795
  const projectDir = found.dir;
791
- const fathomDir = path.join(projectDir, ".fathom");
792
796
 
793
797
  // Read package version from our own package.json
794
798
  const pkgJsonPath = path.join(__dirname, "..", "package.json");
795
799
  const pkg = readJsonFile(pkgJsonPath);
796
800
  const packageVersion = pkg?.version || "unknown";
797
801
 
798
- // Copy all scripts
799
- const scriptsDir = path.join(fathomDir, "scripts");
802
+ // Copy all scripts to central location
803
+ const scriptsDir = path.join(process.env.HOME, ".config", "fathom", "scripts");
800
804
  const copiedScripts = copyScripts(scriptsDir);
801
805
 
802
- // Write version file
803
- fs.mkdirSync(fathomDir, { recursive: true });
804
- fs.writeFileSync(path.join(fathomDir, "version"), packageVersion + "\n");
805
-
806
806
  // Ensure SessionStart hook is registered for agents that support hooks
807
807
  // Detect by config agents field or directory presence (older configs may lack agents)
808
808
  const agents = found.config.agents || [];
809
- const sessionStartCmd = "bash .fathom/scripts/fathom-sessionstart.sh";
809
+ const sessionStartCmd = "bash ~/.config/fathom/scripts/fathom-sessionstart.sh";
810
810
  const registeredHooks = [];
811
811
 
812
812
  // Claude Code / Claude SDK
@@ -836,7 +836,7 @@ async function runUpdate() {
836
836
  console.log(`\n ✓ Fathom hooks updated to v${packageVersion}\n`);
837
837
 
838
838
  if (copiedScripts.length > 0) {
839
- console.log(" Updated scripts:");
839
+ console.log(" Updated scripts in ~/.config/fathom/scripts/:");
840
840
  for (const script of copiedScripts) {
841
841
  console.log(` ${script}`);
842
842
  }
@@ -849,39 +849,218 @@ async function runUpdate() {
849
849
  }
850
850
  }
851
851
 
852
- console.log(`\n Version written to .fathom/version`);
853
- console.log(" Restart your agent session to pick up changes.\n");
852
+ console.log("\n Restart your agent session to pick up changes.\n");
854
853
  }
855
854
 
856
855
  // --- Start command -----------------------------------------------------------
857
856
 
858
857
  function runStart(argv) {
859
- // Find the installed fathom-start.sh script
858
+ // Check if first non-flag arg matches a registry entry
859
+ const firstArg = argv.find((a) => !a.startsWith("-"));
860
+ if (firstArg) {
861
+ const entry = getAgent(firstArg);
862
+ if (entry) {
863
+ const result = startAgent(firstArg, entry);
864
+ console.log(` ${result.message}`);
865
+ process.exit(result.ok ? 0 : 1);
866
+ return;
867
+ }
868
+ }
869
+
870
+ // Legacy fallback: delegate to fathom-start.sh
860
871
  const found = findConfigFile(process.cwd());
861
872
  const projectDir = found?.dir || process.cwd();
862
873
 
863
- // Check .fathom/scripts/ first (installed by init/update), then package scripts/
864
- const localScript = path.join(projectDir, ".fathom", "scripts", "fathom-start.sh");
874
+ const centralScript = path.join(process.env.HOME, ".config", "fathom", "scripts", "fathom-start.sh");
865
875
  const packageScript = path.join(SCRIPTS_DIR, "fathom-start.sh");
866
- const script = fs.existsSync(localScript) ? localScript : packageScript;
876
+ const script = fs.existsSync(centralScript) ? centralScript : packageScript;
867
877
 
868
878
  if (!fs.existsSync(script)) {
869
879
  console.error(" Error: fathom-start.sh not found. Run `npx fathom-mcp update` first.");
870
880
  process.exit(1);
871
881
  }
872
882
 
873
- // Pass remaining args through to the shell script
874
883
  try {
875
884
  execFileSync("bash", [script, ...argv], {
876
885
  cwd: projectDir,
877
886
  stdio: "inherit",
878
887
  });
879
888
  } catch (e) {
880
- // Script already printed its own errors; just propagate exit code
881
889
  process.exit(e.status || 1);
882
890
  }
883
891
  }
884
892
 
893
+ // --- List command -------------------------------------------------------------
894
+
895
+ function runList() {
896
+ const agents = listAgents();
897
+ const names = Object.keys(agents);
898
+
899
+ if (names.length === 0) {
900
+ console.log("\n No agents registered. Run `fathom-mcp add` to register one.\n");
901
+ return;
902
+ }
903
+
904
+ // Header
905
+ const cols = { name: 16, type: 13, mode: 11, status: 10 };
906
+ console.log(
907
+ "\n " +
908
+ "NAME".padEnd(cols.name) +
909
+ "TYPE".padEnd(cols.type) +
910
+ "MODE".padEnd(cols.mode) +
911
+ "STATUS".padEnd(cols.status) +
912
+ "DIR",
913
+ );
914
+
915
+ for (const name of names) {
916
+ const entry = agents[name];
917
+ const type = entry.agentType || "claude-code";
918
+ const mode = entry.executionMode || "tmux";
919
+ const status = entry.ssh ? "[ssh]" : isAgentRunning(name, entry);
920
+ const dir = entry.projectDir.replace(process.env.HOME, "~");
921
+
922
+ console.log(
923
+ " " +
924
+ name.padEnd(cols.name) +
925
+ type.padEnd(cols.type) +
926
+ mode.padEnd(cols.mode) +
927
+ status.padEnd(cols.status) +
928
+ dir,
929
+ );
930
+ }
931
+ console.log();
932
+ }
933
+
934
+ // --- Stop command ------------------------------------------------------------
935
+
936
+ function runStop(name) {
937
+ if (!name) {
938
+ console.error(" Usage: fathom-mcp stop <name>");
939
+ process.exit(1);
940
+ }
941
+ const entry = getAgent(name);
942
+ if (!entry) {
943
+ console.error(` Error: No agent "${name}" in registry. Run \`fathom-mcp list\` to see agents.`);
944
+ process.exit(1);
945
+ }
946
+ const result = stopAgent(name, entry);
947
+ console.log(` ${result.message}`);
948
+ process.exit(result.ok ? 0 : 1);
949
+ }
950
+
951
+ // --- Restart command ---------------------------------------------------------
952
+
953
+ function runRestart(name) {
954
+ if (!name) {
955
+ console.error(" Usage: fathom-mcp restart <name>");
956
+ process.exit(1);
957
+ }
958
+ const entry = getAgent(name);
959
+ if (!entry) {
960
+ console.error(` Error: No agent "${name}" in registry. Run \`fathom-mcp list\` to see agents.`);
961
+ process.exit(1);
962
+ }
963
+
964
+ const stopResult = stopAgent(name, entry);
965
+ if (stopResult.ok) {
966
+ console.log(` ${stopResult.message}`);
967
+ }
968
+
969
+ // Brief pause between stop and start
970
+ try { execFileSync("sleep", ["1"], { stdio: "pipe" }); } catch { /* */ }
971
+
972
+ const startResult = startAgent(name, entry);
973
+ console.log(` ${startResult.message}`);
974
+ process.exit(startResult.ok ? 0 : 1);
975
+ }
976
+
977
+ // --- Add command -------------------------------------------------------------
978
+
979
+ async function runAdd(argv) {
980
+ const flags = parseFlags(argv);
981
+ const nameArg = argv.find((a) => !a.startsWith("-"));
982
+ const cwd = process.cwd();
983
+
984
+ // Try to read .fathom.json from cwd for defaults
985
+ const found = findConfigFile(cwd);
986
+ const fathomConfig = found?.config || {};
987
+ const projectDir = found?.dir || cwd;
988
+
989
+ const defaults = buildEntryFromConfig(projectDir, fathomConfig);
990
+ const defaultName = fathomConfig.workspace || path.basename(projectDir);
991
+
992
+ if (flags.nonInteractive) {
993
+ const name = nameArg || defaultName;
994
+ registryAddAgent(name, defaults);
995
+ console.log(` ✓ Added agent "${name}" to registry.`);
996
+ return;
997
+ }
998
+
999
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1000
+
1001
+ const name = await ask(rl, " Agent name", nameArg || defaultName);
1002
+ const agentProjectDir = await ask(rl, " Project directory", defaults.projectDir);
1003
+ const agentType = await ask(rl, " Agent type (claude-code|claude-sdk|codex|gemini|opencode)", defaults.agentType);
1004
+ const executionMode = await ask(rl, " Execution mode (tmux|headless)", defaults.executionMode);
1005
+ const command = await ask(rl, " Command", defaultCommand(agentType, executionMode));
1006
+ const server = await ask(rl, " Server URL", defaults.server);
1007
+ const apiKey = await ask(rl, " API key", defaults.apiKey);
1008
+ const vault = await ask(rl, " Vault subdirectory", defaults.vault || "vault");
1009
+ const vaultMode = await ask(rl, " Vault mode (hosted|synced|local|none)", defaults.vaultMode);
1010
+ const description = await ask(rl, " Description", defaults.description);
1011
+
1012
+ rl.close();
1013
+
1014
+ const entry = {
1015
+ projectDir: path.resolve(agentProjectDir),
1016
+ agentType,
1017
+ executionMode,
1018
+ command,
1019
+ server,
1020
+ apiKey,
1021
+ vault,
1022
+ vaultMode,
1023
+ description,
1024
+ hooks: defaults.hooks || {},
1025
+ ssh: null,
1026
+ env: {},
1027
+ };
1028
+
1029
+ registryAddAgent(name, entry);
1030
+ console.log(`\n ✓ Added agent "${name}" to registry.`);
1031
+ }
1032
+
1033
+ // --- Remove command ----------------------------------------------------------
1034
+
1035
+ function runRemove(name) {
1036
+ if (!name) {
1037
+ console.error(" Usage: fathom-mcp remove <name>");
1038
+ process.exit(1);
1039
+ }
1040
+ const removed = registryRemoveAgent(name);
1041
+ if (removed) {
1042
+ console.log(` ✓ Removed agent "${name}" from registry.`);
1043
+ } else {
1044
+ console.error(` Error: No agent "${name}" in registry.`);
1045
+ process.exit(1);
1046
+ }
1047
+ }
1048
+
1049
+ // --- Config command ----------------------------------------------------------
1050
+
1051
+ function runConfigCmd(name) {
1052
+ if (!name) {
1053
+ console.error(" Usage: fathom-mcp config <name>");
1054
+ process.exit(1);
1055
+ }
1056
+ const entry = getAgent(name);
1057
+ if (!entry) {
1058
+ console.error(` Error: No agent "${name}" in registry.`);
1059
+ process.exit(1);
1060
+ }
1061
+ console.log(JSON.stringify({ [name]: entry }, null, 2));
1062
+ }
1063
+
885
1064
  // --- Main --------------------------------------------------------------------
886
1065
 
887
1066
  // Guard: only run CLI when this module is the entry point (not when imported by tests)
@@ -892,41 +1071,50 @@ const isMain = process.argv[1] && (
892
1071
  if (isMain) {
893
1072
  const command = process.argv[2];
894
1073
 
1074
+ const asyncHandler = (fn) => fn().catch((e) => {
1075
+ console.error(`Error: ${e.message}`);
1076
+ process.exit(1);
1077
+ });
1078
+
895
1079
  if (command === "init") {
896
- const flags = parseFlags(process.argv.slice(3));
897
- runInit(flags).catch((e) => {
898
- console.error(`Error: ${e.message}`);
899
- process.exit(1);
900
- });
1080
+ asyncHandler(() => runInit(parseFlags(process.argv.slice(3))));
901
1081
  } else if (command === "status") {
902
- runStatus().catch((e) => {
903
- console.error(`Error: ${e.message}`);
904
- process.exit(1);
905
- });
1082
+ asyncHandler(runStatus);
906
1083
  } else if (command === "update") {
907
- runUpdate().catch((e) => {
908
- console.error(`Error: ${e.message}`);
909
- process.exit(1);
910
- });
1084
+ asyncHandler(runUpdate);
911
1085
  } else if (command === "start") {
912
1086
  runStart(process.argv.slice(3));
1087
+ } else if (command === "list" || command === "ls") {
1088
+ runList();
1089
+ } else if (command === "stop") {
1090
+ runStop(process.argv[3]);
1091
+ } else if (command === "restart") {
1092
+ runRestart(process.argv[3]);
1093
+ } else if (command === "add") {
1094
+ asyncHandler(() => runAdd(process.argv.slice(3)));
1095
+ } else if (command === "remove" || command === "rm") {
1096
+ runRemove(process.argv[3]);
1097
+ } else if (command === "config") {
1098
+ runConfigCmd(process.argv[3]);
913
1099
  } else if (!command || command === "serve") {
914
- // Default: start MCP server
915
1100
  import("./index.js");
916
1101
  } else {
917
1102
  console.error(`Unknown command: ${command}`);
918
- console.error(`Usage: fathom-mcp [init|status|update|start|serve]
1103
+ console.error(`Usage: fathom-mcp [command]
919
1104
 
920
- fathom-mcp init Interactive setup
921
- fathom-mcp init -y --api-key KEY Non-interactive setup
922
- fathom-mcp init -y --api-key KEY --server URL Custom server URL
923
- fathom-mcp init -y --api-key KEY --workspace NAME Custom workspace name
1105
+ fathom-mcp Start MCP server (stdio)
1106
+ fathom-mcp serve Same as above
1107
+ fathom-mcp init [-y --api-key KEY] Interactive/non-interactive setup
924
1108
  fathom-mcp status Check connection status
925
1109
  fathom-mcp update Update hooks + version
926
- fathom-mcp start Start agent in tmux session
927
- fathom-mcp start --detach Start without attaching
928
- fathom-mcp start --kill Kill agent session
929
- fathom-mcp Start MCP server`);
1110
+
1111
+ fathom-mcp list List all agents + status
1112
+ fathom-mcp start [name] Start agent (by name or legacy cwd)
1113
+ fathom-mcp stop <name> Stop agent
1114
+ fathom-mcp restart <name> Stop + start agent
1115
+ fathom-mcp add [name] Add agent to registry
1116
+ fathom-mcp remove <name> Remove from registry
1117
+ fathom-mcp config <name> Print agent config JSON`);
930
1118
  process.exit(1);
931
1119
  }
932
1120
  }
package/src/config.js CHANGED
@@ -4,11 +4,13 @@
4
4
  * Precedence (highest wins):
5
5
  * 1. Environment variables (FATHOM_SERVER_URL, FATHOM_API_KEY, FATHOM_WORKSPACE, FATHOM_VAULT_DIR)
6
6
  * 2. .fathom.json (walked up from cwd to filesystem root)
7
- * 3. Built-in defaults
7
+ * 3. Central registry (~/.config/fathom/agents.json, matched by projectDir)
8
+ * 4. Built-in defaults
8
9
  */
9
10
 
10
11
  import fs from "fs";
11
12
  import path from "path";
13
+ import { findAgentByDir } from "./agents.js";
12
14
 
13
15
  const CONFIG_FILENAME = ".fathom.json";
14
16
 
@@ -55,31 +57,54 @@ export function findConfigFile(startDir = process.cwd()) {
55
57
  }
56
58
 
57
59
  /**
58
- * Resolve final config by merging: defaults .fathom.json env vars.
60
+ * Apply fields from a source config object onto a result object.
61
+ */
62
+ function applyConfig(result, config) {
63
+ if (config.workspace) result.workspace = config.workspace;
64
+ if (config.vault) result.vault = config.vault;
65
+ if (config.vaultMode && VALID_VAULT_MODES.has(config.vaultMode)) {
66
+ result.vaultMode = config.vaultMode;
67
+ }
68
+ if (config.server) result.server = config.server;
69
+ if (config.apiKey) result.apiKey = config.apiKey;
70
+ if (config.agents && Array.isArray(config.agents)) {
71
+ result.agents = config.agents;
72
+ }
73
+ if (config.hooks) {
74
+ result.hooks = { ...result.hooks, ...config.hooks };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Resolve final config by merging: defaults → registry → .fathom.json → env vars.
59
80
  */
60
81
  export function resolveConfig(startDir = process.cwd()) {
61
82
  const result = { ...DEFAULTS, hooks: { ...DEFAULTS.hooks } };
62
83
  let projectDir = startDir;
63
84
 
64
- // Layer 2: .fathom.json
85
+ // Layer 3: Central registry (matched by projectDir walk-up)
86
+ let registryMatch = null;
87
+ try {
88
+ registryMatch = findAgentByDir(startDir);
89
+ } catch {
90
+ // Registry not available — skip
91
+ }
92
+ if (registryMatch) {
93
+ const { name, entry } = registryMatch;
94
+ projectDir = entry.projectDir;
95
+ result.workspace = name;
96
+ applyConfig(result, entry);
97
+ if (entry.agentType) {
98
+ result.agents = [entry.agentType];
99
+ }
100
+ result._registryName = name;
101
+ }
102
+
103
+ // Layer 2: .fathom.json (overrides registry)
65
104
  const found = findConfigFile(startDir);
66
105
  if (found) {
67
106
  projectDir = found.dir;
68
- const { config } = found;
69
- if (config.workspace) result.workspace = config.workspace;
70
- if (config.vault) result.vault = config.vault;
71
- if (config.vaultMode && VALID_VAULT_MODES.has(config.vaultMode)) {
72
- result.vaultMode = config.vaultMode;
73
- }
74
- if (config.server) result.server = config.server;
75
- if (config.apiKey) result.apiKey = config.apiKey;
76
- if (config.description) result.description = config.description;
77
- if (config.agents && Array.isArray(config.agents)) {
78
- result.agents = config.agents;
79
- }
80
- if (config.hooks) {
81
- result.hooks = { ...result.hooks, ...config.hooks };
82
- }
107
+ applyConfig(result, found.config);
83
108
  }
84
109
 
85
110
  // Layer 1: Environment variables (highest priority)
@@ -124,7 +149,6 @@ export function writeConfig(dir, config) {
124
149
  vault: config.vault || "vault",
125
150
  server: config.server || DEFAULTS.server,
126
151
  apiKey: config.apiKey || "",
127
- description: config.description || "",
128
152
  agents: config.agents || [],
129
153
  hooks: config.hooks || DEFAULTS.hooks,
130
154
  };
package/src/index.js CHANGED
@@ -947,7 +947,6 @@ async function main() {
947
947
  if (config.server && config.workspace) {
948
948
  client.registerWorkspace(config.workspace, config._projectDir, {
949
949
  vault: config._rawVault,
950
- description: config.description,
951
950
  agents: config.agents,
952
951
  type: config.vaultMode,
953
952
  }).catch(() => {});