seekstone 0.9.0 → 0.10.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/dist/index.js CHANGED
@@ -10,13 +10,21 @@ import {
10
10
 
11
11
  // src/cli-args.ts
12
12
  function parseCliIntent(argv) {
13
- if (argv[0] === "init") return "init";
13
+ if (argv[0] === "init") {
14
+ for (const arg of argv.slice(1)) {
15
+ if (arg === "--help" || arg === "-h") return "init-help";
16
+ }
17
+ return "init";
18
+ }
14
19
  for (const arg of argv) {
15
20
  if (arg === "--version" || arg === "-v") return "version";
16
21
  if (arg === "--help" || arg === "-h") return "help";
17
22
  }
18
23
  return "run";
19
24
  }
25
+ var initOptionLines = ` --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
26
+ --client <name> desktop (default) | code | cursor | vscode
27
+ --write Patch the client config in place (backs it up first)`;
20
28
  function helpText(version) {
21
29
  return `seekstone ${version} \u2014 an Obsidian MCP server (filesystem-direct, low context-tax)
22
30
 
@@ -30,9 +38,7 @@ Usage:
30
38
  seekstone --help Print this help and exit
31
39
 
32
40
  "seekstone init" options:
33
- --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
34
- --client <name> desktop (default) | code | cursor | vscode
35
- --write Patch the client config in place (backs it up first)
41
+ ${initOptionLines}
36
42
 
37
43
  Required environment:
38
44
  SEEKSTONE_VAULT Absolute path to your Obsidian vault
@@ -45,6 +51,25 @@ Optional environment:
45
51
  Add to Claude Code:
46
52
  claude mcp add seekstone --env SEEKSTONE_VAULT=/path/to/vault -- npx -y seekstone
47
53
 
54
+ Docs: https://github.com/shaqmughal/seekstone`;
55
+ }
56
+ function initHelpText(version) {
57
+ return `seekstone ${version} \u2014 seekstone init
58
+
59
+ Validate an Obsidian vault and print (or, with --write, patch) the MCP config
60
+ for the chosen client (Claude Desktop, Claude Code, Cursor, VS Code).
61
+
62
+ Usage:
63
+ seekstone init [options]
64
+
65
+ Options:
66
+ ${initOptionLines}
67
+ --help, -h Print this help and exit
68
+
69
+ Examples:
70
+ seekstone init
71
+ seekstone init --vault "/Users/you/Obsidian/My Vault" --client code --write
72
+
48
73
  Docs: https://github.com/shaqmughal/seekstone`;
49
74
  }
50
75
 
@@ -117,6 +142,37 @@ function parseFrontmatter(text) {
117
142
 
118
143
  // src/tools/append_note.ts
119
144
  import { z } from "zod";
145
+
146
+ // src/policy.ts
147
+ import picomatch from "picomatch";
148
+ function parseWritePolicy(env) {
149
+ const ro = (env.SEEKSTONE_READ_ONLY ?? "").toLowerCase();
150
+ const readOnly = ro === "1" || ro === "true";
151
+ const globs = (env.SEEKSTONE_WRITE_PATHS ?? "").split(",").map((g) => g.trim()).filter((g) => g.length > 0);
152
+ return globs.length > 0 ? { readOnly, writeGlobs: globs } : { readOnly };
153
+ }
154
+ function normalizeRel(relPath) {
155
+ let p = relPath.replaceAll("\\", "/");
156
+ while (p.startsWith("./")) p = p.slice(2);
157
+ return p;
158
+ }
159
+ function isWritable(policy2, relPath) {
160
+ if (policy2.readOnly) return false;
161
+ if (!policy2.writeGlobs) return true;
162
+ return picomatch(policy2.writeGlobs, { dot: true })(normalizeRel(relPath));
163
+ }
164
+ function assertWritable(policy2, relPath) {
165
+ if (policy2.readOnly) {
166
+ throw new Error("Server is read-only (SEEKSTONE_READ_ONLY=1); write tools are disabled.");
167
+ }
168
+ if (!isWritable(policy2, relPath)) {
169
+ throw new Error(
170
+ `Write not permitted: "${relPath}" is outside SEEKSTONE_WRITE_PATHS (${policy2.writeGlobs?.join(", ")}).`
171
+ );
172
+ }
173
+ }
174
+
175
+ // src/tools/append_note.ts
120
176
  var AppendNoteInput = z.object({
121
177
  path: z.string().describe("Vault-relative path to the note."),
122
178
  content: z.string().min(1).describe(
@@ -128,6 +184,7 @@ async function appendNote(ctx2, input) {
128
184
  if (!absPath.startsWith(ctx2.vaultRoot)) {
129
185
  throw new Error(`Path outside vault: ${input.path}`);
130
186
  }
187
+ assertWritable(ctx2.policy, input.path);
131
188
  const original = await readFile(absPath, "utf8");
132
189
  const fm = parseFrontmatter(original);
133
190
  const body = fm.body.endsWith("\n") ? fm.body : `${fm.body}
@@ -232,6 +289,7 @@ async function createNote(ctx2, input) {
232
289
  if (!absPath.startsWith(ctx2.vaultRoot)) {
233
290
  throw new Error(`Path outside vault: ${input.path}`);
234
291
  }
292
+ assertWritable(ctx2.policy, input.path);
235
293
  if (!input.overwrite) {
236
294
  try {
237
295
  await access(absPath);
@@ -265,6 +323,7 @@ async function deleteNote(ctx2, input) {
265
323
  if (!absPath.startsWith(ctx2.vaultRoot)) {
266
324
  throw new Error(`Path outside vault: ${input.path}`);
267
325
  }
326
+ assertWritable(ctx2.policy, input.path);
268
327
  await rm(absPath);
269
328
  if (ctx2.notes.has(input.path)) {
270
329
  ctx2.index.discard(input.path);
@@ -422,19 +481,149 @@ function listTags(ctx2, input) {
422
481
  }
423
482
 
424
483
  // src/tools/move_note.ts
425
- import { access as access2, mkdir as mkdir2, rename } from "fs/promises";
484
+ import { access as access2, mkdir as mkdir2, rename, writeFile as writeFile3 } from "fs/promises";
426
485
  import { dirname as dirname2, join as join6 } from "path";
427
486
  import { z as z8 } from "zod";
487
+
488
+ // src/index/backlinks.ts
489
+ function removeNoteBacklinks(ctx2, relPath) {
490
+ const oldDoc = ctx2.notes.get(relPath);
491
+ if (oldDoc === void 0) return;
492
+ for (const link of extractLinksWithLines(oldDoc.raw)) {
493
+ const resolved = resolveLink(link.target, ctx2.notes);
494
+ if (resolved === void 0) continue;
495
+ const arr = ctx2.backlinks.get(resolved);
496
+ if (arr === void 0) continue;
497
+ const filtered = arr.filter((r) => r.path !== relPath);
498
+ ctx2.backlinks.set(resolved, filtered);
499
+ }
500
+ }
501
+ function addNoteBacklinks(ctx2, relPath, raw) {
502
+ const seen = /* @__PURE__ */ new Set();
503
+ for (const link of extractLinksWithLines(raw)) {
504
+ const resolved = resolveLink(link.target, ctx2.notes);
505
+ if (resolved === void 0) continue;
506
+ const dedupeKey = `${relPath}\0${resolved}`;
507
+ if (seen.has(dedupeKey)) continue;
508
+ seen.add(dedupeKey);
509
+ let arr = ctx2.backlinks.get(resolved);
510
+ if (arr === void 0) {
511
+ arr = [];
512
+ ctx2.backlinks.set(resolved, arr);
513
+ }
514
+ const ref = { path: relPath, line: link.line, linkType: link.linkType };
515
+ arr.push(ref);
516
+ arr.sort((a, b) => a.path.localeCompare(b.path));
517
+ }
518
+ }
519
+
520
+ // src/tools/rewrite_links.ts
521
+ import { posix } from "path";
522
+ var WIKI_RE = /(!?)\[\[([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?\]\]/g;
523
+ var MD_RE = /(!?)\[([^\]\n]*)\]\(([^)\n]+)\)/g;
524
+ var FENCE_RE = /^\s*(```|~~~)/;
525
+ function wikiTargetFor(newPath, postNotes) {
526
+ const noExt = newPath.replace(/\.md$/i, "");
527
+ const base = noExt.split("/").pop() ?? noExt;
528
+ const baseLower = base.toLowerCase();
529
+ let matches2 = 0;
530
+ for (const p of postNotes.keys()) {
531
+ const pBase = (p.replace(/\.md$/i, "").split("/").pop() ?? "").toLowerCase();
532
+ if (pBase === baseLower && ++matches2 > 1) return noExt;
533
+ }
534
+ return base;
535
+ }
536
+ function mdTargetToVaultPath(rawTarget, sourceDir) {
537
+ let t = rawTarget.trim();
538
+ if (t.startsWith("<") && t.endsWith(">")) t = t.slice(1, -1);
539
+ if (/^[a-z][a-z0-9+.-]*:/i.test(t)) return void 0;
540
+ if (t.startsWith("#") || t === "") return void 0;
541
+ const hashIdx = t.indexOf("#");
542
+ if (hashIdx !== -1) t = t.slice(0, hashIdx);
543
+ try {
544
+ t = decodeURIComponent(t);
545
+ } catch {
546
+ }
547
+ const joined = posix.normalize(posix.join(sourceDir, t));
548
+ if (joined.startsWith("..")) return void 0;
549
+ return joined;
550
+ }
551
+ function rewriteNoteLinks(raw, sourcePath, oldPath, newPath, preNotes, postNotes) {
552
+ const sourceDir = posix.dirname(sourcePath);
553
+ const oldNoExt = oldPath.replace(/\.md$/i, "");
554
+ let count = 0;
555
+ const rewriteWikiLine = (line) => line.replace(WIKI_RE, (full, bang, target, frag, alias) => {
556
+ const t = target.trim();
557
+ if (resolveLink(t, postNotes) === newPath) return full;
558
+ if (resolveLink(t, preNotes) !== oldPath) return full;
559
+ count++;
560
+ const next = wikiTargetFor(newPath, postNotes);
561
+ return `${bang}[[${next}${frag ?? ""}${alias ?? ""}]]`;
562
+ });
563
+ const rewriteMdLine = (line) => line.replace(MD_RE, (full, bang, text, rawTarget) => {
564
+ const asRelative = mdTargetToVaultPath(rawTarget, sourceDir);
565
+ const asAbsolute = mdTargetToVaultPath(rawTarget, ".");
566
+ let vaultAbsoluteStyle;
567
+ if (asRelative === oldPath || asRelative === oldNoExt) vaultAbsoluteStyle = false;
568
+ else if (asAbsolute === oldPath || asAbsolute === oldNoExt) vaultAbsoluteStyle = true;
569
+ else return full;
570
+ count++;
571
+ const trimmed = rawTarget.trim();
572
+ const wrapped = trimmed.startsWith("<") && trimmed.endsWith(">");
573
+ const inner = wrapped ? trimmed.slice(1, -1) : trimmed;
574
+ const hashIdx = inner.indexOf("#");
575
+ const fragment = hashIdx === -1 ? "" : inner.slice(hashIdx);
576
+ const hadExt = /\.md(#|$)/i.test(inner);
577
+ let next = vaultAbsoluteStyle ? newPath : posix.relative(sourceDir, newPath);
578
+ if (!hadExt) next = next.replace(/\.md$/i, "");
579
+ if (inner.includes("%20")) next = next.replaceAll(" ", "%20");
580
+ const needsWrap = wrapped || next.includes(" ");
581
+ const target = needsWrap ? `<${next}${fragment}>` : `${next}${fragment}`;
582
+ return `${bang}[${text}](${target})`;
583
+ });
584
+ let inFence = false;
585
+ const lines = raw.split("\n").map((line) => {
586
+ if (FENCE_RE.test(line)) {
587
+ inFence = !inFence;
588
+ return line;
589
+ }
590
+ if (inFence) return line;
591
+ return rewriteMdLine(rewriteWikiLine(line));
592
+ });
593
+ return { content: lines.join("\n"), count };
594
+ }
595
+ function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
596
+ if (!raw.includes("](")) return false;
597
+ const sourceDir = posix.dirname(sourcePath);
598
+ const noExt = targetPath.replace(/\.md$/i, "");
599
+ for (const m of raw.matchAll(MD_RE)) {
600
+ const rawTarget = m[3] ?? "";
601
+ const asRelative = mdTargetToVaultPath(rawTarget, sourceDir);
602
+ const asAbsolute = mdTargetToVaultPath(rawTarget, ".");
603
+ if (asRelative === targetPath || asRelative === noExt || asAbsolute === targetPath || asAbsolute === noExt) {
604
+ return true;
605
+ }
606
+ }
607
+ return false;
608
+ }
609
+
610
+ // src/tools/move_note.ts
428
611
  var MoveNoteInput = z8.object({
429
612
  from: z8.string().describe("Vault-relative path of the note to move."),
430
613
  to: z8.string().describe("Vault-relative destination path."),
431
- overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false.")
614
+ overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
615
+ rewriteLinks: z8.boolean().default(true).describe(
616
+ "Rewrite wikilinks and markdown links in other notes that point at the moved note, so nothing breaks. Defaults to true; pass false to move the file only."
617
+ )
432
618
  });
433
- async function moveNote(ctx2, input) {
619
+ async function moveNote(ctx2, rawInput) {
620
+ const input = MoveNoteInput.parse(rawInput);
434
621
  const absFrom = join6(ctx2.vaultRoot, input.from);
435
622
  const absTo = join6(ctx2.vaultRoot, input.to);
436
623
  if (!absFrom.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.from}`);
437
624
  if (!absTo.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${input.to}`);
625
+ assertWritable(ctx2.policy, input.from);
626
+ assertWritable(ctx2.policy, input.to);
438
627
  try {
439
628
  await access2(absFrom);
440
629
  } catch {
@@ -450,15 +639,59 @@ async function moveNote(ctx2, input) {
450
639
  if (err.code !== "ENOENT") throw err;
451
640
  }
452
641
  }
642
+ const preNotes = new Map(ctx2.notes);
643
+ const candidates = /* @__PURE__ */ new Set();
644
+ if (input.rewriteLinks) {
645
+ for (const ref of ctx2.backlinks.get(input.from) ?? []) candidates.add(ref.path);
646
+ for (const [path2, doc] of preNotes) {
647
+ if (path2 === input.from || candidates.has(path2)) continue;
648
+ if (hasMarkdownLinkTo(doc.raw, path2, input.from)) candidates.add(path2);
649
+ }
650
+ candidates.delete(input.from);
651
+ }
453
652
  await mkdir2(dirname2(absTo), { recursive: true });
454
653
  await rename(absFrom, absTo);
455
654
  const raw = ctx2.notes.get(input.from)?.raw ?? "";
655
+ removeNoteBacklinks(ctx2, input.from);
456
656
  if (ctx2.notes.has(input.from)) {
457
657
  ctx2.index.discard(input.from);
458
658
  ctx2.notes.delete(input.from);
459
659
  }
460
660
  upsertDoc(ctx2, buildDoc(input.to, raw));
461
- return { from: input.from, to: input.to };
661
+ addNoteBacklinks(ctx2, input.to, raw);
662
+ let notesRewritten = 0;
663
+ let linksRewritten = 0;
664
+ const skipped = [];
665
+ for (const path2 of [...candidates].sort()) {
666
+ const doc = ctx2.notes.get(path2);
667
+ if (doc === void 0) continue;
668
+ if (!isWritable(ctx2.policy, path2)) {
669
+ skipped.push(path2);
670
+ continue;
671
+ }
672
+ const { content, count } = rewriteNoteLinks(
673
+ doc.raw,
674
+ path2,
675
+ input.from,
676
+ input.to,
677
+ preNotes,
678
+ ctx2.notes
679
+ );
680
+ removeNoteBacklinks(ctx2, path2);
681
+ if (count > 0) {
682
+ await writeFile3(join6(ctx2.vaultRoot, path2), content, "utf8");
683
+ upsertDoc(ctx2, buildDoc(path2, content));
684
+ notesRewritten++;
685
+ linksRewritten += count;
686
+ addNoteBacklinks(ctx2, path2, content);
687
+ } else {
688
+ addNoteBacklinks(ctx2, path2, doc.raw);
689
+ }
690
+ }
691
+ ctx2.backlinks.delete(input.from);
692
+ const result = { from: input.from, to: input.to, notesRewritten, linksRewritten };
693
+ if (skipped.length > 0) result.skipped = skipped;
694
+ return result;
462
695
  }
463
696
 
464
697
  // src/tools/outline_note.ts
@@ -532,7 +765,7 @@ async function outlineNote(ctx2, input) {
532
765
  }
533
766
 
534
767
  // src/tools/patch_frontmatter.ts
535
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
768
+ import { readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
536
769
  import { join as join8 } from "path";
537
770
  import { parseDocument } from "yaml";
538
771
  import { z as z10 } from "zod";
@@ -547,6 +780,7 @@ async function patchFrontmatter(ctx2, input) {
547
780
  if (!absPath.startsWith(ctx2.vaultRoot)) {
548
781
  throw new Error(`Path outside vault: ${input.path}`);
549
782
  }
783
+ assertWritable(ctx2.policy, input.path);
550
784
  const original = await readFile3(absPath, "utf8");
551
785
  const fm = parseFrontmatter(original);
552
786
  const keysChanged = [];
@@ -589,7 +823,7 @@ ${original}`;
589
823
  const tail = opensWithCRLF ? "---\r\n" : "---\n";
590
824
  newContent = `${head}${doc.toString()}${tail}${fm.body}`;
591
825
  }
592
- await writeFile3(absPath, newContent, "utf8");
826
+ await writeFile4(absPath, newContent, "utf8");
593
827
  const cached = ctx2.notes.get(input.path);
594
828
  if (cached) {
595
829
  const updated = parseFrontmatter(newContent);
@@ -600,7 +834,7 @@ ${original}`;
600
834
  }
601
835
 
602
836
  // src/tools/patch_note.ts
603
- import { readFile as readFile4, rename as rename2, writeFile as writeFile4 } from "fs/promises";
837
+ import { readFile as readFile4, rename as rename2, writeFile as writeFile5 } from "fs/promises";
604
838
  import { join as join9 } from "path";
605
839
  import { z as z11 } from "zod";
606
840
  var PatchNoteInput = z11.object({
@@ -628,7 +862,7 @@ function lineStart(raw, offset) {
628
862
  }
629
863
  async function atomicWrite(absPath, content) {
630
864
  const tmpPath = `${absPath}.seekstone-patch-tmp`;
631
- await writeFile4(tmpPath, content, "utf8");
865
+ await writeFile5(tmpPath, content, "utf8");
632
866
  await rename2(tmpPath, absPath);
633
867
  }
634
868
  async function patchNote(ctx2, input) {
@@ -636,6 +870,7 @@ async function patchNote(ctx2, input) {
636
870
  if (!absPath.startsWith(ctx2.vaultRoot)) {
637
871
  throw new Error(`Path outside vault: ${input.path}`);
638
872
  }
873
+ assertWritable(ctx2.policy, input.path);
639
874
  const raw = await readFile4(absPath, "utf8");
640
875
  const fm = parseFrontmatter(raw);
641
876
  const originalFmRegion = raw.slice(0, fm.bodyStart);
@@ -748,7 +983,7 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
748
983
  }
749
984
 
750
985
  // src/tools/periodic_note.ts
751
- import { access as access3, mkdir as mkdir3, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
986
+ import { access as access3, mkdir as mkdir3, readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
752
987
  import { dirname as dirname3, join as join10 } from "path";
753
988
  import { z as z12 } from "zod";
754
989
  var PERIOD = z12.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
@@ -840,6 +1075,7 @@ async function getPeriodicNote(ctx2, rawInput) {
840
1075
  }
841
1076
  if (existed) return { path: path2, existed: true, created: false };
842
1077
  if (!input.createIfMissing) return { path: path2, existed: false, created: false };
1078
+ assertWritable(ctx2.policy, path2);
843
1079
  let body = "";
844
1080
  if (cfg.template) {
845
1081
  const templateAbs = join10(
@@ -854,7 +1090,7 @@ async function getPeriodicNote(ctx2, rawInput) {
854
1090
  }
855
1091
  }
856
1092
  await mkdir3(dirname3(abs), { recursive: true });
857
- await writeFile5(abs, body, "utf8");
1093
+ await writeFile6(abs, body, "utf8");
858
1094
  upsertDoc(ctx2, buildDoc(path2, body));
859
1095
  return { path: path2, existed: false, created: true };
860
1096
  }
@@ -871,6 +1107,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
871
1107
  const path2 = resolveNotePath(cfg, date);
872
1108
  const abs = join10(ctx2.vaultRoot, path2);
873
1109
  if (!abs.startsWith(ctx2.vaultRoot)) throw new Error(`Path outside vault: ${path2}`);
1110
+ assertWritable(ctx2.policy, path2);
874
1111
  let original = "";
875
1112
  try {
876
1113
  original = await readFile5(abs, "utf8");
@@ -878,7 +1115,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
878
1115
  if (err.code !== "ENOENT") throw err;
879
1116
  if (!input.createIfMissing) throw new Error(`Periodic note not found: ${path2}`);
880
1117
  await mkdir3(dirname3(abs), { recursive: true });
881
- await writeFile5(abs, input.content, "utf8");
1118
+ await writeFile6(abs, input.content, "utf8");
882
1119
  upsertDoc(ctx2, buildDoc(path2, input.content));
883
1120
  return { path: path2, bytesWritten: Buffer.byteLength(input.content, "utf8") };
884
1121
  }
@@ -889,7 +1126,7 @@ async function appendPeriodicNote(ctx2, rawInput) {
889
1126
  const newBody = `${body}${separator}${input.content}`;
890
1127
  const header = original.slice(0, fm.bodyStart);
891
1128
  const newRaw = `${header}${newBody}`;
892
- await writeFile5(abs, newRaw, "utf8");
1129
+ await writeFile6(abs, newRaw, "utf8");
893
1130
  const cached = ctx2.notes.get(path2);
894
1131
  if (cached) {
895
1132
  cached.body = newBody;
@@ -1206,7 +1443,7 @@ async function readNote(ctx2, input) {
1206
1443
  }
1207
1444
 
1208
1445
  // src/tools/replace_in_note.ts
1209
- import { readFile as readFile7, rename as rename3, writeFile as writeFile6 } from "fs/promises";
1446
+ import { readFile as readFile7, rename as rename3, writeFile as writeFile7 } from "fs/promises";
1210
1447
  import { join as join12 } from "path";
1211
1448
  import { z as z16 } from "zod";
1212
1449
  var ReplaceInNoteInput = z16.object({
@@ -1230,7 +1467,7 @@ function buildRegex(find, opts) {
1230
1467
  }
1231
1468
  async function atomicWrite2(absPath, content) {
1232
1469
  const tmpPath = `${absPath}.seekstone-replace-tmp`;
1233
- await writeFile6(tmpPath, content, "utf8");
1470
+ await writeFile7(tmpPath, content, "utf8");
1234
1471
  await rename3(tmpPath, absPath);
1235
1472
  }
1236
1473
  async function replaceInNote(ctx2, input) {
@@ -1238,6 +1475,7 @@ async function replaceInNote(ctx2, input) {
1238
1475
  if (!absPath.startsWith(ctx2.vaultRoot)) {
1239
1476
  throw new Error(`Path outside vault: ${input.path}`);
1240
1477
  }
1478
+ assertWritable(ctx2.policy, input.path);
1241
1479
  let re;
1242
1480
  try {
1243
1481
  re = buildRegex(input.find, {
@@ -1299,6 +1537,16 @@ async function replaceInNote(ctx2, input) {
1299
1537
  }
1300
1538
 
1301
1539
  // src/dispatch.ts
1540
+ var WRITE_TOOLS = /* @__PURE__ */ new Set([
1541
+ "create_note",
1542
+ "delete_note",
1543
+ "move_note",
1544
+ "append_note",
1545
+ "patch_frontmatter",
1546
+ "patch_note",
1547
+ "replace_in_note",
1548
+ "append_periodic_note"
1549
+ ]);
1302
1550
  var META_KEYS = [
1303
1551
  "path",
1304
1552
  "from",
@@ -1339,6 +1587,23 @@ async function dispatch(ctx2, name, args, log2) {
1339
1587
  const start = performance.now();
1340
1588
  const meta = safeMeta(args);
1341
1589
  log2.debug("tool start", { tool: name, args });
1590
+ if (ctx2.policy.readOnly && WRITE_TOOLS.has(name)) {
1591
+ log2.info("tool rejected (read-only)", { tool: name, ...meta });
1592
+ return {
1593
+ content: [
1594
+ {
1595
+ type: "text",
1596
+ text: "Error: Server is read-only (SEEKSTONE_READ_ONLY=1); write tools are disabled."
1597
+ }
1598
+ ],
1599
+ isError: true
1600
+ };
1601
+ }
1602
+ if (ctx2.policy.readOnly && name === "get_periodic_note") {
1603
+ if (typeof args === "object" && args !== null) {
1604
+ args = { ...args, createIfMissing: false };
1605
+ }
1606
+ }
1342
1607
  try {
1343
1608
  const result = await run(ctx2, name, args);
1344
1609
  const durationMs = Math.round((performance.now() - start) * 100) / 100;
@@ -1399,7 +1664,11 @@ async function run(ctx2, name, args) {
1399
1664
  case "move_note": {
1400
1665
  const input = MoveNoteInput.parse(args);
1401
1666
  const result = await moveNote(ctx2, input);
1402
- return { content: [{ type: "text", text: `Moved ${result.from} \u2192 ${result.to}.` }] };
1667
+ let text = `Moved ${result.from} \u2192 ${result.to}. Rewrote ${result.linksRewritten} link${result.linksRewritten === 1 ? "" : "s"} in ${result.notesRewritten} note${result.notesRewritten === 1 ? "" : "s"}.`;
1668
+ if (result.skipped?.length) {
1669
+ text += ` Skipped (outside SEEKSTONE_WRITE_PATHS): ${result.skipped.join(", ")}.`;
1670
+ }
1671
+ return { content: [{ type: "text", text }] };
1403
1672
  }
1404
1673
  case "append_note": {
1405
1674
  const input = AppendNoteInput.parse(args);
@@ -1598,7 +1867,7 @@ function buildBacklinks(notes2) {
1598
1867
 
1599
1868
  // src/init.ts
1600
1869
  import { execFileSync } from "child_process";
1601
- import { access as access4, copyFile, mkdir as mkdir4, readdir, readFile as readFile9, stat as stat2, writeFile as writeFile7 } from "fs/promises";
1870
+ import { access as access4, copyFile, mkdir as mkdir4, readdir, readFile as readFile9, stat as stat2, writeFile as writeFile8 } from "fs/promises";
1602
1871
  import path, { dirname as dirname4, join as join14 } from "path";
1603
1872
  function parseInitArgs(argv) {
1604
1873
  const opts = { write: false, client: "desktop" };
@@ -1609,6 +1878,7 @@ function parseInitArgs(argv) {
1609
1878
  else if (a === "--client") {
1610
1879
  const v = argv[++i];
1611
1880
  if (v === "desktop" || v === "code" || v === "cursor" || v === "vscode") opts.client = v;
1881
+ else opts.invalidClient = v ?? "(missing)";
1612
1882
  }
1613
1883
  }
1614
1884
  return opts;
@@ -1732,6 +2002,15 @@ async function countMarkdown(root) {
1732
2002
  return count;
1733
2003
  }
1734
2004
  async function runInit(opts, deps) {
2005
+ if (opts.invalidClient !== void 0) {
2006
+ return {
2007
+ ok: false,
2008
+ exitCode: 1,
2009
+ output: [
2010
+ `\u2717 Unknown --client: ${opts.invalidClient}. Known clients: desktop, code, cursor, vscode.`
2011
+ ]
2012
+ };
2013
+ }
1735
2014
  const rf = deps.readFile ?? readFile9;
1736
2015
  let vaultPath = opts.vault ?? deps.env.SEEKSTONE_VAULT;
1737
2016
  if (!vaultPath) {
@@ -1868,7 +2147,7 @@ async function runInit(opts, deps) {
1868
2147
  backupPath = `${configPath}.backup-${deps.timestamp}`;
1869
2148
  await copyFile(configPath, backupPath);
1870
2149
  }
1871
- await writeFile7(configPath, `${JSON.stringify(merged, null, 2)}
2150
+ await writeFile8(configPath, `${JSON.stringify(merged, null, 2)}
1872
2151
  `, "utf8");
1873
2152
  out.push(
1874
2153
  `\u2713 Patched ${configPath}`,
@@ -1939,7 +2218,7 @@ function createLogger(opts = {}) {
1939
2218
  );
1940
2219
  }
1941
2220
  }
1942
- function writeFile8(record) {
2221
+ function writeFile9(record) {
1943
2222
  if (!fileOk || filePath === void 0) return;
1944
2223
  const line = `${JSON.stringify(record)}
1945
2224
  `;
@@ -1961,7 +2240,7 @@ function createLogger(opts = {}) {
1961
2240
  const f = fields ?? {};
1962
2241
  stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
1963
2242
  `);
1964
- writeFile8({ t: ts, level: lvl, msg, ...f });
2243
+ writeFile9({ t: ts, level: lvl, msg, ...f });
1965
2244
  }
1966
2245
  return {
1967
2246
  level,
@@ -1992,41 +2271,407 @@ function installProcessGuards(log2) {
1992
2271
  };
1993
2272
  }
1994
2273
 
2274
+ // src/tool-list.ts
2275
+ var ALL_TOOLS = [
2276
+ {
2277
+ name: "search",
2278
+ description: "Full-text search across the vault. Returns ranked excerpts (~200 chars) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search.",
2279
+ inputSchema: {
2280
+ type: "object",
2281
+ properties: {
2282
+ query: { type: "string", description: "Search query." },
2283
+ limit: { type: "number", description: "Max results (1\u201350, default 10)." },
2284
+ folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
2285
+ tag: { type: "string", description: "Restrict to notes with this tag." }
2286
+ },
2287
+ required: ["query"]
2288
+ }
2289
+ },
2290
+ {
2291
+ name: "query_notes",
2292
+ description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
2293
+ inputSchema: {
2294
+ type: "object",
2295
+ properties: {
2296
+ where: {
2297
+ type: "array",
2298
+ description: "Frontmatter predicates \u2014 all must match (AND).",
2299
+ items: {
2300
+ type: "object",
2301
+ properties: {
2302
+ key: { type: "string", description: "Frontmatter key to test." },
2303
+ op: {
2304
+ type: "string",
2305
+ enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
2306
+ description: "eq/ne compare scalars; contains matches array membership or substring; exists/missing test key presence; gt/gte/lt/lte compare numbers or strings (ISO dates sort correctly)."
2307
+ },
2308
+ value: {
2309
+ description: "Comparison value. Required for every op except exists/missing."
2310
+ }
2311
+ },
2312
+ required: ["key", "op"]
2313
+ }
2314
+ },
2315
+ folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
2316
+ tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
2317
+ modifiedAfter: {
2318
+ type: "string",
2319
+ description: "Only notes modified at or after this ISO 8601 date/time."
2320
+ },
2321
+ modifiedBefore: {
2322
+ type: "string",
2323
+ description: "Only notes modified before this ISO 8601 date/time."
2324
+ },
2325
+ minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
2326
+ maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
2327
+ select: {
2328
+ type: "array",
2329
+ items: { type: "string" },
2330
+ description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
2331
+ },
2332
+ sort: {
2333
+ type: "string",
2334
+ enum: ["path", "title", "mtime", "size"],
2335
+ description: "Sort field (default path)."
2336
+ },
2337
+ order: {
2338
+ type: "string",
2339
+ enum: ["asc", "desc"],
2340
+ description: "Sort order (default asc)."
2341
+ },
2342
+ limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
2343
+ },
2344
+ required: []
2345
+ }
2346
+ },
2347
+ {
2348
+ name: "read_note",
2349
+ description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, and total note size so payload savings are measurable. Use search or outline_note first to find the right path and section names.",
2350
+ inputSchema: {
2351
+ type: "object",
2352
+ properties: {
2353
+ path: {
2354
+ type: "string",
2355
+ description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
2356
+ },
2357
+ section: {
2358
+ type: "string",
2359
+ description: "Return only this heading section (heading text, # prefix optional). First match wins."
2360
+ },
2361
+ block: {
2362
+ type: "string",
2363
+ description: "Return only the line anchored by this block id (^ prefix optional)."
2364
+ },
2365
+ lines: {
2366
+ type: "object",
2367
+ description: "Return only this line range (1-indexed, inclusive).",
2368
+ properties: {
2369
+ from: { type: "number", description: "First line (1-indexed)." },
2370
+ to: {
2371
+ type: "number",
2372
+ description: "Last line (1-indexed, inclusive). Defaults to EOF."
2373
+ }
2374
+ },
2375
+ required: ["from"]
2376
+ },
2377
+ includeFrontmatter: {
2378
+ type: "boolean",
2379
+ description: "Prepend frontmatter to section/block span results. Default false for spans."
2380
+ }
2381
+ },
2382
+ required: ["path"]
2383
+ }
2384
+ },
2385
+ {
2386
+ name: "list_notes",
2387
+ description: "List notes, optionally filtered by folder prefix or tag.",
2388
+ inputSchema: {
2389
+ type: "object",
2390
+ properties: {
2391
+ folder: { type: "string", description: "Vault-relative folder prefix." },
2392
+ tag: { type: "string", description: "Filter by tag (# prefix optional)." },
2393
+ limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
2394
+ },
2395
+ required: []
2396
+ }
2397
+ },
2398
+ {
2399
+ name: "list_tags",
2400
+ description: "List all tags in the vault with usage counts. Supports substring filtering, minimum count threshold, and sort order. Nested tags (e.g. area/work) include a parent field.",
2401
+ inputSchema: {
2402
+ type: "object",
2403
+ properties: {
2404
+ pattern: {
2405
+ type: "string",
2406
+ description: 'Substring filter on tag name. Example: "work" matches "work" and "area/work".'
2407
+ },
2408
+ minCount: {
2409
+ type: "number",
2410
+ description: "Only return tags used in at least this many notes."
2411
+ },
2412
+ sort: {
2413
+ type: "string",
2414
+ enum: ["count", "name"],
2415
+ description: "Sort by usage count descending (default) or alphabetically."
2416
+ }
2417
+ },
2418
+ required: []
2419
+ }
2420
+ },
2421
+ {
2422
+ name: "create_note",
2423
+ description: "Create a new note at a vault-relative path. Optionally sets frontmatter and body content. Parent directories are created automatically. Fails if the note already exists unless overwrite is true.",
2424
+ inputSchema: {
2425
+ type: "object",
2426
+ properties: {
2427
+ path: {
2428
+ type: "string",
2429
+ description: 'Vault-relative path, e.g. "Daily Notes/2026-06-01.md".'
2430
+ },
2431
+ content: { type: "string", description: "Body content for the note." },
2432
+ frontmatter: {
2433
+ type: "object",
2434
+ description: "Frontmatter key-value pairs.",
2435
+ additionalProperties: true
2436
+ },
2437
+ overwrite: {
2438
+ type: "boolean",
2439
+ description: "Overwrite an existing note. Defaults to false."
2440
+ }
2441
+ },
2442
+ required: ["path"]
2443
+ }
2444
+ },
2445
+ {
2446
+ name: "delete_note",
2447
+ description: "Permanently delete a note from the vault. This cannot be undone.",
2448
+ inputSchema: {
2449
+ type: "object",
2450
+ properties: {
2451
+ path: { type: "string", description: "Vault-relative path of the note to delete." }
2452
+ },
2453
+ required: ["path"]
2454
+ }
2455
+ },
2456
+ {
2457
+ name: "move_note",
2458
+ description: "Move or rename a note to a new vault-relative path, rewriting wikilinks and markdown links in other notes that point at it so nothing breaks (links inside fenced code blocks are left alone). Parent directories at the destination are created automatically. Fails if the destination already exists unless overwrite is true.",
2459
+ inputSchema: {
2460
+ type: "object",
2461
+ properties: {
2462
+ from: { type: "string", description: "Vault-relative source path." },
2463
+ to: { type: "string", description: "Vault-relative destination path." },
2464
+ overwrite: {
2465
+ type: "boolean",
2466
+ description: "Overwrite destination if it exists. Defaults to false."
2467
+ },
2468
+ rewriteLinks: {
2469
+ type: "boolean",
2470
+ description: "Rewrite references in other notes to follow the move. Defaults to true; pass false to move the file only."
2471
+ }
2472
+ },
2473
+ required: ["from", "to"]
2474
+ }
2475
+ },
2476
+ {
2477
+ name: "append_note",
2478
+ description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
2479
+ inputSchema: {
2480
+ type: "object",
2481
+ properties: {
2482
+ path: { type: "string", description: "Vault-relative path to the note." },
2483
+ content: { type: "string", description: "Text to append." }
2484
+ },
2485
+ required: ["path", "content"]
2486
+ }
2487
+ },
2488
+ {
2489
+ name: "patch_frontmatter",
2490
+ description: "Set, update, or delete frontmatter keys without reordering existing keys or changing quote style. Pass null as a value to delete a key.",
2491
+ inputSchema: {
2492
+ type: "object",
2493
+ properties: {
2494
+ path: { type: "string", description: "Vault-relative path to the note." },
2495
+ patch: {
2496
+ type: "object",
2497
+ description: "Key-value pairs to set. Null value removes the key.",
2498
+ additionalProperties: true
2499
+ }
2500
+ },
2501
+ required: ["path", "patch"]
2502
+ }
2503
+ },
2504
+ {
2505
+ name: "outline_note",
2506
+ description: "Return a note's structure \u2014 heading tree with offsets, block-reference anchors, and frontmatter key list \u2014 without returning any prose. Use this before section reads or patches to discover what sections exist at a fraction of the cost of reading the full note.",
2507
+ inputSchema: {
2508
+ type: "object",
2509
+ properties: {
2510
+ path: { type: "string", description: "Vault-relative path to the note." },
2511
+ includeBlocks: {
2512
+ type: "boolean",
2513
+ description: "Include block-reference anchors (^id). Default true."
2514
+ },
2515
+ includeSizes: {
2516
+ type: "boolean",
2517
+ description: "Include per-section character length in each heading entry. Default false."
2518
+ }
2519
+ },
2520
+ required: ["path"]
2521
+ }
2522
+ },
2523
+ {
2524
+ name: "patch_note",
2525
+ description: "Surgically edit a section of a note \u2014 targeted by heading or block reference \u2014 without rewriting the whole file. Operations: append (add after section), prepend (add after heading line), replace (swap section content). Frontmatter is never touched.",
2526
+ inputSchema: {
2527
+ type: "object",
2528
+ properties: {
2529
+ path: { type: "string", description: "Vault-relative path to the note." },
2530
+ target: {
2531
+ type: "object",
2532
+ description: 'Exactly one of: { heading: "Section Title" } or { block: "block-id" } (without the ^ prefix).'
2533
+ },
2534
+ operation: {
2535
+ type: "string",
2536
+ enum: ["append", "prepend", "replace"],
2537
+ description: "append: insert after section content. prepend: insert after heading line. replace: swap section content."
2538
+ },
2539
+ content: { type: "string", description: "Content to insert or replace with." },
2540
+ createIfMissing: {
2541
+ type: "boolean",
2542
+ description: "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets. Default false."
2543
+ }
2544
+ },
2545
+ required: ["path", "target", "operation", "content"]
2546
+ }
2547
+ },
2548
+ {
2549
+ name: "get_backlinks",
2550
+ description: "Return every note that links to the target note, with the source line and an optional excerpt. Results come from the pre-built reverse-link index so this is a fast, pure index lookup. Sort order: source path ascending.",
2551
+ inputSchema: {
2552
+ type: "object",
2553
+ properties: {
2554
+ path: { type: "string", description: "Vault-relative path to the target note." },
2555
+ includeContext: {
2556
+ type: "boolean",
2557
+ description: "Include a short excerpt (~200 chars) from the linking line. Default true."
2558
+ },
2559
+ limit: {
2560
+ type: "number",
2561
+ description: "Max backlinks to return (1\u2013500). Default 50."
2562
+ }
2563
+ },
2564
+ required: ["path"]
2565
+ }
2566
+ },
2567
+ {
2568
+ name: "get_links",
2569
+ description: "Return all outgoing wikilinks and embeds from a note. Each link is marked resolved (with target path) or unresolved. Duplicate targets are de-duplicated; results sorted by line number.",
2570
+ inputSchema: {
2571
+ type: "object",
2572
+ properties: {
2573
+ path: { type: "string", description: "Vault-relative path to the note." }
2574
+ },
2575
+ required: ["path"]
2576
+ }
2577
+ },
2578
+ {
2579
+ name: "replace_in_note",
2580
+ description: "Find and replace text within a note body. Supports literal and regex search, case sensitivity, whole-word matching, and a replacement limit. Frontmatter is never touched. Use dryRun to preview matches before writing.",
2581
+ inputSchema: {
2582
+ type: "object",
2583
+ properties: {
2584
+ path: { type: "string", description: "Vault-relative path to the note." },
2585
+ find: { type: "string", description: "Text or pattern to find." },
2586
+ replace: {
2587
+ type: "string",
2588
+ description: "Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."
2589
+ },
2590
+ regex: {
2591
+ type: "boolean",
2592
+ description: "Treat find as a regular expression. Default false."
2593
+ },
2594
+ caseSensitive: {
2595
+ type: "boolean",
2596
+ description: "Case-sensitive matching. Default false."
2597
+ },
2598
+ wholeWord: {
2599
+ type: "boolean",
2600
+ description: "Match whole words only (\\b boundary). Default false."
2601
+ },
2602
+ limit: {
2603
+ type: "number",
2604
+ description: "Maximum number of replacements. Omit to replace all."
2605
+ },
2606
+ dryRun: {
2607
+ type: "boolean",
2608
+ description: "If true, report matches without writing. Default false."
2609
+ }
2610
+ },
2611
+ required: ["path", "find", "replace"]
2612
+ }
2613
+ },
2614
+ {
2615
+ name: "get_periodic_note",
2616
+ description: "Get the path and existence status of a periodic note (daily, weekly, monthly, quarterly, or yearly) for a given date. Reads folder/format config from .obsidian/daily-notes.json (daily) or the periodic-notes plugin data.json. Optionally creates the note from the configured template if it is missing.",
2617
+ inputSchema: {
2618
+ type: "object",
2619
+ properties: {
2620
+ period: {
2621
+ type: "string",
2622
+ enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
2623
+ description: "Period type. Default: daily."
2624
+ },
2625
+ date: {
2626
+ type: "string",
2627
+ description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
2628
+ },
2629
+ createIfMissing: {
2630
+ type: "boolean",
2631
+ description: "Create the note from the configured template if it does not exist. Default false."
2632
+ }
2633
+ },
2634
+ required: []
2635
+ }
2636
+ },
2637
+ {
2638
+ name: "append_periodic_note",
2639
+ description: "Append text to a periodic note (daily, weekly, monthly, quarterly, or yearly). Preserves existing frontmatter exactly. Creates the note first (from template if configured) when createIfMissing is true (default).",
2640
+ inputSchema: {
2641
+ type: "object",
2642
+ properties: {
2643
+ period: {
2644
+ type: "string",
2645
+ enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
2646
+ description: "Period type. Default: daily."
2647
+ },
2648
+ date: {
2649
+ type: "string",
2650
+ description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
2651
+ },
2652
+ content: {
2653
+ type: "string",
2654
+ description: "Text to append to the note body."
2655
+ },
2656
+ createIfMissing: {
2657
+ type: "boolean",
2658
+ description: "Create the note if it does not exist before appending. Default true."
2659
+ }
2660
+ },
2661
+ required: ["content"]
2662
+ }
2663
+ }
2664
+ ];
2665
+ function visibleTools(policy2) {
2666
+ if (!policy2.readOnly) return ALL_TOOLS;
2667
+ return ALL_TOOLS.filter((t) => !WRITE_TOOLS.has(t.name));
2668
+ }
2669
+
1995
2670
  // src/watcher.ts
1996
2671
  import { realpathSync } from "fs";
1997
2672
  import { readFile as readFile10 } from "fs/promises";
1998
2673
  import { join as join15, relative as relative2, sep as sep2 } from "path";
1999
2674
  import chokidar from "chokidar";
2000
- function removeNoteBacklinks(ctx2, relPath) {
2001
- const oldDoc = ctx2.notes.get(relPath);
2002
- if (oldDoc === void 0) return;
2003
- for (const link of extractLinksWithLines(oldDoc.raw)) {
2004
- const resolved = resolveLink(link.target, ctx2.notes);
2005
- if (resolved === void 0) continue;
2006
- const arr = ctx2.backlinks.get(resolved);
2007
- if (arr === void 0) continue;
2008
- const filtered = arr.filter((r) => r.path !== relPath);
2009
- ctx2.backlinks.set(resolved, filtered);
2010
- }
2011
- }
2012
- function addNoteBacklinks(ctx2, relPath, raw) {
2013
- const seen = /* @__PURE__ */ new Set();
2014
- for (const link of extractLinksWithLines(raw)) {
2015
- const resolved = resolveLink(link.target, ctx2.notes);
2016
- if (resolved === void 0) continue;
2017
- const dedupeKey = `${relPath}\0${resolved}`;
2018
- if (seen.has(dedupeKey)) continue;
2019
- seen.add(dedupeKey);
2020
- let arr = ctx2.backlinks.get(resolved);
2021
- if (arr === void 0) {
2022
- arr = [];
2023
- ctx2.backlinks.set(resolved, arr);
2024
- }
2025
- const ref = { path: relPath, line: link.line, linkType: link.linkType };
2026
- arr.push(ref);
2027
- arr.sort((a, b) => a.path.localeCompare(b.path));
2028
- }
2029
- }
2030
2675
  function startWatcher(ctx2, log2, opts) {
2031
2676
  const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
2032
2677
  let vaultRoot2 = ctx2.vaultRoot;
@@ -2083,13 +2728,13 @@ function startWatcher(ctx2, log2, opts) {
2083
2728
  });
2084
2729
  });
2085
2730
  return {
2086
- stop: () => void watcher2.close(),
2731
+ stop: () => watcher2.close(),
2087
2732
  ready
2088
2733
  };
2089
2734
  }
2090
2735
 
2091
2736
  // src/index.ts
2092
- var VERSION = true ? "0.9.0" : "0.0.0-dev";
2737
+ var VERSION = true ? "0.10.0" : "0.0.0-dev";
2093
2738
  var intent = parseCliIntent(process.argv.slice(2));
2094
2739
  if (intent === "version") {
2095
2740
  process.stdout.write(`${VERSION}
@@ -2101,6 +2746,11 @@ if (intent === "help") {
2101
2746
  `);
2102
2747
  process.exit(0);
2103
2748
  }
2749
+ if (intent === "init-help") {
2750
+ process.stdout.write(`${initHelpText(VERSION)}
2751
+ `);
2752
+ process.exit(0);
2753
+ }
2104
2754
  if (intent === "init") {
2105
2755
  const result = await runInit(parseInitArgs(process.argv.slice(3)), {
2106
2756
  env: process.env,
@@ -2119,400 +2769,18 @@ if (!vaultRoot) {
2119
2769
  log.error("SEEKSTONE_VAULT env var is required");
2120
2770
  process.exit(1);
2121
2771
  }
2772
+ var policy = parseWritePolicy(process.env);
2773
+ if (policy.readOnly) log.info("read-only mode", { env: "SEEKSTONE_READ_ONLY" });
2774
+ if (policy.writeGlobs) log.info("write paths scoped", { globs: policy.writeGlobs });
2122
2775
  log.info("building index", { vault: vaultRoot });
2123
2776
  var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
2124
2777
  log.info("index ready", { notes: notes.size, buildMs });
2125
- var ctx = { vaultRoot, index, notes, backlinks };
2778
+ var ctx = { vaultRoot, index, notes, backlinks, policy };
2126
2779
  var watcher = startWatcher(ctx, log);
2127
- process.on("exit", watcher.stop);
2780
+ process.on("exit", () => void watcher.stop());
2128
2781
  var server = new Server({ name: "seekstone", version: VERSION }, { capabilities: { tools: {} } });
2129
2782
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
2130
- tools: [
2131
- {
2132
- name: "search",
2133
- description: "Full-text search across the vault. Returns ranked excerpts (~200 chars) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search.",
2134
- inputSchema: {
2135
- type: "object",
2136
- properties: {
2137
- query: { type: "string", description: "Search query." },
2138
- limit: { type: "number", description: "Max results (1\u201350, default 10)." },
2139
- folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
2140
- tag: { type: "string", description: "Restrict to notes with this tag." }
2141
- },
2142
- required: ["query"]
2143
- }
2144
- },
2145
- {
2146
- name: "query_notes",
2147
- description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
2148
- inputSchema: {
2149
- type: "object",
2150
- properties: {
2151
- where: {
2152
- type: "array",
2153
- description: "Frontmatter predicates \u2014 all must match (AND).",
2154
- items: {
2155
- type: "object",
2156
- properties: {
2157
- key: { type: "string", description: "Frontmatter key to test." },
2158
- op: {
2159
- type: "string",
2160
- enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
2161
- description: "eq/ne compare scalars; contains matches array membership or substring; exists/missing test key presence; gt/gte/lt/lte compare numbers or strings (ISO dates sort correctly)."
2162
- },
2163
- value: {
2164
- description: "Comparison value. Required for every op except exists/missing."
2165
- }
2166
- },
2167
- required: ["key", "op"]
2168
- }
2169
- },
2170
- folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
2171
- tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
2172
- modifiedAfter: {
2173
- type: "string",
2174
- description: "Only notes modified at or after this ISO 8601 date/time."
2175
- },
2176
- modifiedBefore: {
2177
- type: "string",
2178
- description: "Only notes modified before this ISO 8601 date/time."
2179
- },
2180
- minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
2181
- maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
2182
- select: {
2183
- type: "array",
2184
- items: { type: "string" },
2185
- description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
2186
- },
2187
- sort: {
2188
- type: "string",
2189
- enum: ["path", "title", "mtime", "size"],
2190
- description: "Sort field (default path)."
2191
- },
2192
- order: {
2193
- type: "string",
2194
- enum: ["asc", "desc"],
2195
- description: "Sort order (default asc)."
2196
- },
2197
- limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
2198
- },
2199
- required: []
2200
- }
2201
- },
2202
- {
2203
- name: "read_note",
2204
- description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, and total note size so payload savings are measurable. Use search or outline_note first to find the right path and section names.",
2205
- inputSchema: {
2206
- type: "object",
2207
- properties: {
2208
- path: {
2209
- type: "string",
2210
- description: 'Vault-relative path, e.g. "Daily Notes/2026-05-29.md".'
2211
- },
2212
- section: {
2213
- type: "string",
2214
- description: "Return only this heading section (heading text, # prefix optional). First match wins."
2215
- },
2216
- block: {
2217
- type: "string",
2218
- description: "Return only the line anchored by this block id (^ prefix optional)."
2219
- },
2220
- lines: {
2221
- type: "object",
2222
- description: "Return only this line range (1-indexed, inclusive).",
2223
- properties: {
2224
- from: { type: "number", description: "First line (1-indexed)." },
2225
- to: {
2226
- type: "number",
2227
- description: "Last line (1-indexed, inclusive). Defaults to EOF."
2228
- }
2229
- },
2230
- required: ["from"]
2231
- },
2232
- includeFrontmatter: {
2233
- type: "boolean",
2234
- description: "Prepend frontmatter to section/block span results. Default false for spans."
2235
- }
2236
- },
2237
- required: ["path"]
2238
- }
2239
- },
2240
- {
2241
- name: "list_notes",
2242
- description: "List notes, optionally filtered by folder prefix or tag.",
2243
- inputSchema: {
2244
- type: "object",
2245
- properties: {
2246
- folder: { type: "string", description: "Vault-relative folder prefix." },
2247
- tag: { type: "string", description: "Filter by tag (# prefix optional)." },
2248
- limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
2249
- },
2250
- required: []
2251
- }
2252
- },
2253
- {
2254
- name: "list_tags",
2255
- description: "List all tags in the vault with usage counts. Supports substring filtering, minimum count threshold, and sort order. Nested tags (e.g. area/work) include a parent field.",
2256
- inputSchema: {
2257
- type: "object",
2258
- properties: {
2259
- pattern: {
2260
- type: "string",
2261
- description: 'Substring filter on tag name. Example: "work" matches "work" and "area/work".'
2262
- },
2263
- minCount: {
2264
- type: "number",
2265
- description: "Only return tags used in at least this many notes."
2266
- },
2267
- sort: {
2268
- type: "string",
2269
- enum: ["count", "name"],
2270
- description: "Sort by usage count descending (default) or alphabetically."
2271
- }
2272
- },
2273
- required: []
2274
- }
2275
- },
2276
- {
2277
- name: "create_note",
2278
- description: "Create a new note at a vault-relative path. Optionally sets frontmatter and body content. Parent directories are created automatically. Fails if the note already exists unless overwrite is true.",
2279
- inputSchema: {
2280
- type: "object",
2281
- properties: {
2282
- path: {
2283
- type: "string",
2284
- description: 'Vault-relative path, e.g. "Daily Notes/2026-06-01.md".'
2285
- },
2286
- content: { type: "string", description: "Body content for the note." },
2287
- frontmatter: {
2288
- type: "object",
2289
- description: "Frontmatter key-value pairs.",
2290
- additionalProperties: true
2291
- },
2292
- overwrite: {
2293
- type: "boolean",
2294
- description: "Overwrite an existing note. Defaults to false."
2295
- }
2296
- },
2297
- required: ["path"]
2298
- }
2299
- },
2300
- {
2301
- name: "delete_note",
2302
- description: "Permanently delete a note from the vault. This cannot be undone.",
2303
- inputSchema: {
2304
- type: "object",
2305
- properties: {
2306
- path: { type: "string", description: "Vault-relative path of the note to delete." }
2307
- },
2308
- required: ["path"]
2309
- }
2310
- },
2311
- {
2312
- name: "move_note",
2313
- description: "Move or rename a note to a new vault-relative path. Parent directories at the destination are created automatically. Fails if the destination already exists unless overwrite is true.",
2314
- inputSchema: {
2315
- type: "object",
2316
- properties: {
2317
- from: { type: "string", description: "Vault-relative source path." },
2318
- to: { type: "string", description: "Vault-relative destination path." },
2319
- overwrite: {
2320
- type: "boolean",
2321
- description: "Overwrite destination if it exists. Defaults to false."
2322
- }
2323
- },
2324
- required: ["from", "to"]
2325
- }
2326
- },
2327
- {
2328
- name: "append_note",
2329
- description: "Append text to a note body without touching the frontmatter. Safe for meeting notes, daily logs, and append-only workflows.",
2330
- inputSchema: {
2331
- type: "object",
2332
- properties: {
2333
- path: { type: "string", description: "Vault-relative path to the note." },
2334
- content: { type: "string", description: "Text to append." }
2335
- },
2336
- required: ["path", "content"]
2337
- }
2338
- },
2339
- {
2340
- name: "patch_frontmatter",
2341
- description: "Set, update, or delete frontmatter keys without reordering existing keys or changing quote style. Pass null as a value to delete a key.",
2342
- inputSchema: {
2343
- type: "object",
2344
- properties: {
2345
- path: { type: "string", description: "Vault-relative path to the note." },
2346
- patch: {
2347
- type: "object",
2348
- description: "Key-value pairs to set. Null value removes the key.",
2349
- additionalProperties: true
2350
- }
2351
- },
2352
- required: ["path", "patch"]
2353
- }
2354
- },
2355
- {
2356
- name: "outline_note",
2357
- description: "Return a note's structure \u2014 heading tree with offsets, block-reference anchors, and frontmatter key list \u2014 without returning any prose. Use this before section reads or patches to discover what sections exist at a fraction of the cost of reading the full note.",
2358
- inputSchema: {
2359
- type: "object",
2360
- properties: {
2361
- path: { type: "string", description: "Vault-relative path to the note." },
2362
- includeBlocks: {
2363
- type: "boolean",
2364
- description: "Include block-reference anchors (^id). Default true."
2365
- },
2366
- includeSizes: {
2367
- type: "boolean",
2368
- description: "Include per-section character length in each heading entry. Default false."
2369
- }
2370
- },
2371
- required: ["path"]
2372
- }
2373
- },
2374
- {
2375
- name: "patch_note",
2376
- description: "Surgically edit a section of a note \u2014 targeted by heading or block reference \u2014 without rewriting the whole file. Operations: append (add after section), prepend (add after heading line), replace (swap section content). Frontmatter is never touched.",
2377
- inputSchema: {
2378
- type: "object",
2379
- properties: {
2380
- path: { type: "string", description: "Vault-relative path to the note." },
2381
- target: {
2382
- type: "object",
2383
- description: 'Exactly one of: { heading: "Section Title" } or { block: "block-id" } (without the ^ prefix).'
2384
- },
2385
- operation: {
2386
- type: "string",
2387
- enum: ["append", "prepend", "replace"],
2388
- description: "append: insert after section content. prepend: insert after heading line. replace: swap section content."
2389
- },
2390
- content: { type: "string", description: "Content to insert or replace with." },
2391
- createIfMissing: {
2392
- type: "boolean",
2393
- description: "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets. Default false."
2394
- }
2395
- },
2396
- required: ["path", "target", "operation", "content"]
2397
- }
2398
- },
2399
- {
2400
- name: "get_backlinks",
2401
- description: "Return every note that links to the target note, with the source line and an optional excerpt. Results come from the pre-built reverse-link index so this is a fast, pure index lookup. Sort order: source path ascending.",
2402
- inputSchema: {
2403
- type: "object",
2404
- properties: {
2405
- path: { type: "string", description: "Vault-relative path to the target note." },
2406
- includeContext: {
2407
- type: "boolean",
2408
- description: "Include a short excerpt (~200 chars) from the linking line. Default true."
2409
- },
2410
- limit: {
2411
- type: "number",
2412
- description: "Max backlinks to return (1\u2013500). Default 50."
2413
- }
2414
- },
2415
- required: ["path"]
2416
- }
2417
- },
2418
- {
2419
- name: "get_links",
2420
- description: "Return all outgoing wikilinks and embeds from a note. Each link is marked resolved (with target path) or unresolved. Duplicate targets are de-duplicated; results sorted by line number.",
2421
- inputSchema: {
2422
- type: "object",
2423
- properties: {
2424
- path: { type: "string", description: "Vault-relative path to the note." }
2425
- },
2426
- required: ["path"]
2427
- }
2428
- },
2429
- {
2430
- name: "replace_in_note",
2431
- description: "Find and replace text within a note body. Supports literal and regex search, case sensitivity, whole-word matching, and a replacement limit. Frontmatter is never touched. Use dryRun to preview matches before writing.",
2432
- inputSchema: {
2433
- type: "object",
2434
- properties: {
2435
- path: { type: "string", description: "Vault-relative path to the note." },
2436
- find: { type: "string", description: "Text or pattern to find." },
2437
- replace: {
2438
- type: "string",
2439
- description: "Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."
2440
- },
2441
- regex: {
2442
- type: "boolean",
2443
- description: "Treat find as a regular expression. Default false."
2444
- },
2445
- caseSensitive: {
2446
- type: "boolean",
2447
- description: "Case-sensitive matching. Default false."
2448
- },
2449
- wholeWord: {
2450
- type: "boolean",
2451
- description: "Match whole words only (\\b boundary). Default false."
2452
- },
2453
- limit: {
2454
- type: "number",
2455
- description: "Maximum number of replacements. Omit to replace all."
2456
- },
2457
- dryRun: {
2458
- type: "boolean",
2459
- description: "If true, report matches without writing. Default false."
2460
- }
2461
- },
2462
- required: ["path", "find", "replace"]
2463
- }
2464
- },
2465
- {
2466
- name: "get_periodic_note",
2467
- description: "Get the path and existence status of a periodic note (daily, weekly, monthly, quarterly, or yearly) for a given date. Reads folder/format config from .obsidian/daily-notes.json (daily) or the periodic-notes plugin data.json. Optionally creates the note from the configured template if it is missing.",
2468
- inputSchema: {
2469
- type: "object",
2470
- properties: {
2471
- period: {
2472
- type: "string",
2473
- enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
2474
- description: "Period type. Default: daily."
2475
- },
2476
- date: {
2477
- type: "string",
2478
- description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
2479
- },
2480
- createIfMissing: {
2481
- type: "boolean",
2482
- description: "Create the note from the configured template if it does not exist. Default false."
2483
- }
2484
- },
2485
- required: []
2486
- }
2487
- },
2488
- {
2489
- name: "append_periodic_note",
2490
- description: "Append text to a periodic note (daily, weekly, monthly, quarterly, or yearly). Preserves existing frontmatter exactly. Creates the note first (from template if configured) when createIfMissing is true (default).",
2491
- inputSchema: {
2492
- type: "object",
2493
- properties: {
2494
- period: {
2495
- type: "string",
2496
- enum: ["daily", "weekly", "monthly", "quarterly", "yearly"],
2497
- description: "Period type. Default: daily."
2498
- },
2499
- date: {
2500
- type: "string",
2501
- description: "ISO date string (YYYY-MM-DD). Defaults to today when omitted."
2502
- },
2503
- content: {
2504
- type: "string",
2505
- description: "Text to append to the note body."
2506
- },
2507
- createIfMissing: {
2508
- type: "boolean",
2509
- description: "Create the note if it does not exist before appending. Default true."
2510
- }
2511
- },
2512
- required: ["content"]
2513
- }
2514
- }
2515
- ]
2783
+ tools: visibleTools(ctx.policy)
2516
2784
  }));
2517
2785
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
2518
2786
  const { name, arguments: args } = req.params;
@@ -2520,7 +2788,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
2520
2788
  });
2521
2789
  var transport = new StdioServerTransport();
2522
2790
  await server.connect(transport);
2523
- log.info("ready", { tools: 17, transport: "stdio" });
2791
+ log.info("ready", { tools: visibleTools(ctx.policy).length, transport: "stdio" });
2524
2792
  process.stderr.write(
2525
2793
  `seekstone: add to Claude Desktop:
2526
2794
  ${JSON.stringify(