@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/README.md +32 -1
- package/codemap/find.mjs +9 -23
- package/codemap/mcp-server.mjs +319 -60
- package/dist/geml.d.ts +1 -0
- package/dist/geml.js +248 -36
- package/dist/mcp.d.ts +11 -3
- package/dist/mcp.js +251 -70
- package/dist/render-html.js +35 -35
- package/package.json +2 -1
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
|
-
|
|
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
|
|
772
|
-
geml mcp --
|
|
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] (
|
|
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 --
|
|
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
|
-
--
|
|
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
|
|
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(
|
|
1441
|
+
listIds(source, file, json);
|
|
1257
1442
|
return;
|
|
1258
1443
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
|
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] +=
|
|
1661
|
+
before[before.length - 1] += nl;
|
|
1468
1662
|
}
|
|
1469
|
-
let frag = fragment
|
|
1663
|
+
let frag = toNewline(fragment, nl);
|
|
1470
1664
|
if (!frag.endsWith("\n"))
|
|
1471
|
-
frag +=
|
|
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]) ?
|
|
1476
|
-
const sepAfter = after.length && !blank(after[0]) ?
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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(
|
|
2051
|
+
process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
|
|
1848
2052
|
return;
|
|
1849
2053
|
}
|
|
1850
|
-
emit(spliceBlock(source, 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(
|
|
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,
|
|
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
|
|
1978
|
-
//
|
|
1979
|
-
// (the stdio transport), and dispatching by
|
|
1980
|
-
// runtime import cycle (mcp.js imports the
|
|
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
|
-
|
|
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
|
|
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 --
|
|
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;
|