@geml/geml 1.4.3 → 1.4.5

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/geml.js CHANGED
@@ -598,7 +598,10 @@ function sectionEnd(lines, i, level) {
598
598
  // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
599
599
  // `get`/`set` operate on the one the parser actually registered). `base` is the
600
600
  // absolute line offset of this slice within the whole document.
601
- function collectSpans(lines, base, out, ctx, depth = 0) {
601
+ function collectSpans(lines, base, out, ctx, depth = 0,
602
+ // Optional second index: every typed block by TYPE, id-bearing or not, so a
603
+ // block the author never named is still addressable (`=== meta`).
604
+ types) {
602
605
  const add = (id, start, end) => {
603
606
  if (!out.has(id))
604
607
  out.set(id, { start, end });
@@ -627,11 +630,16 @@ function collectSpans(lines, base, out, ctx, depth = 0) {
627
630
  const { end, closed } = fenceClose(lines, i, open);
628
631
  if (id !== undefined)
629
632
  add(id, base + i, base + end);
633
+ if (types) {
634
+ const list = types.get(type) ?? [];
635
+ list.push({ span: { start: base + i, end: base + end }, id });
636
+ types.set(type, list);
637
+ }
630
638
  // Only a flow body is scanned for nested blocks (raw/data bodies are
631
639
  // opaque), so an id inside a `code` body is *not* addressable — exactly
632
640
  // the parser's contract.
633
641
  if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
634
- collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
642
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, types);
635
643
  }
636
644
  i = end;
637
645
  continue;
@@ -660,6 +668,13 @@ export function blockSpans(source) {
660
668
  collectSpans(lines, 0, out, ctx);
661
669
  return out;
662
670
  }
671
+ function blockTypeSpans(source) {
672
+ const lines = normalizeSource(source).split("\n");
673
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
674
+ const types = new Map();
675
+ collectSpans(lines, 0, new Map(), ctx, 0, types);
676
+ return types;
677
+ }
663
678
  // Split into physical lines while *keeping* each line's terminator, so
664
679
  // join("") is byte-exact and slicing by span never rewrites line endings.
665
680
  // A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
@@ -668,6 +683,22 @@ export function blockSpans(source) {
668
683
  function splitLines(source) {
669
684
  return source.split(/(?<=\n|\r(?!\n))/);
670
685
  }
686
+ // Newline handling lives HERE, in one place, because it is easy to get subtly
687
+ // wrong in each caller. Content reaching a mutation is often LF even when the
688
+ // document is not: a history revision is stored newline-normalized, `--in` may
689
+ // come from either kind of file, stdin from anywhere. So: detect the DOCUMENT's
690
+ // style, compare on the normalized (LF) form, and convert back on the way in —
691
+ // which is what keeps a CRLF document from ending up half CRLF, half LF.
692
+ function newlineOf(text) {
693
+ return /\r\n/.test(text) ? "\r\n" : "\n";
694
+ }
695
+ function toLf(text) {
696
+ return text.replace(/\r\n?/g, "\n");
697
+ }
698
+ function toNewline(text, nl) {
699
+ const lf = toLf(text);
700
+ return nl === "\n" ? lf : lf.replace(/\n/g, nl);
701
+ }
671
702
  // `--head`: narrow any id's span to its HEAD line — the single declaring line
672
703
  // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
673
704
  // `[^id]:` line). The head is by construction the FIRST line of the span, so
@@ -737,7 +768,32 @@ function parseStamp(s) {
737
768
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
738
769
  }
739
770
  const VERSION = "1.0"; // GEML spec version this CLI targets
740
- const PARSER_VERSION = "1.4.3"; // reference implementation; keep in sync with package.json
771
+ // The published version, read from package.json rather than restated here.
772
+ // "Keep in sync with package.json" was a comment, and comments do not run: this
773
+ // literal said 1.4.3 while the MCP server's own copy still said 0.1.0.
774
+ // Resolved from this module's location — `dist/geml.js` -> `../package.json`,
775
+ // and npm always ships package.json whatever `files` says. In a browser bundle
776
+ // `import.meta.url` degenerates to "" (see the `entry` note below), so every
777
+ // lookup fails and we fall back rather than throw at import time.
778
+ export const PARSER_VERSION = (() => {
779
+ let dir;
780
+ try {
781
+ dir = dirname(fileURLToPath(import.meta.url));
782
+ }
783
+ catch {
784
+ return "0.0.0";
785
+ }
786
+ for (let i = 0; i < 3 && dir; i++) {
787
+ try {
788
+ const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
789
+ if (typeof v === "string" && v)
790
+ return v;
791
+ }
792
+ catch { /* not here — walk up */ }
793
+ dir = dirname(dir);
794
+ }
795
+ return "0.0.0";
796
+ })();
741
797
  const USAGE = `geml — GEML reference CLI
742
798
 
743
799
  Usage:
@@ -768,10 +824,11 @@ Usage:
768
824
  geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
769
825
  (--root widens cross-doc refs to dir d, e.g. the repo root)
770
826
  geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
771
- geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
772
- geml mcp --workspace <dir> [--no-history] serve document CRUD over MCP (stdio)
827
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
828
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
773
829
  (9 tools: list/read/check/history + write/add/delete/rename/revert;
774
- every write is validated before it reaches disk)
830
+ every write is validated before it reaches disk. A code graph under
831
+ --root adds resolve_name/open_symbol/get_backlinks to the same server)
775
832
  geml --help | --version [--json]
776
833
 
777
834
  Use '-' as the file to read from stdin.
@@ -785,7 +842,7 @@ Exit codes:
785
842
  // One-line usage for each subcommand — the single source for both the error
786
843
  // shown on misuse and the `<cmd> --help` text.
787
844
  const SUBHELP = {
788
- get: "usage: geml get <file.geml|-> [#id] [--json] [--head] (with #id: that block, a heading id = its whole section, --head = its head line; without #id: list every addressable id, --json = array)",
845
+ get: "usage: geml get <file.geml|-> [#id | '## Heading' | '=== type'] [--json] [--head] (selector: an #id, or a LINE copied from the document — a heading `## Title` addresses its whole section, a fence `=== meta` addresses that block by type and lists the candidates when several match; --head = the head line; without a selector: list every addressable id, --json = array)",
789
846
  set: "usage: geml set <file.geml|-> #id [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id to #id — --body = body; guarded splice, refused if it breaks the doc)",
790
847
  add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
791
848
  delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
@@ -800,23 +857,27 @@ const SUBHELP = {
800
857
  geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
801
858
  geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
802
859
  geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
803
- geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
804
860
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
805
- mcp: `usage: geml mcp --workspace <dir> [--no-history]
861
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
806
862
 
807
863
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
808
864
  Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
809
865
  geml_write_block · geml_add_block · geml_delete_block
810
866
  geml_rename_id · geml_revert_block
867
+ With a code graph under --root, three more (read-only), so one client entry
868
+ covers both: resolve_name · open_symbol · get_backlinks
811
869
 
812
- --workspace <dir> REQUIRED. Root holding the .geml documents. Every path a
870
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
813
871
  client names is confined here; a client cannot widen it.
872
+ --graph <dir> Code-graph directory, inside --root. Defaults to
873
+ <root>/.geml-code-graph when it holds an index.geml; with
874
+ no graph the three graph tools are not served at all.
814
875
  --no-history Skip the .gemlhistory commit taken before each write
815
876
  (default: commit, so geml_revert_block always has a
816
877
  revision to undo to).
817
878
 
818
879
  Register with a client:
819
- claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`,
880
+ claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
820
881
  };
821
882
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
822
883
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1195,6 +1256,72 @@ function positionals(args, valued) {
1195
1256
  }
1196
1257
  return out;
1197
1258
  }
1259
+ // Resolve a block SELECTOR to an id. Three spellings address the same block:
1260
+ //
1261
+ // `#intro` / `intro` the id — the CANONICAL address
1262
+ // `## Getting Started` the heading LINE, copied out of the document
1263
+ // `##Getting Started` …the space after the `#` run is optional
1264
+ //
1265
+ // Why more than one form: the id is what `[[#id]]` references, codemap tables
1266
+ // and URL fragments (§0.6) all carry, so it must stay accepted verbatim — an id
1267
+ // copied out of a reference or out of `geml get <file>` has to work. But a
1268
+ // heading's id is AUTO-DERIVED from its text (`## API 设计 (v1)` → `#api-设计-v1`),
1269
+ // and nobody can be expected to hand-derive that slug for a heading they can
1270
+ // read on screen. So the heading line itself is accepted too.
1271
+ //
1272
+ // Resolution order, first match wins:
1273
+ // 1. the id, exactly — a pasted id is NEVER reinterpreted as prose. (When a
1274
+ // heading's TEXT happens to equal another block's ID, the id wins.)
1275
+ // 2. the exact heading LINE: `#` count AND text both match.
1276
+ // 3. the text alone, at any level — a heading remembered at the wrong depth
1277
+ // still resolves while its text is unique.
1278
+ // 4. text shared by several headings: the `#` count picks one, or the
1279
+ // candidates are listed. Never guessed at.
1280
+ function resolveSelector(source, file, raw) {
1281
+ const bare = raw.replace(/^#/, "");
1282
+ const m = /^(#{1,6})[ \t]*(.+?)[ \t]*$/.exec(raw);
1283
+ if (!m)
1284
+ return bare; // not a `#`-run form: an id, verbatim
1285
+ // 1. The id is canonical and always wins. Checked without a parse, so the
1286
+ // common `get #id` stays a byte-slice on a document with diagnostics.
1287
+ if (blockSpans(source).has(bare))
1288
+ return bare;
1289
+ const level = m[1].length;
1290
+ const want = m[2];
1291
+ const doc = parse(source, { resolveDoc: resolverFor(file) });
1292
+ const heads = doc.ids.flatMap((id) => {
1293
+ const site = findBlockSite(doc.children, id);
1294
+ const b = site?.siblings[site.index];
1295
+ return b?.kind === "heading" ? [{ id, level: b.level, text: b.text.trim() }] : [];
1296
+ });
1297
+ // 2. exact line — what the caller actually typed.
1298
+ const line = heads.find((h) => h.level === level && h.text === want);
1299
+ if (line)
1300
+ return line.id;
1301
+ // 3. the text alone (exact, then case-insensitive).
1302
+ let byText = heads.filter((h) => h.text === want);
1303
+ if (!byText.length) {
1304
+ const lc = want.toLocaleLowerCase();
1305
+ byText = heads.filter((h) => h.text.toLocaleLowerCase() === lc);
1306
+ }
1307
+ if (byText.length === 1)
1308
+ return byText[0].id;
1309
+ // 4. shared text: the level disambiguates, else show the candidates.
1310
+ if (byText.length > 1) {
1311
+ const atLevel = byText.filter((h) => h.level === level);
1312
+ if (atLevel.length === 1)
1313
+ return atLevel[0].id;
1314
+ const list = byText.map((h) => ` #${h.id} (h${h.level})`).join("\n");
1315
+ fail(`\`${want}\` matches ${byText.length} headings — address one by its id:\n${list}`, 1);
1316
+ }
1317
+ // Nothing matched. A lone `#` with no whitespace was almost certainly meant as
1318
+ // an id, so hand it back and let the caller's own `no block with id` error
1319
+ // stand — the precise diagnosis for a typo'd id. Only a heading-SHAPED
1320
+ // selector gets the heading-flavoured message.
1321
+ if (level === 1 && !/\s/.test(bare))
1322
+ return bare;
1323
+ fail(`no id or heading matches \`${raw}\` — run \`geml get ${file === "-" ? "-" : file}\` to list every addressable id`, 1);
1324
+ }
1198
1325
  // `geml get <file>` with no id: list every addressable id — the document's
1199
1326
  // table of contents. Default output is one id per line with its kind (and, for
1200
1327
  // a heading, its level and text); `--json` is a machine-readable array so an
@@ -1244,6 +1371,61 @@ function listIds(source, file, json) {
1244
1371
  // content: a block/footnote id prints its document-model node; a heading id
1245
1372
  // prints a section envelope `{kind:"section", id, level, blocks:[heading,
1246
1373
  // …siblings up to the boundary]}`.
1374
+ // `geml get <file> '=== <type>'` — address a block by its TYPE. One match is
1375
+ // the block itself; several are LISTED with their line ranges rather than
1376
+ // guessed between, so a document with three notes answers "which one" instead
1377
+ // of failing. The uniqueness that makes `=== meta` work is checked here, at
1378
+ // resolve time — nothing in the format has to promise a document holds only one.
1379
+ function getByType(source, file, type, json, headOnly) {
1380
+ const where = file === "-" ? "stdin" : file;
1381
+ const matches = blockTypeSpans(source).get(type) ?? [];
1382
+ if (!matches.length) {
1383
+ fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
1384
+ }
1385
+ if (matches.length === 1) {
1386
+ const m = matches[0];
1387
+ if (json) {
1388
+ // The ONLY block of its type: locating it in the model needs no index, so
1389
+ // --json can still answer with the parsed node (meta's key/values, a
1390
+ // table's model) rather than a mere location.
1391
+ const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file) }).children, type);
1392
+ if (node) {
1393
+ console.log(JSON.stringify(node, null, 2));
1394
+ return;
1395
+ }
1396
+ }
1397
+ const span = headOnly ? narrowToHead(m.span) : m.span;
1398
+ process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1399
+ return;
1400
+ }
1401
+ // Several: report WHERE they are (data on stdout, the explanation on stderr),
1402
+ // so the caller can name one — by adding an #id, or via its section.
1403
+ if (json) {
1404
+ console.log(JSON.stringify({ kind: "blocks", type, matches: matches.map((m) => ({ ...(m.id ? { id: m.id } : {}), lines: [m.span.start + 1, m.span.end] })) }, null, 2));
1405
+ return;
1406
+ }
1407
+ console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
1408
+ for (const m of matches) {
1409
+ console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
1410
+ }
1411
+ }
1412
+ // The single block of `type` in a document, or undefined when there is not
1413
+ // exactly one (nested flow children included, matching the span scan's reach).
1414
+ function onlyBlockOfType(blocks, type) {
1415
+ const hits = [];
1416
+ const walk = (list) => {
1417
+ for (const b of list) {
1418
+ if (b.kind === "block") {
1419
+ if (b.type === type)
1420
+ hits.push(b);
1421
+ if (b.children)
1422
+ walk(b.children);
1423
+ }
1424
+ }
1425
+ };
1426
+ walk(blocks);
1427
+ return hits.length === 1 ? hits[0] : undefined;
1428
+ }
1247
1429
  function runGet(args) {
1248
1430
  const json = args.includes("--json");
1249
1431
  const headOnly = args.includes("--head");
@@ -1252,12 +1434,23 @@ function runGet(args) {
1252
1434
  fail(SUBHELP.get);
1253
1435
  // No id: list every addressable id — the document's "table of contents", so
1254
1436
  // an agent can discover what `get #id` can target without pulling the model.
1437
+ // One read: stdin can only be consumed once, and the selector resolver needs
1438
+ // the same bytes the slice below works on.
1439
+ const source = readInput(file);
1255
1440
  if (!rawId) {
1256
- listIds(readInput(file), file, json);
1441
+ listIds(source, file, json);
1257
1442
  return;
1258
1443
  }
1259
- const id = rawId.replace(/^#/, "");
1260
- const source = readInput(file);
1444
+ // A FENCE line as the selector (`=== meta`): the same "copy the line out of
1445
+ // the document" move as a heading line, for the blocks that carry no id.
1446
+ // A pasted fence that DOES declare an id defers to the id path below.
1447
+ const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
1448
+ const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
1449
+ if (fence && fenceId === undefined) {
1450
+ getByType(source, file, fence[1], json, headOnly);
1451
+ return;
1452
+ }
1453
+ const id = fenceId ?? resolveSelector(source, file, rawId);
1261
1454
  if (json) {
1262
1455
  // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1263
1456
  // to resolve the tree (and nested-block ids), but only the target prints.
@@ -1394,7 +1587,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
1394
1587
  let head = headLine;
1395
1588
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
1396
1589
  head += "\n";
1397
- let b = body.replace(/\r\n?/g, "\n");
1590
+ let b = toLf(body); // spliceBlock converts the result to the document's style
1398
1591
  if (closeLine !== null && b !== "" && !b.endsWith("\n"))
1399
1592
  b += "\n";
1400
1593
  const replacement = closeLine !== null ? head + b + closeLine : head + b;
@@ -1462,18 +1655,19 @@ function insertFragment(source, lines, at, fragment, file) {
1462
1655
  const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
1463
1656
  const before = lines.slice(0, at);
1464
1657
  const after = lines.slice(at);
1658
+ const nl = newlineOf(source); // the fragment AND every separator we add
1465
1659
  // The preceding line must end in a newline so the fragment starts on its own.
1466
1660
  if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
1467
- before[before.length - 1] += "\n";
1661
+ before[before.length - 1] += nl;
1468
1662
  }
1469
- let frag = fragment.replace(/\r\n?/g, "\n");
1663
+ let frag = toNewline(fragment, nl);
1470
1664
  if (!frag.endsWith("\n"))
1471
- frag += "\n";
1665
+ frag += nl;
1472
1666
  // A single blank separator on each side that has adjacent content and isn't
1473
1667
  // already blank — keeps a following head / preceding block from fusing.
1474
1668
  const blank = (s) => stripEol(s).trim() === "";
1475
- const sepBefore = before.length && !blank(before[before.length - 1]) ? "\n" : "";
1476
- const sepAfter = after.length && !blank(after[0]) ? "\n" : "";
1669
+ const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1670
+ const sepAfter = after.length && !blank(after[0]) ? nl : "";
1477
1671
  const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1478
1672
  const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
1479
1673
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
@@ -1717,10 +1911,11 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1717
1911
  const span = headOnly ? narrowToHead(found) : found;
1718
1912
  const before = orig.slice(0, span.start);
1719
1913
  const after = orig.slice(span.end);
1720
- let inject = replacement.replace(/\r\n?/g, "\n");
1914
+ const nl = newlineOf(source); // adopt the document's style, not LF
1915
+ let inject = toNewline(replacement, nl);
1721
1916
  const lastLine = span.end >= orig.length;
1722
1917
  if (!inject.endsWith("\n") && !lastLine)
1723
- inject += "\n";
1918
+ inject += nl;
1724
1919
  const updated = before.join("") + inject + after.join("");
1725
1920
  // Re-parse and refuse a broken result. A parse error or a duplicate id both
1726
1921
  // surface as error diagnostics (registerId flags dups); one check covers both.
@@ -1786,6 +1981,13 @@ function runRevert(args) {
1786
1981
  const id = rawId.replace(/^#/, "");
1787
1982
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1788
1983
  const source = readInput(file);
1984
+ // The sidecar stores every revision newline-NORMALIZED (history.ts), so a
1985
+ // revision's text always comes back LF while the working file may be CRLF.
1986
+ // Comparing those raw would make EVERY block look changed on a CRLF document
1987
+ // (`--rev changed` reverting blocks nobody touched, and the no-op check never
1988
+ // firing), so compare normalized and write back in the file's own style.
1989
+ const norm = toLf; // compare on the LF form
1990
+ const toFileNl = (s) => toNewline(s, newlineOf(source));
1789
1991
  const curFull = blockSpans(source).get(id); // undefined => absent now
1790
1992
  const curBlock = curFull === undefined ? undefined : (() => {
1791
1993
  const span = headOnly ? narrowToHead(curFull) : curFull;
@@ -1804,7 +2006,8 @@ function runRevert(args) {
1804
2006
  const target = (() => {
1805
2007
  try {
1806
2008
  if (changed) {
1807
- const found = firstChangedContent(historyPath, curBlock ?? "", pick);
2009
+ // `pick` reads normalized revision text, so normalize this side too.
2010
+ const found = firstChangedContent(historyPath, curBlock === undefined ? "" : norm(curBlock), pick);
1808
2011
  if (!found)
1809
2012
  fail(`no earlier revision changes \`${id}\``, 1);
1810
2013
  return found;
@@ -1831,7 +2034,7 @@ function runRevert(args) {
1831
2034
  }
1832
2035
  // both present -> SPLICE (undo set)
1833
2036
  if (curBlock !== undefined && oldBlock !== undefined) {
1834
- if (oldBlock === curBlock) {
2037
+ if (norm(oldBlock) === norm(curBlock)) {
1835
2038
  console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
1836
2039
  // A no-op still has to PRODUCE the document when an output destination was
1837
2040
  // asked for: `-o` means "write the result somewhere", and the result of a
@@ -1842,12 +2045,13 @@ function runRevert(args) {
1842
2045
  emit(source, `#${id} unchanged`);
1843
2046
  return;
1844
2047
  }
2048
+ const replacement = toFileNl(oldBlock); // keep the file's newline style
1845
2049
  if (dryRun) {
1846
2050
  console.error(`would revert #${id} to ${target.id}:`);
1847
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
2051
+ process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
1848
2052
  return;
1849
2053
  }
1850
- emit(spliceBlock(source, id, oldBlock, file, headOnly), `reverted #${id} to ${target.id}`);
2054
+ emit(spliceBlock(source, id, replacement, file, headOnly), `reverted #${id} to ${target.id}`);
1851
2055
  return;
1852
2056
  }
1853
2057
  // --head is only meaningful for the splice cell (it can't resurrect or remove).
@@ -1859,24 +2063,25 @@ function runRevert(args) {
1859
2063
  // Guard: if the block we'd resurrect is the same (modulo id) as one already
1860
2064
  // present under a different id, #id was likely renamed away — resurrecting
1861
2065
  // would duplicate it. Point at `rename` instead of writing.
1862
- const cmpKey = normalizeBlockId(oldBlock, "__cmp__");
2066
+ const cmpKey = normalizeBlockId(norm(oldBlock), "__cmp__");
1863
2067
  for (const [cid, cs] of blockSpans(source)) {
1864
2068
  if (cid === id)
1865
2069
  continue;
1866
2070
  const csrc = splitLines(source).slice(cs.start, cs.end).join("");
1867
- if (normalizeBlockId(csrc, "__cmp__") === cmpKey) {
2071
+ if (normalizeBlockId(norm(csrc), "__cmp__") === cmpKey) {
1868
2072
  fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
1869
2073
  }
1870
2074
  }
1871
2075
  const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
2076
+ const fragment = toFileNl(oldBlock); // keep the file's newline style
1872
2077
  if (dryRun) {
1873
2078
  console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
1874
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
2079
+ process.stdout.write(fragment.endsWith("\n") ? fragment : fragment + "\n");
1875
2080
  return;
1876
2081
  }
1877
2082
  if (warn)
1878
2083
  console.error(`warning: anchors for #${id} are gone; appended at end`);
1879
- emit(insertFragment(source, splitLines(source), at, oldBlock, file), `resurrected #${id} from ${target.id} at ${where}`);
2084
+ emit(insertFragment(source, splitLines(source), at, fragment, file), `resurrected #${id} from ${target.id} at ${where}`);
1880
2085
  return;
1881
2086
  }
1882
2087
  // present now, absent at R -> REMOVE (undo add)
@@ -1884,7 +2089,7 @@ function runRevert(args) {
1884
2089
  // under a different id, #id was likely renamed IN — removing would delete a
1885
2090
  // renamed block. Point at `rename` instead (the dangerous direction).
1886
2091
  {
1887
- const cmpKey = normalizeBlockId(curBlock, "__cmp__");
2092
+ const cmpKey = normalizeBlockId(norm(curBlock), "__cmp__");
1888
2093
  for (const [rid, rs] of blockSpans(target.text)) {
1889
2094
  if (rid === id)
1890
2095
  continue;
@@ -1964,9 +2169,15 @@ function runCodemap(args) {
1964
2169
  serve: "serve.mjs",
1965
2170
  refresh: "refresh.mjs",
1966
2171
  find: "find.mjs",
1967
- mcp: "mcp-server.mjs",
1968
2172
  };
1969
2173
  const sub = args[0] ?? "";
2174
+ // `codemap mcp` was a second stdio server over the same repository. It is
2175
+ // gone, not renamed, so name the replacement instead of letting it fall into
2176
+ // `unknown codemap subcommand`: this string is what an operator sees in a
2177
+ // client's server log when the entry they registered stops starting.
2178
+ if (sub === "mcp") {
2179
+ fail("geml codemap mcp was removed: use `geml mcp --root <dir>`, which serves the three code-graph tools alongside the document tools (graph: <root>/.geml-code-graph, or --graph <dir>).");
2180
+ }
1970
2181
  const script = scripts[sub];
1971
2182
  if (!script)
1972
2183
  fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
@@ -1974,10 +2185,11 @@ function runCodemap(args) {
1974
2185
  const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
1975
2186
  process.exit(r.status ?? 1);
1976
2187
  }
1977
- // geml mcp: the document-CRUD MCP server. Runs as a child's MAIN module for the
1978
- // same reason `codemap mcp` does it owns stdin/stdout for the whole session
1979
- // (the stdio transport), and dispatching by spawn keeps this module free of a
1980
- // runtime import cycle (mcp.js imports the parser from here).
2188
+ // geml mcp: the MCP server document CRUD, plus the code-graph tools when the
2189
+ // root holds a graph. It runs as a child's MAIN module because it owns
2190
+ // stdin/stdout for the whole session (the stdio transport), and dispatching by
2191
+ // spawn keeps this module free of a runtime import cycle (mcp.js imports the
2192
+ // parser from here).
1981
2193
  function runMcp(args) {
1982
2194
  const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
1983
2195
  const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
package/dist/mcp.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  export interface McpOptions {
3
- workspace: string;
3
+ root: string;
4
4
  history: boolean;
5
+ graph?: string;
5
6
  }
6
7
  /** Configure the server. Exported so the suite can point it at a temp dir. */
7
8
  export declare function configure(o: Partial<McpOptions>): McpOptions;
8
- export declare function resolveInWorkspace(file: string): string;
9
+ export declare function resolveInRoot(file: string): string;
9
10
  export interface Tool {
10
11
  name: string;
11
12
  description: string;
@@ -13,6 +14,13 @@ export interface Tool {
13
14
  run: (args: Record<string, any>) => unknown;
14
15
  }
15
16
  export declare const TOOLS: Tool[];
17
+ /** Tools served right now: the ten document tools, plus the graph tools when a graph is configured. */
18
+ export declare function allTools(): Tool[];
19
+ /**
20
+ * Load and confine the code-graph tools. Idempotent; awaited at startup and by
21
+ * the suite, which drives `handleLine` in-process.
22
+ */
23
+ export declare function loadGraphTools(): Promise<Tool[]>;
16
24
  export declare function handleLine(line: string, write?: (s: string) => void): void;
17
- export declare const MCP_USAGE = "usage: geml mcp --workspace <dir> [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).\n\n --workspace <dir> REQUIRED. Root directory holding the .geml documents.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert_block always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs";
25
+ export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--graph <dir>] [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the\n read-only code-graph tools when the root holds a code graph.\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --graph <dir> Code-graph directory, inside --root. Defaults to\n <root>/.geml-code-graph when that holds an index.geml.\n With no graph, the code-graph tools are not served\n at all (a client sees only the document tools).\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml -- geml mcp --root /abs/path/to/repo";
18
26
  export declare function parseArgs(args: string[]): McpOptions;