@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 +1 -0
- package/dist/geml.js +75 -22
- package/dist/mcp.d.ts +3 -3
- package/dist/mcp.js +60 -45
- package/dist/render-html.js +35 -35
- package/package.json +2 -1
package/dist/geml.d.ts
CHANGED
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
|
-
|
|
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 --
|
|
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 --
|
|
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
|
-
--
|
|
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
|
|
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
|
|
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] +=
|
|
1509
|
+
before[before.length - 1] += nl;
|
|
1468
1510
|
}
|
|
1469
|
-
let frag = fragment
|
|
1511
|
+
let frag = toNewline(fragment, nl);
|
|
1470
1512
|
if (!frag.endsWith("\n"))
|
|
1471
|
-
frag +=
|
|
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]) ?
|
|
1476
|
-
const sepAfter = after.length && !blank(after[0]) ?
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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(
|
|
1899
|
+
process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
|
|
1848
1900
|
return;
|
|
1849
1901
|
}
|
|
1850
|
-
emit(spliceBlock(source, 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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 --
|
|
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
|
|
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
|
|
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 `--
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
80
|
+
const serverRoot = realpathSync(OPTS.root);
|
|
77
81
|
if (root === undefined || root === "")
|
|
78
|
-
return
|
|
79
|
-
const target = resolve(
|
|
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
|
|
89
|
+
throw new Error(`no such directory under the server root: ${root}`);
|
|
86
90
|
}
|
|
87
|
-
if (real !==
|
|
88
|
-
throw new Error(`root escapes the
|
|
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 =
|
|
137
|
+
const real = resolveInRoot(spec.file);
|
|
134
138
|
const before = readFileSync(real, "utf8");
|
|
135
|
-
const root = realpathSync(OPTS.
|
|
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 --
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 --
|
|
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
|
-
--
|
|
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
|
|
496
|
+
claude mcp add geml -- geml mcp --root /abs/path/to/docs`;
|
|
491
497
|
export function parseArgs(args) {
|
|
492
|
-
let
|
|
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 === "--
|
|
497
|
-
|
|
498
|
-
else if (a.startsWith("--
|
|
499
|
-
|
|
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 (!
|
|
506
|
-
throw new Error("--
|
|
507
|
-
|
|
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(`--
|
|
510
|
-
return {
|
|
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.
|
package/dist/render-html.js
CHANGED
|
@@ -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 = {}) {
|