@geml/geml 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +248 -217
  3. package/codemap/adapters/crg.mjs +120 -120
  4. package/codemap/adapters/joern.mjs +131 -131
  5. package/codemap/adapters/scip.mjs +658 -658
  6. package/codemap/browser-stub.mjs +34 -29
  7. package/codemap/build.mjs +609 -609
  8. package/codemap/cross-stack.mjs +303 -303
  9. package/codemap/detect.mjs +399 -399
  10. package/codemap/emit.mjs +480 -480
  11. package/codemap/entries.mjs +129 -129
  12. package/codemap/exclude.mjs +52 -52
  13. package/codemap/find.mjs +49 -49
  14. package/codemap/foldings.mjs +110 -110
  15. package/codemap/joern-export.sc +83 -83
  16. package/codemap/mcp-server.mjs +431 -431
  17. package/codemap/normalize.mjs +275 -275
  18. package/codemap/recipe-trust.mjs +103 -103
  19. package/codemap/refresh.mjs +310 -310
  20. package/codemap/render-all.mjs +77 -77
  21. package/codemap/serve.mjs +585 -585
  22. package/codemap/sfc-virtualize.mjs +367 -367
  23. package/codemap/verify.mjs +155 -148
  24. package/dist/chart.d.ts +1 -0
  25. package/dist/chart.js +4 -1
  26. package/dist/diagnostics.d.ts +1 -1
  27. package/dist/diagnostics.js +16 -0
  28. package/dist/geml.d.ts +5 -1
  29. package/dist/geml.js +1277 -283
  30. package/dist/history.d.ts +11 -8
  31. package/dist/history.js +20 -15
  32. package/dist/mcp.d.ts +1 -1
  33. package/dist/mcp.js +108 -38
  34. package/dist/render-html.d.ts +5 -0
  35. package/dist/render-html.js +45 -36
  36. package/dist/render.d.ts +1 -0
  37. package/dist/render.js +172 -136
  38. package/dist/selector.d.ts +55 -0
  39. package/dist/selector.js +112 -0
  40. package/dist/serialize.js +12 -0
  41. package/dist/table.js +27 -1
  42. package/dist/to-md.js +5 -0
  43. package/package.json +67 -66
  44. package/skill/SKILL.md +82 -0
  45. package/skill/references/authoring.geml +333 -0
package/dist/geml.js CHANGED
@@ -9,23 +9,25 @@
9
9
  // math, media embeds, links, auto-references, footnotes) and build-time
10
10
  // reference validation (§8 — unique ids, resolvable internal/cross-document
11
11
  // references).
