@geml/geml 1.4.3 → 1.4.4

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.d.ts CHANGED
@@ -71,3 +71,4 @@ export interface Span {
71
71
  end: number;
72
72
  }
73
73
  export declare function blockSpans(source: string): Map<string, Span>;
74
+ export declare const PARSER_VERSION: string;
package/dist/geml.js CHANGED
@@ -668,6 +668,22 @@ export function blockSpans(source) {
668
668
  function splitLines(source) {
669
669
  return source.split(/(?<=\n|\r(?!\n))/);
670
670
  }
671
+ // Newline handling lives HERE, in one place, because it is easy to get subtly
672
+ // wrong in each caller. Content reaching a mutation is often LF even when the
673
+ // document is not: a history revision is stored newline-normalized, `--in` may
674
+ // come from either kind of file, stdin from anywhere. So: detect the DOCUMENT's
675
+ // style, compare on the normalized (LF) form, and convert back on the way in —
676
+ // which is what keeps a CRLF document from ending up half CRLF, half LF.
677
+ function newlineOf(text) {
678
+ return /\r\n/.test(text) ? "\r\n" : "\n";
679
+ }
680
+ function toLf(text) {
681
+ return text.replace(/\r\n?/g, "\n");
682
+ }
683
+ function toNewline(text, nl) {
684
+ const lf = toLf(text);
685
+ return nl === "\n" ? lf : lf.replace(/\n/g, nl);
686
+ }
671
687
  // `--head`: narrow any id's span to its HEAD line — the single declaring line
672
688
  // (a heading's `# … {#id}` line, a typed block's opening fence, a footnote's
673
689
  // `[^id]:` line). The head is by construction the FIRST line of the span, so
@@ -737,7 +753,32 @@ function parseStamp(s) {
737
753
  return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
738
754
  }
739
755
  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
756
+ // The published version, read from package.json rather than restated here.
757
+ // "Keep in sync with package.json" was a comment, and comments do not run: this
758
+ // literal said 1.4.3 while the MCP server's own copy still said 0.1.0.
759
+ // Resolved from this module's location — `dist/geml.js` -> `../package.json`,
760
+ // and npm always ships package.json whatever `files` says. In a browser bundle
761
+ // `import.meta.url` degenerates to "" (see the `entry` note below), so every
762
+ // lookup fails and we fall back rather than throw at import time.
763
+ export const PARSER_VERSION = (() => {
764
+ let dir;
765
+ try {
766
+ dir = dirname(fileURLToPath(import.meta.url));
767
+ }
768
+ catch {
769
+ return "0.0.0";
770
+ }
771
+ for (let i = 0; i < 3 && dir; i++) {
772
+ try {
773
+ const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
774
+ if (typeof v === "string" && v)
775
+ return v;
776
+ }
777
+ catch { /* not here — walk up */ }
778
+ dir = dirname(dir);
779
+ }
780
+ return "0.0.0";
781
+ })();
741
782
  const USAGE = `geml — GEML reference CLI
742
783
 
743
784
  Usage:
@@ -769,7 +810,7 @@ Usage:
769
810
  (--root widens cross-doc refs to dir d, e.g. the repo root)
770
811
  geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
771
812
  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)
813
+ geml mcp --root <dir> [--no-history] serve document CRUD over MCP (stdio)
773
814
  (9 tools: list/read/check/history + write/add/delete/rename/revert;
774
815
  every write is validated before it reaches disk)
775
816
  geml --help | --version [--json]
@@ -802,21 +843,21 @@ const SUBHELP = {
802
843
  geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
803
844
  geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
804
845
  (<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]
846
+ mcp: `usage: geml mcp --root <dir> [--no-history]
806
847
 
807
848
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
808
849
  Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
809
850
  geml_write_block · geml_add_block · geml_delete_block
810
851
  geml_rename_id · geml_revert_block
811
852
 
812
- --workspace <dir> REQUIRED. Root holding the .geml documents. Every path a
853
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
813
854
  client names is confined here; a client cannot widen it.
814
855
  --no-history Skip the .gemlhistory commit taken before each write
815
856
  (default: commit, so geml_revert_block always has a
816
857
  revision to undo to).
817
858
 
818
859
  Register with a client:
819
- claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`,
860
+ claude mcp add geml -- geml mcp --root /abs/path/to/docs`,
820
861
  };
