@lifeaitools/clauth 1.5.83 → 1.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.
Files changed (2) hide show
  1. package/cli/commands/serve.js +858 -643
  2. package/package.json +1 -1
@@ -17,7 +17,7 @@ import ora from "ora";
17
17
  import { execSync as execSyncTop } from "child_process";
18
18
  import Conf from "conf";
19
19
  import { getConfOptions } from "../conf-path.js";
20
- import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename } from "node:fs/promises";
20
+ import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
21
21
  import fg from "fast-glob";
22
22
  import { rgPath } from "@vscode/ripgrep";
23
23
  import { createStudioDebugRuntime } from "../studio-debug.js";
@@ -609,6 +609,11 @@ function dashboardHtml(port, whitelist, isStaged = false) {
609
609
  .project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
610
610
  .project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
611
611
  .project-tab.active .tab-count{color:#3b82f6}
612
+ .service-search{display:flex;align-items:center;gap:10px;margin:-.35rem 0 1rem;background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:9px 12px}
613
+ .service-search-label{font-size:.76rem;color:#64748b;font-weight:600;letter-spacing:.02em;text-transform:uppercase;white-space:nowrap}
614
+ .service-search-input{flex:1;min-width:180px;background:#0a0f1a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:8px 11px;outline:none;transition:border-color .15s}
615
+ .service-search-input:focus{border-color:#3b82f6}
616
+ .service-search-count{font-size:.78rem;color:#64748b;white-space:nowrap}
612
617
  .project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
613
618
  .project-edit.open{display:flex;gap:6px;align-items:center}
614
619
  .project-edit input{background:#1e293b;border:1px solid #334155;border-radius:4px;color:#e2e8f0;font-size:.78rem;padding:4px 8px;outline:none;flex:1;font-family:'Courier New',monospace;transition:border-color .15s}
@@ -864,6 +869,11 @@ function dashboardHtml(port, whitelist, isStaged = false) {
864
869
  </div>
865
870
 
866
871
  <div id="project-tabs" class="project-tabs" style="display:none"></div>
872
+ <div id="service-search" class="service-search">
873
+ <span class="service-search-label">Search</span>
874
+ <input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false" oninput="setServiceSearch(this.value)">
875
+ <span id="service-search-count" class="service-search-count"></span>
876
+ </div>
867
877
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
868
878
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
869
879
  </div>
@@ -1160,6 +1170,23 @@ async function stopDaemon() {
1160
1170
  // ── Load services ───────────────────────────
1161
1171
  let allServices = [];
1162
1172
  let activeProjectTab = "all";
1173
+ let serviceSearchQuery = "";
1174
+
1175
+ function serviceSort(a, b) {
1176
+ return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
1177
+ }
1178
+
1179
+ function matchesServiceSearch(s, query) {
1180
+ if (!query) return true;
1181
+ const q = query.toLowerCase();
1182
+ return String(s.name || "").toLowerCase().includes(q) ||
1183
+ String(s.label || "").toLowerCase().includes(q);
1184
+ }
1185
+
1186
+ function setServiceSearch(value) {
1187
+ serviceSearchQuery = value || "";
1188
+ renderServiceGrid(allServices);
1189
+ }
1163
1190
 
1164
1191
  function renderProjectTabs(services) {
1165
1192
  const tabsEl = document.getElementById("project-tabs");
@@ -1199,7 +1226,21 @@ function renderServiceGrid(services) {
1199
1226
  } else if (activeProjectTab !== "all") {
1200
1227
  filtered = services.filter(s => s.project === activeProjectTab);
1201
1228
  }
1202
- if (!filtered.length) { grid.innerHTML = '<p class="loading">No services in this group.</p>'; return; }
1229
+ filtered = filtered
1230
+ .filter(s => matchesServiceSearch(s, serviceSearchQuery))
1231
+ .slice()
1232
+ .sort(serviceSort);
1233
+ const searchCount = document.getElementById("service-search-count");
1234
+ if (searchCount) {
1235
+ const trimmed = serviceSearchQuery.trim();
1236
+ searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
1237
+ }
1238
+ if (!filtered.length) {
1239
+ grid.innerHTML = serviceSearchQuery.trim()
1240
+ ? '<p class="loading">No services match that search.</p>'
1241
+ : '<p class="loading">No services in this group.</p>';
1242
+ return;
1243
+ }
1203
1244
  grid.innerHTML = filtered.map(s => \`
1204
1245
  <div class="card">
1205
1246
  <div style="display:flex;align-items:flex-start;justify-content:space-between">
@@ -5076,8 +5117,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5076
5117
  }
5077
5118
 
5078
5119
  // POST /update-service — update service metadata (project, label, description)
5079
- if (method === "POST" && reqPath === "/update-service") {
5080
- if (lockedGuard(res)) return;
5120
+ if (method === "POST" && reqPath === "/update-service") {
5121
+ if (lockedGuard(res)) return;
5081
5122
 
5082
5123
  let body;
5083
5124
  try { body = await readBody(req); } catch {
@@ -5108,39 +5149,39 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5108
5149
  return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
5109
5150
  } catch (err) {
5110
5151
  return strike(res, 502, err.message);
5111
- }
5112
- }
5113
-
5114
- if (method === "GET" && reqPath === "/search") {
5115
- if (lockedGuard(res)) return;
5116
- const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
5117
- const project = (url.searchParams.get("project") || "").trim() || undefined;
5118
- const includeAddresses = url.searchParams.get("addresses") !== "false";
5119
- if (!query) {
5120
- res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5121
- return res.end(JSON.stringify({ error: "q query parameter is required" }));
5122
- }
5123
-
5124
- try {
5125
- const { token, timestamp } = deriveToken(password, machineHash);
5126
- const result = await searchServices({
5127
- password,
5128
- machineHash,
5129
- token,
5130
- timestamp,
5131
- project,
5132
- query,
5133
- includeAddresses,
5134
- whitelist
5135
- });
5136
- if (result.error) return strike(res, 502, result.error);
5137
- return ok(res, result);
5138
- } catch (err) {
5139
- return strike(res, 502, err.message);
5140
- }
5141
- }
5142
-
5143
- // Unknown route — a wrong URL is not an auth failure. Log it, return 404,
5152
+ }
5153
+ }
5154
+
5155
+ if (method === "GET" && reqPath === "/search") {
5156
+ if (lockedGuard(res)) return;
5157
+ const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
5158
+ const project = (url.searchParams.get("project") || "").trim() || undefined;
5159
+ const includeAddresses = url.searchParams.get("addresses") !== "false";
5160
+ if (!query) {
5161
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
5162
+ return res.end(JSON.stringify({ error: "q query parameter is required" }));
5163
+ }
5164
+
5165
+ try {
5166
+ const { token, timestamp } = deriveToken(password, machineHash);
5167
+ const result = await searchServices({
5168
+ password,
5169
+ machineHash,
5170
+ token,
5171
+ timestamp,
5172
+ project,
5173
+ query,
5174
+ includeAddresses,
5175
+ whitelist
5176
+ });
5177
+ if (result.error) return strike(res, 502, result.error);
5178
+ return ok(res, result);
5179
+ } catch (err) {
5180
+ return strike(res, 502, err.message);
5181
+ }
5182
+ }
5183
+
5184
+ // Unknown route — a wrong URL is not an auth failure. Log it, return 404,
5144
5185
  // but do NOT increment failCount (which locks the vault at MAX_FAILS).
5145
5186
  // Auth failures (wrong password, wrong token) still strike via /auth and /get/:service.
5146
5187
  try {
@@ -5511,8 +5552,8 @@ async function actionForeground(opts) {
5511
5552
  // Reuses the same auth model as the HTTP daemon.
5512
5553
  // Secrets are delivered via temp files — never in the MCP response.
5513
5554
 
5514
- import { createInterface } from "readline";
5515
- import { execSync, spawn as spawnProc, spawnSync } from "child_process";
5555
+ import { createInterface } from "readline";
5556
+ import { execSync, spawn as spawnProc, spawnSync } from "child_process";
5516
5557
 
5517
5558
  // ── Monkey dispatch — headless Claude CLI worker ─────────────────
5518
5559
  function findClaudeBinary() {
@@ -6115,9 +6156,9 @@ function stopCodevelopSession(session_id) {
6115
6156
  return { stopped: true, session_id };
6116
6157
  }
6117
6158
 
6118
- const ENV_MAP = {
6119
- "github": "GITHUB_TOKEN",
6120
- "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
6159
+ const ENV_MAP = {
6160
+ "github": "GITHUB_TOKEN",
6161
+ "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
6121
6162
  "supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
6122
6163
  "supabase-db": "SUPABASE_DB_URL",
6123
6164
  "vercel": "VERCEL_TOKEN",
@@ -6129,116 +6170,116 @@ const ENV_MAP = {
6129
6170
  "rocketreach": "ROCKETREACH_API_KEY",
6130
6171
  "npm": "NPM_TOKEN",
6131
6172
  "namecheap": "NAMECHEAP_API_KEY",
6132
- "gmail": "GMAIL_CREDENTIALS",
6133
- };
6134
-
6135
- const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
6136
- const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
6137
-
6138
- function normalizeSearchText(value) {
6139
- return String(value || "").toLowerCase();
6140
- }
6141
-
6142
- function redactUrlish(value) {
6143
- const text = String(value || "").trim();
6144
- if (!text) return "";
6145
- try {
6146
- const url = new URL(text);
6147
- if (url.username) url.username = "***";
6148
- if (url.password) url.password = "***";
6149
- return url.toString();
6150
- } catch {
6151
- return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
6152
- }
6153
- }
6154
-
6155
- function collectAddressHints(value, keyType) {
6156
- if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
6157
- const hints = new Set();
6158
-
6159
- function add(candidate) {
6160
- if (candidate === undefined || candidate === null) return;
6161
- const text = redactUrlish(candidate);
6162
- if (text) hints.add(text);
6163
- }
6164
-
6165
- function walk(node, fieldName = "") {
6166
- if (node === undefined || node === null) return;
6167
- if (typeof node === "string") {
6168
- if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
6169
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
6170
- return;
6171
- }
6172
- if (Array.isArray(node)) {
6173
- for (const item of node) walk(item, fieldName);
6174
- return;
6175
- }
6176
- if (typeof node === "object") {
6177
- for (const [key, child] of Object.entries(node)) walk(child, key);
6178
- }
6179
- }
6180
-
6181
- try {
6182
- walk(JSON.parse(value));
6183
- } catch {
6184
- walk(value);
6185
- }
6186
-
6187
- return [...hints];
6188
- }
6189
-
6190
- async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
6191
- const q = normalizeSearchText(query);
6192
- if (!q) return { error: "query is required" };
6193
-
6194
- const result = await api.status(password, machineHash, token, timestamp, project);
6195
- if (result.error) return { error: result.error };
6196
-
6197
- let services = result.services || [];
6198
- if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
6199
-
6200
- const matches = [];
6201
- for (const s of services) {
6202
- const fields = {
6203
- name: s.name,
6204
- label: s.label,
6205
- project: s.project,
6206
- type: s.key_type,
6207
- description: s.description
6208
- };
6209
- const matched = Object.entries(fields)
6210
- .filter(([, value]) => normalizeSearchText(value).includes(q))
6211
- .map(([field]) => field);
6212
-
6213
- let addressHints = [];
6214
- if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
6215
- const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
6216
- if (!secret.error) {
6217
- addressHints = collectAddressHints(secret.value, s.key_type);
6218
- if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
6219
- }
6220
- }
6221
-
6222
- if (matched.length) {
6223
- matches.push({
6224
- name: s.name,
6225
- label: s.label || null,
6226
- project: s.project || null,
6227
- key_type: s.key_type,
6228
- enabled: !!s.enabled,
6229
- has_key: !!s.vault_key,
6230
- description: s.description || null,
6231
- matched: [...new Set(matched)],
6232
- address_hints: addressHints
6233
- });
6234
- }
6235
- }
6236
-
6237
- return { query, count: matches.length, matches };
6238
- }
6239
-
6240
- // ── Filesystem service config — loaded from clauth vault ──
6241
- let _fsMountsCache = null;
6173
+ "gmail": "GMAIL_CREDENTIALS",
6174
+ };
6175
+
6176
+ const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
6177
+ const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
6178
+
6179
+ function normalizeSearchText(value) {
6180
+ return String(value || "").toLowerCase();
6181
+ }
6182
+
6183
+ function redactUrlish(value) {
6184
+ const text = String(value || "").trim();
6185
+ if (!text) return "";
6186
+ try {
6187
+ const url = new URL(text);
6188
+ if (url.username) url.username = "***";
6189
+ if (url.password) url.password = "***";
6190
+ return url.toString();
6191
+ } catch {
6192
+ return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
6193
+ }
6194
+ }
6195
+
6196
+ function collectAddressHints(value, keyType) {
6197
+ if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
6198
+ const hints = new Set();
6199
+
6200
+ function add(candidate) {
6201
+ if (candidate === undefined || candidate === null) return;
6202
+ const text = redactUrlish(candidate);
6203
+ if (text) hints.add(text);
6204
+ }
6205
+
6206
+ function walk(node, fieldName = "") {
6207
+ if (node === undefined || node === null) return;
6208
+ if (typeof node === "string") {
6209
+ if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
6210
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
6211
+ return;
6212
+ }
6213
+ if (Array.isArray(node)) {
6214
+ for (const item of node) walk(item, fieldName);
6215
+ return;
6216
+ }
6217
+ if (typeof node === "object") {
6218
+ for (const [key, child] of Object.entries(node)) walk(child, key);
6219
+ }
6220
+ }
6221
+
6222
+ try {
6223
+ walk(JSON.parse(value));
6224
+ } catch {
6225
+ walk(value);
6226
+ }
6227
+
6228
+ return [...hints];
6229
+ }
6230
+
6231
+ async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
6232
+ const q = normalizeSearchText(query);
6233
+ if (!q) return { error: "query is required" };
6234
+
6235
+ const result = await api.status(password, machineHash, token, timestamp, project);
6236
+ if (result.error) return { error: result.error };
6237
+
6238
+ let services = result.services || [];
6239
+ if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
6240
+
6241
+ const matches = [];
6242
+ for (const s of services) {
6243
+ const fields = {
6244
+ name: s.name,
6245
+ label: s.label,
6246
+ project: s.project,
6247
+ type: s.key_type,
6248
+ description: s.description
6249
+ };
6250
+ const matched = Object.entries(fields)
6251
+ .filter(([, value]) => normalizeSearchText(value).includes(q))
6252
+ .map(([field]) => field);
6253
+
6254
+ let addressHints = [];
6255
+ if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
6256
+ const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
6257
+ if (!secret.error) {
6258
+ addressHints = collectAddressHints(secret.value, s.key_type);
6259
+ if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
6260
+ }
6261
+ }
6262
+
6263
+ if (matched.length) {
6264
+ matches.push({
6265
+ name: s.name,
6266
+ label: s.label || null,
6267
+ project: s.project || null,
6268
+ key_type: s.key_type,
6269
+ enabled: !!s.enabled,
6270
+ has_key: !!s.vault_key,
6271
+ description: s.description || null,
6272
+ matched: [...new Set(matched)],
6273
+ address_hints: addressHints
6274
+ });
6275
+ }
6276
+ }
6277
+
6278
+ return { query, count: matches.length, matches };
6279
+ }
6280
+
6281
+ // ── Filesystem service config — loaded from clauth vault ──
6282
+ let _fsMountsCache = null;
6242
6283
  let _fsMountsCacheTime = 0;
6243
6284
  const FS_CACHE_TTL = 60000; // 1 minute
6244
6285
 
@@ -6272,115 +6313,115 @@ async function getFileserverMounts(vault) {
6272
6313
  }
6273
6314
  }
6274
6315
 
6275
- async function resolveInMount(requestedPath, mountName, vault) {
6316
+ async function resolveInMount(requestedPath, mountName, vault) {
6276
6317
  const { mounts, error } = await getFileserverMounts(vault);
6277
6318
  if (error) return { error };
6278
6319
  if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwd\"}" };
6279
6320
  const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
6280
6321
  if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
6281
6322
  if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
6282
- const resolved = path.resolve(mount.path, requestedPath);
6283
- const normalized = path.normalize(resolved);
6284
- const relative = path.relative(path.normalize(mount.path), normalized);
6285
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
6286
- return { error: `Path escapes mount: ${requestedPath}` };
6287
- }
6288
- return { resolved: normalized, mount };
6289
- }
6290
-
6291
- function checkAccess(mount, flag) {
6292
- return mount.access.includes(flag);
6293
- }
6294
-
6295
- function sha256Hex(value) {
6296
- return crypto.createHash("sha256").update(value).digest("hex");
6297
- }
6298
-
6299
- async function atomicWriteText(filePath, content) {
6300
- await mkdir(path.dirname(filePath), { recursive: true });
6301
- const tempPath = path.join(
6302
- path.dirname(filePath),
6303
- `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
6304
- );
6305
- await writeFile(tempPath, content, "utf8");
6306
- await rename(tempPath, filePath);
6307
- }
6308
-
6309
- async function fileInfo(filePath, requestedPath) {
6310
- const s = await stat(filePath);
6311
- const info = {
6312
- path: requestedPath,
6313
- type: s.isDirectory() ? "dir" : "file",
6314
- size: s.size,
6315
- modified: s.mtime.toISOString(),
6316
- };
6317
- if (s.isFile()) {
6318
- const content = await readFile(filePath);
6319
- info.sha256 = sha256Hex(content);
6320
- }
6321
- return info;
6322
- }
6323
-
6324
- const FS_UPLOAD_SESSIONS = new Map();
6325
- const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
6326
- const FS_MAX_CHUNKS = 500;
6327
- const FS_MAX_CHUNK_BYTES = 128 * 1024;
6328
- const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
6329
- const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
6330
- "docs/",
6331
- ".rdc/plans/",
6332
- ".rdc/guides/",
6333
- ".claude/context/",
6334
- ".claude/rules/",
6335
- ".rdc/relay/from-claude-ai/",
6336
- ];
6337
-
6338
- function cleanupFsUploadSessions() {
6339
- const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
6340
- for (const [id, session] of FS_UPLOAD_SESSIONS) {
6341
- if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
6342
- }
6343
- }
6344
-
6345
- function runGit(cwd, args, opts = {}) {
6346
- const res = spawnSync("git", args, {
6347
- cwd,
6348
- encoding: "utf8",
6349
- windowsHide: true,
6350
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6351
- });
6352
- if (res.status !== 0) {
6353
- const detail = (res.stderr || res.stdout || "").trim();
6354
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6355
- }
6356
- return (res.stdout || "").trim();
6357
- }
6358
-
6359
- function runGitRaw(cwd, args, opts = {}) {
6360
- const res = spawnSync("git", args, {
6361
- cwd,
6362
- encoding: "buffer",
6363
- windowsHide: true,
6364
- maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6365
- });
6366
- if (res.status !== 0) {
6367
- const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
6368
- throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6369
- }
6370
- return res.stdout || Buffer.alloc(0);
6371
- }
6372
-
6373
- function normalizeRepoPath(p) {
6374
- if (!p || typeof p !== "string") return null;
6375
- const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
6376
- const parts = normalized.split("/").filter(Boolean);
6377
- if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
6378
- return parts.join("/");
6379
- }
6380
-
6381
- function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
6382
- return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
6383
- }
6323
+ const resolved = path.resolve(mount.path, requestedPath);
6324
+ const normalized = path.normalize(resolved);
6325
+ const relative = path.relative(path.normalize(mount.path), normalized);
6326
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
6327
+ return { error: `Path escapes mount: ${requestedPath}` };
6328
+ }
6329
+ return { resolved: normalized, mount };
6330
+ }
6331
+
6332
+ function checkAccess(mount, flag) {
6333
+ return mount.access.includes(flag);
6334
+ }
6335
+
6336
+ function sha256Hex(value) {
6337
+ return crypto.createHash("sha256").update(value).digest("hex");
6338
+ }
6339
+
6340
+ async function atomicWriteText(filePath, content) {
6341
+ await mkdir(path.dirname(filePath), { recursive: true });
6342
+ const tempPath = path.join(
6343
+ path.dirname(filePath),
6344
+ `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
6345
+ );
6346
+ await writeFile(tempPath, content, "utf8");
6347
+ await rename(tempPath, filePath);
6348
+ }
6349
+
6350
+ async function fileInfo(filePath, requestedPath) {
6351
+ const s = await stat(filePath);
6352
+ const info = {
6353
+ path: requestedPath,
6354
+ type: s.isDirectory() ? "dir" : "file",
6355
+ size: s.size,
6356
+ modified: s.mtime.toISOString(),
6357
+ };
6358
+ if (s.isFile()) {
6359
+ const content = await readFile(filePath);
6360
+ info.sha256 = sha256Hex(content);
6361
+ }
6362
+ return info;
6363
+ }
6364
+
6365
+ const FS_UPLOAD_SESSIONS = new Map();
6366
+ const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
6367
+ const FS_MAX_CHUNKS = 500;
6368
+ const FS_MAX_CHUNK_BYTES = 128 * 1024;
6369
+ const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
6370
+ const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
6371
+ "docs/",
6372
+ ".rdc/plans/",
6373
+ ".rdc/guides/",
6374
+ ".claude/context/",
6375
+ ".claude/rules/",
6376
+ ".rdc/relay/from-claude-ai/",
6377
+ ];
6378
+
6379
+ function cleanupFsUploadSessions() {
6380
+ const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
6381
+ for (const [id, session] of FS_UPLOAD_SESSIONS) {
6382
+ if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
6383
+ }
6384
+ }
6385
+
6386
+ function runGit(cwd, args, opts = {}) {
6387
+ const res = spawnSync("git", args, {
6388
+ cwd,
6389
+ encoding: "utf8",
6390
+ windowsHide: true,
6391
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6392
+ });
6393
+ if (res.status !== 0) {
6394
+ const detail = (res.stderr || res.stdout || "").trim();
6395
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6396
+ }
6397
+ return (res.stdout || "").trim();
6398
+ }
6399
+
6400
+ function runGitRaw(cwd, args, opts = {}) {
6401
+ const res = spawnSync("git", args, {
6402
+ cwd,
6403
+ encoding: "buffer",
6404
+ windowsHide: true,
6405
+ maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
6406
+ });
6407
+ if (res.status !== 0) {
6408
+ const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
6409
+ throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
6410
+ }
6411
+ return res.stdout || Buffer.alloc(0);
6412
+ }
6413
+
6414
+ function normalizeRepoPath(p) {
6415
+ if (!p || typeof p !== "string") return null;
6416
+ const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
6417
+ const parts = normalized.split("/").filter(Boolean);
6418
+ if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
6419
+ return parts.join("/");
6420
+ }
6421
+
6422
+ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
6423
+ return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
6424
+ }
6384
6425
 
6385
6426
  const MCP_TOOLS = [
6386
6427
  {
@@ -6403,28 +6444,28 @@ const MCP_TOOLS = [
6403
6444
  description: "List all services with type, enabled state, key presence, and last retrieval time",
6404
6445
  inputSchema: { type: "object", properties: {}, additionalProperties: false }
6405
6446
  },
6406
- {
6407
- name: "clauth_list",
6408
- description: "List registered service names",
6409
- inputSchema: { type: "object", properties: {}, additionalProperties: false }
6410
- },
6411
- {
6412
- name: "clauth_search",
6413
- description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
6414
- inputSchema: {
6415
- type: "object",
6416
- properties: {
6417
- query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
6418
- project: { type: "string", description: "Optional project scope" },
6419
- addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
6420
- },
6421
- required: ["query"],
6422
- additionalProperties: false
6423
- }
6424
- },
6425
- {
6426
- name: "clauth_get",
6427
- description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
6447
+ {
6448
+ name: "clauth_list",
6449
+ description: "List registered service names",
6450
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
6451
+ },
6452
+ {
6453
+ name: "clauth_search",
6454
+ description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
6455
+ inputSchema: {
6456
+ type: "object",
6457
+ properties: {
6458
+ query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
6459
+ project: { type: "string", description: "Optional project scope" },
6460
+ addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
6461
+ },
6462
+ required: ["query"],
6463
+ additionalProperties: false
6464
+ }
6465
+ },
6466
+ {
6467
+ name: "clauth_get",
6468
+ description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
6428
6469
  inputSchema: {
6429
6470
  type: "object",
6430
6471
  properties: {
@@ -6887,9 +6928,9 @@ const MCP_TOOLS = [
6887
6928
  additionalProperties: false,
6888
6929
  },
6889
6930
  },
6890
- {
6891
- name: "fs_write",
6892
- description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
6931
+ {
6932
+ name: "fs_write",
6933
+ description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
6893
6934
  inputSchema: {
6894
6935
  type: "object",
6895
6936
  properties: {
@@ -6898,93 +6939,93 @@ const MCP_TOOLS = [
6898
6939
  mount: { type: "string", description: "Mount name (default: first mount)" },
6899
6940
  },
6900
6941
  required: ["path", "content"],
6901
- additionalProperties: false,
6902
- },
6903
- },
6904
- {
6905
- name: "fs_stat",
6906
- description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
6907
- inputSchema: {
6908
- type: "object",
6909
- properties: {
6910
- path: { type: "string", description: "Relative path within mount" },
6911
- mount: { type: "string", description: "Mount name (default: first mount)" },
6912
- },
6913
- required: ["path"],
6914
- additionalProperties: false,
6915
- },
6916
- },
6917
- {
6918
- name: "fs_append",
6919
- description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
6920
- inputSchema: {
6921
- type: "object",
6922
- properties: {
6923
- path: { type: "string", description: "Relative path within mount" },
6924
- content: { type: "string", description: "Text to append" },
6925
- mount: { type: "string", description: "Mount name (default: first mount)" },
6926
- expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
6927
- },
6928
- required: ["path", "content"],
6929
- additionalProperties: false,
6930
- },
6931
- },
6932
- {
6933
- name: "fs_write_chunk",
6934
- description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
6935
- inputSchema: {
6936
- type: "object",
6937
- properties: {
6938
- upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
6939
- path: { type: "string", description: "Relative path within mount" },
6940
- chunk_index: { type: "number", description: "Zero-based chunk index" },
6941
- total_chunks: { type: "number", description: "Total chunks expected" },
6942
- content: { type: "string", description: "Chunk text content" },
6943
- mount: { type: "string", description: "Mount name (default: first mount)" },
6944
- expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
6945
- },
6946
- required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
6947
- additionalProperties: false,
6948
- },
6949
- },
6950
- {
6951
- name: "fs_ingest_url",
6952
- description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
6953
- inputSchema: {
6954
- type: "object",
6955
- properties: {
6956
- url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
6957
- path: { type: "string", description: "Relative output path within mount" },
6958
- mount: { type: "string", description: "Mount name (default: first mount)" },
6959
- max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
6960
- expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
6961
- },
6962
- required: ["url", "path"],
6963
- additionalProperties: false,
6964
- },
6965
- },
6966
- {
6967
- name: "fs_import_git_files",
6968
- description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
6969
- inputSchema: {
6970
- type: "object",
6971
- properties: {
6972
- remote: { type: "string", description: "Git remote name (default: origin)" },
6973
- ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
6974
- paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
6975
- mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
6976
- commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
6977
- message: { type: "string", description: "Commit subject when commit=true" },
6978
- mount: { type: "string", description: "Mount name (default: first mount)" },
6979
- allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
6980
- },
6981
- required: ["ref", "paths"],
6982
- additionalProperties: false,
6983
- },
6984
- },
6985
- {
6986
- name: "fs_list",
6987
- description: "List directory contents with file type, size, and modification time.",
6942
+ additionalProperties: false,
6943
+ },
6944
+ },
6945
+ {
6946
+ name: "fs_stat",
6947
+ description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
6948
+ inputSchema: {
6949
+ type: "object",
6950
+ properties: {
6951
+ path: { type: "string", description: "Relative path within mount" },
6952
+ mount: { type: "string", description: "Mount name (default: first mount)" },
6953
+ },
6954
+ required: ["path"],
6955
+ additionalProperties: false,
6956
+ },
6957
+ },
6958
+ {
6959
+ name: "fs_append",
6960
+ description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
6961
+ inputSchema: {
6962
+ type: "object",
6963
+ properties: {
6964
+ path: { type: "string", description: "Relative path within mount" },
6965
+ content: { type: "string", description: "Text to append" },
6966
+ mount: { type: "string", description: "Mount name (default: first mount)" },
6967
+ expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
6968
+ },
6969
+ required: ["path", "content"],
6970
+ additionalProperties: false,
6971
+ },
6972
+ },
6973
+ {
6974
+ name: "fs_write_chunk",
6975
+ description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
6976
+ inputSchema: {
6977
+ type: "object",
6978
+ properties: {
6979
+ upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
6980
+ path: { type: "string", description: "Relative path within mount" },
6981
+ chunk_index: { type: "number", description: "Zero-based chunk index" },
6982
+ total_chunks: { type: "number", description: "Total chunks expected" },
6983
+ content: { type: "string", description: "Chunk text content" },
6984
+ mount: { type: "string", description: "Mount name (default: first mount)" },
6985
+ expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
6986
+ },
6987
+ required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
6988
+ additionalProperties: false,
6989
+ },
6990
+ },
6991
+ {
6992
+ name: "fs_ingest_url",
6993
+ description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
6994
+ inputSchema: {
6995
+ type: "object",
6996
+ properties: {
6997
+ url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
6998
+ path: { type: "string", description: "Relative output path within mount" },
6999
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7000
+ max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
7001
+ expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
7002
+ },
7003
+ required: ["url", "path"],
7004
+ additionalProperties: false,
7005
+ },
7006
+ },
7007
+ {
7008
+ name: "fs_import_git_files",
7009
+ description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
7010
+ inputSchema: {
7011
+ type: "object",
7012
+ properties: {
7013
+ remote: { type: "string", description: "Git remote name (default: origin)" },
7014
+ ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
7015
+ paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
7016
+ mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
7017
+ commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
7018
+ message: { type: "string", description: "Commit subject when commit=true" },
7019
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7020
+ allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
7021
+ },
7022
+ required: ["ref", "paths"],
7023
+ additionalProperties: false,
7024
+ },
7025
+ },
7026
+ {
7027
+ name: "fs_list",
7028
+ description: "List directory contents with file type, size, and modification time.",
6988
7029
  inputSchema: {
6989
7030
  type: "object",
6990
7031
  properties: {
@@ -7051,6 +7092,53 @@ const MCP_TOOLS = [
7051
7092
  additionalProperties: false,
7052
7093
  },
7053
7094
  },
7095
+ {
7096
+ name: "fs_edit",
7097
+ description: "Replace a unique string in a file. Like Edit in Claude Code: fails if old_string is not found or matches multiple times unless replace_all=true. Atomic write. Returns new file info including sha256.",
7098
+ inputSchema: {
7099
+ type: "object",
7100
+ properties: {
7101
+ path: { type: "string", description: "Relative path within mount" },
7102
+ old_string: { type: "string", description: "Exact text to find (must be unique unless replace_all=true)" },
7103
+ new_string: { type: "string", description: "Replacement text (must differ from old_string)" },
7104
+ replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
7105
+ expected_sha256: { type: "string", description: "Optional current file SHA-256 guard. If provided and mismatched, edit is rejected." },
7106
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7107
+ },
7108
+ required: ["path", "old_string", "new_string"],
7109
+ additionalProperties: false,
7110
+ },
7111
+ },
7112
+ {
7113
+ name: "fs_move",
7114
+ description: "Move or rename a file or directory within a mount. Single syscall when possible; falls back to copy+delete across devices. Refuses to overwrite existing destination unless overwrite=true.",
7115
+ inputSchema: {
7116
+ type: "object",
7117
+ properties: {
7118
+ from: { type: "string", description: "Source relative path within mount" },
7119
+ to: { type: "string", description: "Destination relative path within mount" },
7120
+ overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
7121
+ mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
7122
+ },
7123
+ required: ["from", "to"],
7124
+ additionalProperties: false,
7125
+ },
7126
+ },
7127
+ {
7128
+ name: "fs_copy",
7129
+ description: "Copy a file or directory tree within a mount. Recursive by default. Refuses to overwrite existing destination unless overwrite=true.",
7130
+ inputSchema: {
7131
+ type: "object",
7132
+ properties: {
7133
+ from: { type: "string", description: "Source relative path within mount" },
7134
+ to: { type: "string", description: "Destination relative path within mount" },
7135
+ overwrite: { type: "boolean", description: "Overwrite destination if it exists (default false)" },
7136
+ mount: { type: "string", description: "Mount name for both from and to (default: first mount)" },
7137
+ },
7138
+ required: ["from", "to"],
7139
+ additionalProperties: false,
7140
+ },
7141
+ },
7054
7142
  {
7055
7143
  name: "fs_mounts",
7056
7144
  description: "List configured filesystem mounts (fileserver services from vault).",
@@ -7141,10 +7229,10 @@ async function handleMcpTool(vault, name, args) {
7141
7229
  }
7142
7230
  }
7143
7231
 
7144
- case "clauth_list": {
7145
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7146
- try {
7147
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7232
+ case "clauth_list": {
7233
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7234
+ try {
7235
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7148
7236
  const result = await api.status(vault.password, vault.machineHash, token, timestamp);
7149
7237
  if (result.error) return mcpError(result.error);
7150
7238
  let services = result.services || [];
@@ -7153,34 +7241,34 @@ async function handleMcpTool(vault, name, args) {
7153
7241
  }
7154
7242
  return mcpResult(services.map(s => s.name).join(", "));
7155
7243
  } catch (err) {
7156
- return mcpError(err.message);
7157
- }
7158
- }
7159
-
7160
- case "clauth_search": {
7161
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7162
- try {
7163
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7164
- const result = await searchServices({
7165
- password: vault.password,
7166
- machineHash: vault.machineHash,
7167
- token,
7168
- timestamp,
7169
- query: args.query,
7170
- project: args.project,
7171
- includeAddresses: args.addresses !== false,
7172
- whitelist: vault.whitelist
7173
- });
7174
- if (result.error) return mcpError(result.error);
7175
- return mcpResult(JSON.stringify(result, null, 2));
7176
- } catch (err) {
7177
- return mcpError(err.message);
7178
- }
7179
- }
7180
-
7181
- case "clauth_get": {
7182
- if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7183
- const service = (args.service || "").toLowerCase();
7244
+ return mcpError(err.message);
7245
+ }
7246
+ }
7247
+
7248
+ case "clauth_search": {
7249
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7250
+ try {
7251
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
7252
+ const result = await searchServices({
7253
+ password: vault.password,
7254
+ machineHash: vault.machineHash,
7255
+ token,
7256
+ timestamp,
7257
+ query: args.query,
7258
+ project: args.project,
7259
+ includeAddresses: args.addresses !== false,
7260
+ whitelist: vault.whitelist
7261
+ });
7262
+ if (result.error) return mcpError(result.error);
7263
+ return mcpResult(JSON.stringify(result, null, 2));
7264
+ } catch (err) {
7265
+ return mcpError(err.message);
7266
+ }
7267
+ }
7268
+
7269
+ case "clauth_get": {
7270
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7271
+ const service = (args.service || "").toLowerCase();
7184
7272
  const target = args.target || "file";
7185
7273
  if (!service) return mcpError("service is required");
7186
7274
  if (vault.whitelist && !vault.whitelist.includes(service)) {
@@ -7474,250 +7562,250 @@ async function handleMcpTool(vault, name, args) {
7474
7562
  }
7475
7563
  }
7476
7564
 
7477
- case "fs_write": {
7478
- const r = await resolveInMount(args.path, args.mount, vault);
7479
- if (r.error) return mcpError(r.error);
7480
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7481
- try {
7482
- await atomicWriteText(r.resolved, args.content);
7483
- return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
7484
- } catch (err) {
7485
- return mcpError(`Write failed: ${err.message}`);
7486
- }
7487
- }
7488
-
7489
- case "fs_stat": {
7490
- const r = await resolveInMount(args.path, args.mount, vault);
7491
- if (r.error) return mcpError(r.error);
7492
- if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
7493
- try {
7494
- return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
7495
- } catch (err) {
7496
- if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
7497
- return mcpError(`Stat failed: ${err.message}`);
7498
- }
7499
- }
7500
-
7501
- case "fs_append": {
7502
- const r = await resolveInMount(args.path, args.mount, vault);
7503
- if (r.error) return mcpError(r.error);
7504
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7505
- try {
7506
- try {
7507
- const current = await readFile(r.resolved);
7508
- if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
7509
- return mcpError("Append rejected: current file hash does not match expected_sha256");
7510
- }
7511
- } catch (err) {
7512
- if (err.code !== "ENOENT") throw err;
7513
- if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
7514
- }
7515
- await mkdir(path.dirname(r.resolved), { recursive: true });
7516
- await appendFile(r.resolved, args.content, "utf8");
7517
- const info = await fileInfo(r.resolved, args.path);
7518
- return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
7519
- } catch (err) {
7520
- return mcpError(`Append failed: ${err.message}`);
7521
- }
7522
- }
7523
-
7524
- case "fs_write_chunk": {
7525
- cleanupFsUploadSessions();
7526
- const { upload_id, chunk_index, total_chunks, content } = args;
7527
- const index = Number(chunk_index);
7528
- const total = Number(total_chunks);
7529
- if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
7530
- return mcpError("Invalid chunk_index/total_chunks");
7531
- }
7532
- if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
7533
- if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
7534
- return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
7535
- }
7536
-
7537
- const r = await resolveInMount(args.path, args.mount, vault);
7538
- if (r.error) return mcpError(r.error);
7539
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7540
-
7541
- const key = `${r.mount.name}:${args.path}:${upload_id}`;
7542
- let session = FS_UPLOAD_SESSIONS.get(key);
7543
- if (!session) {
7544
- session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
7545
- FS_UPLOAD_SESSIONS.set(key, session);
7546
- }
7547
- if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
7548
- return mcpError("Upload id collision: path or total_chunks differs from existing session");
7549
- }
7550
- if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
7551
- return mcpError("Upload id collision: expected_sha256 differs from existing session");
7552
- }
7553
-
7554
- session.chunks.set(index, content);
7555
- session.updatedAt = Date.now();
7556
-
7557
- if (session.chunks.size < total) {
7558
- return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
7559
- }
7560
-
7561
- const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
7562
- const actualSha = sha256Hex(assembled);
7563
- if (session.expectedSha256 && actualSha !== session.expectedSha256) {
7564
- FS_UPLOAD_SESSIONS.delete(key);
7565
- return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
7566
- }
7567
-
7568
- try {
7569
- await atomicWriteText(r.resolved, assembled);
7570
- FS_UPLOAD_SESSIONS.delete(key);
7571
- return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
7572
- } catch (err) {
7573
- return mcpError(`Chunked write failed: ${err.message}`);
7574
- }
7575
- }
7576
-
7577
- case "fs_ingest_url": {
7578
- const r = await resolveInMount(args.path, args.mount, vault);
7579
- if (r.error) return mcpError(r.error);
7580
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7581
-
7582
- let url;
7583
- try {
7584
- url = new URL(args.url);
7585
- } catch {
7586
- return mcpError("Invalid URL");
7587
- }
7588
- if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
7589
-
7590
- const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
7591
- try {
7592
- const response = await fetch(url, { redirect: "follow" });
7593
- if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
7594
- const length = Number(response.headers.get("content-length") || 0);
7595
- if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
7596
-
7597
- const reader = response.body?.getReader();
7598
- if (!reader) return mcpError("Fetch failed: response body is not readable");
7599
-
7600
- let received = 0;
7601
- const chunks = [];
7602
- while (true) {
7603
- const { done, value } = await reader.read();
7604
- if (done) break;
7605
- received += value.byteLength;
7606
- if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
7607
- chunks.push(Buffer.from(value));
7608
- }
7609
-
7610
- const content = Buffer.concat(chunks).toString("utf8");
7611
- const actualSha = sha256Hex(content);
7612
- if (args.expected_sha256 && actualSha !== args.expected_sha256) {
7613
- return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
7614
- }
7615
- await atomicWriteText(r.resolved, content);
7616
- return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
7617
- } catch (err) {
7618
- return mcpError(`Ingest failed: ${err.message}`);
7619
- }
7620
- }
7621
-
7622
- case "fs_import_git_files": {
7623
- if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
7624
- if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
7625
-
7626
- const r = await resolveInMount(".", args.mount, vault);
7627
- if (r.error) return mcpError(r.error);
7628
- if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7629
-
7630
- const repoRoot = r.resolved;
7631
- const remote = args.remote || "origin";
7632
- const mode = args.mode || "new_only";
7633
- const doCommit = args.commit === true;
7634
- const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
7635
- ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
7636
- : FS_GIT_IMPORT_ALLOWED_PREFIXES;
7637
-
7638
- try {
7639
- const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
7640
- if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
7641
- return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
7642
- }
7643
-
7644
- const normalizedPaths = [];
7645
- for (const rawPath of args.paths) {
7646
- const normalized = normalizeRepoPath(rawPath);
7647
- if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
7648
- if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
7649
- normalizedPaths.push(normalized);
7650
- }
7651
-
7652
- if (mode === "new_only") {
7653
- for (const rel of normalizedPaths) {
7654
- const localPath = path.join(repoRoot, rel);
7655
- try {
7656
- await stat(localPath);
7657
- return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
7658
- } catch (err) {
7659
- if (err.code !== "ENOENT") throw err;
7660
- }
7661
- }
7662
- }
7663
-
7664
- if (doCommit) {
7665
- const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
7666
- if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
7667
- if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
7668
- }
7669
-
7670
- runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
7671
- const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
7672
-
7673
- for (const rel of normalizedPaths) {
7674
- runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
7675
- }
7676
-
7677
- runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
7678
-
7679
- const imported = [];
7680
- for (const rel of normalizedPaths) {
7681
- const localPath = path.join(repoRoot, rel);
7682
- const info = await fileInfo(localPath, rel);
7683
- const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
7684
- const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
7685
- imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
7686
- }
7687
-
7688
- let localCommit = null;
7689
- if (doCommit) {
7690
- runGit(repoRoot, ["add", "--", ...normalizedPaths]);
7691
- const body = [
7692
- args.message.trim(),
7693
- "",
7694
- "Imported from Claude.ai GitHub upload.",
7695
- "",
7696
- `Source remote: ${remote}`,
7697
- `Source ref: ${args.ref}`,
7698
- `Source commit: ${sourceCommit}`,
7699
- "",
7700
- "Paths:",
7701
- ...normalizedPaths.map((p) => `- ${p}`),
7702
- ].join("\n");
7703
- runGit(repoRoot, ["commit", "-m", body]);
7704
- localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
7705
- }
7706
-
7707
- return mcpResult(JSON.stringify({
7708
- status: "ok",
7709
- mode,
7710
- committed: doCommit,
7711
- source_commit: sourceCommit,
7712
- local_commit: localCommit,
7713
- imported,
7714
- }, null, 2));
7715
- } catch (err) {
7716
- return mcpError(`Git import failed: ${err.message}`);
7717
- }
7718
- }
7719
-
7720
- case "fs_list": {
7565
+ case "fs_write": {
7566
+ const r = await resolveInMount(args.path, args.mount, vault);
7567
+ if (r.error) return mcpError(r.error);
7568
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7569
+ try {
7570
+ await atomicWriteText(r.resolved, args.content);
7571
+ return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
7572
+ } catch (err) {
7573
+ return mcpError(`Write failed: ${err.message}`);
7574
+ }
7575
+ }
7576
+
7577
+ case "fs_stat": {
7578
+ const r = await resolveInMount(args.path, args.mount, vault);
7579
+ if (r.error) return mcpError(r.error);
7580
+ if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
7581
+ try {
7582
+ return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
7583
+ } catch (err) {
7584
+ if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
7585
+ return mcpError(`Stat failed: ${err.message}`);
7586
+ }
7587
+ }
7588
+
7589
+ case "fs_append": {
7590
+ const r = await resolveInMount(args.path, args.mount, vault);
7591
+ if (r.error) return mcpError(r.error);
7592
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7593
+ try {
7594
+ try {
7595
+ const current = await readFile(r.resolved);
7596
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
7597
+ return mcpError("Append rejected: current file hash does not match expected_sha256");
7598
+ }
7599
+ } catch (err) {
7600
+ if (err.code !== "ENOENT") throw err;
7601
+ if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
7602
+ }
7603
+ await mkdir(path.dirname(r.resolved), { recursive: true });
7604
+ await appendFile(r.resolved, args.content, "utf8");
7605
+ const info = await fileInfo(r.resolved, args.path);
7606
+ return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
7607
+ } catch (err) {
7608
+ return mcpError(`Append failed: ${err.message}`);
7609
+ }
7610
+ }
7611
+
7612
+ case "fs_write_chunk": {
7613
+ cleanupFsUploadSessions();
7614
+ const { upload_id, chunk_index, total_chunks, content } = args;
7615
+ const index = Number(chunk_index);
7616
+ const total = Number(total_chunks);
7617
+ if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
7618
+ return mcpError("Invalid chunk_index/total_chunks");
7619
+ }
7620
+ if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
7621
+ if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
7622
+ return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
7623
+ }
7624
+
7625
+ const r = await resolveInMount(args.path, args.mount, vault);
7626
+ if (r.error) return mcpError(r.error);
7627
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7628
+
7629
+ const key = `${r.mount.name}:${args.path}:${upload_id}`;
7630
+ let session = FS_UPLOAD_SESSIONS.get(key);
7631
+ if (!session) {
7632
+ session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
7633
+ FS_UPLOAD_SESSIONS.set(key, session);
7634
+ }
7635
+ if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
7636
+ return mcpError("Upload id collision: path or total_chunks differs from existing session");
7637
+ }
7638
+ if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
7639
+ return mcpError("Upload id collision: expected_sha256 differs from existing session");
7640
+ }
7641
+
7642
+ session.chunks.set(index, content);
7643
+ session.updatedAt = Date.now();
7644
+
7645
+ if (session.chunks.size < total) {
7646
+ return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
7647
+ }
7648
+
7649
+ const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
7650
+ const actualSha = sha256Hex(assembled);
7651
+ if (session.expectedSha256 && actualSha !== session.expectedSha256) {
7652
+ FS_UPLOAD_SESSIONS.delete(key);
7653
+ return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
7654
+ }
7655
+
7656
+ try {
7657
+ await atomicWriteText(r.resolved, assembled);
7658
+ FS_UPLOAD_SESSIONS.delete(key);
7659
+ return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
7660
+ } catch (err) {
7661
+ return mcpError(`Chunked write failed: ${err.message}`);
7662
+ }
7663
+ }
7664
+
7665
+ case "fs_ingest_url": {
7666
+ const r = await resolveInMount(args.path, args.mount, vault);
7667
+ if (r.error) return mcpError(r.error);
7668
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7669
+
7670
+ let url;
7671
+ try {
7672
+ url = new URL(args.url);
7673
+ } catch {
7674
+ return mcpError("Invalid URL");
7675
+ }
7676
+ if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
7677
+
7678
+ const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
7679
+ try {
7680
+ const response = await fetch(url, { redirect: "follow" });
7681
+ if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
7682
+ const length = Number(response.headers.get("content-length") || 0);
7683
+ if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
7684
+
7685
+ const reader = response.body?.getReader();
7686
+ if (!reader) return mcpError("Fetch failed: response body is not readable");
7687
+
7688
+ let received = 0;
7689
+ const chunks = [];
7690
+ while (true) {
7691
+ const { done, value } = await reader.read();
7692
+ if (done) break;
7693
+ received += value.byteLength;
7694
+ if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
7695
+ chunks.push(Buffer.from(value));
7696
+ }
7697
+
7698
+ const content = Buffer.concat(chunks).toString("utf8");
7699
+ const actualSha = sha256Hex(content);
7700
+ if (args.expected_sha256 && actualSha !== args.expected_sha256) {
7701
+ return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
7702
+ }
7703
+ await atomicWriteText(r.resolved, content);
7704
+ return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
7705
+ } catch (err) {
7706
+ return mcpError(`Ingest failed: ${err.message}`);
7707
+ }
7708
+ }
7709
+
7710
+ case "fs_import_git_files": {
7711
+ if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
7712
+ if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
7713
+
7714
+ const r = await resolveInMount(".", args.mount, vault);
7715
+ if (r.error) return mcpError(r.error);
7716
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7717
+
7718
+ const repoRoot = r.resolved;
7719
+ const remote = args.remote || "origin";
7720
+ const mode = args.mode || "new_only";
7721
+ const doCommit = args.commit === true;
7722
+ const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
7723
+ ? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
7724
+ : FS_GIT_IMPORT_ALLOWED_PREFIXES;
7725
+
7726
+ try {
7727
+ const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
7728
+ if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
7729
+ return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
7730
+ }
7731
+
7732
+ const normalizedPaths = [];
7733
+ for (const rawPath of args.paths) {
7734
+ const normalized = normalizeRepoPath(rawPath);
7735
+ if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
7736
+ if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
7737
+ normalizedPaths.push(normalized);
7738
+ }
7739
+
7740
+ if (mode === "new_only") {
7741
+ for (const rel of normalizedPaths) {
7742
+ const localPath = path.join(repoRoot, rel);
7743
+ try {
7744
+ await stat(localPath);
7745
+ return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
7746
+ } catch (err) {
7747
+ if (err.code !== "ENOENT") throw err;
7748
+ }
7749
+ }
7750
+ }
7751
+
7752
+ if (doCommit) {
7753
+ const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
7754
+ if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
7755
+ if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
7756
+ }
7757
+
7758
+ runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
7759
+ const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
7760
+
7761
+ for (const rel of normalizedPaths) {
7762
+ runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
7763
+ }
7764
+
7765
+ runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
7766
+
7767
+ const imported = [];
7768
+ for (const rel of normalizedPaths) {
7769
+ const localPath = path.join(repoRoot, rel);
7770
+ const info = await fileInfo(localPath, rel);
7771
+ const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
7772
+ const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
7773
+ imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
7774
+ }
7775
+
7776
+ let localCommit = null;
7777
+ if (doCommit) {
7778
+ runGit(repoRoot, ["add", "--", ...normalizedPaths]);
7779
+ const body = [
7780
+ args.message.trim(),
7781
+ "",
7782
+ "Imported from Claude.ai GitHub upload.",
7783
+ "",
7784
+ `Source remote: ${remote}`,
7785
+ `Source ref: ${args.ref}`,
7786
+ `Source commit: ${sourceCommit}`,
7787
+ "",
7788
+ "Paths:",
7789
+ ...normalizedPaths.map((p) => `- ${p}`),
7790
+ ].join("\n");
7791
+ runGit(repoRoot, ["commit", "-m", body]);
7792
+ localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
7793
+ }
7794
+
7795
+ return mcpResult(JSON.stringify({
7796
+ status: "ok",
7797
+ mode,
7798
+ committed: doCommit,
7799
+ source_commit: sourceCommit,
7800
+ local_commit: localCommit,
7801
+ imported,
7802
+ }, null, 2));
7803
+ } catch (err) {
7804
+ return mcpError(`Git import failed: ${err.message}`);
7805
+ }
7806
+ }
7807
+
7808
+ case "fs_list": {
7721
7809
  const dirPath = args.path || ".";
7722
7810
  const r = await resolveInMount(dirPath, args.mount, vault);
7723
7811
  if (r.error) return mcpError(r.error);
@@ -7840,6 +7928,133 @@ async function handleMcpTool(vault, name, args) {
7840
7928
  }
7841
7929
  }
7842
7930
 
7931
+ case "fs_edit": {
7932
+ if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
7933
+ return mcpError("old_string and new_string must be strings");
7934
+ }
7935
+ if (args.old_string === args.new_string) {
7936
+ return mcpError("old_string and new_string must differ");
7937
+ }
7938
+ if (args.old_string.length === 0) {
7939
+ return mcpError("old_string must not be empty");
7940
+ }
7941
+ const r = await resolveInMount(args.path, args.mount, vault);
7942
+ if (r.error) return mcpError(r.error);
7943
+ if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
7944
+ try {
7945
+ const current = await readFile(r.resolved, "utf8");
7946
+ if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
7947
+ return mcpError("Edit rejected: current file hash does not match expected_sha256");
7948
+ }
7949
+ const parts = current.split(args.old_string);
7950
+ const occurrences = parts.length - 1;
7951
+ if (occurrences === 0) {
7952
+ return mcpError(`Edit failed: old_string not found in ${args.path}`);
7953
+ }
7954
+ if (occurrences > 1 && !args.replace_all) {
7955
+ return mcpError(`Edit failed: old_string is not unique (${occurrences} matches in ${args.path}). Set replace_all=true to replace all, or provide more surrounding context.`);
7956
+ }
7957
+ const updated = args.replace_all
7958
+ ? parts.join(args.new_string)
7959
+ : current.replace(args.old_string, args.new_string);
7960
+ await atomicWriteText(r.resolved, updated);
7961
+ const info = await fileInfo(r.resolved, args.path);
7962
+ return mcpResult(JSON.stringify({ replacements: args.replace_all ? occurrences : 1, ...info }, null, 2));
7963
+ } catch (err) {
7964
+ if (err.code === "ENOENT") return mcpError(`File not found: ${args.path}`);
7965
+ return mcpError(`Edit failed: ${err.message}`);
7966
+ }
7967
+ }
7968
+
7969
+ case "fs_move": {
7970
+ const src = await resolveInMount(args.from, args.mount, vault);
7971
+ if (src.error) return mcpError(src.error);
7972
+ const dst = await resolveInMount(args.to, args.mount, vault);
7973
+ if (dst.error) return mcpError(dst.error);
7974
+ if (!checkAccess(src.mount, "w") || !checkAccess(src.mount, "d")) {
7975
+ return mcpError("Move requires write+delete access on this mount");
7976
+ }
7977
+ try {
7978
+ await stat(src.resolved);
7979
+ } catch (err) {
7980
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
7981
+ return mcpError(`Stat failed: ${err.message}`);
7982
+ }
7983
+ let dstExists = false;
7984
+ try {
7985
+ await stat(dst.resolved);
7986
+ dstExists = true;
7987
+ } catch (err) {
7988
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
7989
+ }
7990
+ if (dstExists && !args.overwrite) {
7991
+ return mcpError(`Move refused: destination already exists: ${args.to} (use overwrite=true)`);
7992
+ }
7993
+ try {
7994
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
7995
+ if (dstExists && args.overwrite) {
7996
+ const dstStat = await stat(dst.resolved);
7997
+ await rm(dst.resolved, { recursive: dstStat.isDirectory(), force: true });
7998
+ }
7999
+ try {
8000
+ await rename(src.resolved, dst.resolved);
8001
+ } catch (err) {
8002
+ if (err.code === "EXDEV") {
8003
+ await cp(src.resolved, dst.resolved, { recursive: true, errorOnExist: false, force: true });
8004
+ const srcStat = await stat(src.resolved);
8005
+ await rm(src.resolved, { recursive: srcStat.isDirectory(), force: true });
8006
+ } else {
8007
+ throw err;
8008
+ }
8009
+ }
8010
+ return mcpResult(JSON.stringify({ status: "moved", from: args.from, to: args.to }, null, 2));
8011
+ } catch (err) {
8012
+ return mcpError(`Move failed: ${err.message}`);
8013
+ }
8014
+ }
8015
+
8016
+ case "fs_copy": {
8017
+ const src = await resolveInMount(args.from, args.mount, vault);
8018
+ if (src.error) return mcpError(src.error);
8019
+ const dst = await resolveInMount(args.to, args.mount, vault);
8020
+ if (dst.error) return mcpError(dst.error);
8021
+ if (!checkAccess(src.mount, "r")) return mcpError("Read access denied on this mount");
8022
+ if (!checkAccess(src.mount, "w")) return mcpError("Write access denied on this mount");
8023
+ let srcStat;
8024
+ try {
8025
+ srcStat = await stat(src.resolved);
8026
+ } catch (err) {
8027
+ if (err.code === "ENOENT") return mcpError(`Source not found: ${args.from}`);
8028
+ return mcpError(`Stat failed: ${err.message}`);
8029
+ }
8030
+ let dstExists = false;
8031
+ try {
8032
+ await stat(dst.resolved);
8033
+ dstExists = true;
8034
+ } catch (err) {
8035
+ if (err.code !== "ENOENT") return mcpError(`Stat failed: ${err.message}`);
8036
+ }
8037
+ if (dstExists && !args.overwrite) {
8038
+ return mcpError(`Copy refused: destination already exists: ${args.to} (use overwrite=true)`);
8039
+ }
8040
+ try {
8041
+ await mkdir(path.dirname(dst.resolved), { recursive: true });
8042
+ await cp(src.resolved, dst.resolved, {
8043
+ recursive: true,
8044
+ errorOnExist: false,
8045
+ force: !!args.overwrite,
8046
+ });
8047
+ return mcpResult(JSON.stringify({
8048
+ status: "copied",
8049
+ from: args.from,
8050
+ to: args.to,
8051
+ type: srcStat.isDirectory() ? "dir" : "file",
8052
+ }, null, 2));
8053
+ } catch (err) {
8054
+ return mcpError(`Copy failed: ${err.message}`);
8055
+ }
8056
+ }
8057
+
7843
8058
  case "fs_mounts": {
7844
8059
  const { mounts, error } = await getFileserverMounts(vault);
7845
8060
  if (error) return mcpError(error);