12
- import { readFileSync, writeFileSync, realpathSync, statSync, existsSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs";
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
14
+ import { homedir } from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { spawnSync } from "node:child_process";
16
- import { commit, restore, verify, listRevisions, resolveContent, firstChangedContent } from "./history.js";
17
+ import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
17
18
  import { renderHtml } from "./render-html.js";
18
19
  import { normalizeBlockId } from "./block-edit.js";
19
20
  import { normalizeSource } from "./diagnostics.js";
20
21
  import { coerce, parseAttrs } from "./attrs.js";
21
22
  import { META_REF_SRC, parseInline, isSafeUrl, schemeOf } from "./inline.js";
22
23
  import { parseTable } from "./table.js";
23
- import { buildChart } from "./chart.js";
24
+ import { USES, buildChart } from "./chart.js";
24
25
  import { mdToGeml } from "./from-md.js";
25
26
  import { serialize } from "./serialize.js";
27
+ import { addressUnits, discoveryHint, matchContent, matchType, parseSelector, shortestAddress, } from "./selector.js";
26
28
  import { gemlToMd } from "./to-md.js";
27
29
  export { mdToGeml } from "./from-md.js";
28
- export { renderHtml } from "./render-html.js";
30
+ export { renderHtml, pageAssets } from "./render-html.js";
29
31
  export { serialize } from "./serialize.js";
30
32
  export { gemlToMd } from "./to-md.js";
31
33
  // A block id is any non-whitespace run (§4), so it may contain regex
@@ -36,6 +38,57 @@ export { gemlToMd } from "./to-md.js";
36
38
  function reLit(s) {
37
39
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
40
  }
41
+ // The `data` block's format engines (GEP-0005), shared by the inline-body
42
+ // path and the `src=` pass: parse `body` under `fmt`, returning the value
43
+ // and/or diagnostics. `openLineNo` anchors line numbers — the open fence for
44
+ // an inline body, the block's own line for external content.
45
+ function parseDataBody(fmt, body, openLineNo) {
46
+ const diags = [];
47
+ if (fmt === "json") {
48
+ const text = body.join("\n");
49
+ try {
50
+ return { value: JSON.parse(text), diags };
51
+ }
52
+ catch (e) {
53
+ diags.push({ severity: "error", code: "data-parse", message: `data: body is not valid JSON (${e instanceof Error ? e.message : String(e)})`, line: jsonErrorLine(e, text, openLineNo) });
54
+ }
55
+ }
56
+ else if (fmt === "jsonl") {
57
+ const values = [];
58
+ let ok = true;
59
+ for (let li = 0; li < body.length; li++) {
60
+ const t = body[li].trim();
61
+ if (t === "")
62
+ continue; // blank lines are permitted and ignored
63
+ try {
64
+ values.push(JSON.parse(t));
65
+ }
66
+ catch {
67
+ diags.push({ severity: "error", code: "data-parse", message: `data: body line ${li + 1} is not one JSON value`, line: openLineNo + 1 + li });
68
+ ok = false;
69
+ }
70
+ }
71
+ if (ok)
72
+ return { value: values, diags };
73
+ }
74
+ else if (fmt === "yaml" || fmt === "toml") {
75
+ diags.push({ severity: "warning", code: "data-format-no-engine", message: `data: no \`${fmt}\` engine in this processor; body kept raw, not verified`, line: openLineNo });
76
+ }
77
+ else {
78
+ diags.push({ severity: "warning", code: "unknown-data-format", message: `unknown data format \`${fmt}\`; body kept raw`, line: openLineNo });
79
+ }
80
+ return { diags };
81
+ }
82
+ // Map a JSON.parse failure to the document line it happened on. V8 messages
83
+ // carry "at position N" (newer Nodes add line/column, but position is the
84
+ // stable token); counting newlines up to it gives the 1-based body line, and
85
+ // the open fence line offsets it into the document. No position -> the fence.
86
+ function jsonErrorLine(e, text, openLineNo) {
87
+ const m = /position (\d+)/.exec(e instanceof Error ? e.message : "");
88
+ if (!m)
89
+ return openLineNo;
90
+ return openLineNo + text.slice(0, Number(m[1])).split("\n").length;
91
+ }
39
92
  // Re-exported from ./diagnostics.js so that `Diagnostic` stays importable from
40
93
  // the package root. The catalogue of codes lives there (spec Appendix A).
41
94
  export { SEVERITY } from "./diagnostics.js";
@@ -46,6 +99,7 @@ const REGISTRY = {
46
99
  diagram: "raw",
47
100
  math: "raw",
48
101
  table: "raw", // structured table parsing lands in M3
102
+ data: "raw", // GEP-0005: value tree — a format engine parses the raw body in a second stage
49
103
  embed: "raw", // block transclusion: `src=` points at the content, body unused
50
104
  note: "flow",
51
105
  text: "flow", // addressable prose container: an id/attrs for a run of flow, no callout chrome
@@ -59,6 +113,24 @@ const DIAGRAM_RENDERERS = new Set(["mermaid", "graphviz", "dot", "d2", "plantuml
59
113
  // ---------------------------------------------------------------------------
60
114
  const FENCE_OPEN = /^(={3,})[ \t]+([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/;
61
115
  const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*(\{[^}]*\})?[ \t]*$/;
116
+ // A line with the exact shape of a labeled close (§3): a `=` run and a `#id`,
117
+ // nothing else. Matched against lines that fell through to paragraph text,
118
+ // where such a line means the close closed nothing (stray-labeled-fence).
119
+ const STRAY_LABELED_FENCE = /^={3,}[ \t]+#(\S+)[ \t]*$/;
120
+ // The registered block types (§3's registry), for the fence-like check below:
121
+ // an unknown word after `===` is likelier a wall of `=` art or foreign syntax,
122
+ // so only a KNOWN type name earns the warning.
123
+ const REGISTERED_TYPES = new Set(["code", "diagram", "table", "math", "embed", "note", "text", "meta", "data"]);
124
+ // A line that WANTS to open a fence — a `=` run and a registered type name —
125
+ // but failed the fence production. The classic shape is bare, unbraced
126
+ // attributes (`=== embed src=#a`): the line silently became prose and any
127
+ // reference in it was never checked, which buried a real bug (all eight
128
+ // embeds of the playground showcase shipped in this shape, rendering as
129
+ // paragraphs under a green `check`). Matched, like STRAY_LABELED_FENCE, only
130
+ // against lines that fell through to paragraph text — raw block bodies and
131
+ // `\`-folded fence lines never reach that position, so the measured corpus
132
+ // false-positive rate is zero.
133
+ const FENCE_LIKE = /^={3,}[ \t]+([A-Za-z][A-Za-z0-9_-]*)\b/;
62
134
  const LIST_ITEM = /^[ \t]*(?:[-*]|\d+\.)[ \t]+(.*)$/;
63
135
  // Maximum block/list nesting depth the recursive-descent scanner will build
64
136
  // before emitting a diagnostic instead of recursing further. Guards parse()
@@ -294,22 +366,36 @@ function scanBlocks(lines, base, ctx, depth = 0) {
294
366
  const type = open[2];
295
367
  const attrs = open[3] ? parseAttrs(open[3]) : { classes: [], attrs: {} };
296
368
  const openLineNo = base + i + 1;
297
- // Collect the body. A block closes on a bare fence of exactly the opening
298
- // length, OR — when it has an id — on a labeled fence `=== #id` (a `=` run
299
- // of any length ≥ 3 followed by the block's id). The labeled close is a
300
- // *local* close: it can't be gotten wrong by miscounting `=`, so it is the
301
- // safe way to nest (§3).
369
+ // Collect the body. A block closes on the FIRST line that is a bare fence
370
+ // of exactly the opening length, OR — when it has an id — a labeled fence
371
+ // `=== #id` (a `=` run of any length ≥ 3 followed by the block's id). The
372
+ // labeled close can't be gotten wrong by miscounting `=`, but it does NOT
373
+ // shadow the bare close: a same-length bare fence in the body still ends
374
+ // the block first, so nesting needs a longer outer fence (§3).
302
375
  const labeled = attrs.id !== undefined ? new RegExp(`^={3,}[ \\t]+#${reLit(attrs.id)}[ \\t]*$`) : null;
303
376
  const body = [];
304
377
  let j = i + consumed;
305
378
  let closed = false;
379
+ let closedByBare = false;
306
380
  for (; j < lines.length; j++) {
307
- if (isCloseFence(lines[j], openLen) || (labeled && labeled.test(lines[j]))) {
381
+ if (isCloseFence(lines[j], openLen)) {
382
+ closed = true;
383
+ closedByBare = true;
384
+ break;
385
+ }
386
+ if (labeled && labeled.test(lines[j])) {
308
387
  closed = true;
309
388
  break;
310
389
  }
311
390
  body.push(lines[j]);
312
391
  }
392
+ // Remember a bare close of an id-bearing block (first definition wins,
393
+ // mirroring ctx.ids): if a `=== #id` line for it turns up later as plain
394
+ // text, the stray-labeled-fence warning can name the line that really
395
+ // closed the block.
396
+ if (closedByBare && attrs.id !== undefined && !ctx.bareClosed?.has(attrs.id)) {
397
+ (ctx.bareClosed ??= new Map()).set(attrs.id, base + j + 1);
398
+ }
313
399
  if (!closed) {
314
400
  const how = attrs.id !== undefined ? `${"=".repeat(openLen)} or \`=== #${attrs.id}\`` : "=".repeat(openLen);
315
401
  diags.push({ severity: "error", code: "unterminated-block", message: `unterminated \`${type}\` block (no matching ${how})`, line: openLineNo });
@@ -325,11 +411,13 @@ function scanBlocks(lines, base, ctx, depth = 0) {
325
411
  // the extras below are per type.
326
412
  let validRe;
327
413
  if (type === "table")
328
- validRe = /^(src|format|header|format-data|compute\d*|summary\d*|span\d*)$/;
414
+ validRe = /^(src|format|delim|header|format-data|compute\d*|summary\d*|span\d*)$/;
415
+ else if (type === "data")
416
+ validRe = /^(format|schema|src)$/;
329
417
  else if (type === "embed")
330
418
  validRe = /^(src)$/;
331
419
  else if (type === "diagram")
332
- validRe = /^(src|data|format|type|rows|x|y|size|series)$/;
420
+ validRe = /^(src|data|format|format-data|delim|header|type|rows|x|y|size|series)$/;
333
421
  // `src`/`anchor` on a `code` block are the code-graph profile's
334
422
  // (docs/codemap-profile.md): every document `geml codemap build` writes
335
423
  // carries them, so warning on them would warn on our own output.
@@ -415,7 +503,72 @@ function scanBlocks(lines, base, ctx, depth = 0) {
415
503
  }
416
504
  else {
417
505
  block.raw = body;
418
- if (type === "table") {
506
+ if (type === "data") {
507
+ // §GEP-0005: the value tree. The body stayed raw at scan time; a
508
+ // format engine parses it here — the same two-stage shape `table`
509
+ // uses. Admission to the format registry requires a SELF-DESCRIBING
510
+ // syntax (bytes alone determine the value): the core ships `json`
511
+ // (default — the model's own serialization) and `jsonl`; `yaml` and
512
+ // `toml` are reserved names with no engine here, and degrade exactly
513
+ // like an unknown `diagram` format: body kept raw, one warning.
514
+ const fmtRaw = attrs.attrs["format"];
515
+ const fmt = fmtRaw === undefined ? "json" : String(fmtRaw);
516
+ // `src=` names external content — the same one-source rule tables
517
+ // have (§6): exactly one of `src=` and an inline body. The engine
518
+ // runs over the file in a second pass (resolveDataSources); running
519
+ // it here over the empty body would report a spurious parse error.
520
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
521
+ const hasBody = body.some((l) => l.trim() !== "");
522
+ if (srcAttr !== undefined && srcAttr !== "" && hasBody) {
523
+ diags.push({ severity: "error", code: "data-src-and-body", message: "data: carries both `src=` and an inline body; exactly one is permitted (the body wins here)", line: openLineNo });
524
+ }
525
+ if (srcAttr !== undefined && srcAttr !== "" && !hasBody) {
526
+ (ctx.dataSources ??= []).push({ block, line: openLineNo, target: srcAttr });
527
+ }
528
+ else {
529
+ const parsed = parseDataBody(fmt, body, openLineNo);
530
+ for (const d of parsed.diags)
531
+ diags.push(d);
532
+ if (parsed.value !== undefined)
533
+ block.value = parsed.value;
534
+ }
535
+ // `schema=` is reference-checked ONLY (GEP-0005): it must name a
536
+ // block or a GEML document; validating the value against it is a
537
+ // later GEP. The reference goes through the ordinary §8 resolver so
538
+ // a dangling schema rots loudly like any other reference.
539
+ const schema = attrs.attrs["schema"];
540
+ if (schema !== undefined) {
541
+ const s = typeof schema === "string" ? schema.trim() : "";
542
+ if (s.startsWith("#") && s.length > 1) {
543
+ ctx.refs.push({ kind: "internal", anchor: s.slice(1), line: openLineNo });
544
+ }
545
+ else if (/\.geml(#|$)/i.test(s)) {
546
+ const h = s.indexOf("#");
547
+ if (h < 0)
548
+ ctx.refs.push({ kind: "cross", doc: s, anchor: undefined, line: openLineNo });
549
+ else
550
+ ctx.refs.push({ kind: "cross", doc: s.slice(0, h), anchor: s.slice(h + 1), line: openLineNo });
551
+ }
552
+ else {
553
+ diags.push({ severity: "error", code: "bad-data-schema", message: `data: \`schema=${s}\` must name a block (\`#id\`) or a GEML document (\`doc.geml[#id]\`)`, line: openLineNo });
554
+ }
555
+ }
556
+ // First definition wins, matching ctx.ids/ctx.tables.
557
+ if (block.id !== undefined && block.value !== undefined && !ctx.dataValues?.has(block.id)) {
558
+ (ctx.dataValues ??= new Map()).set(block.id, block.value);
559
+ }
560
+ }
561
+ else if (type === "code") {
562
+ // `src=` on a code block is a ROUTE to the code it shows —
563
+ // `<path>[#L<start>[-<end>]]` — resolved in a second pass, like a
564
+ // table's `src=`. The code-graph runtime has always fetched and
565
+ // sliced it at render time; checking it here is what makes a stale
566
+ // range a build error instead of a panel that silently shows a path.
567
+ const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
568
+ if (srcAttr !== undefined && srcAttr !== "")
569
+ (ctx.codeSources ??= []).push({ block, line: openLineNo, target: srcAttr });
570
+ }
571
+ else if (type === "table") {
419
572
  const srcAttr = typeof attrs.attrs["src"] === "string" ? attrs.attrs["src"].trim() : undefined;
420
573
  // §6: parse the raw body (visual or csv/tsv) into one table model.
421
574
  const { model, diagnostics } = parseTable(body, attrs.attrs, openLineNo, ctx);
@@ -501,6 +654,38 @@ function scanBlocks(lines, base, ctx, depth = 0) {
501
654
  para.push(lines[i]);
502
655
  i++;
503
656
  }
657
+ // A line shaped exactly like a labeled close (`=== #id`) that got this far
658
+ // closed nothing — when the id's block was already ended by a same-length
659
+ // bare fence in its body (§3), everything from that fence on silently fell
660
+ // out of the block. Warn: "ok: no diagnostics" over a truncated document is
661
+ // the failure mode this diagnostic exists for. The id is used only as a Map
662
+ // key here — no RegExp is built from it, so reLit() does not apply.
663
+ for (let k = 0; k < para.length; k++) {
664
+ const stray = STRAY_LABELED_FENCE.exec(para[k]);
665
+ if (!stray)
666
+ continue;
667
+ const id = stray[1];
668
+ const lineNo = paraStart + k;
669
+ const closedAt = ctx.bareClosed?.get(id);
670
+ diags.push({
671
+ severity: "warning", code: "stray-labeled-fence", line: lineNo,
672
+ message: closedAt !== undefined
673
+ ? `labeled fence for \`#${id}\` at line ${lineNo}, but block \`#${id}\` was already closed by a bare fence at line ${closedAt} — body may be silently truncated`
674
+ : `labeled fence for \`#${id}\` closes no block; the line is plain paragraph text`,
675
+ });
676
+ }
677
+ for (let k = 0; k < para.length; k++) {
678
+ // Sibling trap to the stray labeled close: a would-be OPEN fence that
679
+ // missed the production and silently became prose (§3 requires braced
680
+ // attributes; `=== embed src=#a` is the classic miss).
681
+ const like = FENCE_LIKE.exec(para[k]);
682
+ if (like && REGISTERED_TYPES.has(like[1])) {
683
+ diags.push({
684
+ severity: "warning", code: "fence-like-line", line: paraStart + k,
685
+ message: `line looks like an open fence for \`${like[1]}\` but is not one — attributes must be braced (\`=== ${like[1]} {…}\`); the line reads as plain paragraph text`,
686
+ });
687
+ }
688
+ }
504
689
  const text = interpolate(para.join("\n"), paraStart, ctx);
505
690
  blocks.push({ kind: "paragraph", text, inlines: parseInline(text, paraStart, ctx) });
506
691
  }
@@ -554,6 +739,11 @@ function chartSourceTable(ctx, opts, block, target, line) {
554
739
  format: typeof block.attrs["format-data"] === "string" ? block.attrs["format-data"] : inferDataFormat(target),
555
740
  header: block.attrs["header"] === undefined ? true : block.attrs["header"],
556
741
  };
742
+ // A chart reading a `;`-delimited export needs the same delimiter override a
743
+ // table does; the table rules validate it (§6).
744
+ const delim = block.attrs["delim"];
745
+ if (delim !== undefined)
746
+ attrs["delim"] = delim;
557
747
  const { model, diagnostics } = parseTable(normalizeSource(text).split("\n"), attrs, line, ctx);
558
748
  for (const d of diagnostics)
559
749
  ctx.diags.push({ ...d, line });
@@ -734,6 +924,111 @@ function relDirPath(p) {
734
924
  const i = p.lastIndexOf("/");
735
925
  return i < 0 ? "" : p.slice(0, i);
736
926
  }
927
+ // A chain that cannot reach an entity block. Carries the diagnostic code it
928
+ // corresponds to (§3) so the message can name it without inventing a new one.
929
+ class ViewError extends Error {
930
+ code;
931
+ constructor(code, message) {
932
+ super(message);
933
+ this.code = code;
934
+ }
935
+ }
936
+ // Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
937
+ // content, so without a confinement root a document could name any path on the
938
+ // machine. And never a URL — `geml get` is a read command that agents and
939
+ // editors call constantly, so letting content steer it at the network would turn
940
+ // it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
941
+ function readConfined(rel, root) {
942
+ if (!/\.geml$/i.test(rel)) {
943
+ throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
944
+ }
945
+ const base = resolvePath(root);
946
+ const abs = resolvePath(root, rel);
947
+ if (abs !== base && !abs.startsWith(base + sep)) {
948
+ throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
949
+ }
950
+ try {
951
+ return readFileSync(abs, "utf8");
952
+ }
953
+ catch {
954
+ throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
955
+ }
956
+ }
957
+ // One hop: read the target document and select what the fragment names. Several
958
+ // units come back when the fragment names a section (§4.3).
959
+ function oneHop(file, src, root) {
960
+ const hash = src.indexOf("#");
961
+ const docPath = hash < 0 ? src : src.slice(0, hash);
962
+ const frag = hash < 0 ? undefined : src.slice(hash + 1);
963
+ // Check the scheme on what the DOCUMENT wrote, before composition: a URL can
964
+ // only arrive through `src=`, never from joining relative paths — and testing
965
+ // the composed path instead would read a Windows drive letter (`C:/…`) as a
966
+ // scheme and refuse every absolute path, which is exactly what the MCP layer
967
+ // hands the CLI.
968
+ if (schemeOf(docPath) !== null) {
969
+ throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
970
+ }
971
+ const rel = relJoinPath(relDirPath(file), docPath);
972
+ const text = readConfined(rel, root);
973
+ if (frag === undefined) {
974
+ // `src=other.geml`: the frame looks onto the WHOLE document. Every block
975
+ // comes from the same target, so the resolution base stays uniform — unlike
976
+ // a host-side section selector, where splicing would mix two documents.
977
+ // `meta` is frontmatter, not content (render.ts's selectEmbed).
978
+ //
979
+ // Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
980
+ // every addressed unit would emit the blocks inside a section twice.
981
+ const every = addressedUnits(text).map((a) => a.unit);
982
+ const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
983
+ && (o.span.start < u.span.start || o.span.end > u.span.end)));
984
+ return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
985
+ }
986
+ const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
987
+ return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
988
+ }
989
+ // Provenance is stated relative to the confinement root, not as the path the
990
+ // walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
991
+ // so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
992
+ // server's layout, and not a path any caller could pass back in.
993
+ function shownPath(rel, root) {
994
+ const r = relative(root, rel).replace(/\\/g, "/");
995
+ return r === "" ? rel : r;
996
+ }
997
+ function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
998
+ const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
999
+ if (src === undefined)
1000
+ return [{ doc: file, text: source, unit, all: [], from: "" }];
1001
+ // The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
1002
+ // cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
1003
+ // not expanded — `--view` may not: stopping here means what we are holding is
1004
+ // still a frame, and returning it would break the contract silently.
1005
+ if (depth >= EMBED_DEPTH_LIMIT) {
1006
+ throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
1007
+ }
1008
+ const hop = oneHop(file, src, root);
1009
+ // Same key shape as the check's cycle detector: a document plus what was
1010
+ // selected in it.
1011
+ const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
1012
+ if (seen.has(key)) {
1013
+ throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
1014
+ }
1015
+ const nextSeen = new Set(seen).add(key);
1016
+ // Per-unit application, recursively: what a frame looks onto may itself be a
1017
+ // frame, and a section may hold a mix (§4.3).
1018
+ return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
1019
+ // An inner identity step has no provenance of its own, so carry this hop's:
1020
+ // `from` must always name where the bytes actually came from.
1021
+ .map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
1022
+ }
1023
+ // The `src=` of an embed unit, read off its head line: a Unit carries the span,
1024
+ // not parsed attributes.
1025
+ function embedSrcOf(source, unit) {
1026
+ const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, true, false));
1027
+ if (!braces)
1028
+ return undefined;
1029
+ const v = parseAttrs(braces[0]).attrs["src"];
1030
+ return typeof v === "string" ? v : undefined;
1031
+ }
737
1032
  function gatherEmbeds(source) {
738
1033
  const ctx = { diags: [], ids: new Map(), refs: [], meta: new Map(), embeds: [] };
739
1034
  scanBlocks(normalizeSource(source).split("\n"), 0, ctx);
@@ -750,6 +1045,11 @@ function tableFromDocument(source, id) {
750
1045
  const found = ctx.tables?.get(id);
751
1046
  if (found !== undefined)
752
1047
  return found;
1048
+ // GEP-0005: a remote `data` block is the other chart-source form; its value
1049
+ // is projected by the CALLER (recordsToTable needs the chart's attributes).
1050
+ const dv = ctx.dataValues?.get(id);
1051
+ if (dv !== undefined)
1052
+ return { records: dv };
753
1053
  const anyBlock = (function find(bs) {
754
1054
  for (const b of bs) {
755
1055
  if ((b.kind === "block" || b.kind === "heading") && b.id === id)
@@ -846,7 +1146,11 @@ function resolveTableSources(ctx, opts) {
846
1146
  err(line, "unresolved-cross-document-reference", `unresolved reference \`${target}\``);
847
1147
  continue;
848
1148
  }
849
- if (remote === "not-a-table") {
1149
+ // A table's `src=` names a TABLE. A `data` block is a chart-source form
1150
+ // (§7.1, GEP-0005), not a table-source form — the column algebra a
1151
+ // borrowing table implies (compute/summary against named columns) has
1152
+ // no defined meaning over a value tree.
1153
+ if (remote === "not-a-table" || "records" in remote) {
850
1154
  err(line, "table-source-not-a-table", `table source \`${target}\` is not a table`);
851
1155
  continue;
852
1156
  }
@@ -922,6 +1226,213 @@ function validateRefs(ctx, opts) {
922
1226
  }
923
1227
  }
924
1228
  }
1229
+ // `src=` on a `code` block: the route to the code the block shows,
1230
+ // `<path>[#L<start>[-<end>]]` (1-based, inclusive). The code-graph runtime has
1231
+ // always fetched and sliced this at render time; resolving it here is what
1232
+ // turns a range that no longer exists — the source moved or shrank — from a
1233
+ // silently empty panel into a build error. There is no extension gate (code is
1234
+ // any language); the safety rule is the resolver's confinement to the document
1235
+ // tree, widened only by `--root`.
1236
+ const SOURCE_RANGE = /^L(\d+)(?:-(\d+))?$/;
1237
+ // One route syntax for the two types whose `src=` fragment position is free —
1238
+ // `code` and `data`. (A table's is already taken: `src=doc.geml#id` names a
1239
+ // block.) `<path>[#L<start>[-<end>]]`, 1-based and inclusive; `to === 0` means
1240
+ // "through end of file". Returns null after reporting, so callers just skip.
1241
+ function parseSourceRoute(target, kind, line, ctx) {
1242
+ const hash = target.indexOf("#");
1243
+ const path = hash < 0 ? target : target.slice(0, hash);
1244
+ const frag = hash < 0 ? "" : target.slice(hash + 1);
1245
+ if (frag === "")
1246
+ return { path, from: 1, to: 0 };
1247
+ const m = SOURCE_RANGE.exec(frag);
1248
+ if (!m) {
1249
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: unrecognised fragment (expected \`#L<start>\` or \`#L<start>-<end>\`)`, line });
1250
+ return null;
1251
+ }
1252
+ const from = Number(m[1]);
1253
+ const to = m[2] === undefined ? from : Number(m[2]);
1254
+ if (from < 1 || to < from) {
1255
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: line range is empty or starts before line 1`, line });
1256
+ return null;
1257
+ }
1258
+ return { path, from, to };
1259
+ }
1260
+ // Slice a resolved file to a route's range, or report that the range no longer
1261
+ // exists — the signal a stale reference exists at all.
1262
+ function sliceSourceRange(text, route, target, kind, line, ctx) {
1263
+ const all = normalizeSource(text).split("\n");
1264
+ // A trailing newline yields a final empty element; it is not a line.
1265
+ if (all.length > 0 && all[all.length - 1] === "")
1266
+ all.pop();
1267
+ if (route.to > all.length) {
1268
+ ctx.diags.push({ severity: "error", code: "bad-source-range", message: `${kind} source \`${target}\`: the file has ${all.length} line(s), so lines ${route.from}-${route.to} no longer exist — the range is stale`, line });
1269
+ return null;
1270
+ }
1271
+ return all.slice(route.from - 1, route.to === 0 ? all.length : route.to);
1272
+ }
1273
+ function resolveCodeSources(ctx, opts) {
1274
+ for (const { block, line, target } of ctx.codeSources ?? []) {
1275
+ const scheme = schemeOf(target);
1276
+ // A remote source is fetched by the RENDERER (§9.4), as for a table.
1277
+ if (scheme === "http" || scheme === "https")
1278
+ continue;
1279
+ if (scheme !== null) {
1280
+ ctx.diags.push({ severity: "error", code: "bad-code-source", message: `code source \`${target}\` names a disallowed URL scheme`, line });
1281
+ continue;
1282
+ }
1283
+ const route = parseSourceRoute(target, "code", line, ctx);
1284
+ if (route === null)
1285
+ continue;
1286
+ const { path, from, to } = route;
1287
+ if (!opts.resolveDoc) {
1288
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `code source \`${target}\` not checked (no document resolver)`, line });
1289
+ continue;
1290
+ }
1291
+ const text = opts.resolveDoc(path);
1292
+ if (text === null) {
1293
+ // A WARNING, not an error, and the code/value model split is the reason:
1294
+ // a value that cannot be loaded is a promise the document failed to keep
1295
+ // (an error — see `unresolvable-data-source`), while a code region that
1296
+ // cannot be reached right now is still a code region at a location. A
1297
+ // generated code graph read away from its sources — published on its own,
1298
+ // or describing another checkout — must stay valid, exactly as the
1299
+ // render-time runtime degrades to showing the path.
1300
+ ctx.diags.push({ severity: "warning", code: "unresolvable-code-source", message: `cannot resolve code source \`${path}\` — not checked`, line });
1301
+ continue;
1302
+ }
1303
+ const slice = sliceSourceRange(text, { from, to }, target, "code", line, ctx);
1304
+ if (slice === null)
1305
+ continue;
1306
+ const hasBody = (block.raw ?? []).some((l) => l.trim() !== "");
1307
+ if (!hasBody) {
1308
+ block.raw = slice;
1309
+ }
1310
+ else if ((block.raw ?? []).join("\n") !== slice.join("\n")) {
1311
+ // A body alongside `src=` is a cached snapshot, kept for offline reading.
1312
+ // Silence would let the two drift — the very thing the route prevents.
1313
+ ctx.diags.push({ severity: "warning", code: "stale-code-snapshot", message: `code block body differs from its source \`${target}\` — the body is a snapshot and is now out of date`, line });
1314
+ }
1315
+ }
1316
+ }
1317
+ // GEP-0005: `src=` on a `data` block names external content — the same
1318
+ // external-source discipline tables have (§6, §9.4): an http(s) source is
1319
+ // fetched by the RENDERER, never the parser (the block defers, and so does a
1320
+ // chart over it); any other scheme is refused; the file must look like data
1321
+ // (`.json`/`.jsonl`); a missing resolver leaves it unchecked with a warning.
1322
+ function resolveDataSources(ctx, opts) {
1323
+ for (const { block, line, target } of ctx.dataSources ?? []) {
1324
+ const defer = () => { if (block.id !== undefined)
1325
+ (ctx.dataSrcPending ??= new Set()).add(block.id); };
1326
+ const scheme = schemeOf(target);
1327
+ if (scheme === "http" || scheme === "https") {
1328
+ defer();
1329
+ continue;
1330
+ }
1331
+ if (scheme !== null) {
1332
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `data source \`${target}\` names a disallowed URL scheme`, line });
1333
+ continue;
1334
+ }
1335
+ // The route shares `code`'s syntax (§3.2): a line range MAY narrow the file.
1336
+ const route = parseSourceRoute(target, "data", line, ctx);
1337
+ if (route === null)
1338
+ continue;
1339
+ const { path } = route;
1340
+ // A data source is data — the same shape rule table sources enforce, so
1341
+ // the loader cannot be pointed at a `.env` or a private key.
1342
+ if (!/\.(json|jsonl)$/i.test(path)) {
1343
+ ctx.diags.push({ severity: "error", code: "bad-data-source", message: `data source \`${path}\` is not a \`.json\`/\`.jsonl\` data file`, line });
1344
+ continue;
1345
+ }
1346
+ // Explicit format= wins; otherwise the (already-gated) extension names it.
1347
+ const fmtAttr = block.attrs["format"];
1348
+ const fmt = typeof fmtAttr === "string" ? fmtAttr : /\.jsonl$/i.test(path) ? "jsonl" : "json";
1349
+ // A range narrows the file to lines; what those lines mean is then the
1350
+ // format's business, unchanged. Slicing a `jsonl` log is the obvious use;
1351
+ // slicing a `json` file works whenever the slice is itself a value, and
1352
+ // when it is not, the ordinary data-parse error already names the line.
1353
+ // No extra rule.
1354
+ if (!opts.resolveDoc) {
1355
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `data source \`${target}\` not checked (no document resolver)`, line });
1356
+ defer();
1357
+ continue;
1358
+ }
1359
+ const text = opts.resolveDoc(path);
1360
+ if (text === null) {
1361
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `cannot resolve data source \`${path}\``, line });
1362
+ continue;
1363
+ }
1364
+ const lines = sliceSourceRange(text, route, target, "data", line, ctx);
1365
+ if (lines === null)
1366
+ continue;
1367
+ const parsed = parseDataBody(fmt, lines, line);
1368
+ for (const d of parsed.diags)
1369
+ ctx.diags.push(d);
1370
+ if (parsed.value !== undefined) {
1371
+ block.value = parsed.value;
1372
+ if (block.id !== undefined && !ctx.dataValues?.has(block.id)) {
1373
+ (ctx.dataValues ??= new Map()).set(block.id, parsed.value);
1374
+ }
1375
+ }
1376
+ }
1377
+ }
1378
+ // GEP-0005: a chart's `data=` may target a `data` block whose value is a
1379
+ // RECORD ARRAY — a non-empty array of objects. Keys project to columns in
1380
+ // first-seen order; every column the chart actually references (x/y/size/
1381
+ // series) must be present with a SCALAR value in every record, and a
1382
+ // violation is an error naming the first offending record. Columns the chart
1383
+ // does not reference may hold anything (nested values project as compact
1384
+ // JSON text). The projection feeds the unchanged table machinery.
1385
+ function recordsToTable(value, attrs, line, ctx) {
1386
+ const fail = (msg) => {
1387
+ ctx.diags.push({ severity: "error", code: "chart-data-not-records", message: `geml-chart: ${msg}`, line });
1388
+ return null;
1389
+ };
1390
+ if (!Array.isArray(value) || value.length === 0)
1391
+ return fail("data target is not a non-empty record array");
1392
+ const columns = [];
1393
+ for (let i = 0; i < value.length; i++) {
1394
+ const r = value[i];
1395
+ if (r === null || typeof r !== "object" || Array.isArray(r))
1396
+ return fail(`record ${i + 1} is not an object`);
1397
+ for (const k of Object.keys(r))
1398
+ if (!columns.includes(k))
1399
+ columns.push(k);
1400
+ }
1401
+ // Only the channels this chart TYPE reads are "referenced" (§7.1): a stray
1402
+ // size= on a bar chart is buildChart's chart-unused-channel WARNING, and the
1403
+ // projection must not turn it into an error a table source would not raise.
1404
+ // An unknown/missing type validates x/y only; buildChart reports the type.
1405
+ const typeAttr = String(attrs["type"] ?? "");
1406
+ const uses = USES[typeAttr] ?? new Set(["x", "y"]);
1407
+ const channels = [];
1408
+ for (const c of ["x", "y", "size", "series"]) {
1409
+ if (!uses.has(c))
1410
+ continue;
1411
+ const v = attrs[c];
1412
+ if (typeof v === "string")
1413
+ for (const name of v.split(",").map((s) => s.trim()).filter(Boolean))
1414
+ channels.push(name);
1415
+ }
1416
+ for (const col of channels) {
1417
+ for (let i = 0; i < value.length; i++) {
1418
+ const v = value[i][col];
1419
+ if (v === undefined || v === null || typeof v === "object") {
1420
+ return fail(`column \`${col}\` is missing or non-scalar in record ${i + 1}`);
1421
+ }
1422
+ }
1423
+ }
1424
+ const rows = value.map((r) => columns.map((c) => {
1425
+ const v = r[c];
1426
+ const text = v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
1427
+ // Data, not prose: cells carry plain-text inlines, never inline-parsed —
1428
+ // the same treatment `format=csv` cells get (a `*` in a value is a `*`).
1429
+ const cell = { text, inlines: text === "" ? [] : [{ type: "text", value: text }] };
1430
+ if (typeof v === "number" && Number.isFinite(v))
1431
+ cell.value = v;
1432
+ return cell;
1433
+ }));
1434
+ return { header: true, columns, align: columns.map(() => undefined), rows };
1435
+ }
925
1436
  // §7: resolve every geml-chart against its referenced table. Runs after the
