fathom-mcp 0.5.21 → 0.6.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/package.json +2 -1
- package/scripts/vault-frontmatter-lint.js +65 -0
- package/src/cli.js +45 -230
- package/src/config.js +4 -24
- package/src/frontmatter.js +77 -0
- package/src/index.js +54 -331
- package/src/server-client.js +6 -45
- package/src/ws-connection.js +4 -6
- package/src/agents.js +0 -196
- package/src/vault-ops.js +0 -386
package/src/agents.js
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Central agent registry — ~/.config/fathom-mcp/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
|
-
* All agents use stream-json transport — lifecycle delegated to fathom-server.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import fs from "fs";
|
|
11
|
-
import path from "path";
|
|
12
|
-
import { execSync } from "child_process";
|
|
13
|
-
|
|
14
|
-
const CONFIG_DIR = process.env.FATHOM_CONFIG_DIR || path.join(process.env.HOME || "/tmp", ".config", "fathom-mcp");
|
|
15
|
-
const AGENTS_FILE = path.join(CONFIG_DIR, "agents.json");
|
|
16
|
-
|
|
17
|
-
const EMPTY_CONFIG = { version: 1, agents: {} };
|
|
18
|
-
|
|
19
|
-
// ── Config I/O ──────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
export function loadAgentsConfig() {
|
|
22
|
-
try {
|
|
23
|
-
const raw = fs.readFileSync(AGENTS_FILE, "utf-8");
|
|
24
|
-
const parsed = JSON.parse(raw);
|
|
25
|
-
if (!parsed.agents || typeof parsed.agents !== "object") {
|
|
26
|
-
return { ...EMPTY_CONFIG };
|
|
27
|
-
}
|
|
28
|
-
return parsed;
|
|
29
|
-
} catch {
|
|
30
|
-
return { ...EMPTY_CONFIG };
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function saveAgentsConfig(config) {
|
|
35
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
36
|
-
fs.writeFileSync(AGENTS_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
export function getAgent(name) {
|
|
42
|
-
const config = loadAgentsConfig();
|
|
43
|
-
return config.agents[name] || null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function listAgents() {
|
|
47
|
-
const config = loadAgentsConfig();
|
|
48
|
-
return config.agents;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function addAgent(name, entry) {
|
|
52
|
-
const config = loadAgentsConfig();
|
|
53
|
-
config.agents[name] = entry;
|
|
54
|
-
saveAgentsConfig(config);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function removeAgent(name) {
|
|
58
|
-
const config = loadAgentsConfig();
|
|
59
|
-
if (!config.agents[name]) return false;
|
|
60
|
-
delete config.agents[name];
|
|
61
|
-
saveAgentsConfig(config);
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ── Status ──────────────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
export function isAgentRunning(name, entry) {
|
|
68
|
-
if (entry.ssh) return "ssh";
|
|
69
|
-
|
|
70
|
-
// All agents: check via server API
|
|
71
|
-
try {
|
|
72
|
-
const serverUrl = entry.server || "http://localhost:4243";
|
|
73
|
-
const apiKey = entry.apiKey || "";
|
|
74
|
-
const url = `${serverUrl}/api/activation/session?workspace=${encodeURIComponent(name)}`;
|
|
75
|
-
const resp = execSync(`curl -sf -H "Authorization: Bearer ${apiKey}" "${url}"`, {
|
|
76
|
-
encoding: "utf-8",
|
|
77
|
-
timeout: 5000,
|
|
78
|
-
});
|
|
79
|
-
const data = JSON.parse(resp);
|
|
80
|
-
return data.running ? "running" : "stopped";
|
|
81
|
-
} catch {
|
|
82
|
-
return "stopped";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── Start / Stop ────────────────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
export function startAgent(name, entry) {
|
|
89
|
-
// All agents: delegate lifecycle to fathom-server
|
|
90
|
-
const serverUrl = entry.server || "http://localhost:4243";
|
|
91
|
-
const apiKey = entry.apiKey || "";
|
|
92
|
-
try {
|
|
93
|
-
const url = `${serverUrl}/api/activation/session/start?workspace=${encodeURIComponent(name)}`;
|
|
94
|
-
const resp = execSync(`curl -sf -X POST -H "Authorization: Bearer ${apiKey}" "${url}"`, {
|
|
95
|
-
encoding: "utf-8",
|
|
96
|
-
timeout: 30000,
|
|
97
|
-
});
|
|
98
|
-
return { ok: true, message: `Delegated to server subprocess manager: ${resp.trim()}` };
|
|
99
|
-
} catch (e) {
|
|
100
|
-
return { ok: false, message: `Server delegation failed: ${e.message}` };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function stopAgent(name, entry) {
|
|
105
|
-
if (entry.ssh) {
|
|
106
|
-
return { ok: false, message: "Cannot stop SSH agents remotely — connect to the host directly." };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// All agents: delegate to server
|
|
110
|
-
try {
|
|
111
|
-
const serverUrl = entry.server || "http://localhost:4243";
|
|
112
|
-
const apiKey = entry.apiKey || "";
|
|
113
|
-
const url = `${serverUrl}/api/activation/session/stop?workspace=${encodeURIComponent(name)}`;
|
|
114
|
-
const resp = execSync(`curl -sf -X POST -H "Authorization: Bearer ${apiKey}" "${url}"`, {
|
|
115
|
-
encoding: "utf-8",
|
|
116
|
-
timeout: 15000,
|
|
117
|
-
});
|
|
118
|
-
return { ok: true, message: `Server stopped subprocess: ${resp.trim()}` };
|
|
119
|
-
} catch (e) {
|
|
120
|
-
return { ok: false, message: `Server stop failed: ${e.message}` };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Default command per agent type (vestigial — used by CLI `add` flow).
|
|
126
|
-
* Stream-json agents don't use this at runtime; the server manages lifecycle.
|
|
127
|
-
*/
|
|
128
|
-
export function defaultCommand(agentType) {
|
|
129
|
-
const cmds = {
|
|
130
|
-
"claude-code": "claude",
|
|
131
|
-
codex: "codex",
|
|
132
|
-
gemini: "gemini",
|
|
133
|
-
opencode: "opencode",
|
|
134
|
-
};
|
|
135
|
-
return cmds[agentType] || cmds["claude-code"];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Build a default agent entry from a .fathom.json config in a directory.
|
|
142
|
-
*/
|
|
143
|
-
export function buildEntryFromConfig(projectDir, fathomConfig) {
|
|
144
|
-
const agentType = fathomConfig.agents?.[0] || "claude-code";
|
|
145
|
-
return {
|
|
146
|
-
projectDir,
|
|
147
|
-
agentType,
|
|
148
|
-
command: "",
|
|
149
|
-
server: fathomConfig.server || "http://localhost:4243",
|
|
150
|
-
apiKey: fathomConfig.apiKey || "",
|
|
151
|
-
vault: fathomConfig.vault || "vault",
|
|
152
|
-
vaultMode: fathomConfig.vaultMode || "local",
|
|
153
|
-
description: fathomConfig.description || "",
|
|
154
|
-
hooks: fathomConfig.hooks || {},
|
|
155
|
-
ssh: null,
|
|
156
|
-
env: {},
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Look up an agent entry by workspace name from the central registry.
|
|
162
|
-
* Returns null if no match found.
|
|
163
|
-
*/
|
|
164
|
-
export function findAgentByWorkspace(workspace) {
|
|
165
|
-
const config = loadAgentsConfig();
|
|
166
|
-
// Direct name match first
|
|
167
|
-
if (config.agents[workspace]) {
|
|
168
|
-
return { name: workspace, entry: config.agents[workspace] };
|
|
169
|
-
}
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Look up an agent entry by projectDir from the central registry.
|
|
175
|
-
* Walks up from startDir matching against registered projectDirs.
|
|
176
|
-
* Returns { name, entry } or null.
|
|
177
|
-
*/
|
|
178
|
-
export function findAgentByDir(startDir) {
|
|
179
|
-
const config = loadAgentsConfig();
|
|
180
|
-
let dir = path.resolve(startDir);
|
|
181
|
-
const root = path.parse(dir).root;
|
|
182
|
-
|
|
183
|
-
while (true) {
|
|
184
|
-
for (const [name, entry] of Object.entries(config.agents)) {
|
|
185
|
-
if (entry.projectDir === dir) {
|
|
186
|
-
return { name, entry };
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
const parent = path.dirname(dir);
|
|
190
|
-
if (parent === dir || dir === root) break;
|
|
191
|
-
dir = parent;
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export { AGENTS_FILE, CONFIG_DIR };
|
package/src/vault-ops.js
DELETED
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Direct file I/O vault operations — read, write, append, frontmatter, images.
|
|
3
|
-
*
|
|
4
|
-
* These run locally (no network hop) for speed. The server is only notified
|
|
5
|
-
* after writes for access tracking / indexing.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import fs from "fs";
|
|
9
|
-
import path from "path";
|
|
10
|
-
|
|
11
|
-
// --- Constants ---------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const VALID_STATUSES = new Set(["draft", "published", "archived"]);
|
|
14
|
-
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]);
|
|
15
|
-
const IMAGE_MIME_TYPES = {
|
|
16
|
-
".jpg": "image/jpeg",
|
|
17
|
-
".jpeg": "image/jpeg",
|
|
18
|
-
".png": "image/png",
|
|
19
|
-
".gif": "image/gif",
|
|
20
|
-
".webp": "image/webp",
|
|
21
|
-
".svg": "image/svg+xml",
|
|
22
|
-
};
|
|
23
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
|
|
24
|
-
const VAULT_SCHEMA = {
|
|
25
|
-
title: { required: true, type: "string" },
|
|
26
|
-
date: { required: true, type: "string" },
|
|
27
|
-
tags: { required: false, type: "array" },
|
|
28
|
-
status: { required: false, type: "string" },
|
|
29
|
-
project: { required: false, type: "string" },
|
|
30
|
-
aliases: { required: false, type: "array" },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// --- Path safety -------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve and validate a relative path within a vault directory.
|
|
37
|
-
* Returns { abs, vaultPath } on success, { error } on failure.
|
|
38
|
-
*/
|
|
39
|
-
export function safePath(relPath, vaultPath) {
|
|
40
|
-
if (!vaultPath || typeof vaultPath !== "string") {
|
|
41
|
-
return { error: "Vault path not configured" };
|
|
42
|
-
}
|
|
43
|
-
const abs = path.resolve(vaultPath, relPath);
|
|
44
|
-
if (abs !== vaultPath && !abs.startsWith(vaultPath + path.sep)) {
|
|
45
|
-
return { error: "Path traversal detected" };
|
|
46
|
-
}
|
|
47
|
-
return { abs, vaultPath };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// --- Frontmatter -------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
export function parseFrontmatter(content) {
|
|
53
|
-
if (!content.startsWith("---")) return { fm: {}, body: content };
|
|
54
|
-
const lines = content.split("\n");
|
|
55
|
-
let endIdx = null;
|
|
56
|
-
for (let i = 1; i < lines.length; i++) {
|
|
57
|
-
if (lines[i].trim() === "---") { endIdx = i; break; }
|
|
58
|
-
}
|
|
59
|
-
if (endIdx === null) return { fm: {}, body: content };
|
|
60
|
-
try {
|
|
61
|
-
const fmLines = lines.slice(1, endIdx);
|
|
62
|
-
const fm = {};
|
|
63
|
-
let currentKey = null;
|
|
64
|
-
for (const line of fmLines) {
|
|
65
|
-
const listMatch = line.match(/^[ ]{2}- (.+)$/);
|
|
66
|
-
const kvMatch = line.match(/^(\w+):\s*(.*)$/);
|
|
67
|
-
if (listMatch && currentKey) {
|
|
68
|
-
fm[currentKey].push(listMatch[1].trim());
|
|
69
|
-
} else if (kvMatch) {
|
|
70
|
-
currentKey = kvMatch[1];
|
|
71
|
-
const val = kvMatch[2].trim();
|
|
72
|
-
if (val === "") {
|
|
73
|
-
fm[currentKey] = [];
|
|
74
|
-
} else {
|
|
75
|
-
fm[currentKey] = val;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const body = lines.slice(endIdx + 1).join("\n");
|
|
80
|
-
return { fm, body };
|
|
81
|
-
} catch {
|
|
82
|
-
return { fm: {}, body: content };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function validateFrontmatter(fm) {
|
|
87
|
-
const errors = [];
|
|
88
|
-
for (const [field, spec] of Object.entries(VAULT_SCHEMA)) {
|
|
89
|
-
const val = fm[field];
|
|
90
|
-
if (spec.required && val == null) {
|
|
91
|
-
errors.push(`Missing required field: '${field}'`);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (val != null) {
|
|
95
|
-
const actualType = Array.isArray(val) ? "array" : typeof val;
|
|
96
|
-
if (actualType !== spec.type) {
|
|
97
|
-
errors.push(`Field '${field}' must be ${spec.type}, got ${actualType}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
const status = fm["status"];
|
|
102
|
-
if (status != null && !VALID_STATUSES.has(status)) {
|
|
103
|
-
errors.push(`Field 'status' must be one of [${[...VALID_STATUSES].join(", ")}], got '${status}'`);
|
|
104
|
-
}
|
|
105
|
-
return errors;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// --- File handlers -----------------------------------------------------------
|
|
109
|
-
|
|
110
|
-
export function handleVaultWrite({ path: relPath, content }, vaultPath) {
|
|
111
|
-
if (!relPath) return { error: "path is required" };
|
|
112
|
-
if (typeof content !== "string") return { error: "content must be a string" };
|
|
113
|
-
|
|
114
|
-
const { abs, error } = safePath(relPath, vaultPath);
|
|
115
|
-
if (error) return { error };
|
|
116
|
-
|
|
117
|
-
if (content.startsWith("---")) {
|
|
118
|
-
const { fm } = parseFrontmatter(content);
|
|
119
|
-
if (Object.keys(fm).length > 0) {
|
|
120
|
-
const errors = validateFrontmatter(fm);
|
|
121
|
-
if (errors.length > 0) {
|
|
122
|
-
return { error: "Frontmatter validation failed", validation_errors: errors };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
129
|
-
fs.writeFileSync(abs, content, "utf-8");
|
|
130
|
-
return { ok: true, path: relPath };
|
|
131
|
-
} catch (e) {
|
|
132
|
-
return { error: e.message };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function handleVaultAppend({ path: relPath, content }, vaultPath) {
|
|
137
|
-
if (!relPath) return { error: "path is required" };
|
|
138
|
-
if (typeof content !== "string") return { error: "content must be a string" };
|
|
139
|
-
|
|
140
|
-
const { abs, error } = safePath(relPath, vaultPath);
|
|
141
|
-
if (error) return { error };
|
|
142
|
-
|
|
143
|
-
const created = !fs.existsSync(abs);
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
147
|
-
if (created) {
|
|
148
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
149
|
-
const title = path.basename(relPath, path.extname(relPath))
|
|
150
|
-
.replace(/-/g, " ")
|
|
151
|
-
.replace(/\b\w/g, c => c.toUpperCase());
|
|
152
|
-
const initial = `---\ntitle: ${title}\ndate: ${today}\n---\n\n${content}\n`;
|
|
153
|
-
fs.writeFileSync(abs, initial, "utf-8");
|
|
154
|
-
} else {
|
|
155
|
-
fs.appendFileSync(abs, "\n" + content + "\n", "utf-8");
|
|
156
|
-
}
|
|
157
|
-
return { ok: true, path: relPath, created };
|
|
158
|
-
} catch (e) {
|
|
159
|
-
return { error: e.message };
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function handleVaultRead({ path: relPath }, vaultPath) {
|
|
164
|
-
if (!relPath) return { error: "path is required" };
|
|
165
|
-
|
|
166
|
-
const { abs, error } = safePath(relPath, vaultPath);
|
|
167
|
-
if (error) return { error };
|
|
168
|
-
|
|
169
|
-
if (!fs.existsSync(abs)) return { error: "File not found" };
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
const content = fs.readFileSync(abs, "utf-8");
|
|
173
|
-
const stat = fs.statSync(abs);
|
|
174
|
-
const { fm, body } = parseFrontmatter(content);
|
|
175
|
-
return {
|
|
176
|
-
path: relPath,
|
|
177
|
-
content,
|
|
178
|
-
frontmatter: fm,
|
|
179
|
-
body,
|
|
180
|
-
modified: stat.mtime.toISOString(),
|
|
181
|
-
size: stat.size,
|
|
182
|
-
};
|
|
183
|
-
} catch (e) {
|
|
184
|
-
return { error: e.message };
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// --- List/folder handlers ----------------------------------------------------
|
|
189
|
-
|
|
190
|
-
export function handleVaultList(vaultPath) {
|
|
191
|
-
if (!vaultPath) return { error: "Vault path not configured" };
|
|
192
|
-
|
|
193
|
-
const allFolders = [];
|
|
194
|
-
|
|
195
|
-
function scanDir(dirAbs, relPath) {
|
|
196
|
-
let entries;
|
|
197
|
-
try {
|
|
198
|
-
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
199
|
-
} catch {
|
|
200
|
-
return { totalMdCount: 0, maxMtime: null, maxMtimeFile: null };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const childDirNames = entries
|
|
204
|
-
.filter(e => e.isDirectory() && !e.name.startsWith("."))
|
|
205
|
-
.map(e => e.name);
|
|
206
|
-
const mdFiles = entries
|
|
207
|
-
.filter(e => e.isFile() && e.name.endsWith(".md"))
|
|
208
|
-
.map(e => e.name);
|
|
209
|
-
|
|
210
|
-
let maxMtime = null;
|
|
211
|
-
let maxMtimeFile = null;
|
|
212
|
-
let totalMdCount = mdFiles.length;
|
|
213
|
-
|
|
214
|
-
for (const fname of mdFiles) {
|
|
215
|
-
try {
|
|
216
|
-
const mtime = fs.statSync(path.join(dirAbs, fname)).mtime;
|
|
217
|
-
if (!maxMtime || mtime > maxMtime) {
|
|
218
|
-
maxMtime = mtime;
|
|
219
|
-
maxMtimeFile = fname;
|
|
220
|
-
}
|
|
221
|
-
} catch { /* skip */ }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (const childName of childDirNames) {
|
|
225
|
-
const childAbs = path.join(dirAbs, childName);
|
|
226
|
-
const childRel = relPath ? `${relPath}/${childName}` : childName;
|
|
227
|
-
const child = scanDir(childAbs, childRel);
|
|
228
|
-
totalMdCount += child.totalMdCount;
|
|
229
|
-
if (child.maxMtime && (!maxMtime || child.maxMtime > maxMtime)) {
|
|
230
|
-
maxMtime = child.maxMtime;
|
|
231
|
-
maxMtimeFile = child.maxMtimeFile;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (relPath) {
|
|
236
|
-
allFolders.push({
|
|
237
|
-
name: path.basename(dirAbs),
|
|
238
|
-
path: relPath,
|
|
239
|
-
file_count: totalMdCount,
|
|
240
|
-
last_modified: maxMtime ? maxMtime.toISOString() : null,
|
|
241
|
-
last_modified_file: maxMtimeFile,
|
|
242
|
-
children: childDirNames,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return { totalMdCount, maxMtime, maxMtimeFile };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
scanDir(vaultPath, "");
|
|
251
|
-
allFolders.sort((a, b) => {
|
|
252
|
-
if (!a.last_modified && !b.last_modified) return 0;
|
|
253
|
-
if (!a.last_modified) return 1;
|
|
254
|
-
if (!b.last_modified) return -1;
|
|
255
|
-
return b.last_modified.localeCompare(a.last_modified);
|
|
256
|
-
});
|
|
257
|
-
return allFolders;
|
|
258
|
-
} catch (e) {
|
|
259
|
-
return { error: e.message };
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function handleVaultFolder({ folder = "", limit = 50, sort = "modified", recursive = false, tag } = {}, vaultPath) {
|
|
264
|
-
if (!vaultPath) return { error: "Vault path not configured" };
|
|
265
|
-
|
|
266
|
-
const targetAbs = folder ? path.resolve(vaultPath, folder) : vaultPath;
|
|
267
|
-
if (targetAbs !== vaultPath && !targetAbs.startsWith(vaultPath + path.sep)) {
|
|
268
|
-
return { error: "Path traversal detected" };
|
|
269
|
-
}
|
|
270
|
-
if (!fs.existsSync(targetAbs)) return { error: `Folder not found: ${folder || "(root)"}` };
|
|
271
|
-
if (!fs.statSync(targetAbs).isDirectory()) return { error: `Not a directory: ${folder}` };
|
|
272
|
-
|
|
273
|
-
const items = [];
|
|
274
|
-
const tagLower = tag ? tag.toLowerCase() : null;
|
|
275
|
-
|
|
276
|
-
function collect(dirAbs) {
|
|
277
|
-
let entries;
|
|
278
|
-
try {
|
|
279
|
-
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
280
|
-
} catch {
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
for (const e of entries) {
|
|
284
|
-
const absPath = path.join(dirAbs, e.name);
|
|
285
|
-
if (e.isDirectory() && recursive && !e.name.startsWith(".")) {
|
|
286
|
-
collect(absPath);
|
|
287
|
-
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
288
|
-
try {
|
|
289
|
-
const stat = fs.statSync(absPath);
|
|
290
|
-
const content = fs.readFileSync(absPath, "utf-8");
|
|
291
|
-
const { fm, body } = parseFrontmatter(content);
|
|
292
|
-
|
|
293
|
-
if (tagLower) {
|
|
294
|
-
const fmTags = Array.isArray(fm.tags) ? fm.tags : [];
|
|
295
|
-
if (!fmTags.some(t => String(t).toLowerCase() === tagLower)) continue;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
items.push({
|
|
299
|
-
path: path.relative(vaultPath, absPath),
|
|
300
|
-
title: fm.title || null,
|
|
301
|
-
date: fm.date || null,
|
|
302
|
-
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
303
|
-
status: fm.status || null,
|
|
304
|
-
project: fm.project || null,
|
|
305
|
-
preview: body.trim().slice(0, 200),
|
|
306
|
-
modified: stat.mtime.toISOString(),
|
|
307
|
-
size_bytes: stat.size,
|
|
308
|
-
});
|
|
309
|
-
} catch { /* skip */ }
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
collect(targetAbs);
|
|
316
|
-
if (sort === "name") {
|
|
317
|
-
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
318
|
-
} else {
|
|
319
|
-
items.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
320
|
-
}
|
|
321
|
-
return { folder: folder || "", total: items.length, files: items.slice(0, limit) };
|
|
322
|
-
} catch (e) {
|
|
323
|
-
return { error: e.message };
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// --- Image handlers ----------------------------------------------------------
|
|
328
|
-
|
|
329
|
-
export function handleVaultImage({ path: relPath }, vaultPath) {
|
|
330
|
-
if (!relPath) return { error: "path is required" };
|
|
331
|
-
|
|
332
|
-
const { abs, error } = safePath(relPath, vaultPath);
|
|
333
|
-
if (error) return { error };
|
|
334
|
-
|
|
335
|
-
const ext = path.extname(abs).toLowerCase();
|
|
336
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
337
|
-
return {
|
|
338
|
-
error: `Not an allowed image extension: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}`,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (!fs.existsSync(abs)) return { error: `File not found: ${relPath}` };
|
|
343
|
-
|
|
344
|
-
const stat = fs.statSync(abs);
|
|
345
|
-
if (stat.size > MAX_IMAGE_BYTES) {
|
|
346
|
-
return { error: `Image too large (${stat.size} bytes, max 5MB)` };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const mimeType = IMAGE_MIME_TYPES[ext];
|
|
350
|
-
const data = fs.readFileSync(abs).toString("base64");
|
|
351
|
-
return { _image: true, data, mimeType };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export function handleVaultWriteAsset({ folder, filename, data }, vaultPath) {
|
|
355
|
-
if (typeof folder !== "string") return { error: "folder is required (use empty string for root)" };
|
|
356
|
-
if (!filename) return { error: "filename is required" };
|
|
357
|
-
if (!data) return { error: "data is required" };
|
|
358
|
-
|
|
359
|
-
const ext = path.extname(filename).toLowerCase();
|
|
360
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
361
|
-
return {
|
|
362
|
-
error: `Not an allowed image extension: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}`,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const relPath = folder
|
|
367
|
-
? path.join(folder, "assets", filename)
|
|
368
|
-
: path.join("assets", filename);
|
|
369
|
-
|
|
370
|
-
const { abs, error } = safePath(relPath, vaultPath);
|
|
371
|
-
if (error) return { error };
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
375
|
-
const buffer = Buffer.from(data, "base64");
|
|
376
|
-
fs.writeFileSync(abs, buffer);
|
|
377
|
-
return {
|
|
378
|
-
saved: true,
|
|
379
|
-
path: relPath,
|
|
380
|
-
markdown: ``,
|
|
381
|
-
fullPath: abs,
|
|
382
|
-
};
|
|
383
|
-
} catch (e) {
|
|
384
|
-
return { error: e.message };
|
|
385
|
-
}
|
|
386
|
-
}
|