@magic-markdown/cli 0.3.0 → 0.3.2

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/dist/index.js +774 -48
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -321,6 +321,17 @@ function fnv1a(value) {
321
321
  return hash;
322
322
  }
323
323
 
324
+ // ../core/src/wikilinks.ts
325
+ function parseWikilinkBody(value) {
326
+ const separatorIndex = value.indexOf("|");
327
+ const rawTarget = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
328
+ const rawLabel = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : void 0;
329
+ const target = rawTarget.trim();
330
+ if (!target) return void 0;
331
+ const label = rawLabel?.trim();
332
+ return label ? { target, label } : { target };
333
+ }
334
+
324
335
  // ../core/src/markdown.ts
325
336
  var POLLUTING_PATTERNS = [
326
337
  { name: "CriticMarkup addition", pattern: /\{\+\+[\s\S]*?\+\+\}/ },
@@ -469,6 +480,86 @@ function extractMarkdownImages(markdown, documentPath) {
469
480
  }
470
481
  return images;
471
482
  }
483
+ function extractMarkdownLinks(markdown) {
484
+ const codeMasked = maskCode(markdown);
485
+ const referenceDefinitions = collectReferenceDefinitions(markdown, codeMasked);
486
+ const masked = maskReferenceDefinitions(markdown, codeMasked);
487
+ const lineStarts = getLineStarts(markdown);
488
+ const links = [];
489
+ let index = 0;
490
+ while (index < masked.length) {
491
+ const start = masked.indexOf("[", index);
492
+ if (start === -1) break;
493
+ if (masked[start + 1] === "[" || markdown[start - 1] === "!" || isEscaped(markdown, start)) {
494
+ index = start + 1;
495
+ continue;
496
+ }
497
+ const labelClose = findClosingBracket(masked, start);
498
+ if (labelClose === -1) {
499
+ index = start + 1;
500
+ continue;
501
+ }
502
+ const label = markdown.slice(start + 1, labelClose);
503
+ const afterLabel = labelClose + 1;
504
+ const position = positionAt(lineStarts, start);
505
+ if (masked[afterLabel] === "(") {
506
+ const destinationClose = findClosingParen(markdown, afterLabel + 1);
507
+ if (destinationClose === -1) {
508
+ index = afterLabel + 1;
509
+ continue;
510
+ }
511
+ const parsed = parseDestinationAndTitle(markdown.slice(afterLabel + 1, destinationClose));
512
+ if (parsed) {
513
+ links.push(toLinkReference({
514
+ label,
515
+ target: parsed.source,
516
+ title: parsed.title,
517
+ line: position.line,
518
+ column: position.column,
519
+ syntax: "inline"
520
+ }));
521
+ }
522
+ index = destinationClose + 1;
523
+ continue;
524
+ }
525
+ if (masked[afterLabel] === "[") {
526
+ const referenceClose = findClosingBracket(masked, afterLabel);
527
+ if (referenceClose === -1) {
528
+ index = afterLabel + 1;
529
+ continue;
530
+ }
531
+ const explicitLabel = markdown.slice(afterLabel + 1, referenceClose);
532
+ const definitionLabel = explicitLabel.length > 0 ? explicitLabel : label;
533
+ const definition = referenceDefinitions.get(normalizeReferenceLabel(definitionLabel));
534
+ if (definition) {
535
+ links.push(toLinkReference({
536
+ label,
537
+ target: definition.source,
538
+ title: definition.title,
539
+ line: position.line,
540
+ column: position.column,
541
+ syntax: explicitLabel.length > 0 ? "reference" : "collapsed_reference"
542
+ }));
543
+ }
544
+ index = referenceClose + 1;
545
+ continue;
546
+ }
547
+ const shortcutDefinition = referenceDefinitions.get(normalizeReferenceLabel(label));
548
+ if (shortcutDefinition) {
549
+ links.push(toLinkReference({
550
+ label,
551
+ target: shortcutDefinition.source,
552
+ title: shortcutDefinition.title,
553
+ line: position.line,
554
+ column: position.column,
555
+ syntax: "shortcut_reference"
556
+ }));
557
+ }
558
+ index = labelClose + 1;
559
+ }
560
+ extractWikilinks(markdown, masked, lineStarts).forEach((link2) => links.push(link2));
561
+ return links.sort((left, right) => left.line - right.line || left.column - right.column);
562
+ }
472
563
  function toImageReference(image2) {
473
564
  const workspacePath = resolveWorkspaceImagePath(image2.source, image2.documentPath);
474
565
  return {
@@ -483,6 +574,54 @@ function toImageReference(image2) {
483
574
  ...workspacePath ? { workspacePath } : {}
484
575
  };
485
576
  }
577
+ function toLinkReference(link2) {
578
+ return {
579
+ label: unescapeMarkdown(link2.label),
580
+ target: link2.target,
581
+ ...link2.title ? { title: link2.title } : {},
582
+ line: link2.line,
583
+ column: link2.column,
584
+ syntax: link2.syntax
585
+ };
586
+ }
587
+ function extractWikilinks(markdown, masked, lineStarts) {
588
+ const links = [];
589
+ let index = 0;
590
+ while (index < masked.length) {
591
+ const start = masked.indexOf("[[", index);
592
+ if (start === -1) break;
593
+ if (isEscaped(markdown, start)) {
594
+ index = start + 2;
595
+ continue;
596
+ }
597
+ const close2 = masked.indexOf("]]", start + 2);
598
+ if (close2 === -1 || markdown.slice(start + 2, close2).includes("\n")) {
599
+ index = start + 2;
600
+ continue;
601
+ }
602
+ const parsed = parseWikilinkBody(markdown.slice(start + 2, close2));
603
+ if (parsed) {
604
+ const position = positionAt(lineStarts, start);
605
+ links.push({
606
+ label: parsed.label ?? parsed.target,
607
+ target: parsed.target,
608
+ line: position.line,
609
+ column: position.column,
610
+ syntax: "wikilink"
611
+ });
612
+ }
613
+ index = close2 + 2;
614
+ }
615
+ return links;
616
+ }
617
+ function maskReferenceDefinitions(markdown, masked) {
618
+ const markdownLines = markdown.split(/(?<=\n)/);
619
+ const maskedLines = masked.split(/(?<=\n)/);
620
+ return maskedLines.map((line, index) => {
621
+ const original = markdownLines[index] ?? "";
622
+ return parseReferenceDefinition(original.replace(/\r?\n$/, "")) ? maskLine(line) : line;
623
+ }).join("");
624
+ }
486
625
  function collectReferenceDefinitions(markdown, masked) {
487
626
  const definitions = /* @__PURE__ */ new Map();
488
627
  const lines = getLines(markdown);
@@ -840,6 +979,184 @@ function applySuggestion(markdown, suggestion) {
840
979
  return next;
841
980
  }
842
981
 
982
+ // ../core/src/graph.ts
983
+ function resolveMarkdownLinks(links, currentDocument, documents) {
984
+ return links.map((link2) => resolveMarkdownLink(link2, currentDocument, documents));
985
+ }
986
+ function buildWorkspaceGraph(workspaceId, schemaVersion, documents) {
987
+ const edges = documents.flatMap((document) => document.links.map((link2, index) => toGraphEdge(document, link2, index)));
988
+ const incomingByDocId = /* @__PURE__ */ new Map();
989
+ for (const edge of edges) {
990
+ if (!edge.targetDocId) continue;
991
+ incomingByDocId.set(edge.targetDocId, (incomingByDocId.get(edge.targetDocId) ?? 0) + 1);
992
+ }
993
+ const nodes = documents.map((document) => ({
994
+ docId: document.docId,
995
+ path: document.path,
996
+ title: document.title,
997
+ openComments: document.openComments,
998
+ openSuggestions: document.openSuggestions,
999
+ anchorsNeedingReview: document.anchorsNeedingReview,
1000
+ outgoingLinks: document.links.length,
1001
+ incomingLinks: incomingByDocId.get(document.docId) ?? 0,
1002
+ unresolvedLinks: document.unresolvedLinks,
1003
+ externalLinks: document.externalLinks
1004
+ }));
1005
+ return {
1006
+ workspaceId,
1007
+ schemaVersion,
1008
+ nodes,
1009
+ edges
1010
+ };
1011
+ }
1012
+ function resolveMarkdownLink(link2, currentDocument, documents) {
1013
+ const target = link2.target.trim();
1014
+ const parsed = parseInternalTarget(target);
1015
+ if (!target) return { ...link2, status: "unresolved" };
1016
+ if (isExternalTarget(target)) return { ...link2, status: "external" };
1017
+ if (!parsed) return { ...link2, status: "unresolved" };
1018
+ if (!parsed.path) {
1019
+ return parsed.fragment ? {
1020
+ ...link2,
1021
+ status: "anchor",
1022
+ fragment: parsed.fragment,
1023
+ targetDocId: currentDocument.docId,
1024
+ targetPath: currentDocument.path,
1025
+ targetTitle: currentDocument.title
1026
+ } : { ...link2, status: "unresolved" };
1027
+ }
1028
+ if (link2.syntax === "wikilink" && !isPathLikeWikilinkTarget(parsed.path)) {
1029
+ const candidates = findBasenameWikilinkCandidates(parsed.path, parsed.fragment, documents);
1030
+ if (candidates.length === 1) return resolvedLink(link2, candidates[0]);
1031
+ if (candidates.length > 1) return { ...link2, status: "ambiguous", fragment: parsed.fragment, candidates };
1032
+ return { ...link2, status: "unresolved", fragment: parsed.fragment };
1033
+ }
1034
+ const linked = link2.syntax === "wikilink" ? resolvePathLikeWikilink(target, currentDocument.path, documents) : resolveWorkspaceDocumentLink(target, currentDocument.path, documents);
1035
+ return linked ? resolvedLink(link2, linked) : { ...link2, status: "unresolved", fragment: parsed.fragment };
1036
+ }
1037
+ function resolvedLink(link2, target) {
1038
+ return {
1039
+ ...link2,
1040
+ status: "resolved",
1041
+ ...target.fragment ? { fragment: target.fragment } : {},
1042
+ targetDocId: target.docId,
1043
+ targetPath: target.path,
1044
+ targetTitle: target.title
1045
+ };
1046
+ }
1047
+ function toGraphEdge(document, link2, index) {
1048
+ return {
1049
+ id: `${document.docId}:${index}`,
1050
+ sourceDocId: document.docId,
1051
+ sourcePath: document.path,
1052
+ sourceTitle: document.title,
1053
+ label: link2.label,
1054
+ target: link2.target,
1055
+ ...link2.title ? { title: link2.title } : {},
1056
+ line: link2.line,
1057
+ column: link2.column,
1058
+ syntax: link2.syntax,
1059
+ status: link2.status,
1060
+ ...link2.fragment ? { fragment: link2.fragment } : {},
1061
+ ...link2.targetDocId ? { targetDocId: link2.targetDocId } : {},
1062
+ ...link2.targetPath ? { targetPath: link2.targetPath } : {},
1063
+ ...link2.targetTitle ? { targetTitle: link2.targetTitle } : {},
1064
+ ...link2.candidates ? { candidates: link2.candidates } : {}
1065
+ };
1066
+ }
1067
+ function resolveWorkspaceDocumentLink(target, currentPath, documents) {
1068
+ const parsed = parseInternalTarget(target);
1069
+ if (!parsed || !parsed.path) return void 0;
1070
+ const workspacePath = resolveWorkspacePath(parsed.path, currentPath);
1071
+ if (!workspacePath) return void 0;
1072
+ const document = findLinkedDocument(candidateDocumentPaths(workspacePath), documents);
1073
+ return document ? { ...document, ...parsed.fragment ? { fragment: parsed.fragment } : {} } : void 0;
1074
+ }
1075
+ function resolvePathLikeWikilink(target, currentPath, documents) {
1076
+ const linked = resolveWorkspaceDocumentLink(target, currentPath, documents);
1077
+ if (linked) return linked;
1078
+ if (/^(?:\/|\.\/|\.\.\/)/.test(target)) return void 0;
1079
+ return resolveWorkspaceDocumentLink(`/${target}`, currentPath, documents);
1080
+ }
1081
+ function findBasenameWikilinkCandidates(name, fragment, documents) {
1082
+ const normalizedName = normalizeWikilinkName(name);
1083
+ return documents.filter((doc) => normalizeWikilinkName(markdownStem(doc.path)) === normalizedName).map((doc) => ({ ...doc, ...fragment ? { fragment } : {} })).sort((left, right) => left.path.localeCompare(right.path, void 0, { numeric: true, sensitivity: "base" }));
1084
+ }
1085
+ function findLinkedDocument(paths, documents) {
1086
+ for (const path of paths) {
1087
+ const document = documents.find((candidate) => candidate.path === path);
1088
+ if (document) return document;
1089
+ }
1090
+ return void 0;
1091
+ }
1092
+ function parseInternalTarget(target) {
1093
+ const trimmed = target.trim();
1094
+ if (!trimmed) return void 0;
1095
+ const hashIndex = trimmed.indexOf("#");
1096
+ const withoutFragment = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
1097
+ const fragment = hashIndex >= 0 ? safeDecode(trimmed.slice(hashIndex + 1)) : void 0;
1098
+ const queryIndex = withoutFragment.indexOf("?");
1099
+ const path = safeDecode(queryIndex >= 0 ? withoutFragment.slice(0, queryIndex) : withoutFragment);
1100
+ return { path, ...fragment ? { fragment } : {} };
1101
+ }
1102
+ function resolveWorkspacePath(linkPath, currentPath) {
1103
+ const path = linkPath.replaceAll("\\", "/").trim();
1104
+ if (!path) return void 0;
1105
+ const source = path.startsWith("/") ? path.slice(1) : `${dirname2(currentPath)}/${path}`;
1106
+ return normalizeWorkspacePath2(source);
1107
+ }
1108
+ function normalizeWorkspacePath2(path) {
1109
+ const segments = [];
1110
+ for (const segment of path.split("/")) {
1111
+ if (!segment || segment === ".") continue;
1112
+ if (segment === "..") {
1113
+ if (segments.length === 0) return void 0;
1114
+ segments.pop();
1115
+ continue;
1116
+ }
1117
+ segments.push(segment);
1118
+ }
1119
+ return segments.join("/");
1120
+ }
1121
+ function candidateDocumentPaths(path) {
1122
+ const candidates = [path];
1123
+ if (path.endsWith("/")) {
1124
+ candidates.push(`${path}README.md`, `${path}index.md`);
1125
+ } else if (!/\.[^/.]+$/.test(path)) {
1126
+ candidates.push(`${path}.md`, `${path}.mdx`, `${path}/README.md`, `${path}/index.md`);
1127
+ }
1128
+ return [...new Set(candidates)];
1129
+ }
1130
+ function isPathLikeWikilinkTarget(path) {
1131
+ return /^(?:\/|\.\/|\.\.\/)/.test(path) || path.includes("/") || path.includes("\\") || /\.(md|mdx)$/i.test(path);
1132
+ }
1133
+ function isExternalTarget(target) {
1134
+ const trimmed = target.trim();
1135
+ if (!trimmed || trimmed.startsWith("#")) return false;
1136
+ return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//") || looksLikeBareDomain(trimmed);
1137
+ }
1138
+ function looksLikeBareDomain(value) {
1139
+ return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(value) && !/\.(md|mdx)(?:[?#]|$)/i.test(value);
1140
+ }
1141
+ function markdownStem(path) {
1142
+ const basename3 = path.split("/").pop() ?? path;
1143
+ return basename3.replace(/\.mdx?$/i, "");
1144
+ }
1145
+ function normalizeWikilinkName(value) {
1146
+ return value.trim().toLowerCase();
1147
+ }
1148
+ function dirname2(path) {
1149
+ const index = path.lastIndexOf("/");
1150
+ return index >= 0 ? path.slice(0, index) : "";
1151
+ }
1152
+ function safeDecode(value) {
1153
+ try {
1154
+ return decodeURIComponent(value);
1155
+ } catch {
1156
+ return value;
1157
+ }
1158
+ }
1159
+
843
1160
  // ../core/src/sidecar.ts
844
1161
  function defaultActor(name = "mdocs") {
845
1162
  return {
@@ -916,9 +1233,12 @@ async function indexWorkspace(io) {
916
1233
  }
917
1234
  async function getWorkspaceMap(io) {
918
1235
  const manifest = await indexWorkspace(io);
1236
+ const graph = await workspaceGraphForManifest(io, manifest);
1237
+ const graphNodesByDocId = new Map(graph.nodes.map((node) => [node.docId, node]));
919
1238
  const docs = await Promise.all(
920
1239
  manifest.docs.map(async (entry) => {
921
1240
  const sidecar = await readSidecar(io, entry.docId);
1241
+ const graphNode = graphNodesByDocId.get(entry.docId);
922
1242
  return {
923
1243
  docId: entry.docId,
924
1244
  path: entry.path,
@@ -926,7 +1246,11 @@ async function getWorkspaceMap(io) {
926
1246
  sidecarPath: sidecarPath(entry.docId),
927
1247
  openComments: sidecar.comments.filter((comment2) => comment2.status === "open").length,
928
1248
  openSuggestions: sidecar.suggestions.filter((suggestion) => suggestion.status === "open").length,
929
- anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length
1249
+ anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length,
1250
+ outgoingLinks: graphNode?.outgoingLinks ?? 0,
1251
+ incomingLinks: graphNode?.incomingLinks ?? 0,
1252
+ unresolvedLinks: graphNode?.unresolvedLinks ?? 0,
1253
+ externalLinks: graphNode?.externalLinks ?? 0
930
1254
  };
931
1255
  })
932
1256
  );
@@ -936,6 +1260,9 @@ async function getWorkspaceMap(io) {
936
1260
  docs
937
1261
  };
938
1262
  }
1263
+ async function getWorkspaceGraph(io) {
1264
+ return workspaceGraphForManifest(io, await indexWorkspace(io));
1265
+ }
939
1266
  async function getDocumentState(io, pathOrDocId) {
940
1267
  const manifest = await indexWorkspace(io);
941
1268
  const entry = findDoc(manifest, pathOrDocId);
@@ -964,10 +1291,31 @@ async function getDocumentState(io, pathOrDocId) {
964
1291
  confidence: anchor.confidence
965
1292
  })),
966
1293
  images: extractMarkdownImages(markdown, entry.path),
1294
+ links: resolveMarkdownLinks(extractMarkdownLinks(markdown), entry, manifest.docs),
967
1295
  openComments: nextSidecar.comments.filter((comment2) => comment2.status === "open").length,
968
1296
  openSuggestions: nextSidecar.suggestions.filter((suggestion) => suggestion.status === "open").length
969
1297
  };
970
1298
  }
1299
+ async function workspaceGraphForManifest(io, manifest) {
1300
+ const documents = await Promise.all(
1301
+ manifest.docs.map(async (entry) => {
1302
+ const [markdown, sidecar] = await Promise.all([io.readText(entry.path), readSidecar(io, entry.docId)]);
1303
+ const links = resolveMarkdownLinks(extractMarkdownLinks(markdown), entry, manifest.docs);
1304
+ return {
1305
+ docId: entry.docId,
1306
+ path: entry.path,
1307
+ title: entry.title,
1308
+ openComments: sidecar.comments.filter((comment2) => comment2.status === "open").length,
1309
+ openSuggestions: sidecar.suggestions.filter((suggestion) => suggestion.status === "open").length,
1310
+ anchorsNeedingReview: sidecar.anchors.filter((anchor) => anchor.status !== "mapped").length,
1311
+ externalLinks: links.filter((link2) => link2.status === "external").length,
1312
+ unresolvedLinks: links.filter((link2) => link2.status === "unresolved" || link2.status === "ambiguous").length,
1313
+ links
1314
+ };
1315
+ })
1316
+ );
1317
+ return buildWorkspaceGraph(manifest.workspaceId, manifest.schemaVersion, documents);
1318
+ }
971
1319
  async function addComment(io, pathOrDocId, range, body, author = defaultActor()) {
972
1320
  const state = await getDocumentState(io, pathOrDocId);
973
1321
  assertLineRangeWithin(state.markdown, range);
@@ -1234,7 +1582,7 @@ var DEFAULT_ROOT_PATH_RULES = {
1234
1582
  maxFileSizeBytes: 1024 * 1024
1235
1583
  };
1236
1584
  var MARKDOWN_FILE_PATTERN = /\.mdx?$/i;
1237
- function normalizeWorkspacePath2(path) {
1585
+ function normalizeWorkspacePath3(path) {
1238
1586
  const normalized = path.replaceAll("\\", "/").replace(/\/+/g, "/").trim();
1239
1587
  if (!normalized) throw new Error("Workspace path cannot be empty.");
1240
1588
  if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized)) {
@@ -1247,7 +1595,7 @@ function normalizeWorkspacePath2(path) {
1247
1595
  return parts.filter((part) => part && part !== ".").join("/");
1248
1596
  }
1249
1597
  function assertWorkspacePathAllowed(path, rules = DEFAULT_ROOT_PATH_RULES, sizeBytes, ignoreMatcher) {
1250
- const normalized = normalizeWorkspacePath2(path);
1598
+ const normalized = normalizeWorkspacePath3(path);
1251
1599
  if (isIgnoredWorkspacePath(normalized, rules)) {
1252
1600
  throw new Error(`Workspace path is ignored by root policy: ${normalized}`);
1253
1601
  }
@@ -1278,7 +1626,7 @@ function isWorkspaceMarkdownPathAllowed(path, rules = DEFAULT_ROOT_PATH_RULES, s
1278
1626
  function createWorkspaceIgnoreMatcher(ignoreFiles) {
1279
1627
  const rules = parseWorkspaceIgnoreRules(ignoreFiles);
1280
1628
  return (path) => {
1281
- const normalized = normalizeWorkspacePath2(path);
1629
+ const normalized = normalizeWorkspacePath3(path);
1282
1630
  let ignored = false;
1283
1631
  for (const rule of rules) {
1284
1632
  if (!matchesWorkspaceIgnoreRule(normalized, rule)) continue;
@@ -1288,7 +1636,7 @@ function createWorkspaceIgnoreMatcher(ignoreFiles) {
1288
1636
  };
1289
1637
  }
1290
1638
  function isIgnoredWorkspacePath(path, rules = DEFAULT_ROOT_PATH_RULES) {
1291
- const normalized = normalizeWorkspacePath2(path);
1639
+ const normalized = normalizeWorkspacePath3(path);
1292
1640
  const parts = normalized.split("/");
1293
1641
  return parts.some((part) => rules.ignoredDirectories.includes(part));
1294
1642
  }
@@ -1308,8 +1656,8 @@ function matchesAny(path, patterns) {
1308
1656
  return patterns.some((pattern) => globToRegExp(pattern).test(path));
1309
1657
  }
1310
1658
  function parseWorkspaceIgnoreRules(ignoreFiles) {
1311
- return [...ignoreFiles].sort((left, right) => normalizeWorkspacePath2(left.path).localeCompare(normalizeWorkspacePath2(right.path))).flatMap((file) => {
1312
- const normalizedPath = normalizeWorkspacePath2(file.path);
1659
+ return [...ignoreFiles].sort((left, right) => normalizeWorkspacePath3(left.path).localeCompare(normalizeWorkspacePath3(right.path))).flatMap((file) => {
1660
+ const normalizedPath = normalizeWorkspacePath3(file.path);
1313
1661
  const basePath = normalizedPath.endsWith("/.gitignore") ? normalizedPath.slice(0, -"/.gitignore".length) : "";
1314
1662
  return file.content.split(/\r?\n/).flatMap((line) => {
1315
1663
  const rule = parseWorkspaceIgnoreRule(basePath, line);
@@ -1416,14 +1764,14 @@ function createSourceMapping(input, updatedAt = (/* @__PURE__ */ new Date()).toI
1416
1764
  };
1417
1765
  }
1418
1766
  function canonicalPathToReplicaPath(path, mapping) {
1419
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
1420
- const canonicalPath = normalizeWorkspacePath2(path);
1767
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
1768
+ const canonicalPath = normalizeWorkspacePath3(path);
1421
1769
  const relativePath = stripPrefix(canonicalPath, normalizeOptionalPrefix(mapping.canonicalPrefix), "canonical");
1422
1770
  return joinWorkspacePath(normalizeOptionalPrefix(mapping.replicaPrefix), relativePath);
1423
1771
  }
1424
1772
  function replicaPathToCanonicalPath(path, mapping) {
1425
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
1426
- const replicaPath = normalizeWorkspacePath2(path);
1773
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
1774
+ const replicaPath = normalizeWorkspacePath3(path);
1427
1775
  const relativePath = stripPrefix(replicaPath, normalizeOptionalPrefix(mapping.replicaPrefix), "replica");
1428
1776
  return joinWorkspacePath(normalizeOptionalPrefix(mapping.canonicalPrefix), relativePath);
1429
1777
  }
@@ -1438,7 +1786,7 @@ function sourceMappingPathRules(mapping, base) {
1438
1786
  function normalizeOptionalPrefix(prefix) {
1439
1787
  const value = prefix?.trim();
1440
1788
  if (!value || value === ".") return "";
1441
- return normalizeWorkspacePath2(value).replace(/\/+$/, "");
1789
+ return normalizeWorkspacePath3(value).replace(/\/+$/, "");
1442
1790
  }
1443
1791
  function stripPrefix(path, prefix, label) {
1444
1792
  if (!prefix) return path;
@@ -1447,9 +1795,9 @@ function stripPrefix(path, prefix, label) {
1447
1795
  throw new Error(`Path ${path} is outside ${label} prefix ${prefix}`);
1448
1796
  }
1449
1797
  function joinWorkspacePath(prefix, path) {
1450
- if (!prefix) return normalizeWorkspacePath2(path);
1798
+ if (!prefix) return normalizeWorkspacePath3(path);
1451
1799
  if (!path) return prefix;
1452
- return normalizeWorkspacePath2(`${prefix}/${path}`);
1800
+ return normalizeWorkspacePath3(`${prefix}/${path}`);
1453
1801
  }
1454
1802
 
1455
1803
  // ../core/src/share-routes.ts
@@ -1462,6 +1810,10 @@ function parseSharePath(pathname) {
1462
1810
  docId: docId ? decodeURIComponent(docId) : void 0
1463
1811
  };
1464
1812
  }
1813
+ function buildSharePath(workspaceId, rootId, docId) {
1814
+ const base = `/share/${encodeURIComponent(workspaceId)}/${encodeURIComponent(rootId)}`;
1815
+ return docId ? `${base}/${encodeURIComponent(docId)}` : base;
1816
+ }
1465
1817
 
1466
1818
  // ../../node_modules/orderedmap/dist/index.js
1467
1819
  function OrderedMap(content) {
@@ -3901,7 +4253,10 @@ function gatherMarks(schema2, marks) {
3901
4253
  // ../core/src/pm-schema.ts
3902
4254
  var canonicalSchema = new Schema({
3903
4255
  nodes: {
3904
- doc: { content: "block+" },
4256
+ // The doc allows suggestion marks on its block children so pending
4257
+ // block-level suggestions (node marks) survive fromJSON and can be
4258
+ // structurally reverted before serialization.
4259
+ doc: { content: "block+", marks: "insertion deletion modification" },
3905
4260
  paragraph: { group: "block", content: "inline*" },
3906
4261
  heading: {
3907
4262
  group: "block",
@@ -3969,14 +4324,45 @@ var canonicalSchema = new Schema({
3969
4324
  },
3970
4325
  inclusive: false
3971
4326
  },
4327
+ wikilink: {
4328
+ attrs: {
4329
+ target: { default: null }
4330
+ },
4331
+ inclusive: false
4332
+ },
3972
4333
  bold: {},
3973
4334
  italic: {},
3974
4335
  strike: {},
3975
- code: {}
4336
+ code: {},
4337
+ // Pending-suggestion marks (mirroring the web editor's extensions). They
4338
+ // are never serialized — the canonical serializer reverts them first —
4339
+ // but the schema must know them so suggestion-marked docs load intact.
4340
+ insertion: {
4341
+ attrs: { id: { default: null } },
4342
+ inclusive: false,
4343
+ excludes: "deletion modification insertion"
4344
+ },
4345
+ deletion: {
4346
+ attrs: { id: { default: null } },
4347
+ inclusive: false,
4348
+ excludes: "insertion modification deletion"
4349
+ },
4350
+ modification: {
4351
+ attrs: {
4352
+ id: { default: null },
4353
+ type: { default: null },
4354
+ attrName: { default: null },
4355
+ previousValue: { default: null },
4356
+ newValue: { default: null }
4357
+ },
4358
+ inclusive: false,
4359
+ excludes: "deletion insertion"
4360
+ }
3976
4361
  }
3977
4362
  });
4363
+ var SUGGESTION_MARK_TYPES = /* @__PURE__ */ new Set(["insertion", "deletion", "modification"]);
3978
4364
  var CANONICAL_NODE_TYPES = new Set(Object.keys(canonicalSchema.nodes));
3979
- var CANONICAL_MARK_TYPES = new Set(Object.keys(canonicalSchema.marks));
4365
+ var CANONICAL_MARK_TYPES = new Set(Object.keys(canonicalSchema.marks).filter((name) => !SUGGESTION_MARK_TYPES.has(name)));
3980
4366
 
3981
4367
  // ../../node_modules/markdown-it/lib/common/utils.mjs
3982
4368
  var utils_exports = {};
@@ -10171,9 +10557,28 @@ var markdownSerializer = new MarkdownSerializer(
10171
10557
  return `](${href.replace(/[()"]/g, "\\$&")})`;
10172
10558
  },
10173
10559
  mixable: false
10560
+ },
10561
+ wikilink: {
10562
+ open(_state, mark, parent, index) {
10563
+ const target = typeof mark.attrs.target === "string" ? mark.attrs.target : "";
10564
+ const label = markedRangeText(parent, index, mark);
10565
+ if (!target || label === target) return "[[";
10566
+ return `[[${target}|`;
10567
+ },
10568
+ close: "]]",
10569
+ mixable: false
10174
10570
  }
10175
10571
  }
10176
10572
  );
10573
+ function markedRangeText(parent, index, mark) {
10574
+ let text2 = "";
10575
+ for (let childIndex = index; childIndex < parent.childCount; childIndex += 1) {
10576
+ const child = parent.child(childIndex);
10577
+ if (!mark.isInSet(child.marks)) break;
10578
+ text2 += child.textContent;
10579
+ }
10580
+ return text2;
10581
+ }
10177
10582
  function escapeImageAlt(value) {
10178
10583
  return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/]/g, "\\]").replace(/\n+/g, " ");
10179
10584
  }
@@ -10237,7 +10642,7 @@ var RemoteDocumentIO = class {
10237
10642
  };
10238
10643
 
10239
10644
  // src/agent.ts
10240
- var CLI_VERSION = "0.3.0";
10645
+ var CLI_VERSION = "0.3.2";
10241
10646
  var CLI_PACKAGE_NAME = "@magic-markdown/cli";
10242
10647
  var AGENT_COMMANDS = [
10243
10648
  {
@@ -10288,8 +10693,8 @@ var AGENT_COMMANDS = [
10288
10693
  },
10289
10694
  {
10290
10695
  name: "remote",
10291
- summary: "Read, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
10292
- usage: "mdocs remote map|context|comment|suggest|reject|events|history|restore --json",
10696
+ summary: "Read, organize, comment on, suggest edits to, monitor, and restore the active Magic Markdown remote join.",
10697
+ usage: "mdocs remote map|graph|context|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder --json",
10293
10698
  output: "json",
10294
10699
  mutates: true,
10295
10700
  examples: [
@@ -10297,14 +10702,24 @@ var AGENT_COMMANDS = [
10297
10702
  "mdocs remote context --start-line 1 --end-line 100 --json",
10298
10703
  "mdocs remote context --start-line 101 --end-line 200 --json",
10299
10704
  "mdocs remote map --json",
10705
+ "mdocs remote graph --json",
10706
+ "mdocs remote create-file docs/new-note.md --with-file /tmp/initial.md --json",
10707
+ "mdocs remote move-file docs/new-note.md --to archive/new-note.md --json",
10300
10708
  "mdocs remote suggest --range 4:9 --with-file /tmp/replacement.md --message-file /tmp/message.txt --json",
10301
10709
  "mdocs remote comment docs/example.md --range 3:5 --body-file /tmp/comment.txt --json",
10302
10710
  "mdocs remote reject suggestion_abc123 --json",
10303
10711
  "mdocs remote history --json",
10304
- "mdocs remote restore head_abc123 --json"
10712
+ "mdocs remote restore head_abc123 --json",
10713
+ "mdocs remote library --json",
10714
+ "mdocs remote create-folder Research --json",
10715
+ "mdocs remote move-root --folder fold_abc123 --json",
10716
+ "mdocs remote invite-folder fold_abc123 --email person@example.com --role edit --json"
10305
10717
  ],
10306
10718
  notes: [
10307
10719
  "remote context accepts --start-line and --end-line (1-based, inclusive) to page through large documents. The response always includes totalLines, startLine, and endLine \u2014 if totalLines > endLine, request the next page with --start-line <endLine+1>.",
10720
+ "remote graph requires a project-scoped join and returns the shared project's Obsidian-style Markdown/wikilink graph.",
10721
+ "remote create-file and remote move-file require a project-scoped join and edit access. They write through the root sync API, so version history and conflict handling stay intact.",
10722
+ "remote library, create-folder, update-folder, move-root, and invite-folder use the organization suite on top of the joined project. Folder invites require admin access on the joined root and can only target folders containing that root.",
10308
10723
  "remote comment/suggest never clobber concurrent edits: the server merges review additions, and the CLI rebases and retries automatically if a conflict still occurs.",
10309
10724
  "remote reject <suggestionId> withdraws a suggestion you submitted (for example, a duplicate). You can always withdraw your own; rejecting another author's suggestion requires edit access, and resolving a human's review work needs the user's explicit ask.",
10310
10725
  "remote events returns truncated: true when older events were dropped from the bounded log; refetch full state with remote context instead of trusting the gap.",
@@ -10322,15 +10737,27 @@ var AGENT_COMMANDS = [
10322
10737
  },
10323
10738
  {
10324
10739
  name: "map",
10325
- summary: "Return the indexed Markdown documents and review counts.",
10740
+ summary: "Return the indexed Markdown documents, review counts, and link counts.",
10326
10741
  usage: "mdocs map --json",
10327
10742
  output: "json",
10328
10743
  mutates: true,
10329
10744
  examples: ["mdocs map --json"]
10330
10745
  },
10746
+ {
10747
+ name: "graph",
10748
+ summary: "Return the workspace knowledge graph as document nodes and Markdown/wikilink edges.",
10749
+ usage: "mdocs graph --json",
10750
+ output: "json",
10751
+ mutates: true,
10752
+ examples: ["mdocs graph --json"],
10753
+ notes: [
10754
+ "Edges include resolved document links, ambiguous wikilinks, unresolved links, external links, and same-document anchor links.",
10755
+ "Use this before broad agent edits to understand the Obsidian-style document graph and avoid changing related notes in isolation."
10756
+ ]
10757
+ },
10331
10758
  {
10332
10759
  name: "context",
10333
- summary: "Return agent-ready document Markdown, open review state, anchors, and image references.",
10760
+ summary: "Return agent-ready document Markdown, open review state, anchors, image references, and resolved outgoing links.",
10334
10761
  usage: "mdocs context <path|docId> [--start-line N] [--end-line N] --json",
10335
10762
  output: "json",
10336
10763
  mutates: true,
@@ -10493,7 +10920,7 @@ function getAgentGuidePayload() {
10493
10920
  function getAgentSkillMarkdown() {
10494
10921
  return `---
10495
10922
  name: magic-markdown
10496
- description: Use the Magic Markdown CLI or MCP server to read, review, comment on, suggest edits to, and sync clean Markdown workspaces without hand-editing .mdocs sidecars. Use when an agent needs to interact with Magic Markdown documents, comments, suggestions, image context, checkpoints, or local source sync.
10923
+ description: Use the Magic Markdown CLI or MCP server to read, organize, review, comment on, suggest edits to, and sync clean Markdown workspaces without hand-editing .mdocs sidecars. Use when an agent needs to interact with Magic Markdown documents, library folders, comments, suggestions, image context, checkpoints, or local source sync.
10497
10924
  metadata:
10498
10925
  short-description: Operate Magic Markdown via CLI/MCP
10499
10926
  ---
@@ -10520,9 +10947,10 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
10520
10947
 
10521
10948
  1. If given a Magic Markdown share URL, run \`mdocs join <share-url> --json\` first, and always include \`--name "<your agent name>"\` (for example \`--name "Claude Code"\`) so collaborators can see which agent is connected. Use \`mdocs remote ...\` commands after joining.
10522
10949
  2. If working in a local workspace, run \`mdocs doctor --json\` to validate the workspace and learn recommended next commands.
10523
- 3. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, and anchor review counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --json\` instead.
10524
- 4. For a document, run \`mdocs context <path|docId> --json\` locally or \`mdocs remote context <path|docId> --json\` for a joined share.
10525
- 5. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
10950
+ 3. Run \`mdocs map --json\` or \`mdocs remote map --json\` to list documents, paths, docIds, open comments, open suggestions, anchor review counts, and link counts. File-scoped joins (most share links) cover a single document, so \`mdocs remote map\` is unavailable for them \u2014 use \`mdocs remote context --json\` instead. To organize a shared project, rejoin with \`--scope project\` and use \`mdocs remote library --json\`.
10951
+ 4. Run \`mdocs graph --json\` or project-scoped \`mdocs remote graph --json\` before broad edits to inspect the Obsidian-style document graph built from Markdown links and wikilinks.
10952
+ 5. For a document, run \`mdocs context <path|docId> --json\` locally or \`mdocs remote context <path|docId> --json\` for a joined share.
10953
+ 6. Use \`mdocs comment\` / \`mdocs remote comment\` for review notes and \`mdocs suggest\` / \`mdocs remote suggest\` for proposed replacements. Do not insert comments, CriticMarkup, directives, or Magic markers into Markdown files.
10526
10954
 
10527
10955
  ## Editing Rules
10528
10956
 
@@ -10547,6 +10975,7 @@ Run \`mdocs help <command>\` (or \`mdocs <command> --help\`) for usage, examples
10547
10975
  ## Access Roles and Version History
10548
10976
 
10549
10977
  - Agents are **suggest-only by default**: comments and suggestions always work, but direct content rewrites are rejected with a \`forbidden_role\` error until a human grants edit access. Propose changes with \`mdocs remote suggest\` instead of rewriting content.
10978
+ - Project-scoped agents with edit access can create and move Markdown files and can move the joined root into library folders. Folder invites and folder hierarchy updates require admin access on the joined root.
10550
10979
  - Every accepted change is a content-addressed commit with a full snapshot. \`mdocs remote history --json\` lists commits with changed paths; \`mdocs remote restore <headId> --json\` restores the joined document to that snapshot (as a new commit, so it is reversible). Restore requires edit access \u2014 only run it when the user asked.
10551
10980
 
10552
10981
  ## Core Commands
@@ -10561,7 +10990,7 @@ Start the local stdio MCP server with:
10561
10990
  mdocs serve-mcp --cwd /path/to/workspace
10562
10991
  \`\`\`
10563
10992
 
10564
- The MCP server exposes document resources, image resources, a workspace map resource, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt.
10993
+ The MCP server exposes document resources, image resources, workspace map and graph resources, an agent guide resource, typed tools for comments and suggestions, and the \`magic_markdown_agent_workflow\` prompt.
10565
10994
  `;
10566
10995
  }
10567
10996
  function formatCommandReference() {
@@ -10618,7 +11047,7 @@ function toCliError(error) {
10618
11047
 
10619
11048
  // src/checkpoints.ts
10620
11049
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
10621
- import { basename, dirname as dirname2, join } from "node:path";
11050
+ import { basename, dirname as dirname3, join } from "node:path";
10622
11051
  async function createCheckpoint(root, label) {
10623
11052
  const id = createId("checkpoint");
10624
11053
  const createdAt = nowIso();
@@ -10683,7 +11112,7 @@ async function restoreTree(snapshotRoot, root) {
10683
11112
  await mkdir(dest, { recursive: true });
10684
11113
  await restoreTree(source, dest);
10685
11114
  } else if (entry.isFile()) {
10686
- await mkdir(dirname2(dest), { recursive: true });
11115
+ await mkdir(dirname3(dest), { recursive: true });
10687
11116
  await cp(source, dest);
10688
11117
  }
10689
11118
  }
@@ -10739,7 +11168,7 @@ function nextCommands(map) {
10739
11168
 
10740
11169
  // src/fs-io.ts
10741
11170
  import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, realpath, rename, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
10742
- import { dirname as dirname3, join as join2, relative, resolve } from "node:path";
11171
+ import { dirname as dirname4, join as join2, relative, resolve } from "node:path";
10743
11172
  var NodeWorkspaceIO = class {
10744
11173
  constructor(root, pathRules = DEFAULT_ROOT_PATH_RULES) {
10745
11174
  this.root = root;
@@ -10752,12 +11181,12 @@ var NodeWorkspaceIO = class {
10752
11181
  }
10753
11182
  async writeText(path, content) {
10754
11183
  const target = await this.resolveWritablePath(path, Buffer.byteLength(content, "utf8"));
10755
- await mkdir2(dirname3(target), { recursive: true });
11184
+ await mkdir2(dirname4(target), { recursive: true });
10756
11185
  await writeFile2(target, content, "utf8");
10757
11186
  }
10758
11187
  async writeTextAtomic(path, content) {
10759
11188
  const target = await this.resolveWritablePath(path, Buffer.byteLength(content, "utf8"));
10760
- await mkdir2(dirname3(target), { recursive: true });
11189
+ await mkdir2(dirname4(target), { recursive: true });
10761
11190
  const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
10762
11191
  await writeFile2(temporary, content, "utf8");
10763
11192
  await rename(temporary, target);
@@ -10794,12 +11223,12 @@ var NodeWorkspaceIO = class {
10794
11223
  const normalized = assertWorkspacePathAllowed(path, this.pathRules, sizeBytes);
10795
11224
  const root = await realpath(this.root);
10796
11225
  const target = resolve(root, normalized);
10797
- await mkdir2(dirname3(target), { recursive: true });
10798
- assertPathInsideRoot(root, await realpath(dirname3(target)));
11226
+ await mkdir2(dirname4(target), { recursive: true });
11227
+ assertPathInsideRoot(root, await realpath(dirname4(target)));
10799
11228
  return target;
10800
11229
  }
10801
11230
  async resolvePath(path) {
10802
- const normalized = normalizeWorkspacePath2(path);
11231
+ const normalized = normalizeWorkspacePath3(path);
10803
11232
  const root = await realpath(this.root);
10804
11233
  const target = resolve(root, normalized);
10805
11234
  assertPathInsideRoot(root, target);
@@ -10836,7 +11265,7 @@ var PathMappedWorkspaceIO = class {
10836
11265
  return files.map((path) => replicaPathToCanonicalPath(path, this.mapping)).sort();
10837
11266
  }
10838
11267
  toReplicaPath(path) {
10839
- if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath2(path);
11268
+ if (path === ".mdocs" || path.startsWith(".mdocs/")) return normalizeWorkspacePath3(path);
10840
11269
  return canonicalPathToReplicaPath(path, this.mapping);
10841
11270
  }
10842
11271
  };
@@ -10857,14 +11286,14 @@ async function walk(root, output, base, rules, ignoreFiles) {
10857
11286
  }
10858
11287
  if (entry.isFile()) {
10859
11288
  const stats = await stat2(absolute);
10860
- if (isWorkspaceMarkdownPathAllowed(relativePath, rules, stats.size, ignoreMatcher)) output.push(normalizeWorkspacePath2(relativePath));
11289
+ if (isWorkspaceMarkdownPathAllowed(relativePath, rules, stats.size, ignoreMatcher)) output.push(normalizeWorkspacePath3(relativePath));
10861
11290
  }
10862
11291
  }
10863
11292
  }
10864
11293
  async function withDirectoryIgnoreFile(root, base, ignoreFiles) {
10865
11294
  try {
10866
11295
  const path = join2(relative(base, root).replaceAll("\\", "/"), ".gitignore").replace(/^\.\//, "");
10867
- const normalizedPath = path === ".gitignore" ? path : normalizeWorkspacePath2(path);
11296
+ const normalizedPath = path === ".gitignore" ? path : normalizeWorkspacePath3(path);
10868
11297
  const content = await readFile2(join2(root, ".gitignore"), "utf8");
10869
11298
  return [...ignoreFiles, { path: normalizedPath, content }];
10870
11299
  } catch {
@@ -10997,6 +11426,7 @@ async function contextForDocument(io, path, startLine, endLine) {
10997
11426
  endLine: el,
10998
11427
  markdown,
10999
11428
  images: withMcpImageResources(io, state).images,
11429
+ links: state.links,
11000
11430
  comments: state.sidecar.comments.filter((comment2) => comment2.status === "open"),
11001
11431
  suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
11002
11432
  anchors: state.anchors
@@ -11074,7 +11504,12 @@ var actorProperties = {
11074
11504
  var tools = [
11075
11505
  {
11076
11506
  name: "mdocs_map",
11077
- description: "Return the workspace document map.",
11507
+ description: "Return the workspace document map with review and link counts.",
11508
+ inputSchema: noArgsSchema
11509
+ },
11510
+ {
11511
+ name: "mdocs_graph",
11512
+ description: "Return the workspace knowledge graph as document nodes and Markdown/wikilink edges. Edges include resolved, ambiguous, unresolved, external, and same-document anchor links.",
11078
11513
  inputSchema: noArgsSchema
11079
11514
  },
11080
11515
  {
@@ -11084,7 +11519,7 @@ var tools = [
11084
11519
  },
11085
11520
  {
11086
11521
  name: "mdocs_context",
11087
- description: "Return agent-ready document Markdown, open review state, anchors, and image references. Use startLine/endLine to read a specific line range when the full document is too large for one context window. The response always includes totalLines so you know whether to page.",
11522
+ description: "Return agent-ready document Markdown, open review state, anchors, image references, and resolved outgoing links. Use startLine/endLine to read a specific line range when the full document is too large for one context window. The response always includes totalLines so you know whether to page.",
11088
11523
  inputSchema: {
11089
11524
  type: "object",
11090
11525
  properties: {
@@ -11223,6 +11658,11 @@ async function dispatch(io, method, params) {
11223
11658
  uri: "mdocs://workspace/map",
11224
11659
  name: "Workspace Map",
11225
11660
  mimeType: "application/vnd.mdocs.workspace-map+json"
11661
+ },
11662
+ {
11663
+ uri: "mdocs://workspace/graph",
11664
+ name: "Workspace Knowledge Graph",
11665
+ mimeType: "application/vnd.mdocs.workspace-graph+json"
11226
11666
  }
11227
11667
  ])
11228
11668
  };
@@ -11315,6 +11755,17 @@ async function readResource(io, path) {
11315
11755
  ]
11316
11756
  };
11317
11757
  }
11758
+ if (path === "mdocs://workspace/graph") {
11759
+ return {
11760
+ contents: [
11761
+ {
11762
+ uri: path,
11763
+ mimeType: "application/vnd.mdocs.workspace-graph+json",
11764
+ text: JSON.stringify(await getWorkspaceGraph(io), null, 2)
11765
+ }
11766
+ ]
11767
+ };
11768
+ }
11318
11769
  const imageMatch = /^mdocs:\/\/documents\/([^/]+)\/images\/(\d+)$/.exec(path);
11319
11770
  if (imageMatch) {
11320
11771
  return readImageResource(io, path, imageMatch[1] ?? "", Number(imageMatch[2]));
@@ -11333,6 +11784,7 @@ async function readResource(io, path) {
11333
11784
  }
11334
11785
  async function callTool(io, name, args) {
11335
11786
  if (name === "mdocs_map") return getWorkspaceMap(io);
11787
+ if (name === "mdocs_graph") return getWorkspaceGraph(io);
11336
11788
  if (name === "mdocs_doctor") return runDoctor(io, io.root);
11337
11789
  if (name === "mdocs_context") {
11338
11790
  const startLine = typeof args.startLine === "number" ? args.startLine : void 0;
@@ -11459,6 +11911,42 @@ async function fetchTree(record) {
11459
11911
  headers: shareHeaders(record.shareUrl)
11460
11912
  });
11461
11913
  }
11914
+ async function fetchLibrary(record) {
11915
+ return fetchJson(libraryUrl(record), {
11916
+ headers: shareHeaders(record.shareUrl)
11917
+ });
11918
+ }
11919
+ async function postLibraryFolder(record, input, actor) {
11920
+ return fetchJson(`${libraryUrl(record)}/folders`, {
11921
+ method: "POST",
11922
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
11923
+ body: JSON.stringify({ ...input, actor })
11924
+ });
11925
+ }
11926
+ async function patchLibraryFolder(record, folderId, input, actor) {
11927
+ return fetchJson(`${libraryUrl(record)}/folders/${encodeURIComponent(folderId)}`, {
11928
+ method: "PATCH",
11929
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
11930
+ body: JSON.stringify({ ...input, actor })
11931
+ });
11932
+ }
11933
+ async function postMoveRoot(record, folderId, actor) {
11934
+ return fetchJson(`${libraryUrl(record)}/move-root`, {
11935
+ method: "POST",
11936
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
11937
+ body: JSON.stringify({ folderId, actor })
11938
+ });
11939
+ }
11940
+ async function postFolderInvite(record, folderId, input, actor) {
11941
+ return fetchJson(
11942
+ `${libraryUrl(record)}/folders/${encodeURIComponent(folderId)}/invites`,
11943
+ {
11944
+ method: "POST",
11945
+ headers: { ...shareHeaders(record.shareUrl), "Content-Type": "application/json" },
11946
+ body: JSON.stringify({ ...input, actor })
11947
+ }
11948
+ );
11949
+ }
11462
11950
  async function fetchDocument(record, pathOrDocId) {
11463
11951
  if (record.scope === "file" && record.docId) {
11464
11952
  const document = await fetchDocumentById(record, record.docId);
@@ -11579,6 +12067,9 @@ function agentUrl(record, docId, action, query) {
11579
12067
  const base = `${record.origin}/api/agent/${encodeURIComponent(record.workspaceId)}/${encodeURIComponent(record.rootId)}/${encodeURIComponent(docId)}/${action}`;
11580
12068
  return query ? `${base}?${query}` : base;
11581
12069
  }
12070
+ function libraryUrl(record) {
12071
+ return `${record.origin}/api/workspaces/${encodeURIComponent(record.workspaceId)}/roots/${encodeURIComponent(record.rootId)}/library`;
12072
+ }
11582
12073
  function shareHeaders(shareUrl) {
11583
12074
  return { Referer: shareUrl, Authorization: `Bearer ${shareUrl}` };
11584
12075
  }
@@ -11758,12 +12249,13 @@ async function runJoinCommand(root, parsed) {
11758
12249
  const agentName = resolveAgentName(parsed.flags);
11759
12250
  const agentId = normalizeAgentId(String(parsed.flags["agent-id"] ?? parsed.flags.actor ?? agentName));
11760
12251
  const docId = parsedShare.docId ?? await firstDocIdForProject(parsedShare);
11761
- const state = await postPresenceAndReadState(parsedShare, target, docId, agentId, agentName);
12252
+ const shareUrl = shareUrlForScope(target, { ...parsedShare, docId });
12253
+ const state = await postPresenceAndReadState(parsedShare, shareUrl, docId, agentId, agentName);
11762
12254
  const now = (/* @__PURE__ */ new Date()).toISOString();
11763
12255
  const record = {
11764
12256
  joinId: joinIdFor(target),
11765
12257
  scope: parsedShare.scope,
11766
- shareUrl: target,
12258
+ shareUrl,
11767
12259
  origin: parsedShare.origin,
11768
12260
  workspaceId: parsedShare.workspaceId,
11769
12261
  rootId: parsedShare.rootId,
@@ -11782,7 +12274,7 @@ async function runJoinsCommand(root) {
11782
12274
  return listJoinRecords(root);
11783
12275
  }
11784
12276
  async function runRemoteCommand(root, subcommand, parsed) {
11785
- const record = await readSelectedJoin(root, parsed.flags);
12277
+ const record = await normalizedSelectedJoin(root, parsed.flags);
11786
12278
  switch (subcommand) {
11787
12279
  case "map":
11788
12280
  case void 0:
@@ -11793,8 +12285,14 @@ async function runRemoteCommand(root, subcommand, parsed) {
11793
12285
  }
11794
12286
  await refreshPresence(record, record.docId);
11795
12287
  return fetchTree(record);
12288
+ case "graph":
12289
+ return remoteGraph(record);
11796
12290
  case "context":
11797
12291
  return remoteContext(record, parsed.command[2], parsed.flags);
12292
+ case "create-file":
12293
+ return remoteCreateFile(root, record, parsed);
12294
+ case "move-file":
12295
+ return remoteMoveFile(root, record, parsed);
11798
12296
  case "comment":
11799
12297
  return remoteComment(root, record, parsed);
11800
12298
  case "suggest":
@@ -11807,14 +12305,48 @@ async function runRemoteCommand(root, subcommand, parsed) {
11807
12305
  return remoteHistory(record, parsed.flags);
11808
12306
  case "restore":
11809
12307
  return remoteRestore(record, parsed);
12308
+ case "library":
12309
+ assertProjectScope(record, "library");
12310
+ await refreshPresence(record, record.docId);
12311
+ return fetchLibrary(record);
12312
+ case "create-folder":
12313
+ return remoteCreateFolder(record, parsed);
12314
+ case "update-folder":
12315
+ return remoteUpdateFolder(record, parsed);
12316
+ case "move-root":
12317
+ return remoteMoveRoot(record, parsed);
12318
+ case "invite-folder":
12319
+ return remoteInviteFolder(record, parsed);
11810
12320
  case "rejoin":
11811
12321
  return rejoin(root, record.joinId, parsed.flags);
11812
12322
  default:
11813
12323
  throw new CliError("usage_error", `Unknown remote subcommand: ${subcommand}`, {
11814
- hint: "Use mdocs remote map|context|comment|suggest|reject|events|history|restore|rejoin."
12324
+ hint: "Use mdocs remote map|graph|context|create-file|move-file|comment|suggest|reject|events|history|restore|library|create-folder|update-folder|move-root|invite-folder|rejoin."
11815
12325
  });
11816
12326
  }
11817
12327
  }
12328
+ async function remoteGraph(record) {
12329
+ assertProjectScope(record, "graph");
12330
+ await refreshPresence(record, record.docId);
12331
+ const tree = await fetchTree(record);
12332
+ const documents = await Promise.all(tree.docs.map((doc) => fetchDocument(record, doc.docId)));
12333
+ const lookup = documents.map((doc) => ({ docId: doc.docId, path: doc.path, title: doc.title }));
12334
+ const graphDocuments = documents.map((doc) => {
12335
+ const links = doc.links ?? resolveMarkdownLinks(extractMarkdownLinks(doc.markdown), doc, lookup);
12336
+ return {
12337
+ docId: doc.docId,
12338
+ path: doc.path,
12339
+ title: doc.title,
12340
+ openComments: doc.openComments,
12341
+ openSuggestions: doc.openSuggestions,
12342
+ anchorsNeedingReview: doc.anchors.filter((anchor) => anchor.status !== "mapped").length,
12343
+ externalLinks: links.filter((link2) => link2.status === "external").length,
12344
+ unresolvedLinks: links.filter((link2) => link2.status === "unresolved" || link2.status === "ambiguous").length,
12345
+ links
12346
+ };
12347
+ });
12348
+ return buildWorkspaceGraph(record.workspaceId, SIDECAR_SCHEMA_VERSION, graphDocuments);
12349
+ }
11818
12350
  async function remoteHistory(record, flags) {
11819
12351
  await refreshPresence(record, record.docId);
11820
12352
  const limit = typeof flags.limit === "string" ? Number(flags.limit) || 50 : 50;
@@ -11844,9 +12376,11 @@ async function remoteRestore(record, parsed) {
11844
12376
  async function rejoin(root, joinId, flags) {
11845
12377
  const record = await readSelectedJoin(root, { ...flags, ...joinId ? { join: joinId } : {} });
11846
12378
  const docId = record.docId ?? await firstDocIdForProject(record);
11847
- const state = await postPresenceAndReadState(record, record.shareUrl, docId, record.agentId, record.agentName);
12379
+ const shareUrl = shareUrlForScope(record.shareUrl, { ...record, docId });
12380
+ const state = await postPresenceAndReadState(record, shareUrl, docId, record.agentId, record.agentName);
11848
12381
  const next = {
11849
12382
  ...record,
12383
+ shareUrl,
11850
12384
  docId,
11851
12385
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
11852
12386
  currentHead: state.document.currentSha
@@ -11854,6 +12388,15 @@ async function rejoin(root, joinId, flags) {
11854
12388
  await writeJoinRecord(root, next);
11855
12389
  return joinSummary(next, state.document);
11856
12390
  }
12391
+ async function normalizedSelectedJoin(root, flags) {
12392
+ const record = await readSelectedJoin(root, flags);
12393
+ if (!record.docId && record.scope === "file") return record;
12394
+ const shareUrl = shareUrlForScope(record.shareUrl, record);
12395
+ if (shareUrl === record.shareUrl) return record;
12396
+ const next = { ...record, shareUrl, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
12397
+ await writeJoinRecord(root, next);
12398
+ return next;
12399
+ }
11857
12400
  async function refreshPresence(record, docId) {
11858
12401
  if (!docId) return;
11859
12402
  await postPresence(record, record.shareUrl, docId, record.agentId, record.agentName).catch(() => void 0);
@@ -11887,8 +12430,74 @@ async function remoteContext(record, pathOrDocId, flags = {}) {
11887
12430
  comments: document.sidecar.comments.filter((comment2) => comment2.status === "open"),
11888
12431
  suggestions: document.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
11889
12432
  anchors: document.anchors,
11890
- images: document.images
12433
+ images: document.images,
12434
+ links: document.links
12435
+ };
12436
+ }
12437
+ async function remoteCreateFile(root, record, parsed) {
12438
+ assertProjectScope(record, "create-file");
12439
+ const path = parsed.command[2];
12440
+ if (!path) {
12441
+ throw new CliError("usage_error", "Missing file path.", {
12442
+ hint: "Run mdocs remote create-file <path> --with-file /tmp/initial.md --json."
12443
+ });
12444
+ }
12445
+ const tree = await fetchTree(record);
12446
+ const markdownInput = await readOptionalTextFlag(parsed.flags, root, ["with", "markdown", "body"]);
12447
+ const markdown = markdownInput ?? defaultMarkdown(path);
12448
+ const normalizedPath = normalizeRemoteWorkspacePath(path, tree.root.pathRules, Buffer.byteLength(markdown, "utf8"));
12449
+ if (tree.docs.some((doc) => doc.path === normalizedPath)) {
12450
+ throw new CliError("validation_error", `Document already exists: ${normalizedPath}`, {
12451
+ hint: "Choose a new path or use mdocs remote context to inspect the existing document."
12452
+ });
12453
+ }
12454
+ const docId = createDocId();
12455
+ const title = titleFromMarkdown(normalizedPath, markdown);
12456
+ const sidecar = emptySidecar(docId, normalizedPath, title);
12457
+ const baseDocument = {
12458
+ workspaceId: record.workspaceId,
12459
+ rootId: record.rootId,
12460
+ docId,
12461
+ path: normalizedPath,
12462
+ title,
12463
+ markdown,
12464
+ sidecar,
12465
+ anchors: [],
12466
+ images: [],
12467
+ links: [],
12468
+ openComments: 0,
12469
+ openSuggestions: 0,
12470
+ currentSha: tree.root.canonical.head
11891
12471
  };
12472
+ const document = await pushDocument(record, baseDocument, markdown, sidecar);
12473
+ await recordHead(root, record, document);
12474
+ return { document };
12475
+ }
12476
+ async function remoteMoveFile(root, record, parsed) {
12477
+ assertProjectScope(record, "move-file");
12478
+ const pathOrDocId = parsed.command[2];
12479
+ if (!pathOrDocId) {
12480
+ throw new CliError("usage_error", "Missing source document path or docId.", {
12481
+ hint: "Run mdocs remote move-file <path|docId> --to <new-path> --json."
12482
+ });
12483
+ }
12484
+ const nextPathFlag = requiredFlag(parsed.flags, "to");
12485
+ const document = await fetchDocument(record, pathOrDocId);
12486
+ const tree = await fetchTree(record);
12487
+ const nextPath = normalizeRemoteWorkspacePath(nextPathFlag, tree.root.pathRules, Buffer.byteLength(document.markdown, "utf8"));
12488
+ if (tree.docs.some((doc) => doc.docId !== document.docId && doc.path === nextPath)) {
12489
+ throw new CliError("validation_error", `Document already exists: ${nextPath}`, {
12490
+ hint: "Choose a path that is not already in use."
12491
+ });
12492
+ }
12493
+ const moved = await pushDocument(
12494
+ record,
12495
+ { ...document, path: nextPath, title: titleFromMarkdown(nextPath, document.markdown) },
12496
+ document.markdown,
12497
+ document.sidecar
12498
+ );
12499
+ await recordHead(root, record, moved);
12500
+ return { document: moved };
11892
12501
  }
11893
12502
  async function remoteComment(root, record, parsed) {
11894
12503
  const body = await readRequiredTextFlag(parsed.flags, root, ["body", "message"]);
@@ -11913,6 +12522,62 @@ async function remoteSuggest(root, record, parsed) {
11913
12522
  );
11914
12523
  return { suggestion: result.created, document: result.document };
11915
12524
  }
12525
+ async function remoteCreateFolder(record, parsed) {
12526
+ assertProjectScope(record, "create-folder");
12527
+ const name = folderNameFromArgs(parsed);
12528
+ const parentId = optionalFolderTarget(parsed.flags.parent);
12529
+ await refreshPresence(record, record.docId);
12530
+ return postLibraryFolder(record, { name, ...parentId ? { parentId } : {} }, actorForRecord(record));
12531
+ }
12532
+ async function remoteUpdateFolder(record, parsed) {
12533
+ assertProjectScope(record, "update-folder");
12534
+ const folderId = parsed.command[2];
12535
+ if (!folderId) {
12536
+ throw new CliError("usage_error", "Missing folder id.", {
12537
+ hint: "Run mdocs remote update-folder <folderId> --name <name> --json."
12538
+ });
12539
+ }
12540
+ const input = {};
12541
+ if (typeof parsed.flags.name === "string") input.name = parsed.flags.name;
12542
+ if (parsed.flags.home === true || parsed.flags.home === "true") input.parentId = null;
12543
+ else if (parsed.flags.parent !== void 0) input.parentId = optionalFolderTarget(parsed.flags.parent);
12544
+ if (input.name === void 0 && input.parentId === void 0) {
12545
+ throw new CliError("usage_error", "Nothing to update.", {
12546
+ hint: "Pass --name <name>, --parent <folderId>, or --home."
12547
+ });
12548
+ }
12549
+ await refreshPresence(record, record.docId);
12550
+ return patchLibraryFolder(record, folderId, input, actorForRecord(record));
12551
+ }
12552
+ async function remoteMoveRoot(record, parsed) {
12553
+ assertProjectScope(record, "move-root");
12554
+ const folderId = parsed.flags.home === true || parsed.flags.home === "true" ? null : optionalFolderTarget(parsed.flags.folder);
12555
+ if (folderId === void 0) {
12556
+ throw new CliError("usage_error", "Missing target folder.", {
12557
+ hint: "Pass --folder <folderId> to move into a folder, or --home to move to the top level."
12558
+ });
12559
+ }
12560
+ await refreshPresence(record, record.docId);
12561
+ return postMoveRoot(record, folderId, actorForRecord(record));
12562
+ }
12563
+ async function remoteInviteFolder(record, parsed) {
12564
+ assertProjectScope(record, "invite-folder");
12565
+ const folderId = parsed.command[2];
12566
+ if (!folderId) {
12567
+ throw new CliError("usage_error", "Missing folder id.", {
12568
+ hint: "Run mdocs remote invite-folder <folderId> --email person@example.com --role edit --json."
12569
+ });
12570
+ }
12571
+ const email = requiredFlag(parsed.flags, "email");
12572
+ const role = typeof parsed.flags.role === "string" ? parsed.flags.role : "edit";
12573
+ if (!["read", "suggest", "edit", "admin"].includes(role)) {
12574
+ throw new CliError("usage_error", "Invalid --role.", {
12575
+ hint: "Use --role read, suggest, edit, or admin."
12576
+ });
12577
+ }
12578
+ await refreshPresence(record, record.docId);
12579
+ return postFolderInvite(record, folderId, { email, role }, actorForRecord(record));
12580
+ }
11916
12581
  async function remoteReject(root, record, parsed) {
11917
12582
  const suggestionId = parsed.command[2];
11918
12583
  if (!suggestionId) {
@@ -12078,6 +12743,11 @@ function parseShareUrl(value, flags) {
12078
12743
  lastSeenEventId: 0
12079
12744
  };
12080
12745
  }
12746
+ function shareUrlForScope(value, record) {
12747
+ const url = new URL(value);
12748
+ url.pathname = buildSharePath(record.workspaceId, record.rootId, record.scope === "file" ? record.docId : void 0);
12749
+ return url.toString();
12750
+ }
12081
12751
  function joinSummary(record, document) {
12082
12752
  return {
12083
12753
  connected: true,
@@ -12099,6 +12769,52 @@ function joinSummary(record, document) {
12099
12769
  ].filter(Boolean)
12100
12770
  };
12101
12771
  }
12772
+ function assertProjectScope(record, command) {
12773
+ if (record.scope === "project") return;
12774
+ throw new CliError("usage_error", `remote ${command} requires a project-scoped join.`, {
12775
+ hint: "Rejoin the share with `mdocs join <share-url> --scope project --json`, then retry."
12776
+ });
12777
+ }
12778
+ function emptySidecar(docId, path, title) {
12779
+ return {
12780
+ schemaVersion: SIDECAR_SCHEMA_VERSION,
12781
+ docId,
12782
+ path,
12783
+ title,
12784
+ anchors: [],
12785
+ comments: [],
12786
+ suggestions: [],
12787
+ changeSets: [],
12788
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
12789
+ };
12790
+ }
12791
+ function defaultMarkdown(path) {
12792
+ return `# ${titleFromMarkdown(path, "")}
12793
+ `;
12794
+ }
12795
+ function normalizeRemoteWorkspacePath(path, rules, sizeBytes) {
12796
+ try {
12797
+ return assertWorkspacePathAllowed(path, rules, sizeBytes);
12798
+ } catch (error) {
12799
+ throw new CliError("validation_error", error instanceof Error ? error.message : String(error));
12800
+ }
12801
+ }
12802
+ function folderNameFromArgs(parsed) {
12803
+ const name = parsed.command[2] ?? (typeof parsed.flags.name === "string" ? parsed.flags.name : void 0);
12804
+ if (!name?.trim()) {
12805
+ throw new CliError("usage_error", "Missing folder name.", {
12806
+ hint: "Run mdocs remote create-folder <name> --json."
12807
+ });
12808
+ }
12809
+ return name;
12810
+ }
12811
+ function optionalFolderTarget(value) {
12812
+ if (value === void 0) return void 0;
12813
+ if (typeof value !== "string") return void 0;
12814
+ const trimmed = value.trim();
12815
+ if (!trimmed || trimmed === "home" || trimmed === "null" || trimmed === "top") return null;
12816
+ return trimmed;
12817
+ }
12102
12818
 
12103
12819
  // src/bridge.ts
12104
12820
  import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
@@ -12529,6 +13245,10 @@ async function main() {
12529
13245
  print(await getWorkspaceMap(io), parsed.flags);
12530
13246
  return;
12531
13247
  }
13248
+ case "graph": {
13249
+ print(await getWorkspaceGraph(io), parsed.flags);
13250
+ return;
13251
+ }
12532
13252
  case "state": {
12533
13253
  print(await getDocumentState(io, requiredArg(parsed.command[1], "path")), parsed.flags);
12534
13254
  return;
@@ -12559,6 +13279,7 @@ async function main() {
12559
13279
  ...image2,
12560
13280
  ...image2.workspacePath ? { absolutePath: resolve5(cwd, image2.workspacePath) } : {}
12561
13281
  })),
13282
+ links: state.links,
12562
13283
  comments: state.sidecar.comments.filter((comment2) => comment2.status === "open"),
12563
13284
  suggestions: state.sidecar.suggestions.filter((suggestion) => suggestion.status === "open"),
12564
13285
  anchors: state.anchors
@@ -12822,6 +13543,7 @@ Commands:
12822
13543
  agent commands --json Print machine-readable command metadata
12823
13544
  init [path] Initialize .mdocs and index Markdown files
12824
13545
  map --json Print workspace map
13546
+ graph --json Print workspace knowledge graph nodes and edges
12825
13547
  state <path> --json Print merged Markdown and sidecar state
12826
13548
  context <path> --json Print agent-ready context
12827
13549
  comments <path> --json List comments
@@ -12841,9 +13563,13 @@ Commands:
12841
13563
  checkpoint create|list|restore Manage local reversible checkpoints
12842
13564
  join <share-url> --json Join a Magic Markdown share through the CLI
12843
13565
  joins --json List saved Magic Markdown remote joins
12844
- remote map|context|comment|suggest Work with the active remote join
13566
+ remote map|graph|context|create-file|move-file
13567
+ Work with documents in the active remote join
13568
+ remote comment|suggest Add remote review comments and suggestions
12845
13569
  remote reject <suggestionId> Withdraw a suggestion you submitted remotely
12846
13570
  remote events|history|restore Poll events, list commits, restore snapshots
13571
+ remote library|create-folder|update-folder|move-root|invite-folder
13572
+ Organize the joined project library
12847
13573
  bridge --workspace <id> --root . --url <base-url> --token <bridge-token>
12848
13574
  Sync an approved local root with the workspace
12849
13575
  (--token from the web "Bind agent filesystem"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magic-markdown/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Magic Markdown agent CLI (mdocs): read, review, comment on, suggest edits to, and sync clean Markdown workspaces.",
5
5
  "type": "module",
6
6
  "license": "MIT",