926
1437
  // scan so that `data=#id` may point at a table defined anywhere in the doc.
927
1438
  function resolveCharts(ctx, opts) {
@@ -941,6 +1452,21 @@ function resolveCharts(ctx, opts) {
941
1452
  let table;
942
1453
  if (docPath === "") {
943
1454
  table = ctx.tables?.get(id);
1455
+ if (!table && ctx.dataValues?.has(id)) {
1456
+ // GEP-0005: the target is a `data` block. A RECORD ARRAY projects to
1457
+ // the table model (keys -> columns) and feeds the unchanged chart
1458
+ // machinery, so column checks and rendering stay single-sourced.
1459
+ const projected = recordsToTable(ctx.dataValues.get(id), block.attrs, line, ctx);
1460
+ if (projected === null)
1461
+ continue; // reported by the projection
1462
+ table = projected;
1463
+ }
1464
+ if (!table && ctx.dataSrcPending?.has(id)) {
1465
+ // GEP-0005: the target is a `data` block whose `src=` is render-time
1466
+ // (http, or no resolver) — defer exactly like a src table with no
1467
+ // columns; the renderer checks it when the data actually arrives.
1468
+ continue;
1469
+ }
944
1470
  if (!table) {
945
1471
  // A chart is a view of a table, and a data file is one of the three ways
946
1472
  // §6 lets a table name its content. So `data=rows.csv` desugars: it is an
@@ -954,8 +1480,36 @@ function resolveCharts(ctx, opts) {
954
1480
  continue; // already reported by the table rules
955
1481
  table = sugar;
956
1482
  }
1483
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id) && schemeOf(id) === null) {
1484
+ // GEP-0005 sugar, the json/jsonl twin of the csv path: an anonymous
1485
+ // LOCAL data source projected through the record-array rules. A
1486
+ // remote URL needs a NAMED `data` block with `src=` — its fetch is
1487
+ // render-time, and an anonymous source has no block to defer on.
1488
+ if (!opts.resolveDoc) {
1489
+ ctx.diags.push({ severity: "warning", code: "unchecked-cross-document-reference", message: `geml-chart: data source \`${id}\` not checked (no document resolver)`, line });
1490
+ continue;
1491
+ }
1492
+ const text = opts.resolveDoc(id);
1493
+ if (text === null) {
1494
+ ctx.diags.push({ severity: "error", code: "unresolvable-data-source", message: `geml-chart: cannot resolve data source \`${id}\``, line });
1495
+ continue;
1496
+ }
1497
+ const parsed = parseDataBody(/\.jsonl$/i.test(id) ? "jsonl" : "json", normalizeSource(text).split("\n"), line);
1498
+ for (const d of parsed.diags)
1499
+ ctx.diags.push(d);
1500
+ if (parsed.value === undefined)
1501
+ continue;
1502
+ const projected = recordsToTable(parsed.value, block.attrs, line, ctx);
1503
+ if (projected === null)
1504
+ continue; // reported by the projection
1505
+ table = projected;
1506
+ }
1507
+ else if (hash < 0 && /\.(json|jsonl)$/i.test(id)) {
1508
+ ctx.diags.push({ severity: "error", code: "bad-data-source", message: `geml-chart: \`data=${id}\`: a remote json/jsonl source needs a named \`data\` block with \`src=\``, line });
1509
+ continue;
1510
+ }
957
1511
  else if (hash < 0 && /\.[a-z0-9]+$/i.test(id)) {
958
- ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\` data file, and not a \`#id\` naming a table`, line });
1512
+ ctx.diags.push({ severity: "error", code: "unresolvable-table-source", message: `geml-chart: \`data=${id}\` is not a \`.csv\`/\`.tsv\`/\`.json\`/\`.jsonl\` data file, and not a \`#id\` naming a table or data block`, line });
959
1513
  continue;
960
1514
  }
961
1515
  else {
@@ -983,10 +1537,18 @@ function resolveCharts(ctx, opts) {
983
1537
  continue;
984
1538
  }
985
1539
  if (remote === "not-a-table") {
986
- ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is not a table`, line });
1540
+ ctx.diags.push({ severity: "error", code: "chart-data-not-a-table", message: `geml-chart: data target \`${ref}\` is neither a table nor a data block`, line });
987
1541
  continue;
988
1542
  }
989
- table = remote;
1543
+ if ("records" in remote) {
1544
+ const projected = recordsToTable(remote.records, block.attrs, line, ctx);
1545
+ if (projected === null)
1546
+ continue; // reported by the projection
1547
+ table = projected;
1548
+ }
1549
+ else {
1550
+ table = remote;
1551
+ }
990
1552
  }
991
1553
  if (table.src !== undefined && table.columns.length === 0) {
992
1554
  // §6: the table names a source whose data did not arrive at build time — a
@@ -1011,6 +1573,8 @@ export function parse(source, opts = {}) {
1011
1573
  // Table sources first: a chart reads the build-time model of the table it
1012
1574
  // charts, so that model has to be filled before charts are resolved.
1013
1575
  resolveTableSources(ctx, opts);
1576
+ resolveDataSources(ctx, opts);
1577
+ resolveCodeSources(ctx, opts);
1014
1578
  resolveCharts(ctx, opts);
1015
1579
  validateRefs(ctx, opts);
1016
1580
  detectTransclusionCycles(ctx, opts);
@@ -1077,9 +1641,12 @@ function sectionEnd(lines, i, level) {
1077
1641
  // `get`/`set` operate on the one the parser actually registered). `base` is the
1078
1642
  // absolute line offset of this slice within the whole document.
1079
1643
  function collectSpans(lines, base, out, ctx, depth = 0,
1080
- // Optional second index: every typed block by TYPE, id-bearing or not, so a
1081
- // block the author never named is still addressable (`=== meta`).
1082
- types) {
1644
+ // Optional second index: every addressable unit in document order typed
1645
+ // blocks (id-bearing or not, so a block the author never named is still
1646
+ // addressable), headings, footnote definitions. A second SINK on the one
1647
+ // walk, not a second walk: the selector design's "one definition, one
1648
+ // implementation" applies to the scan as much as to the syntax.
1649
+ units) {
1083
1650
  const add = (id, start, end) => {
1084
1651
  if (!out.has(id))
1085
1652
  out.set(id, { start, end });
@@ -1094,6 +1661,7 @@ types) {
1094
1661
  const fndef = /^\[\^([^\]]+)\]:[ \t]?(.*)$/.exec(line);
1095
1662
  if (fndef) {
1096
1663
  add(fndef[1].trim(), base + i, base + i + 1);
1664
+ units?.push({ span: { start: base + i, end: base + i + 1 }, kind: "footnote", id: fndef[1].trim() });
1097
1665
  i++;
1098
1666
  continue;
1099
1667
  }
@@ -1108,16 +1676,12 @@ types) {
1108
1676
  const { end, closed } = fenceClose(lines, i, open);
1109
1677
  if (id !== undefined)
1110
1678
  add(id, base + i, base + end);
1111
- if (types) {
1112
- const list = types.get(type) ?? [];
1113
- list.push({ span: { start: base + i, end: base + end }, id });
1114
- types.set(type, list);
1115
- }
1679
+ units?.push({ span: { start: base + i, end: base + end }, kind: "block", type, ...(id !== undefined ? { id } : {}) });
1116
1680
  // Only a flow body is scanned for nested blocks (raw/data bodies are
1117
1681
  // opaque), so an id inside a `code` body is *not* addressable — exactly
1118
1682
  // the parser's contract.
1119
1683
  if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
1120
- collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, types);
1684
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, units);
1121
1685
  }
1122
1686
  i = end;
1123
1687
  continue;
@@ -1128,7 +1692,10 @@ types) {
1128
1692
  // still advances one line at a time so every nested id inside the
1129
1693
  // section registers its own span — spans intentionally OVERLAP: #sec
1130
1694
  // contains #code, and each remains addressable on its own.
1131
- add(idOfHeading(h[3], h[2], base + i + 1, ctx), base + i, base + sectionEnd(lines, i, h[1].length));
1695
+ const hid = idOfHeading(h[3], h[2], base + i + 1, ctx);
1696
+ const hend = base + sectionEnd(lines, i, h[1].length);
1697
+ add(hid, base + i, hend);
1698
+ units?.push({ span: { start: base + i, end: hend }, kind: "heading", id: hid, level: h[1].length, text: h[2] });
1132
1699
  i++;
1133
1700
  continue;
1134
1701
  }
@@ -1146,12 +1713,23 @@ export function blockSpans(source) {
1146
1713
  collectSpans(lines, 0, out, ctx);
1147
1714
  return out;
1148
1715
  }
1149
- function blockTypeSpans(source) {
1716
+ // Every addressable unit, in document order, each decorated with its content
1717
+ // address (§3.2). Ids are OPTIONAL in GEML (§1: a block MAY carry one), so
1718
+ // `meta`, a callout `note`, a `table` — anything the author had no reason to
1719
+ // name — has no id to address it by; this index is what makes those addressable
1720
+ // anyway, by type (`=== meta`) or by content (`@<hex>`). No block type is
1721
+ // special-cased; meta is merely the one that is usually unique.
1722
+ //
1723
+ // The ONE index selector matching and the listing both work from, so `get`,
1724
+ // `set` and the listing can never disagree about what exists.
1725
+ function addressedUnits(source) {
1150
1726
  const lines = normalizeSource(source).split("\n");
1151
1727
  const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
1152
- const types = new Map();
1153
- collectSpans(lines, 0, new Map(), ctx, 0, types);
1154
- return types;
1728
+ const units = [];
1729
+ collectSpans(lines, 0, new Map(), ctx, 0, units);
1730
+ // The address hashes the block's own source text — the exact bytes `get`
1731
+ // would print for it — so an address can be recomputed from `get` output.
1732
+ return addressUnits(units, (u) => lines.slice(u.span.start, u.span.end).join("\n"));
1155
1733
  }
1156
1734
  // Split into physical lines while *keeping* each line's terminator, so
1157
1735
  // join("") is byte-exact and slicing by span never rewrites line endings.
@@ -1186,6 +1764,32 @@ function toNewline(text, nl) {
1186
1764
  function narrowToHead(span) {
1187
1765
  return { start: span.start, end: span.start + 1 };
1188
1766
  }
1767
+ // The unit's CLOSING fence line, or null when it has none — a heading section,
1768
+ // or a fence left unclosed at EOF. Extracted so `get --body` and `set --body`
1769
+ // decide it in ONE place: the selector design's §4 defines HEAD/BODY by the
1770
+ // round-trip invariant `get X --body | set X --body` leaving the file
1771
+ // byte-identical, and two copies of this judgement is exactly how that breaks.
1772
+ function closeFenceLine(lines, span) {
1773
+ const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
1774
+ if (!open)
1775
+ return null;
1776
+ const lastText = stripEol(lines[span.end - 1] ?? "").replace(/[ \t]+$/, "");
1777
+ const bid = open[3] ? parseAttrs(open[3]).id : undefined;
1778
+ const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
1779
+ return isCloseFence(lastText, open[1].length) || labeled ? lines[span.end - 1] ?? "" : null;
1780
+ }
1781
+ // BODY span: a fenced block's lines BETWEEN the fences; a heading's lines after
1782
+ // the heading through the section boundary — trailing blank lines included,
1783
+ // because that is the span `set --body` replaces (§4's table).
1784
+ function narrowToBody(lines, span) {
1785
+ return { start: span.start + 1, end: closeFenceLine(lines, span) !== null ? span.end - 1 : span.end };
1786
+ }
1787
+ // Slice one unit's output bytes, honouring --head / --body.
1788
+ function sliceUnit(source, span, headOnly, bodyOnly) {
1789
+ const lines = splitLines(source);
1790
+ const s = headOnly ? narrowToHead(span) : bodyOnly ? narrowToBody(lines, span) : span;
1791
+ return lines.slice(s.start, s.end).join("");
1792
+ }
1189
1793
  // Depth-first search for the document-model node carrying `id`, descending into
1190
1794
  // flow-block children (and list-item children) so a nested id is found too.
1191
1795
  // Returns the containing sibling array and index, not just the node: the model
@@ -1238,13 +1842,9 @@ function flag(args, name) {
1238
1842
  function historyPathFor(geml) {
1239
1843
  return geml.replace(/\.geml$/, "") + ".gemlhistory";
1240
1844
  }
1241
- function parseStamp(s) {
1242
- const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s);
1243
- if (!m)
1244
- throw new Error(`bad --at timestamp: ${s} (want YYYYMMDDTHHMMSSZ)`);
1245
- const [, y, mo, d, h, mi, se] = m;
1246
- return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se));
1247
- }
1845
+ // (A `YYYYMMDDTHHMMSSZ` parser lived here for `history commit --at`. That flag
1846
+ // left the CLI with design §9-Q4 — the library API takes a real Date — so the parser
1847
+ // went with it rather than staying as an uncalled branch.)
1248
1848
  const VERSION = "1.0"; // GEML spec version this CLI targets
1249
1849
  // The published version, read from package.json rather than restated here.
1250
1850
  // "Keep in sync with package.json" was a comment, and comments do not run: this
@@ -1272,96 +1872,124 @@ export const PARSER_VERSION = (() => {
1272
1872
  }
1273
1873
  return "0.0.0";
1274
1874
  })();