821
862
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
822
863
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1394,7 +1435,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
1394
1435
  let head = headLine;
1395
1436
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
1396
1437
  head += "\n";
1397
- let b = body.replace(/\r\n?/g, "\n");
1438
+ let b = toLf(body); // spliceBlock converts the result to the document's style
1398
1439
  if (closeLine !== null && b !== "" && !b.endsWith("\n"))
1399
1440
  b += "\n";
1400
1441
  const replacement = closeLine !== null ? head + b + closeLine : head + b;
@@ -1462,18 +1503,19 @@ function insertFragment(source, lines, at, fragment, file) {
1462
1503
  const beforeIds = parse(source, { resolveDoc: resolverFor(file) }).ids;
1463
1504
  const before = lines.slice(0, at);
1464
1505
  const after = lines.slice(at);
1506
+ const nl = newlineOf(source); // the fragment AND every separator we add
1465
1507
  // The preceding line must end in a newline so the fragment starts on its own.
1466
1508
  if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
1467
- before[before.length - 1] += "\n";
1509
+ before[before.length - 1] += nl;
1468
1510
  }
1469
- let frag = fragment.replace(/\r\n?/g, "\n");
1511
+ let frag = toNewline(fragment, nl);
1470
1512
  if (!frag.endsWith("\n"))
1471
- frag += "\n";
1513
+ frag += nl;
1472
1514
  // A single blank separator on each side that has adjacent content and isn't
1473
1515
  // already blank — keeps a following head / preceding block from fusing.
1474
1516
  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" : "";
1517
+ const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1518
+ const sepAfter = after.length && !blank(after[0]) ? nl : "";
1477
1519
  const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1478
1520
  const reparsed = parse(updated, { resolveDoc: resolverFor(file) });
1479
1521
  const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
@@ -1717,10 +1759,11 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
1717
1759
  const span = headOnly ? narrowToHead(found) : found;
1718
1760
  const before = orig.slice(0, span.start);
1719
1761
  const after = orig.slice(span.end);
1720
- let inject = replacement.replace(/\r\n?/g, "\n");
1762
+ const nl = newlineOf(source); // adopt the document's style, not LF
1763
+ let inject = toNewline(replacement, nl);
1721
1764
  const lastLine = span.end >= orig.length;
1722
1765
  if (!inject.endsWith("\n") && !lastLine)
1723
- inject += "\n";
1766
+ inject += nl;
1724
1767
  const updated = before.join("") + inject + after.join("");
1725
1768
  // Re-parse and refuse a broken result. A parse error or a duplicate id both
1726
1769
  // surface as error diagnostics (registerId flags dups); one check covers both.