1275
- const USAGE = `geml — GEML reference CLI
1276
-
1277
- Usage:
1278
- geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1279
- (--root widens cross-doc resolution to dir d, as on check — an
1280
- === embed whose target sits above the file's own directory
1281
- needs it, or it renders unresolved)
1282
- --to <output>: json | html | md | geml
1283
- --to md -> Markdown (lossy)
1284
- --to html -> self-contained HTML
1285
- --to geml -> canonical re-format
1286
- --to json -> document-model JSON (default)
1287
- --from <input>: geml | md | json (overrides extension; html is output-only)
1288
- geml notes.md -> GEML (md inferred from extension)
1289
- geml model.json --to geml -> GEML (round-trips a prior --to json)
1290
- geml - --from md read Markdown on stdin
1291
- geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
1292
- (a heading id = its whole section; --head = head line;
1293
- --json = model node). Without #id: list all addressable
1294
- ids (--json = array).
1295
- geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
1296
- (--in F takes F's block #id, F#src takes #src, else stdin raw;
1297
- default = whole block · --head = head line · --body = body)
1298
- geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
1299
- (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
1300
- geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
1301
- (a missing id is skipped; a dangling reference is a warning, not a refusal)
1302
- geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
1303
- geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
1304
- (sel: 0 | -N | id-prefix | changed; default -1)
1305
- geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1306
- (--root widens cross-doc refs to dir d, e.g. the repo root)
1307
- geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
1308
- geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1309
- geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1310
- (10 tools, each geml_ + its CLI verb: list/get/check/history/to +
1311
- set/add/delete/rename/revert; every write is validated before it
1312
- reaches disk. A code graph under --root adds four read-only
1313
- geml_codemap_* tools to the same server)
1314
- geml --help | --version [--json]
1315
-
1316
- Use '-' as the file to read from stdin.
1317
- Mutations (set/add/delete/rename) write the whole updated document in place for a
1318
- file, or to stdout for '-' input; -o redirects it (-o - = stdout).
1319
- Exit codes:
1320
- 0 ok
1321
- 1 document/operation error
1322
- 2 command usage error.
1875
+ const USAGE = `geml — GEML reference CLI
1876
+
1877
+ Usage:
1878
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
1879
+ (--root widens cross-doc resolution to dir d, as on check — an
1880
+ === embed whose target sits above the file's own directory
1881
+ needs it, or it renders unresolved)
1882
+ --to <output>: json | html | md | geml
1883
+ --to md -> Markdown (lossy)
1884
+ --to html -> self-contained HTML
1885
+ --to html --fragment -> body-only markup, no page shell
1886
+ (embed in your own layout; assets via pageAssets)
1887
+ --to geml -> canonical re-format
1888
+ --to json -> document-model JSON (default)
1889
+ --from <input>: geml | md | json (overrides extension; html is output-only)
1890
+ geml notes.md -> GEML (md inferred from extension)
1891
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
1892
+ geml - --from md read Markdown on stdin
1893
+ geml get <file.geml|-> [#id] [--json] [--head] with #id: print that block
1894
+ (a heading id = its whole section; --head = head line;
1895
+ --json = model node). Without #id: list all addressable
1896
+ ids (--json = array).
1897
+ geml set <file.geml|-> #id [--head|--body] [--in f[#src]|-] [-o f] replace ONE block by id
1898
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
1899
+ default = whole block · --head = head line · --body = body)
1900
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
1901
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
1902
+ geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
1903
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
1904
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
1905
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
1906
+ (sel: 0 | -N | id-prefix | changed; default -1)
1907
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
1908
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
1909
+ geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
1910
+ (save = append the file as a revision · get = list revisions, or
1911
+ print one · restore = overwrite the file with one · verify = rebuild
1912
+ and re-hash the whole chain)
1913
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
1914
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
1915
+ (10 tools, each geml_ + its CLI command path: list/get/check/history/to +
1916
+ set/add/delete/rename/revert; every write is validated before it
1917
+ reaches disk. A code graph under --root adds four read-only
1918
+ geml_codemap_* tools to the same server)
1919
+ geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
1920
+ (authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
1921
+ MCP server registered at user scope; touches no settings.json,
1922
+ installs no hooks; idempotent — re-run to update)
1923
+ geml --help | --version [--json]
1924
+
1925
+ Use '-' as the file to read from stdin.
1926
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
1927
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
1928
+ Exit codes:
1929
+ 0 ok
1930
+ 1 document/operation error
1931
+ 2 command usage error.
1323
1932
  `;
1324
1933
  // One-line usage for each subcommand — the single source for both the error
1325
1934
  // shown on misuse and the `<cmd> --help` text.
1326
1935
  const SUBHELP = {
1327
- 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)",
1328
- 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)",
1936
+ get: "usage: geml get <file.geml|-> [<selector>] [--head|--body] [--view [--root <dir>]] [--json] (selector = a filter over blocks: #id | '## Heading' (its whole section) | '=== type' (every block of that type — N matches print N contents, count on stderr) | '=== type@<hex>[~n]' or '@<hex>[~n]' (content address, for blocks with no #id); --head = head line, --body = body; --view = read THROUGH an `embed` to the entity block it stands for, following a chain to its end (the identity on any other block, and on a section selector it never splices two documents' bytes together); provenance goes to stderr as `view: <sel> -> <doc>[#<id>]`; read-only, `set` refuses it; chain reads are confined to --root (default: the document's own directory) and never fetched over the network; without a selector: list every addressable block with its shortest unique address, --json = array)",
1937
+ set: "usage: geml set <file.geml|-> <selector> [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (selector as in `get`, but it must match exactly ONE block — '=== type' matching several is refused; 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 when the target has one — --body = body; guarded splice, refused if it breaks the doc; writing through an @<hex> address prints the new address on stderr)",
1329
1938
  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)",
1330
1939
  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)",
1331
1940
  rename: "usage: geml rename <file.geml|-> #old #new [-o out.geml] (rewrite an id's declaration AND every reference — [[#id]], [text](#id), chart data=#id, footnote [^id] — id-boundary safe, skipping raw block bodies; #new must be free; refused if it breaks the doc)",
1332
1941
  check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
1333
1942
  revert: "usage: geml revert <file.geml> #id [--rev <sel>] [--append|--before #x|--after #x] [--head] [--dry-run] [-o out] (reconcile #id to a revision: splice / resurrect / remove; sel: 0 | -N | id-prefix | changed; default -1)",
1334
- history: "usage: geml history <commit|verify|show|restore|log> <file.geml> [...]",
1335
- codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
1336
- geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
1337
- geml codemap verify [dir] geml check + profile reference checks
1338
- geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
1339
- 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
1340
- geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
1341
- geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
1943
+ history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
1944
+ geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
1945
+ geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
1946
+ geml history verify <file.geml> rebuild and re-hash every revision in the chain
1947
+ (<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
1948
+ All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
1949
+ codemap: `usage: geml codemap build [--root <repo>] # auto-detect languages, run the indexer(s), and merge into one codemap (--root defaults to the current directory)
1950
+ geml codemap build (--db <graph.db> | --adapter joern|scip --raw <in>)+ [--root <repo>] [--out .geml-code-graph] [--container module|dir|file] [--lang <JAVASRC|NEWC|…>] [--joern <path>] [--history [-m msg]]
1951
+ geml codemap verify [dir] geml check + profile reference checks
1952
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
1953
+ 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
1954
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
1955
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
1342
1956
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
1343
- mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1344
-
1345
- Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
1346
- Every tool is geml_ + its CLI verb, so the terminal and the assistant share
1347
- one vocabulary.
1348
- Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1349
- geml_set · geml_add · geml_delete · geml_rename · geml_revert
1350
- With a code graph under --root, four more (read-only), so one client entry
1351
- covers both: geml_codemap_search · geml_codemap_callchain
1352
- geml_codemap_list · geml_codemap_node
1353
-
1354
- --root <dir> REQUIRED. Root holding the .geml documents. Every path a
1355
- client names is confined here; a client cannot widen it.
1356
- --graph <dir> Code-graph directory, inside --root. Defaults to
1357
- <root>/.geml-code-graph when it holds an index.geml; with
1358
- no graph the four graph tools are not served at all.
1359
- --no-history Skip the .gemlhistory commit taken before each write
1360
- (default: commit, so geml_revert always has a revision to
1361
- undo to).
1362
-
1363
- Register with a client:
1957
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
1958
+
1959
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
1960
+ Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
1961
+ share one vocabulary — geml_history mirrors the "geml history" command group,
1962
+ whose read verb (get) is the only one of the four served here.
1963
+ Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
1964
+ geml_set · geml_add · geml_delete · geml_rename · geml_revert
1965
+ With a code graph under --root, four more (read-only), so one client entry
1966
+ covers both: geml_codemap_search · geml_codemap_callchain
1967
+ geml_codemap_list · geml_codemap_node
1968
+
1969
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
1970
+ client names is confined here; a client cannot widen it.
1971
+ --graph <dir> Code-graph directory, inside --root. Defaults to
1972
+ <root>/.geml-code-graph when it holds an index.geml; with
1973
+ no graph the four graph tools are not served at all.
1974
+ --no-history Skip the .gemlhistory revision saved before each write
1975
+ (default: save one, so geml_revert always has a revision
1976
+ to undo to).
1977
+
1978
+ Register with a client:
1364
1979
  claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
1980
+ skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp]
1981
+
1982
+ One command, three things, all user-global — so any Claude Code session can
1983
+ author, validate, and blockwise-edit GEML:
1984
+ 1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
1985
+ 2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
1986
+ 3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
1987
+ Touches no settings.json and installs no hooks. Idempotent — re-run after an
1988
+ upgrade to refresh the skill text alongside the CLI it teaches.
1989
+
1990
+ --dest <dir> install the skill under <dir> instead of ~/.claude/skills
1991
+ --no-global skip the global npm install
1992
+ --no-mcp skip the MCP server registration`,
1365
1993
  };
1366
1994
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
1367
1995
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1440,7 +2068,19 @@ function resolverFor(file, root) {
1440
2068
  return null;
1441
2069
  // References resolve FROM the document's own directory; the gates below
1442
2070
  // confine them to the (possibly widened) base.
1443
- const targetAbs = resolvePath(dirAbs, d);
2071
+ let targetAbs = resolvePath(dirAbs, d);
2072
+ // A SOURCE route (`code`/`data` `src=`) may instead be written relative to
2073
+ // the resolution root — that is how the code-graph profile writes them
2074
+ // (`geml-parser/src/attrs.ts` from a document two levels down). So when
2075
+ // the document-relative path does not exist and a root was named, try the
2076
+ // root as the base. Only a widened `--root` can enable this, and both
2077
+ // confinement gates below still apply, so it cannot reach further than a
2078
+ // document-relative reference already could.
2079
+ if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
2080
+ const fromBase = resolvePath(baseAbs, d);
2081
+ if (existsSync(fromBase))
2082
+ targetAbs = fromBase;
2083
+ }
1444
2084
  // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
1445
2085
  // before touching the filesystem.
1446
2086
  if (outside(baseAbs, targetAbs))
@@ -1510,23 +2150,130 @@ function historyError(e, file, historyPath) {
1510
2150
  }
1511
2151
  return err?.message ?? String(e);
1512
2152
  }
2153
+ // Subcommand, file and revision, read positionally around the options —
2154
+ // `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
2155
+ // indexing read `--history` itself as the file.
2156
+ //
2157
+ // The generic `positionals()` cannot be reused: it drops every `-`-leading token,
2158
+ // and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
2159
+ // of the first column `history get` prints, so `-N` is admitted and every other
2160
+ // `-`-leading token is treated as an option.
2161
+ function historyPositionals(args) {
2162
+ const out = [];
2163
+ for (let i = 0; i < args.length; i++) {
2164
+ const a = args[i];
2165
+ if (a === "--history" || a === "-m" || a === "--message") {
2166
+ i++;
2167
+ continue;
2168
+ } // flag AND its value
2169
+ if (a.startsWith("-") && !/^-\d+$/.test(a))
2170
+ continue; // --json, --force, …
2171
+ out.push(a);
2172
+ }
2173
+ return out;
2174
+ }
1513
2175
  function runHistory(args) {
1514
- const sub = args[0];
1515
- const file = args[1];
2176
+ const [sub, file, rev, ...extra] = historyPositionals(args);
1516
2177
  if (!sub || !file)
1517
2178
  fail(SUBHELP.history);
1518
2179
  const historyPath = flag(args, "--history") ?? historyPathFor(file);
2180
+ const json = args.includes("--json");
1519
2181
  try {
1520
- if (sub === "commit") {
1521
- const at = flag(args, "--at");
1522
- const r = commit({
2182
+ if (sub === "save") {
2183
+ // design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
2184
+ // outside tests ever passed either). Refusing beats ignoring for the same
2185
+ // reason the retired verbs above refuse: a silently dropped `--author
2186
+ // alice` discards precisely the value the caller went out of their way to
2187
+ // type. Both stay on the library API (save({ author, at })).
2188
+ for (const gone of ["--author", "--at"]) {
2189
+ if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
2190
+ fail(`${gone} is no longer accepted by 'geml history save' — the only option is -m/--message. (Both remain on the library API, save({ author, at }), for embedders and for tests that pin a revision id.)`);
2191
+ }
2192
+ }
2193
+ // design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
2194
+ // so an agent retrying a save it is unsure landed must not lengthen the
2195
+ // chain by a revision with no ops. `geml mcp` already gated its
2196
+ // pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
2197
+ // the same `isCurrent()`, not a second hash comparison.
2198
+ if (existsSync(historyPath) && isCurrent(historyPath, file)) {
2199
+ console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
2200
+ return;
2201
+ }
2202
+ const r = save({
1523
2203
  gemlPath: file,
1524
2204
  historyPath,
1525
2205
  summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
1526
- author: flag(args, "--author"),
1527
- at: at ? parseStamp(at) : undefined,
1528
2206
  });
1529
- console.log(`committed ${r.id}`);
2207
+ console.log(`saved ${r.id}`);
2208
+ }
2209
+ else if (sub === "get") {
2210
+ // Three tiers, split by how many addresses were given — the same rule the
2211
+ // top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
2212
+ // selector inside the revision and reuses the top-level grammar verbatim
2213
+ // (§10.1): a revision rebuilt is just a document's text, so there is no
2214
+ // new algorithm here, and the two selector namespaces cannot collide —
2215
+ // position is fixed and the lexis does not overlap (§10.2).
2216
+ if (extra.length > 1) {
2217
+ fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
2218
+ }
2219
+ if (rev === undefined) {
2220
+ // Newest-first, with each row's selector in the first column (`0` for
2221
+ // the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
2222
+ // `restore` and `revert --rev` alike.
2223
+ const revs = listRevisions(historyPath);
2224
+ if (json) {
2225
+ console.log(JSON.stringify(revs, null, 2));
2226
+ }
2227
+ else {
2228
+ for (const r of revs) {
2229
+ const sel = r.current ? "0" : `-${r.offset}`;
2230
+ console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
2231
+ }
2232
+ }
2233
+ }
2234
+ else {
2235
+ // resolveContent() routes through the ONE selector grammar
2236
+ // (resolveRevision) that the list above prints — see its comment for
2237
+ // what happened the last time that was written twice.
2238
+ const { id, text } = resolveContent(historyPath, rev);
2239
+ const blockSel = extra[0];
2240
+ if (blockSel === undefined) {
2241
+ if (json)
2242
+ console.log(JSON.stringify({ id, text }, null, 2));
2243
+ else
2244
+ process.stdout.write(text);
2245
+ }
2246
+ else {
2247
+ // Tier 2 (§10.1). Cardinality and the flag rules are the top-level
2248
+ // ones, checked here because this tier has its own argument list.
2249
+ const headOnly = args.includes("--head");
2250
+ const bodyOnly = args.includes("--body");
2251
+ if (headOnly && bodyOnly)
2252
+ fail("--head and --body are mutually exclusive", 2);
2253
+ if (json && (headOnly || bodyOnly)) {
2254
+ fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
2255
+ }
2256
+ const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
2257
+ if (json) {
2258
+ // §3.2's tier table: the revision id travels with the block, so the
2259
+ // caller can tell WHICH version it is holding.
2260
+ const nodes = units.map((u) => unitNode(text, file, u, all));
2261
+ console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
2262
+ }
2263
+ else {
2264
+ if (units.length > 1)
2265
+ reportMatches(units[0].type ?? "", units);
2266
+ for (const u of units)
2267
+ process.stdout.write(sliceUnit(text, u.span, headOnly, bodyOnly));
2268
+ }
2269
+ }
2270
+ }
2271
+ }
2272
+ else if (sub === "restore") {
2273
+ if (!rev)
2274
+ fail("usage: geml history restore <file.geml> <revision> [--force]");
2275
+ restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
2276
+ console.log(`restored ${file} to ${rev}`);
1530
2277
  }
1531
2278
  else if (sub === "verify") {
1532
2279
  const res = verify(historyPath, file);
@@ -1538,27 +2285,6 @@ function runHistory(args) {
1538
2285
  if (!res.ok)
1539
2286
  process.exit(1);
1540
2287
  }
1541
- else if (sub === "show") {
1542
- const rev = args[2];
1543
- if (!rev)
1544
- fail("usage: geml history show <file.geml> <revision>");
1545
- process.stdout.write(restore({ historyPath, gemlPath: file, revision: rev }));
1546
- }
1547
- else if (sub === "restore") {
1548
- const rev = args[2];
1549
- if (!rev)
1550
- fail("usage: geml history restore <file.geml> <revision> [--force]");
1551
- restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
1552
- console.log(`restored ${file} to ${rev}`);
1553
- }
1554
- else if (sub === "log") {
1555
- // Newest-first, with the `--rev` selector for each row in the first column
1556
- // (`0` for the tip, then `-1`, `-2`, …) so the output is copy-paste.
1557
- for (const r of listRevisions(historyPath)) {
1558
- const sel = r.current ? "0" : `-${r.offset}`;
1559
- console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.replace(/\s+$/, ""));
1560
- }
1561
- }
1562
2288
  else {
1563
2289
  fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
1564
2290
  }
@@ -1571,6 +2297,13 @@ function runTransform(argv) {
1571
2297
  const out = flag(argv, "-o") ?? flag(argv, "--out");
1572
2298
  const fromRaw = flag(argv, "--from");
1573
2299
  const toRaw = flag(argv, "--to");
2300
+ // `--to html --fragment`: body-only markup for embedding in an existing
2301
+ // layout (library parity: RenderOptions.fragment). Consumed here so it can
2302
+ // be rejected on any other target — a discarded flag is a silent lie.
2303
+ const fragIdx = argv.indexOf("--fragment");
2304
+ const fragment = fragIdx >= 0;
2305
+ if (fragment)
2306
+ argv.splice(fragIdx, 1);
1574
2307
  // Same `--root` as `check`, and for the same reason: cross-document resolution is
1575
2308
  // fail-closed at the document's own directory, so a reference that climbs out of
1576
2309
  // it needs the tree's root named. Without this the transform silently ignored the
@@ -1618,6 +2351,8 @@ function runTransform(argv) {
1618
2351
  else {
1619
2352
  outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
1620
2353
  }
2354
+ if (fragment && outFmt !== "html")
2355
+ fail("--fragment only applies to --to html", 2);
1621
2356
  const src = readInput(file);
1622
2357
  // md -> geml is a direct projection, not a parse/serialize round-trip: emit
1623
2358
  // the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
@@ -1654,6 +2389,7 @@ function runTransform(argv) {
1654
2389
  case "html":
1655
2390
  output = renderHtml(doc, {
1656
2391
  source: file === "-" ? "stdin" : basename(file),
2392
+ fragment,
1657
2393
  // geml-code-graph embeds load + parse sibling codemap docs on demand.
1658
2394
  loadDoc: resolverFor(file, root),
1659
2395
  parseDoc: (s) => parse(s, { resolveDoc: resolverFor(file, root) }),
@@ -1822,39 +2558,55 @@ function resolveSelector(source, file, raw) {
1822
2558
  // resolves against: typed blocks and headings. A `[^id]` reference names one
1823
2559
  // of those (§5.2); the `[^id]: text` definition line was withdrawn.
1824
2560
  function listIds(source, file, json) {
2561
+ const where = file === "-" ? "stdin" : file;
2562
+ const all = addressedUnits(source);
1825
2563
  const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1826
- const rows = doc.ids.map((id) => {
1827
- const site = findBlockSite(doc.children, id);
1828
- const b = site?.siblings[site.index];
1829
- if (b?.kind === "heading")
1830
- return { id, kind: "heading", level: b.level, text: b.text };
1831
- if (b?.kind === "block") {
1832
- const row = { id, kind: b.type };
1833
- // `.footnote` is authored, not synthesized (the `[^id]: text` definition
1834
- // line was withdrawn) but it still marks a block meant as a footnote.
1835
- if (b.classes.includes("footnote"))
2564
+ const rows = all.map((a) => {
2565
+ const u = a.unit;
2566
+ const row = {
2567
+ address: shortestAddress(a, all),
2568
+ kind: u.kind === "block" ? u.type ?? "block" : u.kind,
2569
+ lines: [u.span.start + 1, u.span.end],
2570
+ };
2571
+ // §6.3: EVERY id-less block is flagged, including one whose address works
2572
+ // only because its type happens to be unique (`=== meta`) that it has no
2573
+ // id yet is precisely the fact you might want to act on (§5.2).
2574
+ if (u.id === undefined)
2575
+ row.anon = true;
2576
+ else
2577
+ row.id = u.id;
2578
+ if (u.kind === "heading") {
2579
+ row.level = u.level;
2580
+ row.text = u.text;
2581
+ }
2582
+ // `.footnote` is authored, not synthesized (the `[^id]: text` definition
2583
+ // line was withdrawn) — but it still marks a block meant as a footnote.
2584
+ if (u.id !== undefined) {
2585
+ const site = findBlockSite(doc.children, u.id);
2586
+ const b = site?.siblings[site.index];
2587
+ if (b?.kind === "block" && b.classes.includes("footnote"))
1836
2588
  row.footnote = true;
1837
- return row;
1838
2589
  }
1839
- return { id, kind: b?.kind ?? "unknown" };
2590
+ return row;
1840
2591
  });
2592
+ // §6.6: the empty document is a legitimate empty answer to "list everything",
2593
+ // not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
2594
+ // over a prose-only document does not blow up.
1841
2595
  if (json) {
1842
2596
  console.log(JSON.stringify(rows, null, 2));
1843
2597
  return;
1844
2598
  }
1845
2599
  if (rows.length === 0) {
1846
- console.error(`no addressable ids in ${file === "-" ? "stdin" : file}`);
2600
+ console.error(`no addressable blocks in ${where}`);
1847
2601
  return;
1848
2602
  }
1849
- // Align the id and kind columns; append a heading's level+text or a footnote flag.
1850
- const idW = Math.max(...rows.map((r) => r.id.length + 1));
2603
+ const addrW = Math.max(...rows.map((r) => r.address.length));
1851
2604
  const kindW = Math.max(...rows.map((r) => r.kind.length));
1852
2605
  for (const r of rows) {
1853
- let line = `#${r.id}`.padEnd(idW + 1) + " " + r.kind.padEnd(kindW);
1854
- if (r.kind === "heading")
1855
- line += ` h${r.level} ${r.text}`;
1856
- else if (r.footnote)
1857
- line += " footnote";
2606
+ const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
2607
+ const tail = r.kind === "heading" ? r.text ?? "" : `L${r.lines[0]}-${r.lines[1]}`;
2608
+ const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
2609
+ + (r.footnote ? " footnote" : "");
1858
2610
  console.log(line.replace(/\s+$/, ""));
1859
2611
  }
1860
2612
  }
@@ -1871,42 +2623,11 @@ function listIds(source, file, json) {
1871
2623
  // guessed between, so a document with three notes answers "which one" instead
1872
2624
  // of failing. The uniqueness that makes `=== meta` work is checked here, at
1873
2625
  // resolve time — nothing in the format has to promise a document holds only one.
1874
- function getByType(source, file, type, json, headOnly) {
1875
- const where = file === "-" ? "stdin" : file;
1876
- const matches = blockTypeSpans(source).get(type) ?? [];
1877
- if (!matches.length) {
1878
- fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
1879
- }
1880
- if (matches.length === 1) {
1881
- const m = matches[0];
1882
- if (json) {
1883
- // The ONLY block of its type: locating it in the model needs no index, so
1884
- // --json can still answer with the parsed node (meta's key/values, a
1885
- // table's model) rather than a mere location.
1886
- const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) }).children, type);
1887
- if (node) {
1888
- console.log(JSON.stringify(node, null, 2));
1889
- return;
1890
- }
1891
- }
1892
- const span = headOnly ? narrowToHead(m.span) : m.span;
1893
- process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1894
- return;
1895
- }
1896
- // Several: report WHERE they are (data on stdout, the explanation on stderr),
1897
- // so the caller can name one — by adding an #id, or via its section.
1898
- if (json) {
1899
- 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));
1900
- return;
1901
- }
1902
- console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
1903
- for (const m of matches) {
1904
- console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
1905
- }
1906
- }
1907
- // The single block of `type` in a document, or undefined when there is not
1908
- // exactly one (nested flow children included, matching the span scan's reach).
1909
- function onlyBlockOfType(blocks, type) {
2626
+ // Every block of `type` in document order, nested flow children included —
2627
+ // exactly the span scan's reach and order, so the k-th scan match and the k-th
2628
+ // model node are the same block. That correspondence is what lets an ANONYMOUS
2629
+ // block's `--json` find its node without an id to look it up by.
2630
+ function blocksOfType(blocks, type) {
1910
2631
  const hits = [];
1911
2632
  const walk = (list) => {
1912
2633
  for (const b of list) {
@@ -1919,65 +2640,196 @@ function onlyBlockOfType(blocks, type) {
1919
2640
  }
1920
2641
  };
1921
2642
  walk(blocks);
1922
- return hits.length === 1 ? hits[0] : undefined;
1923
- }
2643
+ return hits;
2644
+ }
2645
+ // A unit's index among the units of its own type, for the positional lookup above.
2646
+ function typeIndex(all, u) {
2647
+ return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
2648
+ }
2649
+ // Resolve a NON-list selector to the units it matches, or fail with the reason.
2650
+ // `where` names the haystack for the error messages — a file for `geml get`, a
2651
+ // revision for `geml history get`'s tier 2. Shared by both so the one selector
2652
+ // grammar has one implementation: history's design §10.1 asks for exactly this,
2653
+ // and its §3.2 records what happened the last time a selector grammar was
2654
+ // written twice (the printed selectors stopped being readable back).
2655
+ function selectUnits(source, file, rawSel, where) {
2656
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2657
+ // Callers handle the empty selector themselves (list for `get`, usage error
2658
+ // for `set`); reaching here with one is a caller bug surfaced as usage.
2659
+ if (sel.form === "list")
2660
+ fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
2661
+ if (sel.form === "attr") {
2662
+ // §7: the wording says "not implemented yet", not "braces are meaningless" —
2663
+ // §2 declares attribute keys as part of the model, so implementing them
2664
+ // later fills in a declared slot rather than reversing this message.
2665
+ fail(`only \`#id\` is supported as a filter key today (got \`${sel.key}\`) — use \`=== ${sel.type}\` for every ${sel.type} block, or address one by \`#id\` / \`@<hex>\``, 2);
2666
+ }
2667
+ const all = addressedUnits(source);
2668
+ if (sel.form === "content") {
2669
+ const hit = matchContent(sel, all);
2670
+ if (!hit.ok) {
2671
+ if (hit.why === "wrong-type") {
2672
+ // §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
2673
+ // a decoration that is allowed to lie, and would silently accept a
2674
+ // hand-edited address.
2675
+ fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
2676
+ }
2677
+ const suffix = sel.nth ? `~${sel.nth}` : "";
2678
+ fail(`no block matching \`@${sel.hex}${suffix}\` in ${where} — a content address goes stale when the block's content changes (that is the point: §3.2); run \`geml get ${where}\` for current addresses`, 1);
2679
+ }
2680
+ return { units: [hit.unit], all };
2681
+ }
2682
+ if (sel.form === "type") {
2683
+ const hits = matchType(sel.type, all);
2684
+ if (!hits.length)
2685
+ fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
2686
+ return { units: hits, all };
2687
+ }
2688
+ // `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
2689
+ // to match heading TEXT, so it stays the one path that reaches the model.
2690
+ const id = resolveSelector(source, file, sel.raw);
2691
+ const unit = all.find((a) => a.unit.id === id)?.unit;
2692
+ // Bare `no block with id \`x\`` — the phrasing every caller of a missing id
2693
+ // has always seen, and which `set`'s own tests pin. `where` is appended only
2694
+ // when it is NOT the file the caller already named (a revision), so the
2695
+ // common case reads the same as before this selector grammar existed.
2696
+ if (!unit)
2697
+ fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
2698
+ return { units: [unit], all };
2699
+ }
2700
+ // The document-model node for one unit; a heading yields its SECTION envelope,
2701
+ // so --json covers the same content as the raw span. `kind:"section"` lets a
2702
+ // consumer branch — every other unit yields the single node (the model is flat).
2703
+ function unitNode(source, file, unit, all) {
2704
+ const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2705
+ if (unit.id !== undefined) {
2706
+ const site = findBlockSite(doc.children, unit.id);
2707
+ if (!site)
2708
+ fail(`no block with id \`${unit.id}\``, 1);
2709
+ const block = site.siblings[site.index];
2710
+ if (block.kind !== "heading")
2711
+ return block;
2712
+ const end = sectionEndIndex(site.siblings, site.index);
2713
+ return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
2714
+ }
2715
+ const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
2716
+ if (!node)
2717
+ fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
2718
+ return node;
2719
+ }
2720
+ // stderr line for an N-match selector: content stays on stdout, so a redirect
2721
+ // captures document bytes only, and the caller still learns how many it got (§5).
2722
+ function reportMatches(type, units) {
2723
+ const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
2724
+ console.error(`${units.length} \`${type}\` blocks (${at})`);
2725
+ }
2726
+ // `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
2727
+ // document's addressable structure, or one/several blocks out of it.
2728
+ //
2729
+ // The selector is a FILTER (§2 of the get/set selector design): no selector
2730
+ // LISTS every addressable block with its shortest unique address; `#id` /
2731
+ // `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
2732
+ // Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
2733
+ // document order with the count on stderr. `--head`/`--body` narrow to one part
2734
+ // of each match, and every flag combination that used to be half-honoured is
2735
+ // now a usage error (§7) — a discarded flag is a command that quietly did
2736
+ // something else.
1924
2737
  function runGet(args) {
1925
2738
  const json = args.includes("--json");
1926
2739
  const headOnly = args.includes("--head");
1927
- const [file, rawId] = positionals(args, []);
2740
+ const bodyOnly = args.includes("--body");
2741
+ const view = args.includes("--view");
2742
+ const [file, rawSel] = positionals(args, ["--root"]);
1928
2743
  if (!file)
1929
2744
  fail(SUBHELP.get);
1930
- // No id: list every addressable id — the document's "table of contents", so
1931
- // an agent can discover what `get #id` can target without pulling the model.
2745
+ if (headOnly && bodyOnly)
2746
+ fail("--head and --body are mutually exclusive", 2);
2747
+ if (json && (headOnly || bodyOnly)) {
2748
+ fail(`--json cannot be combined with ${headOnly ? "--head" : "--body"} — --json returns the model node, which has no sub-node for one part of a block`, 2);
2749
+ }
1932
2750
  // One read: stdin can only be consumed once, and the selector resolver needs
1933
2751
  // the same bytes the slice below works on.
1934
2752
  const source = readInput(file);
1935
- if (!rawId) {
2753
+ const where = file === "-" ? "stdin" : file;
2754
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2755
+ if (sel.form === "list") {
2756
+ // §5.1: nothing here to narrow, and ignoring the flag would make
2757
+ // `get f --head` print byte-for-byte what `get f` prints.
2758
+ if (headOnly || bodyOnly) {
2759
+ fail(`${headOnly ? "--head" : "--body"} names part of ONE block, so it needs a selector — run \`geml get ${where}\` to list what to address`, 2);
2760
+ }
1936
2761
  listIds(source, file, json);
1937
2762
  return;
1938
2763
  }
1939
- // A FENCE line as the selector (`=== meta`): the same "copy the line out of
1940
- // the document" move as a heading line, for the blocks that carry no id.
1941
- // A pasted fence that DOES declare an id defers to the id path below.
1942
- const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
1943
- const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
1944
- if (fence && fenceId === undefined) {
1945
- getByType(source, file, fence[1], json, headOnly);
2764
+ const { units, all } = selectUnits(source, file, rawSel, where);
2765
+ // The chain is composed with `/` relJoinPath's rule, and `src=` values are
2766
+ // always `/`-separated so normalize the PLATFORM path at this boundary. On
2767
+ // Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
2768
+ // tell, and a relative `src=` resolves against the wrong base.
2769
+ const startDoc = where.replace(/\\/g, "/");
2770
+ const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
2771
+ if (json) {
2772
+ // §7: N matches yield N model nodes. The old `{kind:"blocks",
2773
+ // matches:[{lines}]}` coordinate envelope is gone — it answered "where are
2774
+ // they" when the question is "what are they" (§9 change 2).
2775
+ let nodes;
2776
+ try {
2777
+ nodes = units.flatMap((u) => {
2778
+ if (!view)
2779
+ return [unitNode(source, file, u, all)];
2780
+ return viewResolve(source, startDoc, u, viewRoot).map((res) => {
2781
+ const node = unitNode(res.text, res.doc, res.unit, res.all);
2782
+ // Provenance is mandatory (§4): the node's references and relative
2783
+ // paths resolve against ITS document, not the one asked about. A
2784
+ // whole-document target has no `#`, so it carries `doc` alone.
2785
+ if (res.from !== "") {
2786
+ const h = res.from.lastIndexOf("#");
2787
+ node["from"] = h < 0 ? { doc: res.from }
2788
+ : { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
2789
+ }
2790
+ return node;
2791
+ });
2792
+ });
2793
+ }
2794
+ catch (e) {
2795
+ if (e instanceof ViewError)
2796
+ fail(e.message, 1);
2797
+ throw e;
2798
+ }
2799
+ console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
1946
2800
  return;
1947
2801
  }
1948
- const id = fenceId ?? resolveSelector(source, file, rawId);
1949
- if (json) {
1950
- // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1951
- // to resolve the tree (and nested-block ids), but only the target prints.
1952
- const doc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
1953
- const site = findBlockSite(doc.children, id);
1954
- if (!site)
1955
- fail(`no block with id \`${id}\``, 1);
1956
- const block = site.siblings[site.index];
1957
- // `--head` on a heading suppresses the section envelope (the lone heading
1958
- // node IS the head). On a block/footnote id there is nothing finer than
1959
- // the single node — the model has no sub-node for "just the fence line"
1960
- // so --head refines only the RAW output there.
1961
- if (block.kind === "heading" && !headOnly) {
1962
- // A heading id addresses its SECTION, so `--json` covers the same
1963
- // content as the raw span: a self-describing envelope whose blocks[0]
1964
- // is the heading node followed by its siblings up to the boundary.
1965
- // `kind: "section"` lets a consumer branch — a block/footnote id still
1966
- // yields the single model node (the model itself stays flat).
1967
- const end = sectionEndIndex(site.siblings, site.index);
1968
- console.log(JSON.stringify({ kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) }, null, 2));
1969
- return;
2802
+ if (units.length > 1)
2803
+ reportMatches(units[0].type ?? "", units);
2804
+ if (view) {
2805
+ // All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
2806
+ // chain that breaks halfway cannot leave a partial read on stdout for a
2807
+ // caller that ignores the exit code. Partial scenery is not scenery.
2808
+ const out = [];
2809
+ const notes = [];
2810
+ try {
2811
+ for (const u of units) {
2812
+ for (const res of viewResolve(source, startDoc, u, viewRoot)) {
2813
+ if (res.from !== "")
2814
+ notes.push(`view: ${rawSel} -> ${res.from}`);
2815
+ out.push(sliceUnit(res.text, res.unit.span, headOnly, bodyOnly));
2816
+ }
2817
+ }
2818
+ }
2819
+ catch (e) {
2820
+ // A chain that cannot reach an entity block is a failed READ, reported the
2821
+ // way `get` reports a selector that matches nothing: one line, exit 1.
2822
+ if (e instanceof ViewError)
2823
+ fail(e.message, 1);
2824
+ throw e;
1970
2825
  }
1971
- console.log(JSON.stringify(block, null, 2));
2826
+ for (const n of notes)
2827
+ console.error(n);
2828
+ process.stdout.write(out.join(""));
1972
2829
  return;
1973
2830
  }
1974
- // Raw: slice the source span byte-for-byte. No parse required, so `get` still
1975
- // returns the exact bytes even if the document has diagnostics elsewhere.
1976
- const found = blockSpans(source).get(id);
1977
- if (!found)
1978
- fail(`no block with id \`${id}\``, 1);
1979
- const span = headOnly ? narrowToHead(found) : found;
1980
- process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
2831
+ for (const u of units)
2832
+ process.stdout.write(sliceUnit(source, u.span, headOnly, bodyOnly));
1981
2833
  }
1982
2834
  const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
1983
2835
  // `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
@@ -2003,14 +2855,19 @@ function runSet(args) {
2003
2855
  const bodyOnly = args.includes("--body");
2004
2856
  if (headOnly && bodyOnly)
2005
2857
  fail("--head and --body are mutually exclusive", 2);
2006
- const [file, rawId] = positionals(args, ["-o", "--out", "--in"]);
2858
+ // `--view` reads THROUGH an embed (see runGet). Writing through one would mean
2859
+ // one `set` silently editing a different file, so it is refused rather than
2860
+ // ignored — and the message has to point the way, not just say no.
2861
+ if (args.includes("--view")) {
2862
+ fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
2863
+ }
2864
+ const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
2007
2865
  if (!file)
2008
2866
  fail(SUBHELP.set);
2009
- // No id: there is no block to replace. Point the way to discovery, not a bare
2010
- // usage line — `geml get <file>` lists every id `set` can target.
2011
- if (!rawId)
2012
- fail(`no #id given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable ids`, 2);
2013
- const id = rawId.replace(/^#/, "");
2867
+ // No selector: there is no block to replace. Point the way to discovery, not a
2868
+ // bare usage line — `geml get <file>` lists every address `set` can target.
2869
+ if (!rawSel)
2870
+ fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
2014
2871
  // The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
2015
2872
  // a block from a file. Document and content can't BOTH be stdin: reject that
2016
2873
  // up front, before consuming stdin, so the document read below is unambiguous.
@@ -2019,16 +2876,11 @@ function runSet(args) {
2019
2876
  fail("reading the document from stdin needs --in for the new content", 2);
2020
2877
  }
2021
2878
  const source = readInput(file);
2879
+ const target = resolveSetTarget(source, file, rawSel);
2022
2880
  if (bodyOnly) {
2023
- runSetBody(source, id, from, rawChannel, file, out);
2881
+ runSetBody(source, target, from, rawChannel, file, out);
2024
2882
  return;
2025
2883
  }
2026
- // default / --head: content is a whole block (default) or a bare head line.
2027
- // Does the target exist? Asked FIRST: the shape checks below name the id in
2028
- // their advice ("use --body to set the body of #far"), which reads as though the
2029
- // id were there. Whether the content is prose is the second question.
2030
- if (!blockSpans(source).has(id))
2031
- fail(`no block with id \`${id}\``, 1);
2032
2884
  let content;
2033
2885
  if (rawChannel) {
2034
2886
  content = readInput("-");
@@ -2042,39 +2894,71 @@ function runSet(args) {
2042
2894
  if (shape === "empty")
2043
2895
  fail(NO_CONTENT, 1);
2044
2896
  if (shape === "prose")
2045
- fail(`content is prose, not a block — use --body to set the body of #${id}`, 1);
2897
+ fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
2046
2898
  if (shape === "multi")
2047
2899
  fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
2048
2900
  }
2049
2901
  }
2050
2902
  else {
2051
- content = extractBlock(from, id, headOnly ? "head" : "whole");
2052
- }
2053
- const normalized = normalizeBlockId(content, id);
2054
- const updated = spliceBlock(source, id, normalized, file, headOnly);
2903
+ content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
2904
+ }
2905
+ // §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
2906
+ // has no subject — the content is used verbatim, and an id it brings that
2907
+ // collides is caught by the splice guard like any other. An id target keeps
2908
+ // normalizing: naming an id on the command line IS the instruction that the
2909
+ // result carries that id (block-mutation design §4.0).
2910
+ const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
2911
+ const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
2055
2912
  resolveOutTarget(file, out).write(updated);
2913
+ reportNewAddress(updated, target);
2914
+ }
2915
+ // Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
2916
+ // N blocks; `set` may not — §5: with N targets there is no single id to
2917
+ // normalize the content to, so multi-target `set` is undefined, not merely
2918
+ // risky. Refused with exit 2 (a usage error), not exit 1.
2919
+ function resolveSetTarget(source, file, rawSel) {
2920
+ const where = file === "-" ? "<file>" : file;
2921
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
2922
+ if (sel.form === "list")
2923
+ fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
2924
+ const { units, all } = selectUnits(source, file, rawSel, where);
2925
+ if (units.length > 1) {
2926
+ // §5: with N targets there is no single id to normalize the content to, so
2927
+ // multi-target `set` is UNDEFINED, not merely risky. The addresses are
2928
+ // printed because they ARE the fix — each is unique and pastes straight
2929
+ // back into this same command (§6.2).
2930
+ const opts = units.map((u) => {
2931
+ const a = all.find((x) => x.unit === u);
2932
+ return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
2933
+ }).join("\n");
2934
+ fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
2935
+ }
2936
+ const unit = units[0];
2937
+ const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
2938
+ return { unit, label, byContent: sel.form === "content" };
2939
+ }
2940
+ // §5.3: writing through a content address CHANGES it, so print the new one —
2941
+ // otherwise a script editing the same block twice has to re-list in between.
2942
+ // stderr, because stdout may be the document itself (`-o -`).
2943
+ function reportNewAddress(updated, target) {
2944
+ if (!target.byContent)
2945
+ return;
2946
+ const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
2947
+ if (after)
2948
+ console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
2056
2949
  }
2057
2950
  // `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
2058
2951
  // for a typed block, its close fence. Assembles head + new body + close and
2059
2952
  // reuses the guarded spliceBlock — the head carries #id, so the id survives
2060
2953
  // with no normalization needed.
2061
- function runSetBody(source, id, from, rawChannel, file, out) {
2062
- const found = blockSpans(source).get(id);
2063
- if (!found)
2064
- fail(`no block with id \`${id}\``, 1);
2954
+ function runSetBody(source, target, from, rawChannel, file, out) {
2955
+ const found = target.unit.span;
2065
2956
  const lines = splitLines(source);
2066
2957
  const headLine = lines[found.start] ?? "";
2067
- const headText = stripEol(headLine);
2068
- // A typed block keeps its closing fence; a heading section has none.
2069
- let closeLine = null;
2070
- const open = FENCE_OPEN.exec(headText);
2071
- if (open) {
2072
- const lastText = stripEol(lines[found.end - 1] ?? "").replace(/[ \t]+$/, "");
2073
- const bid = open[3] ? parseAttrs(open[3]).id : undefined;
2074
- const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
2075
- if (isCloseFence(lastText, open[1].length) || labeled)
2076
- closeLine = lines[found.end - 1] ?? "";
2077
- }
2958
+ // A typed block keeps its closing fence; a heading section has none. Decided
2959
+ // by the same helper `get --body` uses, so the two agree on the span and the
2960
+ // §4 round-trip invariant holds.
2961
+ const closeLine = closeFenceLine(lines, found);
2078
2962
  let body;
2079
2963
  if (rawChannel) {
2080
2964
  body = readInput("-");
@@ -2082,7 +2966,7 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2082
2966
  fail(NO_CONTENT, 1);
2083
2967
  }
2084
2968
  else {
2085
- body = extractBlock(from, id, "body");
2969
+ body = extractBlock(from, target.unit.id ?? "", "body");
2086
2970
  }
2087
2971
  let head = headLine;
2088
2972
  if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
@@ -2095,8 +2979,9 @@ function runSetBody(source, id, from, rawChannel, file, out) {
2095
2979
  // block-count invariant so a `===` fence in the raw body can't close it early
2096
2980
  // and inject siblings (SEC F2). A heading section body has no close fence and
2097
2981
  // may legitimately contain blocks, so it is not count-guarded.
2098
- const updated = spliceBlock(source, id, replacement, file, false, closeLine !== null);
2982
+ const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
2099
2983
  resolveOutTarget(file, out).write(updated);
2984
+ reportNewAddress(updated, target);
2100
2985
  }
2101
2986
  // `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
2102
2987
  // — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
@@ -2397,6 +3282,13 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2397
3282
  const found = blockSpans(source).get(id);
2398
3283
  if (!found)
2399
3284
  fail(`no block with id \`${id}\``, 1);
3285
+ return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
3286
+ }
3287
+ // The same guarded splice addressed by SPAN rather than by id, because an
3288
+ // anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
3289
+ // is the survival guard's subject and is simply absent for those: every OTHER
3290
+ // pre-existing id must still survive, which the `dropped` check below covers.
3291
+ function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
2400
3292
  const beforeDoc = parse(source, { resolveDoc: resolverFor(file), self: file === "-" ? undefined : basename(file) });
2401
3293
  const beforeIds = beforeDoc.ids;
2402
3294
  // Keep the bytes before and after the target span exactly; give the new block
@@ -2428,7 +3320,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2428
3320
  refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
2429
3321
  }
2430
3322
  const now = new Set(reparsed.ids);
2431
- if (!now.has(id))
3323
+ if (id !== undefined && !now.has(id))
2432
3324
  fail(`replacement removes id \`${id}\`; not written`, 1);
2433
3325
  const dropped = beforeIds.find((x) => x !== id && !now.has(x));
2434
3326
  if (dropped !== undefined) {
@@ -2443,7 +3335,7 @@ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount
2443
3335
  // (Not enforced for heading sections / whole-block set, whose replacement may
2444
3336
  // legitimately span several top-level blocks.)
2445
3337
  if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
2446
- fail(`replacement changes the block count (a fence in the body closed #${id} early and injected sibling block(s)?); not written`, 1);
3338
+ fail(`replacement changes the block count (a fence in the body closed ${id !== undefined ? `#${id}` : "the target"} early and injected sibling block(s)?); not written`, 1);
2447
3339
  }
2448
3340
  return updated;
2449
3341
  }
@@ -2695,6 +3587,105 @@ function runMcp(args) {
2695
3587
  const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
2696
3588
  process.exit(r.status ?? 1);
2697
3589
  }
3590
+ // geml skill install: one command that makes GEML usable everywhere for a
3591
+ // Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
3592
+ // the CLI on the global PATH, and the MCP server registered at user scope.
3593
+ // Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
3594
+ // sidecars. Idempotent, so re-running after an upgrade refreshes everything.
3595
+ function runSkill(args) {
3596
+ const sub = args[0];
3597
+ if (sub !== "install")
3598
+ fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
3599
+ const rest = args.slice(1);
3600
+ const flag = (name) => {
3601
+ const i = rest.indexOf(name);
3602
+ if (i >= 0)
3603
+ rest.splice(i, 1);
3604
+ return i >= 0;
3605
+ };
3606
+ const opt = (name) => {
3607
+ const i = rest.indexOf(name);
3608
+ if (i < 0)
3609
+ return undefined;
3610
+ const v = rest[i + 1];
3611
+ if (!v)
3612
+ fail(`${name} needs a value.\n${SUBHELP.skill}`);
3613
+ rest.splice(i, 2);
3614
+ return v;
3615
+ };
3616
+ const noGlobal = flag("--no-global");
3617
+ const noMcp = flag("--no-mcp");
3618
+ const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
3619
+ if (rest.length)
3620
+ fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
3621
+ // The skill ships inside the npm package, next to dist/ — the installed
3622
+ // skill text always matches the CLI version it teaches.
3623
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
3624
+ if (!existsSync(join(src, "SKILL.md")))
3625
+ fail(`bundled skill not found at ${src} (broken install?)`, 1);
3626
+ const target = join(dest, "geml");
3627
+ const copied = [];
3628
+ const copyTree = (from, to) => {
3629
+ mkdirSync(to, { recursive: true });
3630
+ for (const e of readdirSync(from, { withFileTypes: true })) {
3631
+ // Never ship a history sidecar — skill and config docs carry none.
3632
+ if (e.name.endsWith(".gemlhistory"))
3633
+ continue;
3634
+ const f = join(from, e.name);
3635
+ const t = join(to, e.name);
3636
+ if (e.isDirectory())
3637
+ copyTree(f, t);
3638
+ else {
3639
+ copyFileSync(f, t);
3640
+ copied.push(relative(dest, t));
3641
+ }
3642
+ }
3643
+ };
3644
+ try {
3645
+ copyTree(src, target);
3646
+ }
3647
+ catch (e) {
3648
+ // A clean one-liner, never a raw stack: --dest may name a file, a
3649
+ // read-only tree, or a path whose ancestor is not a directory.
3650
+ fail(`cannot install skill to ${target}: ${e instanceof Error ? e.message : String(e)}`, 1);
3651
+ }
3652
+ console.log(`skill installed -> ${target} (${copied.join(", ")})`);
3653
+ // Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
3654
+ // below is a fixed literal, so shell:true adds no injection surface.
3655
+ const sh = process.platform === "win32";
3656
+ const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
3657
+ if (!noGlobal) {
3658
+ const have = run("geml", ["--version"]);
3659
+ if (have.status === 0) {
3660
+ console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
3661
+ }
3662
+ else {
3663
+ console.log("cli installing @geml/geml globally (npm i -g)...");
3664
+ const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
3665
+ if (r.status !== 0)
3666
+ console.error("cli global install failed — install later with: npm i -g @geml/geml");
3667
+ }
3668
+ }
3669
+ if (!noMcp) {
3670
+ const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
3671
+ const claude = run("claude", ["--version"]);
3672
+ if (claude.status !== 0) {
3673
+ console.log(`mcp claude CLI not found — register later with: ${REG}`);
3674
+ }
3675
+ else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
3676
+ console.log("mcp server 'geml' already registered");
3677
+ }
3678
+ else {
3679
+ const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
3680
+ if (r.status === 0)
3681
+ console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
3682
+ else
3683
+ console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
3684
+ }
3685
+ }
3686
+ console.log("done — new Claude Code sessions pick up the skill.");
3687
+ process.exit(0);
3688
+ }
2698
3689
  // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
2699
3690
  // CLI" by resolving argv[1] to its real path, not by its spelling.
2700
3691
  const entry = (() => {
@@ -2767,6 +3758,9 @@ if (entry && (entry === fileURLToPath(import.meta.url) || entry.endsWith("geml.t
2767
3758
  else if (cmd === "mcp") {
2768
3759
  runMcp(argv.slice(1));
2769
3760
  }
3761
+ else if (cmd === "skill") {
3762
+ runSkill(argv.slice(1));
3763
+ }
2770
3764
  else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
2771
3765
  // A bare word that is neither a known command nor a path is almost always
2772
3766
  // a mistyped command — say so, don't try to read it as a file. (The