@@ -1786,6 +1829,13 @@ function runRevert(args) {
1786
1829
  const id = rawId.replace(/^#/, "");
1787
1830
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
1788
1831
  const source = readInput(file);
1832
+ // The sidecar stores every revision newline-NORMALIZED (history.ts), so a
1833
+ // revision's text always comes back LF while the working file may be CRLF.
1834
+ // Comparing those raw would make EVERY block look changed on a CRLF document
1835
+ // (`--rev changed` reverting blocks nobody touched, and the no-op check never
1836
+ // firing), so compare normalized and write back in the file's own style.
1837
+ const norm = toLf; // compare on the LF form
1838
+ const toFileNl = (s) => toNewline(s, newlineOf(source));
1789
1839
  const curFull = blockSpans(source).get(id); // undefined => absent now
1790
1840
  const curBlock = curFull === undefined ? undefined : (() => {
1791
1841
  const span = headOnly ? narrowToHead(curFull) : curFull;
@@ -1804,7 +1854,8 @@ function runRevert(args) {
1804
1854
  const target = (() => {
1805
1855
  try {
1806
1856
  if (changed) {
1807
- const found = firstChangedContent(historyPath, curBlock ?? "", pick);
1857
+ // `pick` reads normalized revision text, so normalize this side too.
1858
+ const found = firstChangedContent(historyPath, curBlock === undefined ? "" : norm(curBlock), pick);
1808
1859
  if (!found)
1809
1860
  fail(`no earlier revision changes \`${id}\``, 1);
1810
1861
  return found;
@@ -1831,7 +1882,7 @@ function runRevert(args) {
1831
1882
  }
1832
1883
  // both present -> SPLICE (undo set)
1833
1884
  if (curBlock !== undefined && oldBlock !== undefined) {
1834
- if (oldBlock === curBlock) {
1885
+ if (norm(oldBlock) === norm(curBlock)) {
1835
1886
  console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
1836
1887
  // A no-op still has to PRODUCE the document when an output destination was
1837
1888
  // asked for: `-o` means "write the result somewhere", and the result of a
@@ -1842,12 +1893,13 @@ function runRevert(args) {
1842
1893
  emit(source, `#${id} unchanged`);
1843
1894
  return;
1844
1895
  }
1896
+ const replacement = toFileNl(oldBlock); // keep the file's newline style
1845
1897
  if (dryRun) {
1846
1898
  console.error(`would revert #${id} to ${target.id}:`);
1847
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1899
+ process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
1848
1900
  return;
1849
1901
  }
1850
- emit(spliceBlock(source, id, oldBlock, file, headOnly), `reverted #${id} to ${target.id}`);
1902
+ emit(spliceBlock(source, id, replacement, file, headOnly), `reverted #${id} to ${target.id}`);
1851
1903
  return;
1852
1904
  }
1853
1905
  // --head is only meaningful for the splice cell (it can't resurrect or remove).
@@ -1859,24 +1911,25 @@ function runRevert(args) {
1859
1911
  // Guard: if the block we'd resurrect is the same (modulo id) as one already
1860
1912
  // present under a different id, #id was likely renamed away — resurrecting
1861
1913
  // would duplicate it. Point at `rename` instead of writing.
1862
- const cmpKey = normalizeBlockId(oldBlock, "__cmp__");
1914
+ const cmpKey = normalizeBlockId(norm(oldBlock), "__cmp__");
1863
1915
  for (const [cid, cs] of blockSpans(source)) {
1864
1916
  if (cid === id)
1865
1917
  continue;
1866
1918
  const csrc = splitLines(source).slice(cs.start, cs.end).join("");
1867
- if (normalizeBlockId(csrc, "__cmp__") === cmpKey) {
1919
+ if (normalizeBlockId(norm(csrc), "__cmp__") === cmpKey) {
1868
1920
  fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
1869
1921
  }
1870
1922
  }
1871
1923
  const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
1924
+ const fragment = toFileNl(oldBlock); // keep the file's newline style
1872
1925
  if (dryRun) {
1873
1926
  console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
1874
- process.stdout.write(oldBlock.endsWith("\n") ? oldBlock : oldBlock + "\n");
1927
+ process.stdout.write(fragment.endsWith("\n") ? fragment : fragment + "\n");
1875
1928
  return;
1876
1929
  }
1877
1930
  if (warn)
1878
1931
  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}`);
1932
+ emit(insertFragment(source, splitLines(source), at, fragment, file), `resurrected #${id} from ${target.id} at ${where}`);
1880
1933
  return;
1881
1934
  }
1882
1935
  // present now, absent at R -> REMOVE (undo add)
@@ -1884,7 +1937,7 @@ function runRevert(args) {
1884
1937
  // under a different id, #id was likely renamed IN — removing would delete a
1885
1938
  // renamed block. Point at `rename` instead (the dangerous direction).
1886
1939
  {
1887
- const cmpKey = normalizeBlockId(curBlock, "__cmp__");
1940
+ const cmpKey = normalizeBlockId(norm(curBlock), "__cmp__");
1888
1941
  for (const [rid, rs] of blockSpans(target.text)) {
1889
1942
  if (rid === id)
1890
1943
  continue;
package/dist/mcp.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  export interface McpOptions {
3
- workspace: string;
3
+ root: string;
4
4
  history: boolean;
5
5
  }
6
6
  /** Configure the server. Exported so the suite can point it at a temp dir. */
7
7
  export declare function configure(o: Partial<McpOptions>): McpOptions;
8
- export declare function resolveInWorkspace(file: string): string;
8
+ export declare function resolveInRoot(file: string): string;
9
9
  export interface Tool {
10
10
  name: string;
11
11
  description: string;
@@ -14,5 +14,5 @@ export interface Tool {
14
14
  }
15
15
  export declare const TOOLS: Tool[];
16
16
  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";
17
+ export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).\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 --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 -- geml mcp --root /abs/path/to/docs";
18
18
  export declare function parseArgs(args: string[]): McpOptions;
package/dist/mcp.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // `geml mcp` — MCP server for GEML document CRUD.
3
3
  //
4
- // Nine tools over a confined workspace of `.geml` documents: four read-only,
4
+ // Nine tools over a confined root directory of `.geml` documents: four read-only,
5
5
  // five that write. It is the document-editing counterpart to the read-only
6
6
  // code-graph server in `codemap/mcp-server.mjs`, and deliberately mirrors its
7
7
  // shape (newline-delimited JSON-RPC 2.0 over stdio, zero dependencies, an
8
8
  // exported `handleLine` so the suite can drive it in-process).
9
9
  //
10
- // claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs
10
+ // claude mcp add geml -- geml mcp --root /abs/path/to/docs
11
11
  //
12
12
  // Three invariants make this worth more than letting a model `str_replace` the
13
13
  // file itself:
@@ -20,7 +20,7 @@
20
20
  // 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert_block` can
21
21
  // always undo the block that was just touched. Without this the strongest
22
22
  // tool in the set would have nothing to revert to.
23
- // 3. EVERY PATH IS CONFINED to a server-side `--workspace` root the client
23
+ // 3. EVERY PATH IS CONFINED to a server-side `--root` directory the client
24
24
  // cannot override or widen.
25
25
  //
26
26
  // The mutations run through the CLI rather than re-implementing block editing:
@@ -32,10 +32,14 @@ import { resolve, dirname, sep } from "node:path";
32
32
  import { fileURLToPath, pathToFileURL } from "node:url";
33
33
  import { spawnSync } from "node:child_process";
34
34
  import { createInterface } from "node:readline";
35
- import { parse } from "./geml.js";
35
+ import { parse, PARSER_VERSION } from "./geml.js";
36
36
  import { commit, listRevisions, isCurrent } from "./history.js";
37
- const SERVER_VERSION = "0.1.0";
38
- let OPTS = { workspace: process.cwd(), history: true };
37
+ // One version for the whole package: `geml --version` and the MCP handshake
38
+ // must not disagree. This used to be its own literal and had drifted to 0.1.0
39
+ // against a 1.4.x package — invisible to everyone except the user reading their
40
+ // client's server list.
41
+ const SERVER_VERSION = PARSER_VERSION;
42
+ let OPTS = { root: process.cwd(), history: true };
39
43
  /** Configure the server. Exported so the suite can point it at a temp dir. */
40
44
  export function configure(o) {
41
45
  OPTS = { ...OPTS, ...o };
@@ -45,47 +49,47 @@ export function configure(o) {
45
49
  // Workspace confinement
46
50
  // ---------------------------------------------------------------------------
47
51
  // `file` is client-supplied, so `../../../etc/passwd` — or a symlink planted
48
- // inside the workspace that points out of it — must not resolve. Canonicalize
52
+ // inside the root that points out of it — must not resolve. Canonicalize
49
53
  // BOTH sides with realpathSync (which follows every link component) and require
50
54
  // the real target to sit at or under the real root. Unlike the code-graph
51
55
  // server, whose `graph_dir` is intentionally client-chosen, the root here is
52
56
  // fixed by the operator at startup: this server WRITES, so a client that could
53
57
  // name its own root could write anywhere.
54
- export function resolveInWorkspace(file) {
58
+ export function resolveInRoot(file) {
55
59
  if (typeof file !== "string" || file === "")
56
60
  throw new Error("`file` is required");
57
- const root = realpathSync(OPTS.workspace);
61
+ const root = realpathSync(OPTS.root);
58
62
  const target = resolve(root, file);
59
63
  let real;
60
64
  try {
61
65
  real = realpathSync(target);
62
66
  }
63
67
  catch {
64
- throw new Error(`no such file in the workspace: ${file}`);
68
+ throw new Error(`no such file under the server root: ${file}`);
65
69
  }
66
70
  if (real !== root && !real.startsWith(root + sep)) {
67
- throw new Error(`path escapes the workspace: ${file}`);
71
+ throw new Error(`path escapes the server root: ${file}`);
68
72
  }
69
73
  if (!statSync(real).isFile())
70
74
  throw new Error(`not a file: ${file}`);
71
75
  return real;
72
76
  }
73
- // Cross-document references resolve against the workspace root, never against
77
+ // Cross-document references resolve against the SERVER root, never against
74
78
  // a client-named directory: `root` may only NARROW to a directory inside it.
75
79
  function resolveRoot(root) {
76
- const ws = realpathSync(OPTS.workspace);
80
+ const serverRoot = realpathSync(OPTS.root);
77
81
  if (root === undefined || root === "")
78
- return ws;
79
- const target = resolve(ws, root);
82
+ return serverRoot;
83
+ const target = resolve(serverRoot, root);
80
84
  let real;
81
85
  try {
82
86
  real = realpathSync(target);
83
87
  }
84
88
  catch {
85
- throw new Error(`no such directory in the workspace: ${root}`);
89
+ throw new Error(`no such directory under the server root: ${root}`);
86
90
  }
87
- if (real !== ws && !real.startsWith(ws + sep))
88
- throw new Error(`root escapes the workspace: ${root}`);
91
+ if (real !== serverRoot && !real.startsWith(serverRoot + sep))
92
+ throw new Error(`root escapes the server root: ${root}`);
89
93
  return real;
90
94
  }
91
95
  // ---------------------------------------------------------------------------
@@ -130,9 +134,9 @@ function parseRefusal(stderr) {
130
134
  }
131
135
  const asText = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 1));
132
136
  function applyWrite(spec) {
133
- const real = resolveInWorkspace(spec.file);
137
+ const real = resolveInRoot(spec.file);
134
138
  const before = readFileSync(real, "utf8");
135
- const root = realpathSync(OPTS.workspace);
139
+ const root = realpathSync(OPTS.root);
136
140
  const errorKey = (d) => `${d.code}:${d.message}`;
137
141
  const preexisting = new Set(parse(before, { resolveDoc: docResolver(root) }).diagnostics
138
142
  .filter((d) => d.severity === "error")
@@ -206,7 +210,7 @@ function docResolver(root) {
206
210
  };
207
211
  }
208
212
  const hashId = (id) => (id.startsWith("#") ? id : `#${id}`);
209
- const FILE_ARG = { type: "string", description: "Document path relative to the server's --workspace root, e.g. notes/spec.geml" };
213
+ const FILE_ARG = { type: "string", description: "Document path relative to the server's --root directory, e.g. notes/spec.geml" };
210
214
  export const TOOLS = [
211
215
  // ----- read -----
212
216
  {
@@ -214,7 +218,7 @@ export const TOOLS = [
214
218
  description: "List every addressable block in a GEML document: its `#id`, kind, and heading text. Call this FIRST — the ids it returns are what every other tool in this server addresses. Cheaper and more reliable than reading the file to find out what is in it.",
215
219
  inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
216
220
  run: (args) => {
217
- const real = resolveInWorkspace(args.file);
221
+ const real = resolveInRoot(args.file);
218
222
  const run = runCli(["get", real, "--json"]);
219
223
  if (!run.ok)
220
224
  throw new Error(run.stderr || "could not list ids");
@@ -233,7 +237,7 @@ export const TOOLS = [
233
237
  required: ["file", "id"],
234
238
  },
235
239
  run: (args) => {
236
- const real = resolveInWorkspace(args.file);
240
+ const real = resolveInRoot(args.file);
237
241
  const run = runCli(["get", real, hashId(args.id)]);
238
242
  if (!run.ok)
239
243
  throw new Error(run.stderr || `no block with id ${hashId(args.id)}`);
@@ -247,12 +251,12 @@ export const TOOLS = [
247
251
  type: "object",
248
252
  properties: {
249
253
  file: FILE_ARG,
250
- root: { type: "string", description: "Directory (inside the workspace) against which cross-document references resolve. Defaults to the workspace root." },
254
+ root: { type: "string", description: "Directory (inside the server root) against which cross-document references resolve. Defaults to the server root itself. This is a REFERENCE root and is distinct from the server's own --root sandbox, which it can only narrow." },
251
255
  },
252
256
  required: ["file"],
253
257
  },
254
258
  run: (args) => {
255
- const real = resolveInWorkspace(args.file);
259
+ const real = resolveInRoot(args.file);
256
260
  const root = resolveRoot(args.root);
257
261
  const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root) });
258
262
  const errors = doc.diagnostics.filter((d) => d.severity === "error").length;
@@ -270,7 +274,7 @@ export const TOOLS = [
270
274
  description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `geml_revert_block` takes as `rev` (-1 is the revision before the current one). Use this to find WHICH revision to revert a block to; an empty list means the document has no sidecar yet and nothing can be reverted.",
271
275
  inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
272
276
  run: (args) => {
273
- const real = resolveInWorkspace(args.file);
277
+ const real = resolveInRoot(args.file);
274
278
  const historyPath = real.replace(/\.geml$/, "") + ".gemlhistory";
275
279
  if (!existsSync(historyPath))
276
280
  return { file: args.file, revisions: [], note: "no .gemlhistory sidecar yet — the first write through this server creates one" };
@@ -292,7 +296,7 @@ export const TOOLS = [
292
296
  required: ["file", "id", "body"],
293
297
  },
294
298
  run: (args) => {
295
- const real = resolveInWorkspace(args.file);
299
+ const real = resolveInRoot(args.file);
296
300
  const part = args.part ?? "whole";
297
301
  if (!["whole", "head", "body"].includes(part))
298
302
  throw new Error(`part must be whole|head|body, got \`${part}\``);
@@ -319,7 +323,7 @@ export const TOOLS = [
319
323
  required: ["file", "content", "position"],
320
324
  },
321
325
  run: (args) => {
322
- const real = resolveInWorkspace(args.file);
326
+ const real = resolveInRoot(args.file);
323
327
  let where;
324
328
  if (args.position === "append")
325
329
  where = ["--append"];
@@ -350,7 +354,7 @@ export const TOOLS = [
350
354
  required: ["file", "ids"],
351
355
  },
352
356
  run: (args) => {
353
- const real = resolveInWorkspace(args.file);
357
+ const real = resolveInRoot(args.file);
354
358
  const ids = Array.isArray(args.ids) ? args.ids : [args.ids];
355
359
  if (!ids.length)
356
360
  throw new Error("`ids` must name at least one block");
@@ -375,7 +379,7 @@ export const TOOLS = [
375
379
  required: ["file", "old", "new"],
376
380
  },
377
381
  run: (args) => {
378
- const real = resolveInWorkspace(args.file);
382
+ const real = resolveInRoot(args.file);
379
383
  return applyWrite({
380
384
  file: args.file,
381
385
  cliArgs: ["rename", real, hashId(args.old), hashId(args.new), "-o", "-"],
@@ -396,7 +400,7 @@ export const TOOLS = [
396
400
  required: ["file", "id"],
397
401
  },
398
402
  run: (args) => {
399
- const real = resolveInWorkspace(args.file);
403
+ const real = resolveInRoot(args.file);
400
404
  // Default to `--rev changed`, NOT the tip (`0`) or the CLI's own `-1`. Each
401
405
  // write commits the PRE-write state, so the tip undoes the block only when
402
406
  // it was the MOST RECENT write — a later write to ANOTHER block moves the
@@ -434,7 +438,7 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
434
438
  reply(id, {
435
439
  protocolVersion: params?.protocolVersion ?? "2024-11-05",
436
440
  capabilities: { tools: {} },
437
- serverInfo: { name: "geml-docs", version: SERVER_VERSION },
441
+ serverInfo: { name: "geml", version: SERVER_VERSION },
438
442
  });
439
443
  }
440
444
  else if (method?.startsWith("notifications/")) {
@@ -475,11 +479,13 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
475
479
  // ---------------------------------------------------------------------------
476
480
  // Entry
477
481
  // ---------------------------------------------------------------------------
478
- export const MCP_USAGE = `usage: geml mcp --workspace <dir> [--no-history]
482
+ export const MCP_USAGE = `usage: geml mcp --root <dir> [--no-history]
479
483
 
480
484
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
481
485
 
482
- --workspace <dir> REQUIRED. Root directory holding the .geml documents.
486
+ --root <dir> REQUIRED. Root directory holding the .geml documents.
487
+ Relative paths resolve against the server process's CWD,
488
+ which the CLIENT chooses — pass an absolute path.
483
489
  Every path a client names is confined to this directory;
484
490
  a client cannot widen or override it.
485
491
  --no-history Do not auto-commit a .gemlhistory revision before each
@@ -487,27 +493,36 @@ export const MCP_USAGE = `usage: geml mcp --workspace <dir> [--no-history]
487
493
  has a revision to undo to.
488
494
 
489
495
  Register with a client:
490
- claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`;
496
+ claude mcp add geml -- geml mcp --root /abs/path/to/docs`;
491
497
  export function parseArgs(args) {
492
- let workspace;
498
+ let root;
493
499
  let history = true;
494
500
  for (let i = 0; i < args.length; i++) {
495
501
  const a = args[i];
496
- if (a === "--workspace" || a === "-w")
497
- workspace = args[++i];
498
- else if (a.startsWith("--workspace="))
499
- workspace = a.slice("--workspace=".length);
502
+ if (a === "--root" || a === "-r")
503
+ root = args[++i];
504
+ else if (a.startsWith("--root="))
505
+ root = a.slice("--root=".length);
500
506
  else if (a === "--no-history")
501
507
  history = false;
508
+ // The flag used to be --workspace/-w. Name the replacement instead of
509
+ // failing with a bare `unknown option`: this runs inside a client's server
510
+ // config, where the only thing the user sees is that the server did not
511
+ // start, and guessing from `unknown option '--workspace'` is a bad evening.
512
+ else if (a === "--workspace" || a === "-w" || a.startsWith("--workspace=")) {
513
+ throw new Error("--workspace is now --root (same meaning: the one directory the server may read and write)");
514
+ }
502
515
  else
503
516
  throw new Error(`unknown option '${a}'`);
504
517
  }
505
- if (!workspace)
506
- throw new Error("--workspace <dir> is required (the root the server may read and write)");
507
- const abs = resolve(workspace);
518
+ if (!root)
519
+ throw new Error("--root <dir> is required (the one directory the server may read and write)");
520
+ // Relative paths resolve against THIS process's cwd, which an MCP client
521
+ // picks — so they work from a shell and are a coin flip from a client config.
522
+ const abs = resolve(root);
508
523
  if (!existsSync(abs) || !statSync(abs).isDirectory())
509
- throw new Error(`--workspace is not a directory: ${workspace}`);
510
- return { workspace: realpathSync(abs), history };
524
+ throw new Error(`--root is not a directory: ${root}`);
525
+ return { root: realpathSync(abs), history };
511
526
  }
512
527
  // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
513
528
  // child's entry script, while an in-process `import` (the test suite) stays inert.
@@ -36,43 +36,43 @@ function page(title, body, ctx, source) {
36
36
  ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
37
37
  : "";
38
38
  const liveJs = wantLive
39
- ? `<script type="module">
40
- globalThis.process ??= { argv: [], env: {} };
41
- const { parse } = await import("${lg}geml.js");
42
- const { codeGraphWaves } = await import("${lg}render.js");
43
- const w = codeGraphWaves(async (rel) => {
44
- try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
45
- }, parse);
46
- for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
47
- const start = m.getAttribute("data-start");
48
- m._cgView = async (view) => {
49
- // A directed view builds from the node's OWN document (its meta names the
50
- // module and graph-depth); {doc} opens that document; else the mount's.
51
- const src = view && view.doc ? view.doc
52
- : view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
53
- : start;
54
- const r = await w.build(src, view && view.doc ? undefined : view);
55
- return r.error !== undefined ? null : r.data;
56
- };
57
- }
39
+ ? `<script type="module">
40
+ globalThis.process ??= { argv: [], env: {} };
41
+ const { parse } = await import("${lg}geml.js");
42
+ const { codeGraphWaves } = await import("${lg}render.js");
43
+ const w = codeGraphWaves(async (rel) => {
44
+ try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
45
+ }, parse);
46
+ for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
47
+ const start = m.getAttribute("data-start");
48
+ m._cgView = async (view) => {
49
+ // A directed view builds from the node's OWN document (its meta names the
50
+ // module and graph-depth); {doc} opens that document; else the mount's.
51
+ const src = view && view.doc ? view.doc
52
+ : view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
53
+ : start;
54
+ const r = await w.build(src, view && view.doc ? undefined : view);
55
+ return r.error !== undefined ? null : r.data;
56
+ };
57
+ }
58
58
  </script>\n`
59
59
  : "";
60
- return `<!doctype html>
61
- <html lang="en">
62
- <head>
63
- <meta charset="utf-8">
64
- <meta name="viewport" content="width=device-width, initial-scale=1">
65
- <title>${esc(title)}</title>
66
- <style>${CSS}</style>
67
- ${importMap}${mathHead}${mermaidHead}</head>
68
- <body>
69
- <main>
70
- ${body}
71
- </main>
72
- ${footer}
73
- <script>${JS}</script>
74
- ${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
75
- </html>
60
+ return `<!doctype html>
61
+ <html lang="en">
62
+ <head>
63
+ <meta charset="utf-8">
64
+ <meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${esc(title)}</title>
66
+ <style>${CSS}</style>
67
+ ${importMap}${mathHead}${mermaidHead}</head>
68
+ <body>
69
+ <main>
70
+ ${body}
71
+ </main>
72
+ ${footer}
73
+ <script>${JS}</script>
74
+ ${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
75
+ </html>
76
76
  `;
77
77
  }
78
78
  export function renderHtml(doc, opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
+ "mcpName": "io.github.geml-spec/geml",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },