@geml/geml 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,2560 @@
1
+ #!/usr/bin/env node
2
+ // The GEML command line. Split out of geml.ts so that file can be what the
3
+ // viewer imports: a parser LIBRARY. Everything CLI-side lives here — argv
4
+ // dispatch, the verbs, file and stdin I/O, spawning `codemap`/`mcp`, and
5
+ // `skill install`. The browser bundle never reaches this module, so a new
6
+ // node:* import here can no longer break the extension build (it did three
7
+ // times in one day: node:os for homedir, pageAssets, renameSync).
8
+ import { readFileSync, writeFileSync, realpathSync, statSync, existsSync, mkdirSync, readdirSync, copyFileSync, renameSync } from "node:fs";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawnSync } from "node:child_process";
13
+ import { PARSER_VERSION, VERSION, EMBED_DEPTH_LIMIT, FENCE_OPEN, parse, blockSpans, sliceUnit, addressedUnits, relJoinPath, relDirPath, closeFenceLine, findBlockSite, historyPathFor, isCloseFence, narrowToHead, newlineOf, narrowToIntro, reLit, sectionEndIndex, splitLines, stripEol, toLf, toNewline, trimSpaceTabEnd, } from "./geml.js";
14
+ import { schemeOf } from "./inline.js";
15
+ import { parseAttrs } from "./attrs.js";
16
+ import { save, restore, verify, isCurrent, listRevisions, resolveContent, firstChangedContent } from "./history.js";
17
+ import { renderHtml } from "./render-html.js";
18
+ import { normalizeBlockId } from "./block-edit.js";
19
+ import { discoveryHint, matchContent, matchLine, matchType, parseSelector, shortestAddress } from "./selector.js";
20
+ import { mdToGeml } from "./from-md.js";
21
+ import { serialize } from "./serialize.js";
22
+ import { gemlToMd } from "./to-md.js";
23
+ // A chain that cannot reach an entity block. Carries the diagnostic code it
24
+ // corresponds to (§3) so the message can name it without inventing a new one.
25
+ class ViewError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.code = code;
30
+ }
31
+ }
32
+ // Walking a chain is DOCUMENT-DRIVEN file access: `src=` comes from file
33
+ // content, so without a confinement root a document could name any path on the
34
+ // machine. And never a URL — `geml get` is a read command that agents and
35
+ // editors call constantly, so letting content steer it at the network would turn
36
+ // it into an SSRF entry point (§3.1). Both refusals reuse existing codes (§3).
37
+ function readConfined(rel, root) {
38
+ if (!/\.geml$/i.test(rel)) {
39
+ throw new ViewError("embed-target-not-geml", `embed-target-not-geml: \`${rel}\` is not a \`.geml\` document`);
40
+ }
41
+ const base = resolvePath(root);
42
+ const abs = resolvePath(root, rel);
43
+ if (abs !== base && !abs.startsWith(base + sep)) {
44
+ throw new ViewError("unresolvable-document", `unresolvable-document: \`${rel}\` lies outside the confinement root \`${root}\``);
45
+ }
46
+ try {
47
+ return readFileSync(abs, "utf8");
48
+ }
49
+ catch {
50
+ throw new ViewError("unresolvable-document", `unresolvable-document: cannot resolve \`${rel}\``);
51
+ }
52
+ }
53
+ // One hop: read the target document and select what the fragment names. Several
54
+ // units come back when the fragment names a section (§4.3).
55
+ function oneHop(file, src, root) {
56
+ const hash = src.indexOf("#");
57
+ const docPath = hash < 0 ? src : src.slice(0, hash);
58
+ const frag = hash < 0 ? undefined : src.slice(hash + 1);
59
+ // Check the scheme on what the DOCUMENT wrote, before composition: a URL can
60
+ // only arrive through `src=`, never from joining relative paths — and testing
61
+ // the composed path instead would read a Windows drive letter (`C:/…`) as a
62
+ // scheme and refuse every absolute path, which is exactly what the MCP layer
63
+ // hands the CLI.
64
+ if (schemeOf(docPath) !== null) {
65
+ throw new ViewError("unchecked-cross-document-reference", `unchecked-cross-document-reference: \`${docPath}\` is not local; \`--view\` never fetches over the network`);
66
+ }
67
+ const rel = relJoinPath(relDirPath(file), docPath);
68
+ const text = readConfined(rel, root);
69
+ if (frag === undefined) {
70
+ // `src=other.geml`: the frame looks onto the WHOLE document. Every block
71
+ // comes from the same target, so the resolution base stays uniform — unlike
72
+ // a host-side section selector, where splicing would mix two documents.
73
+ // `meta` is frontmatter, not content (render.ts's selectEmbed).
74
+ //
75
+ // Only TOP-LEVEL units: a heading's unit spans its whole section, so taking
76
+ // every addressed unit would emit the blocks inside a section twice.
77
+ const every = addressedUnits(text).map((a) => a.unit);
78
+ const top = every.filter((u) => !every.some((o) => o !== u && o.span.start <= u.span.start && o.span.end >= u.span.end
79
+ && (o.span.start < u.span.start || o.span.end > u.span.end)));
80
+ return { doc: rel, text, units: top.filter((u) => !(u.kind === "block" && u.type === "meta")), all: [], from: shownPath(rel, root) };
81
+ }
82
+ const { units, all } = selectUnits(text, rel, `#${frag}`, rel);
83
+ return { doc: rel, text, units, all, from: `${shownPath(rel, root)}#${frag}` };
84
+ }
85
+ // Provenance is stated relative to the confinement root, not as the path the
86
+ // walk happens to have composed. The MCP layer hands the CLI an ABSOLUTE path,
87
+ // so without this `from` would be `C:/Users/…/part.geml#tip` — leaking the
88
+ // server's layout, and not a path any caller could pass back in.
89
+ function shownPath(rel, root) {
90
+ const r = relative(root, rel).replace(/\\/g, "/");
91
+ return r === "" ? rel : r;
92
+ }
93
+ function viewResolve(source, file, unit, root, depth = 0, seen = new Set()) {
94
+ const src = unit.kind === "block" && unit.type === "embed" ? embedSrcOf(source, unit) : undefined;
95
+ if (src === undefined)
96
+ return [{ doc: file, text: source, unit, all: [], from: "" }];
97
+ // The renderer expands no deeper either (EMBED_DEPTH_LIMIT), but where the
98
+ // cycle detector may stop SILENTLY — a 9-deep chain is legal and simply is
99
+ // not expanded — `--view` may not: stopping here means what we are holding is
100
+ // still a frame, and returning it would break the contract silently.
101
+ if (depth >= EMBED_DEPTH_LIMIT) {
102
+ throw new ViewError("depth", `chain still not on an entity block after ${EMBED_DEPTH_LIMIT} hops (the renderer expands no deeper either)`);
103
+ }
104
+ const hop = oneHop(file, src, root);
105
+ // Same key shape as the check's cycle detector: a document plus what was
106
+ // selected in it.
107
+ const key = `${hop.doc}#${hop.units.map((u) => u.id ?? "").join(",")}`;
108
+ if (seen.has(key)) {
109
+ throw new ViewError("transclusion-cycle", `transclusion-cycle: \`${hop.from}\` is already being expanded in this chain`);
110
+ }
111
+ const nextSeen = new Set(seen).add(key);
112
+ // Per-unit application, recursively: what a frame looks onto may itself be a
113
+ // frame, and a section may hold a mix (§4.3).
114
+ return hop.units.flatMap((u) => viewResolve(hop.text, hop.doc, u, root, depth + 1, nextSeen)
115
+ // An inner identity step has no provenance of its own, so carry this hop's:
116
+ // `from` must always name where the bytes actually came from.
117
+ .map((r) => (r.from === "" ? { ...r, from: hop.from } : r)));
118
+ }
119
+ // The `src=` of an embed unit, read off its head line: a Unit carries the span,
120
+ // not parsed attributes.
121
+ function embedSrcOf(source, unit) {
122
+ const braces = /\{[^}]*\}/.exec(sliceUnit(source, unit.span, "head"));
123
+ if (!braces)
124
+ return undefined;
125
+ const v = parseAttrs(braces[0]).attrs["src"];
126
+ return typeof v === "string" ? v : undefined;
127
+ }
128
+ const USAGE = `geml — GEML reference CLI
129
+
130
+ Usage:
131
+ geml <file.geml|-> [--to <fmt>] [--from <fmt>] [--root d] [-o out] transform a document (default: --to json)
132
+ (--root widens cross-doc resolution to dir d, as on check — an
133
+ === embed whose target sits above the file's own directory
134
+ needs it, or it renders unresolved)
135
+ --to <output>: json | html | md | geml
136
+ --to md -> Markdown (lossy)
137
+ --to html -> self-contained HTML
138
+ --to html --fragment -> body-only markup, no page shell
139
+ (embed in your own layout; assets via pageAssets)
140
+ --to geml -> canonical re-format
141
+ --to json -> document-model JSON (default)
142
+ --from <input>: geml | md | json (overrides extension; html is output-only)
143
+ geml notes.md -> GEML (md inferred from extension)
144
+ geml model.json --to geml -> GEML (round-trips a prior --to json)
145
+ geml - --from md read Markdown on stdin
146
+ geml list <file.geml|-> [--json] list every addressable block: address, kind, lines
147
+ (call this first — its addresses are what every verb below takes)
148
+ geml find <pattern> [<file|dir> …] [--json] [--case] [--head] search block content -> file#address
149
+ (an address, not a line number, so a hit pastes into get/set;
150
+ a dir is walked for *.geml; exit 1 when nothing matched)
151
+ geml get <file.geml|-> [#id] [--json] [--head|--intro|--body] with #id: print that block
152
+ (a heading id = its whole section; --head = head line;
153
+ --json = model node). Without #id: list all addressable
154
+ ids (--json = array). A selector may also be a POSITION,
155
+ 'L27' or 'L27-58' — the smallest block containing those
156
+ lines, which is how a grep hit or a stack trace becomes
157
+ an address.
158
+ geml set <file.geml|-> #id [--head|--intro|--body] [--in f[#src]|-] [-o f] replace ONE block by id
159
+ geml replace <file.geml|-> <old> <new> [--within <selector>] [-o f] EXPERIMENTAL: swap a literal string, checked and reported
160
+ (--in F takes F's block #id, F#src takes #src, else stdin raw;
161
+ default = whole block · --head = head line · --body = body)
162
+ geml add <file.geml|-> (--append | --before #id | --after #id) [--in f[#src]|-] [-o f] insert a fragment
163
+ (1+ blocks and/or prose; content keeps its own ids, a clash is refused)
164
+ geml delete <file.geml|-> #id [#id2 …] [-o f] remove one or more blocks
165
+ (a missing id is skipped; a dangling reference is a warning, not a refusal)
166
+ geml rename <file.geml|-> #old #new [-o f] rename an id and every reference to it (id-boundary safe)
167
+ geml revert <file.geml> #id [--rev <sel>] [--head] undo one block to a past revision (splice / resurrect / remove)
168
+ (sel: 0 | -N | id-prefix | changed; default -1)
169
+ geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
170
+ (--root widens cross-doc refs to dir d, e.g. the repo root)
171
+ geml history <save|get|restore|verify> <file.geml> [...] .gemlhistory version sidecar
172
+ (save = append the file as a revision · get = list revisions, or
173
+ print one · restore = overwrite the file with one · verify = rebuild
174
+ and re-hash the whole chain)
175
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
176
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
177
+ (11 tools, each geml_ + its CLI command path: list/find/get/check/history/to +
178
+ set/add/delete/rename/revert; every write is validated before it
179
+ reaches disk. A code graph under --root adds four read-only
180
+ geml_codemap_* tools to the same server)
181
+ geml skill install [--dest <dir>] [--no-global] [--no-mcp] set up GEML for Claude Code, user-global
182
+ (authoring skill -> ~/.claude/skills/geml, CLI -> npm i -g,
183
+ MCP server registered at user scope; touches no settings.json,
184
+ installs no hooks; idempotent — re-run to update)
185
+ geml --help | --version [--json]
186
+
187
+ Use '-' as the file to read from stdin.
188
+ Mutations (set/add/delete/rename) write the whole updated document in place for a
189
+ file, or to stdout for '-' input; -o redirects it (-o - = stdout).
190
+ Exit codes:
191
+ 0 ok
192
+ 1 document/operation error
193
+ 2 command usage error.
194
+ `;
195
+ // One-line usage for each subcommand — the single source for both the error
196
+ // shown on misuse and the `<cmd> --help` text.
197
+ const SUBHELP = {
198
+ get: "usage: geml get <file.geml|-> [<selector>] [--head|--intro|--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) | L<n> or L<n>-<m> (position — the smallest block that fully contains those lines, so the `L27-58` the listing prints pastes straight back, and a line number from an editor, a linter or a diff hunk becomes a block); a section cuts three ways — --head = the heading line, --intro = its opening region: everything under it up to its FIRST SUBHEADING (empty when one follows immediately, the whole body when none does; a block has no intro and is refused), --body = everything under it; --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)",
199
+ set: "usage: geml set <file.geml|-> <selector> [--head|--intro|--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, --intro = a heading's opening region up to its first subheading (an empty region INSERTS there); guarded splice, refused if it breaks the doc — but a replacement that REMOVES blocks is carried out and reported on stderr, named ones and unnamed alike, with `geml revert` as the way back (the same stance `delete` takes; the ordinary read-edit-write cycle removes nothing, since `get` handed those blocks over); writing through an @<hex> address prints the new address on stderr)",
200
+ 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)",
201
+ 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)",
202
+ 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)",
203
+ list: "usage: geml list <file.geml|-> [--json] (list every addressable block with its shortest unique address, its kind and its line range — the same listing `geml get <file>` prints with no selector, under the name the MCP surface already uses. Call it FIRST: the addresses it prints are what get/set/add/delete/rename/revert all take)",
204
+ find: "usage: geml find <pattern> [<file.geml|dir> …] [--json] [--case] [--head] (search block CONTENT and print `<file>TAB<address>` per hit — an address, never a line number, so a hit is `geml get <file> '<address>'` with no editing. The address is the INNERMOST block holding the match, never its enclosing section, and a block is reported once however many lines in it matched. Substring, case-insensitive unless --case; a directory is walked for *.geml; no path = the current directory; --head adds the matching line as a third column. Exit 1 when nothing matched, so `if geml find …` works in a script)",
205
+ replace: "usage: geml replace <file.geml|-> <old> <new> [--within <selector>] [-o out.geml] (EXPERIMENTAL — this verb MAY BE WITHDRAWN in a later release; it is here to find out whether an addressed, checked replacement earns its place beside `sed`, and if it does not, it goes. Build nothing on it you cannot change, and say so in a discussion if it is doing real work for you. Swaps a LITERAL string — never a pattern, that is what `sed` is for and where the footguns are. Without --within the whole document; with it, only inside the blocks that selector matches, and unlike `set` it may match several: `--within '=== table'` means every table. What this buys over `sed -i`, at the same cost of two short strings and nothing read: the result is re-parsed and refused if it would break the document, the blocks it touched are NAMED on stderr, and the write lands in .gemlhistory where `revert` can undo it. An id is not text — a replacement that would rename one is refused and points at `geml rename`, which fixes every reference too. Exit 1 when nothing matched, so `if geml replace …` works in a script)",
206
+ check: "usage: geml check <file.geml|-> [--root <dir>] [--json] (--root: resolve cross-doc refs within <dir> instead of the file's own directory)",
207
+ 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)",
208
+ history: `usage: geml history save <file.geml> [-m <msg>] append the working file as a new revision (identical to the tip = no-op)
209
+ geml history get <file.geml> [<rev>] [--json] NO <rev>: every revision, newest first, first column = the selector; WITH <rev>: that revision's full text
210
+ geml history restore <file.geml> <rev> [--force] overwrite the working file with a revision (--force discards unsaved changes)
211
+ geml history verify <file.geml> rebuild and re-hash every revision in the chain
212
+ (<rev>: 0 = the tip | -N = N revisions back | an unambiguous revision id — the strings 'get' prints.
213
+ All four take --history <path> to point at a sidecar other than <file>.gemlhistory.)`,
214
+ 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)
215
+ 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]]
216
+ geml codemap verify [dir] geml check + profile reference checks
217
+ geml codemap render [dir] every doc -> sibling .html (open index.html from disk)
218
+ 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
219
+ geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
220
+ geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
221
+ (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
222
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
223
+
224
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
225
+ Every tool is geml_ + its CLI COMMAND PATH, so the terminal and the assistant
226
+ share one vocabulary — geml_history mirrors the "geml history" command group,
227
+ whose read verb (get) is the only one of the four served here.
228
+ Eleven tools: geml_list · geml_find · geml_get · geml_check · geml_history
229
+ geml_to · geml_set · geml_add · geml_delete · geml_rename
230
+ geml_revert
231
+ With a code graph under --root, four more (read-only), so one client entry
232
+ covers both: geml_codemap_search · geml_codemap_callchain
233
+ geml_codemap_list · geml_codemap_node
234
+
235
+ --root <dir> REQUIRED. Root holding the .geml documents. Every path a
236
+ client names is confined here; a client cannot widen it.
237
+ --graph <dir> Code-graph directory, inside --root. Defaults to
238
+ <root>/.geml-code-graph when it holds an index.geml; with
239
+ no graph the four graph tools are not served at all.
240
+ --no-history Skip the .gemlhistory revision saved before each write
241
+ (default: save one, so geml_revert always has a revision
242
+ to undo to).
243
+
244
+ Register with a client:
245
+ claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
246
+ skill: `usage: geml skill install [--dest <skillsDir>] [--no-global] [--no-mcp] [--dry-run]
247
+
248
+ One command, three things, all user-global — so any Claude Code session can
249
+ author, validate, and blockwise-edit GEML:
250
+ 1. the authoring skill -> <skillsDir>/geml (default ~/.claude/skills/geml)
251
+ 2. the geml CLI -> npm i -g @geml/geml (skipped when already on PATH)
252
+ 3. the MCP server -> claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .
253
+ Touches no settings.json and installs no hooks. Idempotent — re-run after an
254
+ upgrade to refresh the skill text alongside the CLI it teaches.
255
+
256
+ --dest <dir> install the skill under <dir> instead of ~/.claude/skills
257
+ --no-global skip the global npm install
258
+ --no-mcp skip the MCP server registration
259
+ --dry-run report what would be written, change nothing
260
+
261
+ Other agent tools are installed by DETECTION: a tool's own context file gets
262
+ the skill text inside a marker pair (refreshed on a re-run, nothing else in
263
+ the file touched) when its directory is already there — ~/.gemini, ~/.qwen,
264
+ and an AGENTS.md in the current project. A tool that is not installed is
265
+ skipped and named; no tool directory is ever created for you.`,
266
+ };
267
+ // Set from argv at dispatch time; when true, errors are emitted as a JSON
268
+ // envelope so an agent that standardizes on --json never has to parse text.
269
+ let jsonMode = false;
270
+ // Clean one-line error + non-zero exit — never a raw Node stack trace. `code`
271
+ // is the process exit status: 2 for a usage error (the default), 1 for a
272
+ // document/operation error. `--json` wraps it in the same {error, code} envelope.
273
+ function fail(msg, code = 2) {
274
+ if (jsonMode)
275
+ console.error(JSON.stringify({ error: msg, code }));
276
+ else
277
+ console.error(`error: ${msg}`);
278
+ process.exit(code);
279
+ }
280
+ // Refuse a mutation whose RESULT would be broken (the pre-write check every
281
+ // mutation runs). Prose mode is the long-standing wording: the first error,
282
+ // phrased by the call site. `--json` additionally carries the FULL diagnostic
283
+ // list with the stable codes of spec Appendix A, so a programmatic caller —
284
+ // `geml mcp` above all — reports what actually broke instead of re-parsing
285
+ // English out of stderr.
286
+ function refuseBroken(prose, errs) {
287
+ if (jsonMode) {
288
+ console.error(JSON.stringify({ error: prose, code: 1, diagnostics: errs }));
289
+ process.exit(1);
290
+ }
291
+ fail(prose, 1);
292
+ }
293
+ // Read a file, or stdin when the path is "-". On failure emit a clean error.
294
+ function readInput(file) {
295
+ try {
296
+ return readFileSync(file === "-" ? 0 : file, "utf8");
297
+ }
298
+ catch {
299
+ fail(file === "-" ? "cannot read stdin" : `cannot read ${file}`);
300
+ }
301
+ }
302
+ // A cross-document resolver rooted at the input's directory (cwd for stdin),
303
+ // CONFINED to that directory's subtree. A reference that resolves outside the
304
+ // base — via a `..` escape, an absolute path, or (on Windows) a different drive
305
+ // — is refused (returns null, i.e. an unresolvable ref) so a crafted document
306
+ // cannot turn `geml check`/parse into an arbitrary local-file read oracle. §8.
307
+ //
308
+ // A purely LEXICAL check is not enough: a symlink that sits lexically inside the
309
+ // subtree but points to `../../outside.geml` passes `path.relative` yet reads an
310
+ // external target. So after the cheap lexical gate we resolve BOTH the base and
311
+ // the target through `realpathSync` (following every symlink component) and
312
+ // re-check that the REAL target still lies within the REAL base subtree before
313
+ // reading. A target that does not exist makes `realpathSync` throw — handled as
314
+ // an ordinary unresolvable ref (null), never a crash.
315
+ //
316
+ // `root` (CLI `--root`, an explicit per-invocation user grant — never
317
+ // document-controlled) widens the confinement base from the input's own
318
+ // directory to an ancestor the user names, so repo-relative `../` references
319
+ // between sibling directories can be checked. It moves WHERE the boundary
320
+ // stands, never whether it is enforced: both gates below run against the
321
+ // widened base, so escapes past the root are refused exactly as above. The
322
+ // viewer/web surfaces never pass a root — their boundary is unchanged.
323
+ function resolverFor(file, root) {
324
+ const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
325
+ const baseAbs = root === undefined ? dirAbs : resolvePath(root);
326
+ // Canonicalise the base once. If the base itself cannot be realpath'd, no
327
+ // cross-doc ref can be safely confined — resolve nothing.
328
+ let realBase = null;
329
+ try {
330
+ realBase = realpathSync(baseAbs);
331
+ }
332
+ catch {
333
+ realBase = null;
334
+ }
335
+ const outside = (from, to) => {
336
+ const rel = relative(from, to);
337
+ return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
338
+ };
339
+ return (d) => {
340
+ if (realBase === null)
341
+ return null;
342
+ // References resolve FROM the document's own directory; the gates below
343
+ // confine them to the (possibly widened) base.
344
+ let targetAbs = resolvePath(dirAbs, d);
345
+ // A SOURCE route (`code`/`data` `src=`) may instead be written relative to
346
+ // the resolution root — that is how the code-graph profile writes them
347
+ // (`geml-parser/src/attrs.ts` from a document two levels down). So when
348
+ // the document-relative path does not exist and a root was named, try the
349
+ // root as the base. Only a widened `--root` can enable this, and both
350
+ // confinement gates below still apply, so it cannot reach further than a
351
+ // document-relative reference already could.
352
+ if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
353
+ const fromBase = resolvePath(baseAbs, d);
354
+ if (existsSync(fromBase))
355
+ targetAbs = fromBase;
356
+ }
357
+ // Cheap lexical gate: reject an obvious `..`/absolute/other-drive escape
358
+ // before touching the filesystem.
359
+ if (outside(baseAbs, targetAbs))
360
+ return null;
361
+ // Real (symlink-resolved) gate: a symlink pointing out of the subtree
362
+ // resolves to a real path outside `realBase` and is refused here.
363
+ let realTarget;
364
+ try {
365
+ realTarget = realpathSync(targetAbs);
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ if (outside(realBase, realTarget))
371
+ return null;
372
+ try {
373
+ return readFileSync(realTarget, "utf8");
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ };
379
+ }
380
+ // The existence half of the same question, behind the SAME gates. A link may
381
+ // point at a directory — `[the extension](integrations/vscode/)` — which has no
382
+ // text for `resolverFor` to return but is not a broken link. Answering this
383
+ // outside the confinement root would turn link checking into a probe for what
384
+ // exists on the machine, so every gate above is repeated rather than skipped.
385
+ function existsFor(file, root) {
386
+ const read = resolverFor(file, root);
387
+ const dirAbs = resolvePath(file === "-" ? "." : dirname(file));
388
+ const baseAbs = root === undefined ? dirAbs : resolvePath(root);
389
+ let realBase = null;
390
+ try {
391
+ realBase = realpathSync(baseAbs);
392
+ }
393
+ catch {
394
+ realBase = null;
395
+ }
396
+ const outside = (from, to) => {
397
+ const rel = relative(from, to);
398
+ return rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
399
+ };
400
+ return (d) => {
401
+ if (realBase === null)
402
+ return false;
403
+ // Readable already means it exists; this only has to answer for the rest.
404
+ if (read(d) !== null)
405
+ return true;
406
+ let targetAbs = resolvePath(dirAbs, d);
407
+ if (baseAbs !== dirAbs && !existsSync(targetAbs)) {
408
+ const fromBase = resolvePath(baseAbs, d);
409
+ if (existsSync(fromBase))
410
+ targetAbs = fromBase;
411
+ }
412
+ if (outside(baseAbs, targetAbs))
413
+ return false;
414
+ let realTarget;
415
+ try {
416
+ realTarget = realpathSync(targetAbs);
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ if (outside(realBase, realTarget))
422
+ return false;
423
+ return existsSync(realTarget);
424
+ };
425
+ }
426
+ // Both halves for a parse: every call site wants them together, and pairing
427
+ // them here keeps a resolver from being wired up without its existence probe.
428
+ function docOpts(file, root) {
429
+ return { resolveDoc: resolverFor(file, root), docExists: existsFor(file, root) };
430
+ }
431
+ // `geml check <file>` — validate only: diagnostics + exit code, no document
432
+ // dump (cheap for agents). `--json` prints the diagnostics array for machines.
433
+ function runCheck(args) {
434
+ const json = args.includes("--json");
435
+ const root = flag(args, "--root");
436
+ const file = args.find((a) => a === "-" || (!a.startsWith("-") && a !== root));
437
+ if (!file)
438
+ fail(SUBHELP.check);
439
+ // A mistyped --root must be a usage error (exit 2), not a wall of misleading
440
+ // "cannot resolve document" errors from a resolver confined to nothing.
441
+ if (root !== undefined) {
442
+ let isDir = false;
443
+ try {
444
+ isDir = statSync(root).isDirectory();
445
+ }
446
+ catch { /* missing -> not a dir */ }
447
+ if (!isDir)
448
+ fail(`--root ${root} is not a directory`);
449
+ }
450
+ const doc = parse(readInput(file), { ...docOpts(file, root), self: file === "-" ? undefined : basename(file) });
451
+ if (json) {
452
+ console.log(JSON.stringify(doc.diagnostics, null, 2));
453
+ }
454
+ else {
455
+ for (const d of doc.diagnostics)
456
+ console.error(`${d.severity}: ${d.message} (line ${d.line})`);
457
+ const errs = doc.diagnostics.filter((d) => d.severity === "error").length;
458
+ const warns = doc.diagnostics.filter((d) => d.severity === "warning").length;
459
+ console.error(errs || warns ? `${errs} error(s), ${warns} warning(s)` : "ok: no diagnostics");
460
+ }
461
+ if (doc.diagnostics.some((d) => d.severity === "error"))
462
+ process.exit(1);
463
+ }
464
+ // Map a thrown error from the history layer to a clean one-line message —
465
+ // never a raw node:fs stack trace, and without leaking the absolute path the
466
+ // runtime resolved (we report the relative path the user actually passed).
467
+ function historyError(e, file, historyPath) {
468
+ const err = e;
469
+ if (err?.code === "ENOENT") {
470
+ const p = err.path ?? "";
471
+ if (p.endsWith(basename(historyPath)))
472
+ return `cannot read history ${historyPath}`;
473
+ return `cannot read ${file}`;
474
+ }
475
+ return err?.message ?? String(e);
476
+ }
477
+ // Subcommand, file and revision, read positionally around the options —
478
+ // `--history <path>` and `-m <msg>` may sit anywhere, and the old args[0..2]
479
+ // indexing read `--history` itself as the file.
480
+ //
481
+ // The generic `positionals()` cannot be reused: it drops every `-`-leading token,
482
+ // and a revision selector `-N` LOOKS exactly like a flag. That is the whole point
483
+ // of the first column `history get` prints, so `-N` is admitted and every other
484
+ // `-`-leading token is treated as an option.
485
+ function historyPositionals(args) {
486
+ const out = [];
487
+ for (let i = 0; i < args.length; i++) {
488
+ const a = args[i];
489
+ if (a === "--history" || a === "-m" || a === "--message") {
490
+ i++;
491
+ continue;
492
+ } // flag AND its value
493
+ if (a.startsWith("-") && !/^-\d+$/.test(a))
494
+ continue; // --json, --force, …
495
+ out.push(a);
496
+ }
497
+ return out;
498
+ }
499
+ function runHistory(args) {
500
+ const [sub, file, rev, ...extra] = historyPositionals(args);
501
+ if (!sub || !file)
502
+ fail(SUBHELP.history);
503
+ const historyPath = flag(args, "--history") ?? historyPathFor(file);
504
+ const json = args.includes("--json");
505
+ try {
506
+ if (sub === "save") {
507
+ // design §3.1/§9-Q4: `--author` and `--at` were withdrawn from the CLI (nothing
508
+ // outside tests ever passed either). Refusing beats ignoring for the same
509
+ // reason the retired verbs above refuse: a silently dropped `--author
510
+ // alice` discards precisely the value the caller went out of their way to
511
+ // type. Both stay on the library API (save({ author, at })).
512
+ for (const gone of ["--author", "--at"]) {
513
+ if (args.some((a) => a === gone || a.startsWith(`${gone}=`))) {
514
+ 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.)`);
515
+ }
516
+ }
517
+ // design §3.1: an empty save is a NO-OP. `save` is the one non-idempotent verb,
518
+ // so an agent retrying a save it is unsure landed must not lengthen the
519
+ // chain by a revision with no ops. `geml mcp` already gated its
520
+ // pre-write snapshot on this exact predicate (mcp.ts snapshot()); this is
521
+ // the same `isCurrent()`, not a second hash comparison.
522
+ if (existsSync(historyPath) && isCurrent(historyPath, file)) {
523
+ console.log(`already saved as ${listRevisions(historyPath)[0].id} (no changes)`);
524
+ return;
525
+ }
526
+ const r = save({
527
+ gemlPath: file,
528
+ historyPath,
529
+ summary: flag(args, "-m") ?? flag(args, "--message") ?? "",
530
+ });
531
+ console.log(`saved ${r.id}`);
532
+ }
533
+ else if (sub === "get") {
534
+ // Three tiers, split by how many addresses were given — the same rule the
535
+ // top-level `geml get` follows (design §1.2). Tier 2 takes a BLOCK
536
+ // selector inside the revision and reuses the top-level grammar verbatim
537
+ // (§10.1): a revision rebuilt is just a document's text, so there is no
538
+ // new algorithm here, and the two selector namespaces cannot collide —
539
+ // position is fixed and the lexis does not overlap (§10.2).
540
+ if (extra.length > 1) {
541
+ fail(`history get takes ONE revision selector and ONE block selector; got ${extra.length + 1} positionals after the file`, 2);
542
+ }
543
+ if (rev === undefined) {
544
+ // Newest-first, with each row's selector in the first column (`0` for
545
+ // the tip, then `-1`, `-2`, …) so the output is copy-paste into `get`,
546
+ // `restore` and `revert --rev` alike.
547
+ const revs = listRevisions(historyPath);
548
+ if (json) {
549
+ console.log(JSON.stringify(revs, null, 2));
550
+ }
551
+ else {
552
+ for (const r of revs) {
553
+ const sel = r.current ? "0" : `-${r.offset}`;
554
+ console.log(`${sel.padEnd(7)} ${r.id} ${r.author ?? "-"} ${r.summary ?? ""}`.trimEnd());
555
+ }
556
+ }
557
+ }
558
+ else {
559
+ // resolveContent() routes through the ONE selector grammar
560
+ // (resolveRevision) that the list above prints — see its comment for
561
+ // what happened the last time that was written twice.
562
+ const { id, text } = resolveContent(historyPath, rev);
563
+ const blockSel = extra[0];
564
+ if (blockSel === undefined) {
565
+ if (json)
566
+ console.log(JSON.stringify({ id, text }, null, 2));
567
+ else
568
+ process.stdout.write(text);
569
+ }
570
+ else {
571
+ // Tier 2 (§10.1). Cardinality and the flag rules are the top-level
572
+ // ones, checked here because this tier has its own argument list.
573
+ const headOnly = args.includes("--head");
574
+ const bodyOnly = args.includes("--body");
575
+ const introOnly = args.includes("--intro");
576
+ const named = [headOnly && "--head", introOnly && "--intro", bodyOnly && "--body"].filter(Boolean);
577
+ const part = headOnly ? "head" : bodyOnly ? "body" : introOnly ? "intro" : "whole";
578
+ if (named.length > 1)
579
+ fail(`${named.join(" and ")} are mutually exclusive — they name different parts of one block`, 2);
580
+ if (json && named.length > 0) {
581
+ fail(`--json cannot be combined with ${named[0]} — --json returns the model node, which has no sub-node for one part of a block`, 2);
582
+ }
583
+ const { units, all } = selectUnits(text, file, blockSel, `revision ${id}`);
584
+ if (json) {
585
+ // §3.2's tier table: the revision id travels with the block, so the
586
+ // caller can tell WHICH version it is holding.
587
+ const nodes = units.map((u) => unitNode(text, file, u, all));
588
+ console.log(JSON.stringify({ id, block: units.length === 1 ? nodes[0] : nodes }, null, 2));
589
+ }
590
+ else {
591
+ if (units.length > 1)
592
+ reportMatches(units[0].type ?? "", units);
593
+ for (const u of units)
594
+ process.stdout.write(sliceUnit(text, u.span, part));
595
+ }
596
+ }
597
+ }
598
+ }
599
+ else if (sub === "restore") {
600
+ if (!rev)
601
+ fail("usage: geml history restore <file.geml> <revision> [--force]");
602
+ restore({ historyPath, gemlPath: file, revision: rev, write: true, force: args.includes("--force") });
603
+ console.log(`restored ${file} to ${rev}`);
604
+ }
605
+ else if (sub === "verify") {
606
+ const res = verify(historyPath, file);
607
+ for (const e of res.errors)
608
+ console.error(`error: ${e}`);
609
+ for (const w of res.warnings)
610
+ console.error(`warning: ${w}`);
611
+ console.log(`verify: ${res.ok ? "OK" : "FAILED"} (${res.checked} revisions reconstructed & hashed)`);
612
+ if (!res.ok)
613
+ process.exit(1);
614
+ }
615
+ else {
616
+ fail(`unknown history subcommand: ${sub}. Run 'geml --help'.`);
617
+ }
618
+ }
619
+ catch (e) {
620
+ fail(historyError(e, file, historyPath));
621
+ }
622
+ }
623
+ function runTransform(argv) {
624
+ const out = flag(argv, "-o") ?? flag(argv, "--out");
625
+ const fromRaw = flag(argv, "--from");
626
+ const toRaw = flag(argv, "--to");
627
+ // `--to html --fragment`: body-only markup for embedding in an existing
628
+ // layout (library parity: RenderOptions.fragment). Consumed here so it can
629
+ // be rejected on any other target — a discarded flag is a silent lie.
630
+ const fragIdx = argv.indexOf("--fragment");
631
+ const fragment = fragIdx >= 0;
632
+ if (fragment)
633
+ argv.splice(fragIdx, 1);
634
+ // Same `--root` as `check`, and for the same reason: cross-document resolution is
635
+ // fail-closed at the document's own directory, so a reference that climbs out of
636
+ // it needs the tree's root named. Without this the transform silently ignored the
637
+ // flag — a document whose embeds `check --root .` validated still rendered with
638
+ // every one of them unresolved, which reads as "transclusion does not work".
639
+ const root = flag(argv, "--root");
640
+ if (argv.includes("--root") && root === undefined)
641
+ fail("--root needs a directory", 2);
642
+ // Dispatch only lands here when argv[0] is `-` or carries a path character,
643
+ // and `positionals` keeps both — so there is always a file. A guard for the
644
+ // empty case would read as a possibility that does not exist; a caller who
645
+ // writes `geml --to md` is told `unknown command '--to'` at the door.
646
+ const file = positionals(argv, ["-o", "--out", "--from", "--to", "--root"])[0];
647
+ // A bare `--to`/`--from` (no following value) is a mistyped flag, not a
648
+ // silent fall-through to the default — flag() would return undefined and we
649
+ // must not quietly ignore it.
650
+ if (argv.includes("--from") && fromRaw === undefined)
651
+ fail("--from needs a format (geml | md | json)", 2);
652
+ if (argv.includes("--to") && toRaw === undefined)
653
+ fail("--to needs a format (json | html | md | geml)", 2);
654
+ // Input format: an explicit --from wins (for any input, file or stdin), else
655
+ // the file extension, else GEML (covers .geml, unknown extensions, and stdin).
656
+ let inFmt;
657
+ if (fromRaw !== undefined) {
658
+ if (fromRaw !== "geml" && fromRaw !== "md" && fromRaw !== "json") {
659
+ fail(`--from: unknown input format '${fromRaw}' (want geml | md | json)`, 2);
660
+ }
661
+ inFmt = fromRaw;
662
+ }
663
+ else if (/\.(md|markdown)$/i.test(file)) {
664
+ inFmt = "md";
665
+ }
666
+ else if (/\.json$/i.test(file)) {
667
+ inFmt = "json";
668
+ }
669
+ else {
670
+ inFmt = "geml";
671
+ }
672
+ // Output format: an explicit --to wins, else md input -> geml, geml -> json.
673
+ let outFmt;
674
+ if (toRaw !== undefined) {
675
+ if (toRaw !== "json" && toRaw !== "html" && toRaw !== "md" && toRaw !== "geml") {
676
+ fail(`--to: unknown output format '${toRaw}' (want json | html | md | geml)`, 2);
677
+ }
678
+ outFmt = toRaw;
679
+ }
680
+ else {
681
+ outFmt = inFmt === "geml" ? "json" : "geml"; // geml->json; md/json->geml
682
+ }
683
+ if (fragment && outFmt !== "html")
684
+ fail("--fragment only applies to --to html", 2);
685
+ const src = readInput(file);
686
+ // md -> geml is a direct projection, not a parse/serialize round-trip: emit
687
+ // the converter's GEML verbatim (the old `convert`; no diagnostics to raise).
688
+ if (inFmt === "md" && outFmt === "geml") {
689
+ const { geml, notes } = mdToGeml(src);
690
+ writeOut(geml, out);
691
+ for (const n of notes)
692
+ console.error(`note: ${n}`);
693
+ return;
694
+ }
695
+ // Otherwise load a document — a md input is converted to GEML first — and
696
+ // project it to the target.
697
+ let notes = [];
698
+ let doc;
699
+ if (inFmt === "json") {
700
+ doc = loadModelJson(src, file); // the inverse of `--to json`
701
+ }
702
+ else if (inFmt === "md") {
703
+ const conv = mdToGeml(src);
704
+ notes = conv.notes;
705
+ doc = parse(conv.geml, { ...docOpts(file, root), self: file === "-" ? undefined : basename(file) });
706
+ }
707
+ else {
708
+ doc = parse(src, { ...docOpts(file, root), self: file === "-" ? undefined : basename(file) });
709
+ }
710
+ let output;
711
+ switch (outFmt) {
712
+ case "json":
713
+ output = JSON.stringify(doc, null, 2) + "\n"; // == the former bare parse
714
+ break;
715
+ case "geml":
716
+ output = serialize(doc); // == the former `fmt`
717
+ break;
718
+ case "html":
719
+ output = renderHtml(doc, {
720
+ source: file === "-" ? "stdin" : basename(file),
721
+ fragment,
722
+ // geml-code-graph embeds load + parse sibling codemap docs on demand.
723
+ loadDoc: resolverFor(file, root),
724
+ parseDoc: (s) => parse(s, { ...docOpts(file, root) }),
725
+ });
726
+ break;
727
+ case "md": {
728
+ const r = gemlToMd(doc); // == the former `export`
729
+ notes = notes.concat(r.notes);
730
+ output = r.md;
731
+ break;
732
+ }
733
+ }
734
+ writeOut(output, out);
735
+ for (const n of notes)
736
+ console.error(`note: ${n}`);
737
+ for (const d of doc.diagnostics)
738
+ console.error(`${d.severity}: ${d.message} (line ${d.line})`);
739
+ if (doc.diagnostics.some((d) => d.severity === "error"))
740
+ process.exit(1);
741
+ }
742
+ // Load a document-model JSON (the exact output of `--to json`) back into a
743
+ // Document, so `--from json --to geml` is the inverse of a prior `--to json`.
744
+ // The model is trusted as-is — no re-parse — so a clean round-trip is byte-stable
745
+ // with `--to geml`. Anything that is not a document model is refused, and any
746
+ // carried diagnostics are preserved (so a broken doc's JSON stays flagged).
747
+ function loadModelJson(src, file) {
748
+ let obj;
749
+ try {
750
+ obj = JSON.parse(src);
751
+ }
752
+ catch (e) {
753
+ fail(`--from json: ${file === "-" ? "stdin" : file} is not valid JSON (${e.message})`, 1);
754
+ }
755
+ const d = obj;
756
+ if (!d || typeof d !== "object" || d.kind !== "document" || !Array.isArray(d.children)) {
757
+ fail(`--from json: not a GEML document-model JSON (expected {"kind":"document","children":[…]})`, 1);
758
+ }
759
+ const doc = d;
760
+ if (!Array.isArray(doc.diagnostics))
761
+ doc.diagnostics = [];
762
+ return doc;
763
+ }
764
+ // Write to `-o out` (with a `wrote` note on stderr) or to stdout.
765
+ function writeOut(text, out) {
766
+ if (out) {
767
+ writeFileSync(out, text);
768
+ console.error(`wrote ${out}`);
769
+ }
770
+ else
771
+ process.stdout.write(text);
772
+ }
773
+ // Output-target rule shared by the MUTATION verbs (set, and — soon — add,
774
+ // delete, rename, revert): a real file input with no `-o` is edited IN PLACE
775
+ // (it's the obvious target, and it's what lets an agent chain edits without
776
+ // re-reading a path back out of stdout); stdin (`file === "-"`) has no such
777
+ // target, so it falls back to stdout. `-o` always wins when given: `-o -`
778
+ // explicitly requests stdout (even for a file input), `-o <path>` writes
779
+ // there. Every write announces itself with `wrote <path>` on stderr; stdout
780
+ // stays reserved for the document bytes so it's still pipeable.
781
+ function resolveOutTarget(file, oFlag) {
782
+ const toFile = (path) => ({
783
+ write(text) { writeFileSync(path, text); console.error(`wrote ${path}`); },
784
+ });
785
+ const toStdout = { write(text) { process.stdout.write(text); } };
786
+ if (oFlag === "-")
787
+ return toStdout;
788
+ if (oFlag !== undefined)
789
+ return toFile(oFlag);
790
+ if (file === "-")
791
+ return toStdout;
792
+ return toFile(file);
793
+ }
794
+ // Positional args (a file, an id) are the non-flag tokens that aren't the value
795
+ // of a value-taking flag. `-` (stdin) is a positional, not a flag. An id may be
796
+ // written `#id` or `id`; a leading `-` never begins an id, so this stays
797
+ // unambiguous. `valued` lists the flags that consume the following token.
798
+ function positionals(args, valued) {
799
+ const out = [];
800
+ for (let i = 0; i < args.length; i++) {
801
+ const a = args[i];
802
+ if (valued.includes(a)) {
803
+ i++;
804
+ continue;
805
+ } // skip the flag *and* its value
806
+ if (a === "-") {
807
+ out.push(a);
808
+ continue;
809
+ }
810
+ if (a.startsWith("-"))
811
+ continue; // a bare flag (e.g. --json)
812
+ out.push(a);
813
+ }
814
+ return out;
815
+ }
816
+ // Resolve a block SELECTOR to an id. Three spellings address the same block:
817
+ //
818
+ // `#intro` / `intro` the id — the CANONICAL address
819
+ // `## Getting Started` the heading LINE, copied out of the document
820
+ // `##Getting Started` …the space after the `#` run is optional
821
+ //
822
+ // Why more than one form: the id is what `[[#id]]` references, codemap tables
823
+ // and URL fragments (§0.6) all carry, so it must stay accepted verbatim — an id
824
+ // copied out of a reference or out of `geml get <file>` has to work. But a
825
+ // heading's id is AUTO-DERIVED from its text (`## API 设计 (v1)` → `#api-设计-v1`),
826
+ // and nobody can be expected to hand-derive that slug for a heading they can
827
+ // read on screen. So the heading line itself is accepted too.
828
+ //
829
+ // Resolution order, first match wins:
830
+ // 1. the id, exactly — a pasted id is NEVER reinterpreted as prose. (When a
831
+ // heading's TEXT happens to equal another block's ID, the id wins.)
832
+ // 2. the exact heading LINE: `#` count AND text both match.
833
+ // 3. the text alone, at any level — a heading remembered at the wrong depth
834
+ // still resolves while its text is unique.
835
+ // 4. text shared by several headings: the `#` count picks one, or the
836
+ // candidates are listed. Never guessed at.
837
+ function resolveSelector(source, file, raw) {
838
+ const bare = raw.replace(/^#/, "");
839
+ const m = /^(#{1,6})[ \t]*(.+?)[ \t]*$/.exec(raw);
840
+ if (!m)
841
+ return bare; // not a `#`-run form: an id, verbatim
842
+ // 1. The id is canonical and always wins. Checked without a parse, so the
843
+ // common `get #id` stays a byte-slice on a document with diagnostics.
844
+ if (blockSpans(source).has(bare))
845
+ return bare;
846
+ const level = m[1].length;
847
+ const want = m[2];
848
+ const doc = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
849
+ const heads = doc.ids.flatMap((id) => {
850
+ const site = findBlockSite(doc.children, id);
851
+ const b = site?.siblings[site.index];
852
+ return b?.kind === "heading" ? [{ id, level: b.level, text: b.text.trim() }] : [];
853
+ });
854
+ // 2. exact line — what the caller actually typed.
855
+ const line = heads.find((h) => h.level === level && h.text === want);
856
+ if (line)
857
+ return line.id;
858
+ // 3. the text alone (exact, then case-insensitive).
859
+ let byText = heads.filter((h) => h.text === want);
860
+ if (!byText.length) {
861
+ const lc = want.toLocaleLowerCase();
862
+ byText = heads.filter((h) => h.text.toLocaleLowerCase() === lc);
863
+ }
864
+ if (byText.length === 1)
865
+ return byText[0].id;
866
+ // 4. shared text: the level disambiguates, else show the candidates.
867
+ if (byText.length > 1) {
868
+ const atLevel = byText.filter((h) => h.level === level);
869
+ if (atLevel.length === 1)
870
+ return atLevel[0].id;
871
+ const list = byText.map((h) => ` #${h.id} (h${h.level})`).join("\n");
872
+ fail(`\`${want}\` matches ${byText.length} headings — address one by its id:\n${list}`, 1);
873
+ }
874
+ // Nothing matched. A lone `#` with no whitespace was almost certainly meant as
875
+ // an id, so hand it back and let the caller's own `no block with id` error
876
+ // stand — the precise diagnosis for a typo'd id. Only a heading-SHAPED
877
+ // selector gets the heading-flavoured message.
878
+ if (level === 1 && !/\s/.test(bare))
879
+ return bare;
880
+ fail(`no id or heading matches \`${raw}\` — run \`geml get ${file === "-" ? "-" : file}\` to list every addressable id`, 1);
881
+ }
882
+ // `geml get <file>` with no id: list every addressable id — the document's
883
+ // table of contents. Default output is one id per line with its kind (and, for
884
+ // a heading, its level and text); `--json` is a machine-readable array so an
885
+ // agent can pick its next `get #id` target. Ids are listed in document order
886
+ // (the registration order parse() records), covering the same set `get #id`
887
+ // resolves against: typed blocks and headings. A `[^id]` reference names one
888
+ // of those (§5.2); the `[^id]: text` definition line was withdrawn.
889
+ function listIds(source, file, json) {
890
+ const where = file === "-" ? "stdin" : file;
891
+ const all = addressedUnits(source);
892
+ const doc = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
893
+ const rows = all.map((a) => {
894
+ const u = a.unit;
895
+ const row = {
896
+ address: shortestAddress(a, all),
897
+ kind: u.kind === "block" ? u.type ?? "block" : u.kind,
898
+ lines: [u.span.start + 1, u.span.end],
899
+ };
900
+ // §6.3: EVERY id-less block is flagged, including one whose address works
901
+ // only because its type happens to be unique (`=== meta`) — that it has no
902
+ // id yet is precisely the fact you might want to act on (§5.2).
903
+ if (u.id === undefined)
904
+ row.anon = true;
905
+ else
906
+ row.id = u.id;
907
+ if (u.kind === "heading") {
908
+ row.level = u.level;
909
+ row.text = u.text;
910
+ }
911
+ // `.footnote` is authored, not synthesized (the `[^id]: text` definition
912
+ // line was withdrawn) — but it still marks a block meant as a footnote.
913
+ if (u.id !== undefined) {
914
+ const site = findBlockSite(doc.children, u.id);
915
+ const b = site?.siblings[site.index];
916
+ if (b?.kind === "block" && b.classes.includes("footnote"))
917
+ row.footnote = true;
918
+ }
919
+ return row;
920
+ });
921
+ // §6.6: the empty document is a legitimate empty answer to "list everything",
922
+ // not a lookup failure — exit 0, and `--json` prints `[]` so a `| jq length`
923
+ // over a prose-only document does not blow up.
924
+ if (json) {
925
+ console.log(JSON.stringify(rows, null, 2));
926
+ return;
927
+ }
928
+ if (rows.length === 0) {
929
+ console.error(`no addressable blocks in ${where}`);
930
+ return;
931
+ }
932
+ const addrW = Math.max(...rows.map((r) => r.address.length));
933
+ const kindW = Math.max(...rows.map((r) => r.kind.length));
934
+ // The line range belongs on EVERY row, headings included. It used to be the
935
+ // alternative to a heading's text, so the one kind of block whose range you
936
+ // most want — a whole section — was the one kind that did not print it, and
937
+ // `L11-493` is itself an address you can paste back into `get`. The heading's
938
+ // text follows it rather than replacing it.
939
+ const lineW = Math.max(...rows.map((r) => `L${r.lines[0]}-${r.lines[1]}`.length));
940
+ for (const r of rows) {
941
+ const mark = r.kind === "heading" ? `h${r.level}` : r.anon ? "anon" : "";
942
+ const span = `L${r.lines[0]}-${r.lines[1]}`;
943
+ const tail = r.kind === "heading" ? `${span.padEnd(lineW)} ${r.text ?? ""}` : span;
944
+ const line = `${r.address.padEnd(addrW)} ${r.kind.padEnd(kindW)} ${mark.padEnd(4)} ${tail}`
945
+ + (r.footnote ? " footnote" : "");
946
+ console.log(line.trimEnd());
947
+ }
948
+ }
949
+ // `geml get <file.geml|-> #id [--json]` — print ONE block, addressed by id,
950
+ // without loading the rest of the document into context. Default output is the
951
+ // block's exact source bytes: a typed block's full `=== … ===` span, a
952
+ // footnote's line, or — for a heading — its whole SECTION (heading line through
953
+ // the line before the next same-or-higher heading). `--json` covers the same
954
+ // content: a block/footnote id prints its document-model node; a heading id
955
+ // prints a section envelope `{kind:"section", id, level, blocks:[heading,
956
+ // …siblings up to the boundary]}`.
957
+ // `geml get <file> '=== <type>'` — address a block by its TYPE. One match is
958
+ // the block itself; several are LISTED with their line ranges rather than
959
+ // guessed between, so a document with three notes answers "which one" instead
960
+ // of failing. The uniqueness that makes `=== meta` work is checked here, at
961
+ // resolve time — nothing in the format has to promise a document holds only one.
962
+ // Every block of `type` in document order, nested flow children included —
963
+ // exactly the span scan's reach and order, so the k-th scan match and the k-th
964
+ // model node are the same block. That correspondence is what lets an ANONYMOUS
965
+ // block's `--json` find its node without an id to look it up by.
966
+ function blocksOfType(blocks, type) {
967
+ const hits = [];
968
+ const walk = (list) => {
969
+ for (const b of list) {
970
+ if (b.kind === "block") {
971
+ if (b.type === type)
972
+ hits.push(b);
973
+ if (b.children)
974
+ walk(b.children);
975
+ }
976
+ }
977
+ };
978
+ walk(blocks);
979
+ return hits;
980
+ }
981
+ // A unit's index among the units of its own type, for the positional lookup above.
982
+ function typeIndex(all, u) {
983
+ return all.filter((a) => a.unit.type === u.type).findIndex((a) => a.unit === u);
984
+ }
985
+ // Resolve a NON-list selector to the units it matches, or fail with the reason.
986
+ // `where` names the haystack for the error messages — a file for `geml get`, a
987
+ // revision for `geml history get`'s tier 2. Shared by both so the one selector
988
+ // grammar has one implementation: history's design §10.1 asks for exactly this,
989
+ // and its §3.2 records what happened the last time a selector grammar was
990
+ // written twice (the printed selectors stopped being readable back).
991
+ function selectUnits(source, file, rawSel, where) {
992
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
993
+ // Callers handle the empty selector themselves (list for `get`, usage error
994
+ // for `set`); reaching here with one is a caller bug surfaced as usage.
995
+ if (sel.form === "list")
996
+ fail(`no selector given — run \`geml get ${where}\` to list addressable blocks`, 2);
997
+ if (sel.form === "attr") {
998
+ // §7: the wording says "not implemented yet", not "braces are meaningless" —
999
+ // §2 declares attribute keys as part of the model, so implementing them
1000
+ // later fills in a declared slot rather than reversing this message.
1001
+ 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);
1002
+ }
1003
+ const all = addressedUnits(source);
1004
+ if (sel.form === "content") {
1005
+ const hit = matchContent(sel, all);
1006
+ if (!hit.ok) {
1007
+ if (hit.why === "wrong-type") {
1008
+ // §3.3: the type prefix is a CHECK. Ignoring a wrong one would make it
1009
+ // a decoration that is allowed to lie, and would silently accept a
1010
+ // hand-edited address.
1011
+ fail(`\`@${sel.hex}\` addresses a \`${hit.found}\` block, not \`${sel.type}\` — drop the type prefix to address it by content alone`, 1);
1012
+ }
1013
+ const suffix = sel.nth ? `~${sel.nth}` : "";
1014
+ 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);
1015
+ }
1016
+ return { units: [hit.unit], all };
1017
+ }
1018
+ if (sel.form === "line") {
1019
+ const hit = matchLine(sel, all);
1020
+ // A range that straddles two blocks contains no single unit — say which
1021
+ // case it is, because "no match" reads like "your line number is wrong"
1022
+ // when the real answer is "that range is not one block".
1023
+ if (!hit) {
1024
+ const span = sel.from === sel.to ? `L${sel.from}` : `L${sel.from}-${sel.to}`;
1025
+ fail(`no block contains ${span} in ${where} — a position selector names ONE block, so a range spanning two of them (or a line past the end) has no answer${discoveryHint(where)}`, 1);
1026
+ }
1027
+ return { units: [hit], all };
1028
+ }
1029
+ if (sel.form === "type") {
1030
+ const hits = matchType(sel.type, all);
1031
+ if (!hits.length)
1032
+ fail(`no \`${sel.type}\` block in ${where}${discoveryHint(where)}`, 1);
1033
+ return { units: hits, all };
1034
+ }
1035
+ // `#id` / bare id / a pasted `## Heading` line — resolveSelector needs a parse
1036
+ // to match heading TEXT, so it stays the one path that reaches the model.
1037
+ const id = resolveSelector(source, file, sel.raw);
1038
+ const unit = all.find((a) => a.unit.id === id)?.unit;
1039
+ // Bare `no block with id \`x\`` — the phrasing every caller of a missing id
1040
+ // has always seen, and which `set`'s own tests pin. `where` is appended only
1041
+ // when it is NOT the file the caller already named (a revision), so the
1042
+ // common case reads the same as before this selector grammar existed.
1043
+ if (!unit)
1044
+ fail(`no block with id \`${id}\`${where.startsWith("revision ") ? ` in ${where}` : ""}`, 1);
1045
+ return { units: [unit], all };
1046
+ }
1047
+ // The document-model node for one unit; a heading yields its SECTION envelope,
1048
+ // so --json covers the same content as the raw span. `kind:"section"` lets a
1049
+ // consumer branch — every other unit yields the single node (the model is flat).
1050
+ function unitNode(source, file, unit, all) {
1051
+ const doc = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1052
+ if (unit.id !== undefined) {
1053
+ const site = findBlockSite(doc.children, unit.id);
1054
+ if (!site)
1055
+ fail(`no block with id \`${unit.id}\``, 1);
1056
+ const block = site.siblings[site.index];
1057
+ if (block.kind !== "heading")
1058
+ return block;
1059
+ const end = sectionEndIndex(site.siblings, site.index);
1060
+ return { kind: "section", id: block.id, level: block.level, blocks: site.siblings.slice(site.index, end) };
1061
+ }
1062
+ const node = blocksOfType(doc.children, unit.type ?? "")[typeIndex(all, unit)];
1063
+ if (!node)
1064
+ fail(`could not locate the \`${unit.type}\` block in the document model`, 1);
1065
+ return node;
1066
+ }
1067
+ // stderr line for an N-match selector: content stays on stdout, so a redirect
1068
+ // captures document bytes only, and the caller still learns how many it got (§5).
1069
+ function reportMatches(type, units) {
1070
+ const at = units.map((u) => `L${u.span.start + 1}-${u.span.end}${u.id ? ` #${u.id}` : ""}`).join(" · ");
1071
+ console.error(`${units.length} \`${type}\` blocks (${at})`);
1072
+ }
1073
+ // `geml get <file.geml|-> [<selector>] [--head|--body] [--json]` — read the
1074
+ // document's addressable structure, or one/several blocks out of it.
1075
+ //
1076
+ // The selector is a FILTER (§2 of the get/set selector design): no selector
1077
+ // LISTS every addressable block with its shortest unique address; `#id` /
1078
+ // `## Heading` / `=== type@<hex>` name at most one; `=== type` matches 0..N.
1079
+ // Cardinality is uniform (§5): 0 → exit 1, 1 → the content, N → N contents in
1080
+ // document order with the count on stderr. `--head`/`--body` narrow to one part
1081
+ // of each match, and every flag combination that used to be half-honoured is
1082
+ // now a usage error (§7) — a discarded flag is a command that quietly did
1083
+ // something else.
1084
+ // `geml list <file>` — the same listing `get` prints with no selector, under
1085
+ // the name the MCP surface has always used for it (`geml_list`). One operation
1086
+ // had two names across two surfaces; this makes the CLI agree with the tool
1087
+ // descriptions agents are already reading. `get <file>` keeps working.
1088
+ function runList(args) {
1089
+ const [file, extra] = positionals(args, ["--root"]);
1090
+ if (!file)
1091
+ fail(SUBHELP.list);
1092
+ // `list` IS the empty filter, so a selector here means the caller wanted
1093
+ // `get`. Naming the command they meant beats ignoring the argument.
1094
+ if (extra !== undefined) {
1095
+ fail(`\`list\` takes no selector — it lists every block. To read one: \`geml get ${file} '${extra}'\``, 2);
1096
+ }
1097
+ listIds(readInput(file), file, args.includes("--json"));
1098
+ }
1099
+ // Walk for `.geml` files. Depth-first, sorted, so output order is stable across
1100
+ // platforms — a listing that reorders between machines is a listing nobody can
1101
+ // diff. Hidden directories and `node_modules` are skipped: a search verb that
1102
+ // dredges up vendored copies trains people to stop reading its output.
1103
+ function gemlFilesUnder(path, out) {
1104
+ let dir = false;
1105
+ try {
1106
+ dir = statSync(path).isDirectory();
1107
+ }
1108
+ catch {
1109
+ return;
1110
+ }
1111
+ if (!dir) {
1112
+ if (path.endsWith(".geml"))
1113
+ out.push(path);
1114
+ return;
1115
+ }
1116
+ for (const e of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
1117
+ if (e.name.startsWith(".") || e.name === "node_modules")
1118
+ continue;
1119
+ gemlFilesUnder(join(path, e.name), out);
1120
+ }
1121
+ }
1122
+ // `geml find <pattern> [path…]` — search block CONTENT, print ADDRESSES.
1123
+ //
1124
+ // This is the half of the workflow that had no verb. `geml list` says what is
1125
+ // addressable and `geml get` reads one block, but "which block mentions X" fell
1126
+ // back to `grep -n`, which answers in line numbers — and a line number stops
1127
+ // being true the moment anything above it changes. `codemap find` already
1128
+ // resolves a substring to `doc#id` for symbols; this is the same move for prose.
1129
+ function runFind(args) {
1130
+ const pos = positionals(args, []);
1131
+ const pattern = pos[0];
1132
+ if (pattern === undefined)
1133
+ fail(SUBHELP.find);
1134
+ const sensitive = args.includes("--case");
1135
+ const withLine = args.includes("--head");
1136
+ const json = args.includes("--json");
1137
+ const needle = sensitive ? pattern : pattern.toLowerCase();
1138
+ const files = [];
1139
+ for (const p of pos.slice(1).length ? pos.slice(1) : ["."])
1140
+ gemlFilesUnder(p, files);
1141
+ const hits = [];
1142
+ for (const f of files) {
1143
+ let source;
1144
+ // An unreadable file mid-walk must not abort the search — report nothing
1145
+ // for it and keep going, the way every search tool behaves.
1146
+ try {
1147
+ source = readFileSync(f, "utf8");
1148
+ }
1149
+ catch {
1150
+ continue;
1151
+ }
1152
+ const all = addressedUnits(source);
1153
+ // Match by LINE, then resolve each line to the innermost unit holding it —
1154
+ // exactly what the `L` selector does, so `find` is `grep` composed with
1155
+ // `L` rather than a second notion of "which block is this in". Testing the
1156
+ // units directly instead would report every ancestor: a heading's span
1157
+ // covers its whole section, so the h1 spans the file and would match every
1158
+ // search ever run.
1159
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
1160
+ const seen = new Map();
1161
+ for (let i = 0; i < lines.length; i++) {
1162
+ const raw = lines[i];
1163
+ if (!(sensitive ? raw : raw.toLowerCase()).includes(needle))
1164
+ continue;
1165
+ const unit = matchLine({ form: "line", from: i + 1, to: i + 1 }, all);
1166
+ if (!unit)
1167
+ continue;
1168
+ const a = all.find((x) => x.unit === unit);
1169
+ const address = shortestAddress(a, all);
1170
+ // One unit, one hit, however many lines inside it matched — a search that
1171
+ // reports the same block eight times is a search you stop reading.
1172
+ const key = `${a.unit.span.start}:${a.unit.span.end}`;
1173
+ if (seen.has(key))
1174
+ continue;
1175
+ const hit = {
1176
+ file: f,
1177
+ address,
1178
+ kind: unit.kind === "block" ? unit.type ?? "block" : unit.kind,
1179
+ lines: [unit.span.start + 1, unit.span.end],
1180
+ };
1181
+ if (withLine)
1182
+ hit.line = raw.trim();
1183
+ seen.set(key, hit);
1184
+ hits.push(hit);
1185
+ }
1186
+ }
1187
+ if (json) {
1188
+ console.log(JSON.stringify(hits, null, 2));
1189
+ }
1190
+ else {
1191
+ for (const h of hits) {
1192
+ // Two columns, `file` then the address EXACTLY as the listing prints it,
1193
+ // so a hit is `geml get <col1> '<col2>'` with no editing. Not glued into
1194
+ // one `file#addr` token: an id-less block's address is `=== code@a3f9`,
1195
+ // which has a space in it and can never be one token — and a format that
1196
+ // is only pasteable for half the rows is worse than one that is uniform.
1197
+ const row = `${h.file}\t${h.address}`;
1198
+ console.log(withLine && h.line !== undefined ? `${row}\t${h.line}` : row);
1199
+ }
1200
+ }
1201
+ // Exit 1 on no match, like grep: it makes `if geml find …; then` mean what a
1202
+ // shell author expects. An empty `--json` array still prints, so a JSON
1203
+ // consumer sees `[]` rather than nothing.
1204
+ if (!hits.length)
1205
+ process.exit(1);
1206
+ }
1207
+ function runGet(args) {
1208
+ const json = args.includes("--json");
1209
+ const headOnly = args.includes("--head");
1210
+ const bodyOnly = args.includes("--body");
1211
+ const introOnly = args.includes("--intro");
1212
+ const view = args.includes("--view");
1213
+ const [file, rawSel] = positionals(args, ["--root"]);
1214
+ if (!file)
1215
+ fail(SUBHELP.get);
1216
+ const parts = [headOnly && "--head", introOnly && "--intro", bodyOnly && "--body"].filter(Boolean);
1217
+ if (parts.length > 1)
1218
+ fail(`${parts.join(" and ")} are mutually exclusive — they name different parts of one block`, 2);
1219
+ const partFlag = parts[0];
1220
+ if (json && partFlag) {
1221
+ fail(`--json cannot be combined with ${partFlag} — --json returns the model node, which has no sub-node for one part of a block`, 2);
1222
+ }
1223
+ const part = headOnly ? "head" : bodyOnly ? "body" : introOnly ? "intro" : "whole";
1224
+ // One read: stdin can only be consumed once, and the selector resolver needs
1225
+ // the same bytes the slice below works on.
1226
+ const source = readInput(file);
1227
+ const where = file === "-" ? "stdin" : file;
1228
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
1229
+ if (sel.form === "list") {
1230
+ // §5.1: nothing here to narrow, and ignoring the flag would make
1231
+ // `get f --head` print byte-for-byte what `get f` prints.
1232
+ if (partFlag) {
1233
+ fail(`${partFlag} names part of ONE block, so it needs a selector — run \`geml list ${where}\` to see what to address`, 2);
1234
+ }
1235
+ listIds(source, file, json);
1236
+ return;
1237
+ }
1238
+ const { units, all } = selectUnits(source, file, rawSel, where);
1239
+ // The chain is composed with `/` — relJoinPath's rule, and `src=` values are
1240
+ // always `/`-separated — so normalize the PLATFORM path at this boundary. On
1241
+ // Windows `sub\host.geml` otherwise has no directory as far as relDirPath can
1242
+ // tell, and a relative `src=` resolves against the wrong base.
1243
+ const startDoc = where.replace(/\\/g, "/");
1244
+ const viewRoot = flag(args, "--root") ?? (relDirPath(startDoc) || ".");
1245
+ if (json) {
1246
+ // §7: N matches yield N model nodes. The old `{kind:"blocks",
1247
+ // matches:[{lines}]}` coordinate envelope is gone — it answered "where are
1248
+ // they" when the question is "what are they" (§9 change 2).
1249
+ let nodes;
1250
+ try {
1251
+ nodes = units.flatMap((u) => {
1252
+ if (!view)
1253
+ return [unitNode(source, file, u, all)];
1254
+ return viewResolve(source, startDoc, u, viewRoot).map((res) => {
1255
+ const node = unitNode(res.text, res.doc, res.unit, res.all);
1256
+ // Provenance is mandatory (§4): the node's references and relative
1257
+ // paths resolve against ITS document, not the one asked about. A
1258
+ // whole-document target has no `#`, so it carries `doc` alone.
1259
+ if (res.from !== "") {
1260
+ const h = res.from.lastIndexOf("#");
1261
+ node["from"] = h < 0 ? { doc: res.from }
1262
+ : { doc: res.from.slice(0, h), id: res.from.slice(h + 1) };
1263
+ }
1264
+ return node;
1265
+ });
1266
+ });
1267
+ }
1268
+ catch (e) {
1269
+ if (e instanceof ViewError)
1270
+ fail(e.message, 1);
1271
+ throw e;
1272
+ }
1273
+ console.log(JSON.stringify(nodes.length === 1 ? nodes[0] : nodes, null, 2));
1274
+ return;
1275
+ }
1276
+ if (units.length > 1)
1277
+ reportMatches(units[0].type ?? "", units);
1278
+ if (view) {
1279
+ // All-or-nothing (§3.3): resolve EVERYTHING before writing a byte, so a
1280
+ // chain that breaks halfway cannot leave a partial read on stdout for a
1281
+ // caller that ignores the exit code. Partial scenery is not scenery.
1282
+ const out = [];
1283
+ const notes = [];
1284
+ try {
1285
+ for (const u of units) {
1286
+ for (const res of viewResolve(source, startDoc, u, viewRoot)) {
1287
+ if (res.from !== "")
1288
+ notes.push(`view: ${rawSel} -> ${res.from}`);
1289
+ out.push(sliceUnit(res.text, res.unit.span, part));
1290
+ }
1291
+ }
1292
+ }
1293
+ catch (e) {
1294
+ // A chain that cannot reach an entity block is a failed READ, reported the
1295
+ // way `get` reports a selector that matches nothing: one line, exit 1.
1296
+ if (e instanceof ViewError)
1297
+ fail(e.message, 1);
1298
+ throw e;
1299
+ }
1300
+ for (const n of notes)
1301
+ console.error(n);
1302
+ process.stdout.write(out.join(""));
1303
+ return;
1304
+ }
1305
+ // A block has no intro: the region is "what this heading says before its
1306
+ // first subheading", and only a heading has subheadings. Silently handing
1307
+ // back the body instead would answer a question that was not asked.
1308
+ for (const u of units) {
1309
+ if (part === "intro" && u.kind !== "heading") {
1310
+ fail(`--intro names a heading's opening region, and \`${rawSel}\` is a \`${u.type ?? u.kind}\` block — use --body for a block's content`, 2);
1311
+ }
1312
+ }
1313
+ for (const u of units)
1314
+ process.stdout.write(sliceUnit(source, u.span, part));
1315
+ }
1316
+ // `geml replace <file> <old> <new> [--within <selector>]` — swap a literal
1317
+ // string, everywhere or inside named blocks, without reading the document.
1318
+ //
1319
+ // This is the one operation where GEML can beat `sed` outright rather than
1320
+ // imitate it. The cost is the same — two short strings out, nothing read in —
1321
+ // and three things come back that `sed -i` cannot give: the write is re-parsed
1322
+ // and refused if it would break the document, the blocks it touched are named,
1323
+ // and it lands in `.gemlhistory` where `revert` can undo it. Measured on a real
1324
+ // day of editing, ten of fourteen changes were bulk blind replacement done with
1325
+ // the original commands; every one of those was an edit that escaped all three.
1326
+ //
1327
+ // LITERAL, never a pattern. Regular expressions are where `sed` is genuinely
1328
+ // better and where the footguns live, and the moment this grows them it stops
1329
+ // being "GEML, addressed" and becomes a worse `sed`.
1330
+ function runReplace(args) {
1331
+ const out = flag(args, "-o") ?? flag(args, "--out");
1332
+ const within = flag(args, "--within");
1333
+ const [file, oldText, newText] = positionals(args, ["-o", "--out", "--within"]);
1334
+ if (!file || oldText === undefined || newText === undefined)
1335
+ fail(SUBHELP.replace);
1336
+ if (oldText === "")
1337
+ fail("the text to replace is empty — that would match everywhere", 2);
1338
+ const source = readInput(file);
1339
+ const where = file === "-" ? "stdin" : file;
1340
+ const all = addressedUnits(source);
1341
+ // Scope: the whole document, or every block a selector matches. Several
1342
+ // matches are fine here — `replace … --within '=== table'` meaning "in all
1343
+ // the tables" is the useful reading, and unlike `set` there is no ambiguity
1344
+ // about which one receives the write.
1345
+ const lines = splitLines(source);
1346
+ const lineStart = [];
1347
+ {
1348
+ let at = 0;
1349
+ for (const l of lines) {
1350
+ lineStart.push(at);
1351
+ at += l.length;
1352
+ }
1353
+ }
1354
+ let scopes;
1355
+ if (within === undefined) {
1356
+ scopes = [{ from: 0, to: source.length }];
1357
+ }
1358
+ else {
1359
+ // `selectUnits` already refuses a selector that matches nothing, with the
1360
+ // message the other verbs give, so there is no empty case to handle here.
1361
+ const { units } = selectUnits(source, file, within, where);
1362
+ scopes = units.map((u) => ({
1363
+ from: lineStart[u.span.start],
1364
+ to: u.span.end >= lineStart.length ? source.length : lineStart[u.span.end],
1365
+ }));
1366
+ }
1367
+ // Find every occurrence inside the scopes, right to left, so replacing one
1368
+ // cannot move the ones not yet done.
1369
+ const hits = [];
1370
+ for (const s of scopes) {
1371
+ let at = source.indexOf(oldText, s.from);
1372
+ while (at !== -1 && at + oldText.length <= s.to) {
1373
+ hits.push(at);
1374
+ at = source.indexOf(oldText, at + oldText.length);
1375
+ }
1376
+ }
1377
+ hits.sort((a, b) => a - b);
1378
+ if (hits.length === 0) {
1379
+ // Exit 1 like `find`, so `if geml replace …` means what it looks like.
1380
+ fail(`\`${oldText}\` does not occur in ${within === undefined ? where : `\`${within}\` of ${where}`} — nothing written`, 1);
1381
+ }
1382
+ let updated = source;
1383
+ for (const at of [...hits].reverse()) {
1384
+ updated = updated.slice(0, at) + newText + updated.slice(at + oldText.length);
1385
+ }
1386
+ // Which blocks were touched — the report has to speak in addresses, or this
1387
+ // is just `sed` with a longer name.
1388
+ const lineOf = (off) => {
1389
+ let lo = 0, hi = lineStart.length - 1;
1390
+ while (lo < hi) {
1391
+ const mid = (lo + hi + 1) >> 1;
1392
+ if (lineStart[mid] <= off)
1393
+ lo = mid;
1394
+ else
1395
+ hi = mid - 1;
1396
+ }
1397
+ return lo;
1398
+ };
1399
+ const touched = new Set();
1400
+ for (const at of hits) {
1401
+ const ln = lineOf(at);
1402
+ let best;
1403
+ for (const a of all) {
1404
+ if (a.unit.span.start <= ln && ln < a.unit.span.end) {
1405
+ if (!best || (a.unit.span.end - a.unit.span.start) < (best.unit.span.end - best.unit.span.start))
1406
+ best = a;
1407
+ }
1408
+ }
1409
+ if (best)
1410
+ touched.add(shortestAddress(best, all));
1411
+ }
1412
+ // An id is not text to be swapped: changing one silently cuts every reference
1413
+ // to it, which is precisely what `rename` exists to do properly.
1414
+ const before = parse(source, { ...docOpts(file) });
1415
+ const after = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1416
+ const goneIds = before.ids.filter((x) => !new Set(after.ids).has(x));
1417
+ const newIds = after.ids.filter((x) => !new Set(before.ids).has(x));
1418
+ if (goneIds.length && newIds.length) {
1419
+ fail(`that would rename \`#${goneIds[0]}\` to \`#${newIds[0]}\` — an id is not text: use \`geml rename ${where} '#${goneIds[0]}' '#${newIds[0]}'\`, which fixes every reference too. Nothing written`, 2);
1420
+ }
1421
+ const errs = after.diagnostics.filter((d) => d.severity === "error");
1422
+ if (errs.length) {
1423
+ refuseBroken(`the replacement would break the document: ${errs[0].message} (line ${errs[0].line}); nothing written`, errs);
1424
+ }
1425
+ // Blocks the replacement removed follow `set`'s rule: carried out, and named.
1426
+ const droppedAnon = Math.max(0, countBlockUnits(source) - countBlockUnits(updated) - goneIds.length);
1427
+ if (goneIds.length || droppedAnon) {
1428
+ const named = goneIds.map((x) => `\`#${x}\``).join(", ");
1429
+ const anon = droppedAnon ? `${droppedAnon} unnamed block${droppedAnon > 1 ? "s" : ""}` : "";
1430
+ console.error(`dropped ${[named, anon].filter(Boolean).join(" and ")} — run 'geml revert' to put them back`);
1431
+ }
1432
+ resolveOutTarget(file, out).write(updated);
1433
+ const list = [...touched].join(", ");
1434
+ console.error(`replaced ${hits.length} occurrence${hits.length > 1 ? "s" : ""}${list ? ` in ${list}` : ""}`);
1435
+ }
1436
+ const NO_CONTENT = "no replacement content (use --in FILE or pipe it on stdin)";
1437
+ // `geml set <file.geml|-> #id [--head|--body] [--in F|F#src|-] [-o out]` —
1438
+ // replace ONE existing block, addressed by #id, with new content, preserving
1439
+ // every other byte. Two content CHANNELS × three MODES:
1440
+ //
1441
+ // channels · `--in F[#src]` extracts a BLOCK from GEML file F (F is always
1442
+ // read as GEML — extension ignored, no md conversion): `--in F`
1443
+ // takes the block whose id == the target #id; `--in F#src` takes
1444
+ // #src. stdin (default, or `--in -`) is raw bytes.
1445
+ // modes · default replaces the WHOLE block, `--head` only the head line,
1446
+ // `--body` only the body. Default and `--head` NORMALIZE the
1447
+ // content's id to #id (its source id is irrelevant); `--body`
1448
+ // keeps the target's head verbatim, so #id is preserved naturally.
1449
+ //
1450
+ // Output follows resolveOutTarget (file -> in place, stdin -> stdout, `-o`/`-o -`
1451
+ // override) and every splice is guarded — re-parsed and rejected if it broke
1452
+ // the doc, so `set` never writes a corrupt file.
1453
+ function runSet(args) {
1454
+ const out = flag(args, "-o") ?? flag(args, "--out");
1455
+ const from = flag(args, "--in");
1456
+ const headOnly = args.includes("--head");
1457
+ const bodyOnly = args.includes("--body");
1458
+ const introOnly = args.includes("--intro");
1459
+ const named = [headOnly && "--head", introOnly && "--intro", bodyOnly && "--body"].filter(Boolean);
1460
+ if (named.length > 1)
1461
+ fail(`${named.join(" and ")} are mutually exclusive — they name different parts of one block`, 2);
1462
+ // `--view` reads THROUGH an embed (see runGet). Writing through one would mean
1463
+ // one `set` silently editing a different file, so it is refused rather than
1464
+ // ignored — and the message has to point the way, not just say no.
1465
+ if (args.includes("--view")) {
1466
+ fail("--view is read-only. To edit the target, read the frame's `src` and edit that document.", 2);
1467
+ }
1468
+ const [file, rawSel] = positionals(args, ["-o", "--out", "--in"]);
1469
+ if (!file)
1470
+ fail(SUBHELP.set);
1471
+ // No selector: there is no block to replace. Point the way to discovery, not a
1472
+ // bare usage line — `geml get <file>` lists every address `set` can target.
1473
+ if (!rawSel)
1474
+ fail(`no selector given — run 'geml get ${file === "-" ? "<file>" : file}' to list addressable blocks`, 2);
1475
+ // The raw channel is stdin — `--in` omitted or `--in -`; anything else sources
1476
+ // a block from a file. Document and content can't BOTH be stdin: reject that
1477
+ // up front, before consuming stdin, so the document read below is unambiguous.
1478
+ const rawChannel = from === undefined || from === "-";
1479
+ if (file === "-" && rawChannel) {
1480
+ fail("reading the document from stdin needs --in for the new content", 2);
1481
+ }
1482
+ const source = readInput(file);
1483
+ const target = resolveSetTarget(source, file, rawSel);
1484
+ if (introOnly) {
1485
+ runSetIntro(source, target, from, rawChannel, file, out);
1486
+ return;
1487
+ }
1488
+ if (bodyOnly) {
1489
+ runSetBody(source, target, from, rawChannel, file, out);
1490
+ return;
1491
+ }
1492
+ let content;
1493
+ if (rawChannel) {
1494
+ content = readInput("-");
1495
+ if (content === "")
1496
+ fail(NO_CONTENT, 1);
1497
+ // Default mode wants exactly ONE block. Pure prose has no head to carry the
1498
+ // id (steer to --body); multiple blocks are `add`'s job. --head takes a
1499
+ // lone head line, so it skips the whole-block shape check.
1500
+ if (!headOnly) {
1501
+ const shape = contentShape(content);
1502
+ if (shape === "empty")
1503
+ fail(NO_CONTENT, 1);
1504
+ if (shape === "prose")
1505
+ fail(`content is prose, not a block — use --body to set the body of ${target.label}`, 1);
1506
+ if (shape === "multi")
1507
+ fail("set replaces ONE block, but the content has multiple blocks (use add)", 1);
1508
+ }
1509
+ }
1510
+ else {
1511
+ content = extractBlock(from, target.unit.id ?? "", headOnly ? "head" : "whole");
1512
+ }
1513
+ // §5.2: `@<hex>` is not an id, so "normalize the content's id to the target's"
1514
+ // has no subject — the content is used verbatim, and an id it brings that
1515
+ // collides is caught by the splice guard like any other. An id target keeps
1516
+ // normalizing: naming an id on the command line IS the instruction that the
1517
+ // result carries that id (block-mutation design §4.0).
1518
+ const replacement = target.unit.id !== undefined ? normalizeBlockId(content, target.unit.id) : content;
1519
+ const updated = spliceSpan(source, target.unit.span, replacement, file, headOnly, false, target.unit.id);
1520
+ resolveOutTarget(file, out).write(updated);
1521
+ reportNewAddress(updated, target);
1522
+ }
1523
+ // Resolve a selector to the ONE unit `set` will overwrite. `get` may answer with
1524
+ // N blocks; `set` may not — §5: with N targets there is no single id to
1525
+ // normalize the content to, so multi-target `set` is undefined, not merely
1526
+ // risky. Refused with exit 2 (a usage error), not exit 1.
1527
+ function resolveSetTarget(source, file, rawSel) {
1528
+ const where = file === "-" ? "<file>" : file;
1529
+ const sel = parseSelector(rawSel, (braces) => parseAttrs(braces).id);
1530
+ if (sel.form === "list")
1531
+ fail(`no selector given — run 'geml get ${where}' to list addressable blocks`, 2);
1532
+ const { units, all } = selectUnits(source, file, rawSel, where);
1533
+ if (units.length > 1) {
1534
+ // §5: with N targets there is no single id to normalize the content to, so
1535
+ // multi-target `set` is UNDEFINED, not merely risky. The addresses are
1536
+ // printed because they ARE the fix — each is unique and pastes straight
1537
+ // back into this same command (§6.2).
1538
+ const opts = units.map((u) => {
1539
+ const a = all.find((x) => x.unit === u);
1540
+ return ` ${shortestAddress(a, all)} L${u.span.start + 1}-${u.span.end}`;
1541
+ }).join("\n");
1542
+ fail(`\`${rawSel.trim()}\` matches ${units.length} blocks — set writes ONE; address it uniquely:\n${opts}`, 2);
1543
+ }
1544
+ const unit = units[0];
1545
+ const label = unit.id !== undefined && sel.form === "id" ? `#${unit.id}` : `\`${rawSel.trim()}\``;
1546
+ return { unit, label, byContent: sel.form === "content" };
1547
+ }
1548
+ // §5.3: writing through a content address CHANGES it, so print the new one —
1549
+ // otherwise a script editing the same block twice has to re-list in between.
1550
+ // stderr, because stdout may be the document itself (`-o -`).
1551
+ function reportNewAddress(updated, target) {
1552
+ if (!target.byContent)
1553
+ return;
1554
+ const after = addressedUnits(updated).find((a) => a.unit.span.start === target.unit.span.start);
1555
+ if (after)
1556
+ console.error(`new address: ${shortestAddress(after, addressedUnits(updated))}`);
1557
+ }
1558
+ // `--body`: swap ONLY the target block's body, keeping its head (and #id) and,
1559
+ // for a typed block, its close fence. Assembles head + new body + close and
1560
+ // reuses the guarded spliceBlock — the head carries #id, so the id survives
1561
+ // with no normalization needed.
1562
+ // `set --intro` — replace only what a heading says before its first subheading.
1563
+ // The heading line and everything from that subheading down stay byte-identical,
1564
+ // which is the whole point: the region `get --intro` hands out is the region
1565
+ // `set --intro` puts back, so a read-edit-write round trip cannot swallow the
1566
+ // subsections. When the region is EMPTY (a subheading follows the heading
1567
+ // immediately) this inserts there — writing an opening for a section that had
1568
+ // none is the same operation as replacing one that did.
1569
+ function runSetIntro(source, target, from, rawChannel, file, out) {
1570
+ if (target.unit.kind !== "heading") {
1571
+ fail(`--intro names a heading's opening region, and \`${target.label}\` is a \`${target.unit.type ?? target.unit.kind}\` block — use --body for a block's content`, 2);
1572
+ }
1573
+ const region = narrowToIntro(source, target.unit.span);
1574
+ let body = rawChannel ? readInput("-") : extractBlock(from, target.unit.id ?? "", "body");
1575
+ if (rawChannel && body === "")
1576
+ fail(NO_CONTENT, 1);
1577
+ body = toLf(body);
1578
+ if (body !== "" && !body.endsWith("\n"))
1579
+ body += "\n";
1580
+ // Give the opening its blank lines back. `get --intro` hands the region over
1581
+ // WITH the blank lines that separated it, so round-tripping that text lands
1582
+ // byte-identical and this adds nothing. Content typed by hand has no such
1583
+ // padding, and without it the result fuses: `# H1` then the text then `## H2`
1584
+ // on consecutive lines. `add` already settled this — one blank separator on
1585
+ // a side whose neighbour is not blank — so the two agree.
1586
+ const around = splitLines(source);
1587
+ const blankLine = (s) => s === undefined || stripEol(s).trim() === "";
1588
+ if (body !== "" && !blankLine(body.split("\n")[0]))
1589
+ body = "\n" + body;
1590
+ // A following heading needs the separation; end-of-document does not.
1591
+ if (body !== "" && region.end < around.length && !blankLine(body.split("\n").slice(-2)[0]))
1592
+ body += "\n";
1593
+ const updated = spliceSpan(source, region, body, file, false, false, target.unit.id);
1594
+ resolveOutTarget(file, out).write(updated);
1595
+ reportNewAddress(updated, target);
1596
+ }
1597
+ function runSetBody(source, target, from, rawChannel, file, out) {
1598
+ const found = target.unit.span;
1599
+ const lines = splitLines(source);
1600
+ const headLine = lines[found.start] ?? "";
1601
+ // A typed block keeps its closing fence; a heading section has none. Decided
1602
+ // by the same helper `get --body` uses, so the two agree on the span and the
1603
+ // §4 round-trip invariant holds.
1604
+ const closeLine = closeFenceLine(lines, found);
1605
+ let body;
1606
+ if (rawChannel) {
1607
+ body = readInput("-");
1608
+ if (body === "")
1609
+ fail(NO_CONTENT, 1);
1610
+ }
1611
+ else {
1612
+ body = extractBlock(from, target.unit.id ?? "", "body");
1613
+ }
1614
+ let head = headLine;
1615
+ if (head !== "" && !/(\r\n|\r|\n)$/.test(head))
1616
+ head += "\n";
1617
+ let b = toLf(body); // spliceBlock converts the result to the document's style
1618
+ if (closeLine !== null && b !== "" && !b.endsWith("\n"))
1619
+ b += "\n";
1620
+ const replacement = closeLine !== null ? head + b + closeLine : head + b;
1621
+ // A typed block (closeLine !== null) must stay ONE block: enforce the
1622
+ // block-count invariant so a `===` fence in the raw body can't close it early
1623
+ // and inject siblings (SEC F2). A heading section body has no close fence and
1624
+ // may legitimately contain blocks, so it is not count-guarded.
1625
+ const updated = spliceSpan(source, found, replacement, file, false, closeLine !== null, target.unit.id);
1626
+ resolveOutTarget(file, out).write(updated);
1627
+ reportNewAddress(updated, target);
1628
+ }
1629
+ // `geml add <file|-> (--append | --before #x | --after #x) [--in F|F#src|-] [-o]`
1630
+ // — insert a GEML fragment (1+ blocks and/or prose) at a position. Unlike `set`,
1631
+ // `add` names no target id, so content keeps its OWN ids (no normalization); an
1632
+ // id colliding with the document (or duplicated within the fragment) makes the
1633
+ // re-parse fail and nothing is written. Bare prose is a valid fragment.
1634
+ function runAdd(args) {
1635
+ const out = flag(args, "-o") ?? flag(args, "--out");
1636
+ const from = flag(args, "--in");
1637
+ const before = flag(args, "--before");
1638
+ const after = flag(args, "--after");
1639
+ const append = args.includes("--append");
1640
+ const posCount = (append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0);
1641
+ if (posCount !== 1)
1642
+ fail("add needs exactly one position: --append | --before #id | --after #id", 2);
1643
+ const [file] = positionals(args, ["-o", "--out", "--in", "--before", "--after"]);
1644
+ if (!file)
1645
+ fail(SUBHELP.add);
1646
+ const rawChannel = from === undefined || from === "-";
1647
+ if (file === "-" && rawChannel)
1648
+ fail("reading the document from stdin needs --in for the new content", 2);
1649
+ const source = readInput(file);
1650
+ // Content: --in F#src -> block #src; --in F -> all of F (a multi-block
1651
+ // fragment is fine here); stdin -> raw. No id-normalization: add keeps ids.
1652
+ let content;
1653
+ if (rawChannel)
1654
+ content = readInput("-");
1655
+ else if (from.includes("#"))
1656
+ content = extractBlock(from, "", "whole");
1657
+ else
1658
+ content = readInput(from);
1659
+ if (content.trim() === "")
1660
+ fail("no content to add (use --in FILE or pipe it on stdin)", 1);
1661
+ // Resolve the physical-line insertion point.
1662
+ const lines = splitLines(source);
1663
+ let at;
1664
+ if (append) {
1665
+ at = lines.length;
1666
+ }
1667
+ else {
1668
+ const anchorId = (before ?? after).replace(/^#/, "");
1669
+ const span = blockSpans(source).get(anchorId);
1670
+ if (!span)
1671
+ fail(`no block with id \`${anchorId}\` in ${file === "-" ? "stdin" : file}`, 1);
1672
+ at = before !== undefined ? span.start : span.end;
1673
+ }
1674
+ const updated = insertFragment(source, lines, at, content, file);
1675
+ resolveOutTarget(file, out).write(updated);
1676
+ }
1677
+ // Splice `fragment` into `source` at physical-line index `at` (splitLines
1678
+ // coords), separating it from adjacent content with a single blank line so
1679
+ // blocks don't fuse, then GUARD: the re-parse must be error-free (a colliding
1680
+ // or duplicate id surfaces as an error diagnostic) and no pre-existing id may
1681
+ // vanish. Returns the updated text; on any violation fail()s and writes nothing.
1682
+ function insertFragment(source, lines, at, fragment, file) {
1683
+ const beforeIds = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) }).ids;
1684
+ const before = lines.slice(0, at);
1685
+ const after = lines.slice(at);
1686
+ const nl = newlineOf(source); // the fragment AND every separator we add
1687
+ // The preceding line must end in a newline so the fragment starts on its own.
1688
+ if (before.length && !/(\r\n|\r|\n)$/.test(before[before.length - 1])) {
1689
+ before[before.length - 1] += nl;
1690
+ }
1691
+ let frag = toNewline(fragment, nl);
1692
+ if (!frag.endsWith("\n"))
1693
+ frag += nl;
1694
+ // A single blank separator on each side that has adjacent content and isn't
1695
+ // already blank — keeps a following head / preceding block from fusing.
1696
+ const blank = (s) => stripEol(s).trim() === "";
1697
+ const sepBefore = before.length && !blank(before[before.length - 1]) ? nl : "";
1698
+ const sepAfter = after.length && !blank(after[0]) ? nl : "";
1699
+ const updated = before.join("") + sepBefore + frag + sepAfter + after.join("");
1700
+ const reparsed = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1701
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1702
+ if (errs.length) {
1703
+ const first = errs[0];
1704
+ refuseBroken(`adding the content would break the document: ${first.message} (line ${first.line}); not written`, errs);
1705
+ }
1706
+ const now = new Set(reparsed.ids);
1707
+ const dropped = beforeIds.find((x) => !now.has(x));
1708
+ if (dropped !== undefined)
1709
+ fail(`adding the content would drop block \`#${dropped}\`; not written`, 1);
1710
+ return updated;
1711
+ }
1712
+ // `geml delete <file|-> #id [#id2 …] [-o]` — remove one or more blocks. A
1713
+ // missing id is SKIPPED with a note (declarative "ensure absent", not an
1714
+ // error). Unlike set/add, delete's write is LENIENT: removing a complete block
1715
+ // can't break the parse structurally, but it may leave a reference dangling —
1716
+ // that is a WARNING, never a refusal (delete is reversible via revert + history,
1717
+ // and `geml check` still flags the dangling ref afterward). Contained/overlapping
1718
+ // spans (a nested block inside a deleted heading section) are handled by deleting
1719
+ // the UNION of target lines, so a line is never spliced twice.
1720
+ function runDelete(args) {
1721
+ const out = flag(args, "-o") ?? flag(args, "--out");
1722
+ const pos = positionals(args, ["-o", "--out"]);
1723
+ const file = pos[0];
1724
+ if (!file)
1725
+ fail(SUBHELP.delete);
1726
+ const ids = pos.slice(1).map((s) => s.replace(/^#/, ""));
1727
+ if (ids.length === 0)
1728
+ fail("delete needs at least one #id (run 'geml get <file>' to list ids)", 2);
1729
+ const source = readInput(file);
1730
+ const spans = blockSpans(source);
1731
+ const toDelete = new Set();
1732
+ let found = 0;
1733
+ for (const id of ids) {
1734
+ const span = spans.get(id);
1735
+ if (!span) {
1736
+ console.error(`skipped #${id}: no such block`);
1737
+ continue;
1738
+ }
1739
+ found++;
1740
+ for (let i = span.start; i < span.end; i++)
1741
+ toDelete.add(i);
1742
+ }
1743
+ if (found === 0) {
1744
+ resolveOutTarget(file, out).write(source);
1745
+ return;
1746
+ } // nothing to remove
1747
+ const updated = splitLines(source).filter((_, i) => !toDelete.has(i)).join("");
1748
+ // Lenient guard: surface any resulting error diagnostic (a reference now
1749
+ // dangling) as a WARNING, but write regardless.
1750
+ const reparsed = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1751
+ for (const d of reparsed.diagnostics.filter((x) => x.severity === "error")) {
1752
+ console.error(`warning: ${d.message} (line ${d.line}) — left dangling by delete; run 'geml check' to see it as an error`);
1753
+ }
1754
+ resolveOutTarget(file, out).write(updated);
1755
+ }
1756
+ // `geml rename <file|-> #old #new [-o]` — the one verb that reaches OUTSIDE a
1757
+ // block: it rewrites #old's declaration AND every reference to it. #new must be
1758
+ // free; the guarded re-parse refuses anything that would break the doc.
1759
+ function runRename(args) {
1760
+ const out = flag(args, "-o") ?? flag(args, "--out");
1761
+ const [file, rawOld, rawNew] = positionals(args, ["-o", "--out"]);
1762
+ if (!file || !rawOld || !rawNew)
1763
+ fail(SUBHELP.rename);
1764
+ const oldId = rawOld.replace(/^#/, "");
1765
+ const newId = rawNew.replace(/^#/, "");
1766
+ if (oldId === newId)
1767
+ fail("#old and #new are the same id — nothing to rename", 2);
1768
+ const source = readInput(file);
1769
+ const before = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1770
+ if (!before.ids.includes(oldId))
1771
+ fail(`no block with id \`${oldId}\``, 1);
1772
+ if (before.ids.includes(newId))
1773
+ fail(`id \`${newId}\` already exists; not written`, 1);
1774
+ // Renaming an id that has recorded history breaks the revert-lineage for it
1775
+ // (revert keys by id and can't follow #old -> #new across the boundary). Warn
1776
+ // so the user knows a later `revert #new` won't reach pre-rename revisions.
1777
+ if (file !== "-") {
1778
+ const hp = historyPathFor(file);
1779
+ if (existsSync(hp)) {
1780
+ try {
1781
+ if (blockSpans(resolveContent(hp, "0").text).has(oldId)) {
1782
+ console.error(`warning: #${oldId} has history; revert across this rename is not tracked — see docs`);
1783
+ }
1784
+ }
1785
+ catch { /* unreadable/empty history: no warning */ }
1786
+ }
1787
+ }
1788
+ const updated = rewriteId(source, oldId, newId, file);
1789
+ const reparsed = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1790
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
1791
+ if (errs.length) {
1792
+ const e = errs[0];
1793
+ refuseBroken(`rename would break the document: ${e.message} (line ${e.line}); not written`, errs);
1794
+ }
1795
+ if (!reparsed.ids.includes(newId))
1796
+ fail(`rename did not produce #${newId}; not written`, 1);
1797
+ if (reparsed.ids.includes(oldId))
1798
+ fail(`#${oldId} still present after rename; not written`, 1);
1799
+ // Every OTHER id must be untouched. The `#old` match boundary treats a char
1800
+ // outside [A-Za-z0-9_-] as an id terminator, but ids may contain e.g. `.`
1801
+ // (`#foo.bar`), so renaming `#foo` could silently rewrite the *different* id
1802
+ // `#foo.bar` -> `#baz.bar`. Reject when the set of ids other than the rename
1803
+ // pair changed at all (SEC/correctness: collateral id corruption).
1804
+ const othersBefore = before.ids.filter((id) => id !== oldId).sort().join("\n");
1805
+ const othersAfter = reparsed.ids.filter((id) => id !== newId).sort().join("\n");
1806
+ if (othersBefore !== othersAfter) {
1807
+ fail(`rename would also change other ids sharing the \`${oldId}\` prefix (e.g. \`#${oldId}…\`); not written`, 1);
1808
+ }
1809
+ resolveOutTarget(file, out).write(updated);
1810
+ }
1811
+ // Rewrite id `old` -> `new` everywhere it is a declaration or reference, id-
1812
+ // boundary-safe: `#old` is replaced only when NOT followed by an id char, so a
1813
+ // longer id like `#old2` / `#old-x` is untouched. Covers the declaration
1814
+ // (`{#old …}`, labeled close `=== #old`), block references (`[[#old]]`,
1815
+ // `[t](#old)`, chart `data=#old`) and footnotes (`[^old]`). RAW / data block
1816
+ // BODIES (code/diagram/math/table/meta) are skipped — a `#old` there is literal
1817
+ // text, not a reference. (Known residual: id-less raw bodies and inline
1818
+ // code/math spans in flow content — see design §8.)
1819
+ function rewriteId(source, oldId, newId, file) {
1820
+ const doc = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1821
+ const spans = blockSpans(source);
1822
+ const protectedLines = new Set();
1823
+ for (const b of doc.children) {
1824
+ if (b.kind === "block" && (b.mode === "raw" || b.mode === "data") && b.id) {
1825
+ const span = spans.get(b.id);
1826
+ if (span) {
1827
+ const br = bodyRange(source, span);
1828
+ for (let i = br.start; i < br.end; i++)
1829
+ protectedLines.add(i);
1830
+ }
1831
+ }
1832
+ }
1833
+ const esc = reLit(oldId);
1834
+ const hashRe = new RegExp(`#${esc}(?![A-Za-z0-9_-])`, "g");
1835
+ const fnRe = new RegExp(`(\\[\\^)${esc}(?![A-Za-z0-9_-])`, "g");
1836
+ const lines = splitLines(source);
1837
+ for (let i = 0; i < lines.length; i++) {
1838
+ if (protectedLines.has(i))
1839
+ continue;
1840
+ lines[i] = lines[i].replace(hashRe, `#${newId}`).replace(fnRe, `$1${newId}`);
1841
+ }
1842
+ return lines.join("");
1843
+ }
1844
+ // Extract one block from a GEML file for `--in`. `spec` is `F` (block whose id
1845
+ // == the target) or `F#src` (block #src) — the last `#` splits path from id, so
1846
+ // a `#` inside the path is tolerated; F is read as GEML regardless of extension
1847
+ // (blockSpans + splitLines, no parse — same slice `geml get` prints). `part`
1848
+ // selects the whole span, its head line, or its body. A missing file or absent
1849
+ // id is an operation error (exit 1); the caller writes nothing.
1850
+ function extractBlock(spec, targetId, part) {
1851
+ const hash = spec.lastIndexOf("#");
1852
+ const fragFile = hash >= 0 ? spec.slice(0, hash) : spec;
1853
+ const fragId = hash >= 0 ? spec.slice(hash + 1).replace(/^#/, "") : targetId;
1854
+ let text;
1855
+ try {
1856
+ text = readFileSync(fragFile, "utf8");
1857
+ }
1858
+ catch {
1859
+ fail(`cannot read ${fragFile}`, 1);
1860
+ }
1861
+ const span = blockSpans(text).get(fragId);
1862
+ if (!span)
1863
+ fail(`no block with id \`${fragId}\` in ${fragFile}`, 1);
1864
+ const lines = splitLines(text);
1865
+ if (part === "head")
1866
+ return lines.slice(span.start, span.start + 1).join("");
1867
+ if (part === "body") {
1868
+ const b = bodyRange(text, span);
1869
+ return lines.slice(b.start, b.end).join("");
1870
+ }
1871
+ return lines.slice(span.start, span.end).join("");
1872
+ }
1873
+ // Strip a single trailing terminator (`\r\n`, `\r`, or `\n`) from one line.
1874
+ // One flag's value out of argv — the CLI's own tiny parser.
1875
+ function flag(args, name) {
1876
+ const i = args.indexOf(name);
1877
+ return i >= 0 ? args[i + 1] : undefined;
1878
+ }
1879
+ // The body sub-range of a block span: [head+1, close) for a closed typed block,
1880
+ // otherwise [head+1, end) — a heading section (no close fence) or an
1881
+ // unterminated block whose span already runs to end-of-scope.
1882
+ function bodyRange(text, span) {
1883
+ const lines = splitLines(text);
1884
+ const open = FENCE_OPEN.exec(stripEol(lines[span.start] ?? ""));
1885
+ if (open) {
1886
+ const lastText = trimSpaceTabEnd(stripEol(lines[span.end - 1] ?? ""));
1887
+ const bid = open[3] ? parseAttrs(open[3]).id : undefined;
1888
+ const labeled = bid !== undefined && new RegExp(`^={3,}[ \\t]+#${reLit(bid)}[ \\t]*$`).test(lastText);
1889
+ const closed = isCloseFence(lastText, open[1].length) || labeled;
1890
+ return { start: span.start + 1, end: closed ? span.end - 1 : span.end };
1891
+ }
1892
+ return { start: span.start + 1, end: span.end };
1893
+ }
1894
+ // The shape of default-mode stdin content, section-aware: a heading OWNS its
1895
+ // section (`# H …blocks…` is ONE unit, not many), matching sectionEnd/blockSpans.
1896
+ // Used to reject pure prose (-> --body) and multi-block content (-> add) before
1897
+ // the splice — extraction via --in is inherently one block and skips this.
1898
+ function contentShape(content) {
1899
+ const bs = parse(content).children;
1900
+ let blockUnits = 0, proseUnits = 0, i = 0;
1901
+ while (i < bs.length) {
1902
+ const b = bs[i];
1903
+ if (b.kind === "heading") {
1904
+ i = sectionEndIndex(bs, i);
1905
+ blockUnits++;
1906
+ }
1907
+ else if (b.kind === "block") {
1908
+ i++;
1909
+ blockUnits++;
1910
+ }
1911
+ else {
1912
+ i++;
1913
+ proseUnits++;
1914
+ }
1915
+ }
1916
+ if (blockUnits === 0)
1917
+ return proseUnits === 0 ? "empty" : "prose";
1918
+ return blockUnits + proseUnits === 1 ? "single" : "multi";
1919
+ }
1920
+ // Replace block #id's source span in `source` with `replacement`, preserving
1921
+ // every other byte, and GUARD the result: the re-parse must be error-free, #id
1922
+ // must survive, and no other pre-existing id may vanish (a malformed replacement
1923
+ // can silently swallow a neighbour). Returns the updated document text; on any
1924
+ // violation it calls fail() and never returns a corrupt document. Shared by
1925
+ // `set` and `revert`.
1926
+ function spliceBlock(source, id, replacement, file, headOnly = false, guardCount = false) {
1927
+ const found = blockSpans(source).get(id);
1928
+ if (!found)
1929
+ fail(`no block with id \`${id}\``, 1);
1930
+ return spliceSpan(source, found, replacement, file, headOnly, guardCount, id);
1931
+ }
1932
+ // The same guarded splice addressed by SPAN rather than by id, because an
1933
+ // anonymous block (addressed by `@<hex>`) has no id to look one up with. `id`
1934
+ // is the survival guard's subject and is simply absent for those: every OTHER
1935
+ // pre-existing id must still survive, which the `dropped` check below covers.
1936
+ // How many typed blocks a document holds. Ids only account for the named ones,
1937
+ // so this is what makes an unnamed block's removal reportable instead of silent
1938
+ // — the whole point of treating both the same.
1939
+ function countBlockUnits(source) {
1940
+ let n = 0;
1941
+ for (const a of addressedUnits(source))
1942
+ if (a.unit.kind === "block")
1943
+ n++;
1944
+ return n;
1945
+ }
1946
+ function spliceSpan(source, found, replacement, file, headOnly = false, guardCount = false, id) {
1947
+ const beforeDoc = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1948
+ const beforeIds = beforeDoc.ids;
1949
+ // Keep the bytes before and after the target span exactly; give the new block
1950
+ // a single trailing newline so the following block still starts on its own
1951
+ // line (unless it is the file's last line, which may legitimately lack one).
1952
+ const orig = splitLines(source);
1953
+ // `--head`: splice only the id's head line; everything below stays
1954
+ // byte-identical. The guard below still applies — the replacement must
1955
+ // re-declare `{#id}` and, for a typed block, keep the fence pairing intact
1956
+ // (an opening line that no longer matches the untouched close fence breaks
1957
+ // the re-parse), or the splice is refused.
1958
+ const span = headOnly ? narrowToHead(found) : found;
1959
+ const before = orig.slice(0, span.start);
1960
+ const after = orig.slice(span.end);
1961
+ const nl = newlineOf(source); // adopt the document's style, not LF
1962
+ let inject = toNewline(replacement, nl);
1963
+ const lastLine = span.end >= orig.length;
1964
+ if (!inject.endsWith("\n") && !lastLine)
1965
+ inject += nl;
1966
+ const updated = before.join("") + inject + after.join("");
1967
+ // Re-parse and refuse a broken result. A parse error or a duplicate id both
1968
+ // surface as error diagnostics (registerId flags dups); one check covers both.
1969
+ //
1970
+ // Blocks the replacement REMOVES are a different matter, and they are reported
1971
+ // rather than refused. Refusing made the region unreachable: a section whose
1972
+ // opening held a `=== note {#n}` could not have that opening replaced at all,
1973
+ // while the same note without an id was dropped in silence — the block's fate
1974
+ // turned on whether someone had named it. `delete` already settled the stance
1975
+ // for a destructive edit: do it, and say what it cost (it writes, and warns
1976
+ // about references it left dangling). This follows that, so there is one rule
1977
+ // for removing content instead of two.
1978
+ //
1979
+ // Note the ordinary read-edit-write cycle never reaches this: `get --intro`
1980
+ // hands the blocks over, sending them back keeps them, and nothing is dropped.
1981
+ const reparsed = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
1982
+ const now = new Set(reparsed.ids);
1983
+ if (id !== undefined && !now.has(id))
1984
+ fail(`replacement removes id \`${id}\`; not written`, 1);
1985
+ const droppedIds = beforeIds.filter((x) => x !== id && !now.has(x));
1986
+ const droppedAnon = Math.max(0, countBlockUnits(source) - countBlockUnits(updated) - droppedIds.length);
1987
+ // A reference left dangling BY THE REMOVAL is a consequence the caller is
1988
+ // being told about, exactly as `delete` tells them. A reference the new
1989
+ // content itself introduces is a broken write and is still refused — the
1990
+ // difference is whether the missing target is one of the blocks this splice
1991
+ // took away.
1992
+ const collateral = (d) => droppedIds.some((x) => d.message.includes(`\`#${x}\``) || d.message.includes(`#${x}\``));
1993
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error" && !collateral(d));
1994
+ if (errs.length) {
1995
+ const first = errs[0];
1996
+ refuseBroken(`replacement would break the document: ${first.message} (line ${first.line}); not written`, errs);
1997
+ }
1998
+ if (droppedIds.length || droppedAnon) {
1999
+ const named = droppedIds.map((x) => `\`#${x}\``).join(", ");
2000
+ const anon = droppedAnon ? `${droppedAnon} unnamed block${droppedAnon > 1 ? "s" : ""}` : "";
2001
+ console.error(`dropped ${[named, anon].filter(Boolean).join(" and ")} — run 'geml revert' to put them back`);
2002
+ for (const d of reparsed.diagnostics.filter((x) => x.severity === "error" && collateral(x))) {
2003
+ console.error(`warning: ${d.message} (line ${d.line}) — left dangling by the replacement; run 'geml check' to see it as an error`);
2004
+ }
2005
+ }
2006
+ // For a typed block with a close fence, the body is opaque and swapping it
2007
+ // keeps exactly ONE block. A raw `--body` can embed a `===` fence of the
2008
+ // block's length that closes the target early and turns the remainder — plus
2009
+ // the close line we re-appended — into NEW sibling blocks, including an id-less
2010
+ // `=== meta` that redefines document metadata (the dropped-id check above
2011
+ // cannot see an id-less injection). Guarded callers refuse any count change.
2012
+ // (Not enforced for heading sections / whole-block set, whose replacement may
2013
+ // legitimately span several top-level blocks.)
2014
+ if (guardCount && reparsed.children.length !== beforeDoc.children.length) {
2015
+ 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);
2016
+ }
2017
+ return updated;
2018
+ }
2019
+ // `geml revert <file.geml> #id [--rev <sel>] [--dry-run] [-o out] [--history PATH]`
2020
+ // Restore ONE block to a past revision's version — a targeted, guarded splice
2021
+ // that leaves the rest of the document untouched. <sel> (default `-1`): `0` (the
2022
+ // tip), `-N` (N revisions back), an id prefix/suffix, or `changed` — a content
2023
+ // selector that skips revisions which never touched the block, landing on its
2024
+ // previous *distinct* version. `--dry-run` prints what would be spliced in,
2025
+ // writing nothing. Writes in place by default (revert is a mutation); `-o` redirects.
2026
+ function runRevert(args) {
2027
+ const dryRun = args.includes("--dry-run");
2028
+ const headOnly = args.includes("--head");
2029
+ const out = flag(args, "-o") ?? flag(args, "--out");
2030
+ const to = flag(args, "--rev") ?? "-1";
2031
+ // `--rev changed` is a CONTENT selector, not a position: skip commits that
2032
+ // never touched this block, landing on its previous *distinct* version. It is
2033
+ // just a `--rev` value, so it cannot conflict with a positional `-N`.
2034
+ const changed = to === "changed";
2035
+ // The former standalone `--changed` flag is now this value; refuse the old
2036
+ // spelling loudly rather than silently ignoring it (and reverting to -1).
2037
+ if (args.includes("--changed"))
2038
+ fail("--changed is now `--rev changed`", 2);
2039
+ const before = flag(args, "--before");
2040
+ const after = flag(args, "--after");
2041
+ const append = args.includes("--append");
2042
+ if ((append ? 1 : 0) + (before !== undefined ? 1 : 0) + (after !== undefined ? 1 : 0) > 1) {
2043
+ fail("revert takes at most one position: --append | --before #id | --after #id", 2);
2044
+ }
2045
+ const [file, rawId] = positionals(args, ["--rev", "--history", "-o", "--out", "--before", "--after"]);
2046
+ if (!file || !rawId)
2047
+ fail(SUBHELP.revert);
2048
+ if (file === "-")
2049
+ fail("revert needs a real file (it reads that file's .gemlhistory)", 2);
2050
+ const id = rawId.replace(/^#/, "");
2051
+ const historyPath = flag(args, "--history") ?? historyPathFor(file);
2052
+ const source = readInput(file);
2053
+ // The sidecar stores every revision newline-NORMALIZED (history.ts), so a
2054
+ // revision's text always comes back LF while the working file may be CRLF.
2055
+ // Comparing those raw would make EVERY block look changed on a CRLF document
2056
+ // (`--rev changed` reverting blocks nobody touched, and the no-op check never
2057
+ // firing), so compare normalized and write back in the file's own style.
2058
+ const norm = toLf; // compare on the LF form
2059
+ const toFileNl = (s) => toNewline(s, newlineOf(source));
2060
+ const curFull = blockSpans(source).get(id); // undefined => absent now
2061
+ const curBlock = curFull === undefined ? undefined : (() => {
2062
+ const span = headOnly ? narrowToHead(curFull) : curFull;
2063
+ return splitLines(source).slice(span.start, span.end).join("");
2064
+ })();
2065
+ // Extract #id's block from a reconstructed revision (undefined => absent
2066
+ // there). Under `--head`, extract only the head line.
2067
+ const pick = (text) => {
2068
+ const s = blockSpans(text).get(id);
2069
+ if (!s)
2070
+ return undefined;
2071
+ const span = headOnly ? narrowToHead(s) : s;
2072
+ return splitLines(text).slice(span.start, span.end).join("");
2073
+ };
2074
+ // Resolve the source revision, formatting any history-layer error cleanly.
2075
+ const target = (() => {
2076
+ try {
2077
+ if (changed) {
2078
+ // `pick` reads normalized revision text, so normalize this side too.
2079
+ const found = firstChangedContent(historyPath, curBlock === undefined ? "" : norm(curBlock), pick);
2080
+ if (!found)
2081
+ fail(`no earlier revision changes \`${id}\``, 1);
2082
+ return found;
2083
+ }
2084
+ return resolveContent(historyPath, to);
2085
+ }
2086
+ catch (e) {
2087
+ fail(historyError(e, file, historyPath), 1);
2088
+ }
2089
+ })();
2090
+ const oldBlock = pick(target.text); // undefined => absent at R
2091
+ // Common write path (bespoke message; -o path redirects; -o - -> stdout).
2092
+ const emit = (updated, verb) => {
2093
+ const dest = out ?? file;
2094
+ if (dest === "-")
2095
+ process.stdout.write(updated);
2096
+ else
2097
+ writeFileSync(dest, updated);
2098
+ console.error(`${verb}${dest === file ? "" : dest === "-" ? " -> stdout" : ` -> ${dest}`}`);
2099
+ };
2100
+ // Reconcile #id between now and revision R across the four presence cells.
2101
+ if (curBlock === undefined && oldBlock === undefined) {
2102
+ fail(`\`${id}\` exists in neither the document nor ${target.id} (try --rev changed)`, 1);
2103
+ }
2104
+ // both present -> SPLICE (undo set)
2105
+ if (curBlock !== undefined && oldBlock !== undefined) {
2106
+ if (norm(oldBlock) === norm(curBlock)) {
2107
+ console.error(`#${id} is unchanged at ${target.id}; nothing to revert${changed ? "" : " (try --rev -2, or --rev changed)"}`);
2108
+ // A no-op still has to PRODUCE the document when an output destination was
2109
+ // asked for: `-o` means "write the result somewhere", and the result of a
2110
+ // no-op revert is the unchanged document. Returning silently here left
2111
+ // `-o -` consumers with exit 0 and empty stdout, which reads as "success,
2112
+ // and the document is now empty".
2113
+ if (out !== undefined)
2114
+ emit(source, `#${id} unchanged`);
2115
+ return;
2116
+ }
2117
+ const replacement = toFileNl(oldBlock); // keep the file's newline style
2118
+ if (dryRun) {
2119
+ console.error(`would revert #${id} to ${target.id}:`);
2120
+ process.stdout.write(replacement.endsWith("\n") ? replacement : replacement + "\n");
2121
+ return;
2122
+ }
2123
+ emit(spliceBlock(source, id, replacement, file, headOnly), `reverted #${id} to ${target.id}`);
2124
+ return;
2125
+ }
2126
+ // --head is only meaningful for the splice cell (it can't resurrect or remove).
2127
+ if (headOnly) {
2128
+ fail("--head only applies when the block exists in both the document and the target revision", 2);
2129
+ }
2130
+ // absent now, present at R -> RESURRECT (undo delete)
2131
+ if (curBlock === undefined && oldBlock !== undefined) {
2132
+ // Guard: if the block we'd resurrect is the same (modulo id) as one already
2133
+ // present under a different id, #id was likely renamed away — resurrecting
2134
+ // would duplicate it. Point at `rename` instead of writing.
2135
+ const cmpKey = normalizeBlockId(norm(oldBlock), "__cmp__");
2136
+ for (const [cid, cs] of blockSpans(source)) {
2137
+ if (cid === id)
2138
+ continue;
2139
+ const csrc = splitLines(source).slice(cs.start, cs.end).join("");
2140
+ if (normalizeBlockId(norm(csrc), "__cmp__") === cmpKey) {
2141
+ fail(`#${id} looks renamed to #${cid}; use 'rename #${cid} #${id}' to undo the rename`, 1);
2142
+ }
2143
+ }
2144
+ const { at, where, warn } = resurrectPosition(source, target.text, id, before, after, append, file);
2145
+ const fragment = toFileNl(oldBlock); // keep the file's newline style
2146
+ if (dryRun) {
2147
+ console.error(`would resurrect #${id} from ${target.id} at ${where}:`);
2148
+ process.stdout.write(fragment.endsWith("\n") ? fragment : fragment + "\n");
2149
+ return;
2150
+ }
2151
+ if (warn)
2152
+ console.error(`warning: anchors for #${id} are gone; appended at end`);
2153
+ emit(insertFragment(source, splitLines(source), at, fragment, file), `resurrected #${id} from ${target.id} at ${where}`);
2154
+ return;
2155
+ }
2156
+ // present now, absent at R -> REMOVE (undo add)
2157
+ // Guard: if the block we'd remove is the same (modulo id) as one present at R
2158
+ // under a different id, #id was likely renamed IN — removing would delete a
2159
+ // renamed block. Point at `rename` instead (the dangerous direction).
2160
+ {
2161
+ const cmpKey = normalizeBlockId(norm(curBlock), "__cmp__");
2162
+ for (const [rid, rs] of blockSpans(target.text)) {
2163
+ if (rid === id)
2164
+ continue;
2165
+ const rsrc = splitLines(target.text).slice(rs.start, rs.end).join("");
2166
+ if (normalizeBlockId(rsrc, "__cmp__") === cmpKey) {
2167
+ fail(`#${id} looks renamed from #${rid}; revert would delete it — use 'rename #${id} #${rid}'`, 1);
2168
+ }
2169
+ }
2170
+ }
2171
+ if (dryRun) {
2172
+ console.error(`would remove #${id} (absent at ${target.id})`);
2173
+ return;
2174
+ }
2175
+ const span = curFull;
2176
+ const beforeIds = parse(source, { ...docOpts(file), self: file === "-" ? undefined : basename(file) }).ids;
2177
+ const updated = splitLines(source).filter((_, i) => i < span.start || i >= span.end).join("");
2178
+ const reparsed = parse(updated, { ...docOpts(file), self: file === "-" ? undefined : basename(file) });
2179
+ const errs = reparsed.diagnostics.filter((d) => d.severity === "error");
2180
+ if (errs.length) {
2181
+ const first = errs[0];
2182
+ refuseBroken(`removing #${id} would break the document: ${first.message} (line ${first.line}); not written`, errs);
2183
+ }
2184
+ const now = new Set(reparsed.ids);
2185
+ const dropped = beforeIds.find((x) => x !== id && !now.has(x));
2186
+ if (dropped !== undefined)
2187
+ fail(`removing #${id} would drop block \`#${dropped}\`; not written`, 1);
2188
+ emit(updated, `removed #${id} (absent at ${target.id})`);
2189
+ }
2190
+ // Choose the physical-line insertion point for a resurrected block. Explicit
2191
+ // --append/--before/--after win; otherwise infer from the block's neighbours in
2192
+ // revision R: the nearest id BEFORE it that still exists now (insert after it),
2193
+ // else the nearest id AFTER it that still exists (insert before it), else append
2194
+ // at end (warn=true). The deleted block's own former descendants are absent now
2195
+ // too, so they are naturally skipped as anchors.
2196
+ function resurrectPosition(source, revText, id, before, after, append, file) {
2197
+ const lines = splitLines(source);
2198
+ const here = blockSpans(source);
2199
+ if (append)
2200
+ return { at: lines.length, where: "end", warn: false };
2201
+ if (before !== undefined) {
2202
+ const a = before.replace(/^#/, "");
2203
+ const s = here.get(a);
2204
+ if (!s)
2205
+ fail(`no block with id \`${a}\` in ${file}`, 1);
2206
+ return { at: s.start, where: `before #${a}`, warn: false };
2207
+ }
2208
+ if (after !== undefined) {
2209
+ const a = after.replace(/^#/, "");
2210
+ const s = here.get(a);
2211
+ if (!s)
2212
+ fail(`no block with id \`${a}\` in ${file}`, 1);
2213
+ return { at: s.end, where: `after #${a}`, warn: false };
2214
+ }
2215
+ const revIds = [...blockSpans(revText).keys()];
2216
+ const idx = revIds.indexOf(id);
2217
+ for (let i = idx - 1; i >= 0; i--) {
2218
+ const s = here.get(revIds[i]);
2219
+ if (s)
2220
+ return { at: s.end, where: `after #${revIds[i]}`, warn: false };
2221
+ }
2222
+ for (let i = idx + 1; i < revIds.length; i++) {
2223
+ const s = here.get(revIds[i]);
2224
+ if (s)
2225
+ return { at: s.start, where: `before #${revIds[i]}`, warn: false };
2226
+ }
2227
+ return { at: lines.length, where: "end", warn: true };
2228
+ }
2229
+ // geml codemap <sub>: the code-graph toolkit ships as plain scripts in the
2230
+ // package's codemap/ directory (they are argv-driven programs, some
2231
+ // long-running like `serve`) — dispatch = run the script in a child node
2232
+ // with the remaining arguments, propagating the exit code.
2233
+ function runCodemap(args) {
2234
+ const scripts = {
2235
+ build: "build.mjs",
2236
+ verify: "verify.mjs",
2237
+ render: "render-all.mjs",
2238
+ serve: "serve.mjs",
2239
+ refresh: "refresh.mjs",
2240
+ find: "find.mjs",
2241
+ };
2242
+ const sub = args[0] ?? "";
2243
+ // `codemap mcp` was a second stdio server over the same repository. It is
2244
+ // gone, not renamed, so name the replacement instead of letting it fall into
2245
+ // `unknown codemap subcommand`: this string is what an operator sees in a
2246
+ // client's server log when the entry they registered stops starting.
2247
+ if (sub === "mcp") {
2248
+ fail("geml codemap mcp was removed: use `geml mcp --root <dir>`, which serves the three code-graph tools alongside the document tools (graph: <root>/.geml-code-graph, or --graph <dir>).");
2249
+ }
2250
+ const script = scripts[sub];
2251
+ if (!script)
2252
+ fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
2253
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "..", "codemap", script);
2254
+ const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
2255
+ process.exit(r.status ?? 1);
2256
+ }
2257
+ // geml mcp: the MCP server — document CRUD, plus the code-graph tools when the
2258
+ // root holds a graph. It runs as a child's MAIN module because it owns
2259
+ // stdin/stdout for the whole session (the stdio transport), and dispatching by
2260
+ // spawn keeps this module free of a runtime import cycle (mcp.js imports the
2261
+ // parser from here).
2262
+ function runMcp(args) {
2263
+ const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
2264
+ const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
2265
+ process.exit(r.status ?? 1);
2266
+ }
2267
+ // geml skill install: one command that makes GEML usable everywhere for a
2268
+ // Claude Code user — the authoring skill resident under ~/.claude/skills/geml,
2269
+ // the CLI on the global PATH, and the MCP server registered at user scope.
2270
+ // Deliberately quiet: no settings.json edits, no hooks, no .gemlhistory
2271
+ // sidecars. Idempotent, so re-running after an upgrade refreshes everything.
2272
+ // The other agent tools: install by DETECTION, never by creation. A tool's
2273
+ // own context file is the one place it is guaranteed to read, so the skill
2274
+ // text goes there — inside a marker pair, so a re-run refreshes our block and
2275
+ // nothing a person wrote is ever touched. If the tool's directory is absent
2276
+ // the tool is absent: skip it and say so. Creating `~/.gemini/` for someone
2277
+ // who does not use Gemini would be a lie on disk.
2278
+ const SKILL_MARK_START = "<!-- geml:skill:start -->";
2279
+ const SKILL_MARK_END = "<!-- geml:skill:end -->";
2280
+ // Where each tool reads its instructions from. `dir` is the detection probe:
2281
+ // present means the tool is installed for this user (or, for a project file,
2282
+ // that the project already keeps one).
2283
+ const SKILL_TARGETS = [
2284
+ { name: "gemini", dir: join(homedir(), ".gemini"), file: join(homedir(), ".gemini", "GEMINI.md"), scope: "user" },
2285
+ { name: "qwen", dir: join(homedir(), ".qwen"), file: join(homedir(), ".qwen", "QWEN.md"), scope: "user" },
2286
+ // AGENTS.md is read by several tools and lives in a project, so the probe is
2287
+ // the file itself: we add our block to one that exists, never start one.
2288
+ { name: "agents-md", dir: resolvePath("AGENTS.md"), file: resolvePath("AGENTS.md"), scope: "project" },
2289
+ ];
2290
+ // The skill text as another tool should see it: the packaged SKILL.md without
2291
+ // its Claude-only frontmatter, with `<skill-base>` resolved to where the
2292
+ // reference document actually landed, so `geml get …/authoring.geml '#tables'`
2293
+ // is a command the reader can paste.
2294
+ function skillTextFor(src, installedAt) {
2295
+ const body = readFileSync(join(src, "SKILL.md"), "utf8").replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n+/, "");
2296
+ return `${SKILL_MARK_START}\n<!-- Written by \`geml skill install\`. Edit the source, not this block: it is replaced on the next run. -->\n\n${body.replace(/<skill-base>/g, installedAt.replace(/\\/g, "/")).trimEnd()}\n${SKILL_MARK_END}\n`;
2297
+ }
2298
+ function installOtherTools(src, installedAt, dryRun) {
2299
+ const block = skillTextFor(src, installedAt);
2300
+ let ok = 0;
2301
+ let failed = 0;
2302
+ for (const t of SKILL_TARGETS) {
2303
+ if (!existsSync(t.dir)) {
2304
+ console.log(`${t.name.padEnd(6)} not detected — skipped (${t.scope === "project" ? "no AGENTS.md here" : `no ${t.dir}`})`);
2305
+ continue;
2306
+ }
2307
+ // Reading is as failure-prone as writing — the name may be a directory, or
2308
+ // unreadable — so the WHOLE per-target step sits inside the guard. One bad
2309
+ // path is reported and stepped over; it never reaches the next target as a
2310
+ // stack trace.
2311
+ try {
2312
+ const had = existsSync(t.file) ? readFileSync(t.file, "utf8") : "";
2313
+ const s = had.indexOf(SKILL_MARK_START);
2314
+ const e = had.indexOf(SKILL_MARK_END);
2315
+ // A marker pair means we have been here: replace just that span, so the
2316
+ // file's own content survives an upgrade untouched.
2317
+ const next = s >= 0 && e > s
2318
+ ? had.slice(0, s) + block + had.slice(e + SKILL_MARK_END.length).replace(/^\r?\n/, "")
2319
+ : (had.trimEnd() ? `${had.trimEnd()}\n\n${block}` : block);
2320
+ if (next === had) {
2321
+ console.log(`${t.name.padEnd(6)} already current -> ${t.file}`);
2322
+ continue;
2323
+ }
2324
+ if (dryRun) {
2325
+ console.log(`${t.name.padEnd(6)} would ${s >= 0 ? "refresh" : "add"} the skill block -> ${t.file}`);
2326
+ continue;
2327
+ }
2328
+ // Atomic: this file can hold the person's own rules, and a half-written
2329
+ // one would destroy them. Write beside it, then rename over.
2330
+ const tmp = `${t.file}.geml-tmp`;
2331
+ writeFileSync(tmp, next);
2332
+ renameSync(tmp, t.file);
2333
+ console.log(`${t.name.padEnd(6)} ${s >= 0 ? "refreshed" : "added"} the skill block -> ${t.file}`);
2334
+ ok++;
2335
+ }
2336
+ catch (err) {
2337
+ // A read-only home, a file another process holds open, a name that is
2338
+ // not a file — say which target and why, then carry on.
2339
+ console.error(`${t.name.padEnd(6)} could not update ${t.file}: ${err instanceof Error ? err.message : String(err)}`);
2340
+ failed++;
2341
+ }
2342
+ }
2343
+ return { ok, failed };
2344
+ }
2345
+ function runSkill(args) {
2346
+ const sub = args[0];
2347
+ if (sub !== "install")
2348
+ fail(`unknown skill subcommand '${sub ?? ""}'.\n${SUBHELP.skill}`);
2349
+ const rest = args.slice(1);
2350
+ const flag = (name) => {
2351
+ const i = rest.indexOf(name);
2352
+ if (i >= 0)
2353
+ rest.splice(i, 1);
2354
+ return i >= 0;
2355
+ };
2356
+ const opt = (name) => {
2357
+ const i = rest.indexOf(name);
2358
+ if (i < 0)
2359
+ return undefined;
2360
+ const v = rest[i + 1];
2361
+ if (!v)
2362
+ fail(`${name} needs a value.\n${SUBHELP.skill}`);
2363
+ rest.splice(i, 2);
2364
+ return v;
2365
+ };
2366
+ const noGlobal = flag("--no-global");
2367
+ const noMcp = flag("--no-mcp");
2368
+ const dryRun = flag("--dry-run");
2369
+ const dest = opt("--dest") ?? join(homedir(), ".claude", "skills");
2370
+ if (rest.length)
2371
+ fail(`unexpected argument '${rest[0]}'.\n${SUBHELP.skill}`);
2372
+ // The skill ships inside the npm package, next to dist/ — the installed
2373
+ // skill text always matches the CLI version it teaches.
2374
+ const src = join(dirname(fileURLToPath(import.meta.url)), "..", "skill");
2375
+ if (!existsSync(join(src, "SKILL.md")))
2376
+ fail(`bundled skill not found at ${src} (broken install?)`, 1);
2377
+ const target = join(dest, "geml");
2378
+ const copied = [];
2379
+ const copyTree = (from, to) => {
2380
+ mkdirSync(to, { recursive: true });
2381
+ for (const e of readdirSync(from, { withFileTypes: true })) {
2382
+ // Never ship a history sidecar — skill and config docs carry none.
2383
+ if (e.name.endsWith(".gemlhistory"))
2384
+ continue;
2385
+ const f = join(from, e.name);
2386
+ const t = join(to, e.name);
2387
+ if (e.isDirectory())
2388
+ copyTree(f, t);
2389
+ else {
2390
+ copyFileSync(f, t);
2391
+ copied.push(relative(dest, t));
2392
+ }
2393
+ }
2394
+ };
2395
+ let ok = 0;
2396
+ let failed = 0;
2397
+ if (dryRun) {
2398
+ console.log(`skill would install -> ${target}`);
2399
+ }
2400
+ else {
2401
+ try {
2402
+ copyTree(src, target);
2403
+ console.log(`skill installed -> ${target} (${copied.join(", ")})`);
2404
+ ok++;
2405
+ }
2406
+ catch (e) {
2407
+ // Not fatal, and deliberately so: an unwritable `~/.claude` — a locked
2408
+ // file, a read-only home, a name that is not a directory — must not stop
2409
+ // the tools that CAN be installed. A clean one-liner, never a raw stack.
2410
+ console.error(`skill could not install to ${target}: ${e instanceof Error ? e.message : String(e)}`);
2411
+ failed++;
2412
+ }
2413
+ }
2414
+ const other = installOtherTools(src, target, dryRun);
2415
+ ok += other.ok;
2416
+ failed += other.failed;
2417
+ // Windows npm/claude/geml are .cmd shims: they need a shell. Every argument
2418
+ // below is a fixed literal, so shell:true adds no injection surface.
2419
+ const sh = process.platform === "win32";
2420
+ const run = (cmd, a, inherit = false) => spawnSync(cmd, a, { shell: sh, encoding: "utf8", ...(inherit ? { stdio: "inherit" } : {}) });
2421
+ if (!noGlobal) {
2422
+ const have = run("geml", ["--version"]);
2423
+ if (have.status === 0) {
2424
+ console.log(`cli ${String(have.stdout ?? "").trim()} already on PATH`);
2425
+ }
2426
+ else {
2427
+ console.log("cli installing @geml/geml globally (npm i -g)...");
2428
+ const r = run("npm", ["install", "-g", "@geml/geml", "--no-audit", "--no-fund", "--loglevel=error"], true);
2429
+ if (r.status !== 0)
2430
+ console.error("cli global install failed — install later with: npm i -g @geml/geml");
2431
+ }
2432
+ }
2433
+ if (!noMcp) {
2434
+ const REG = "claude mcp add --scope user geml -- npx -y @geml/geml mcp --root .";
2435
+ const claude = run("claude", ["--version"]);
2436
+ if (claude.status !== 0) {
2437
+ console.log(`mcp claude CLI not found — register later with: ${REG}`);
2438
+ }
2439
+ else if (run("claude", ["mcp", "get", "geml"]).status === 0) {
2440
+ console.log("mcp server 'geml' already registered");
2441
+ }
2442
+ else {
2443
+ const r = run("claude", ["mcp", "add", "--scope", "user", "geml", "--", "npx", "-y", "@geml/geml", "mcp", "--root", "."]);
2444
+ if (r.status === 0)
2445
+ console.log("mcp registered user-scope server 'geml' (confined to each session's project directory)");
2446
+ else
2447
+ console.error(`mcp registration failed (${String(r.stderr ?? "").trim() || "unknown"}) — register later with: ${REG}`);
2448
+ }
2449
+ }
2450
+ // Every step is independent, so a single unwritable path is reported and
2451
+ // stepped over. Exit non-zero only when NOTHING landed — that is the one
2452
+ // outcome a caller has to react to; a partial install is still an install.
2453
+ if (failed > 0 && ok === 0) {
2454
+ console.error(`nothing was installed (${failed} target(s) failed) — see the messages above.`);
2455
+ process.exit(1);
2456
+ }
2457
+ if (failed > 0)
2458
+ console.log(`done — ${ok} target(s) installed, ${failed} skipped after an error.`);
2459
+ else
2460
+ console.log("done — new Claude Code sessions pick up the skill.");
2461
+ process.exit(0);
2462
+ }
2463
+ // npm's unix bin shim is a symlink named plain `geml`, so detect "run as a
2464
+ // CLI" by resolving argv[1] to its real path, not by its spelling.
2465
+ const entry = (() => {
2466
+ const argv1 = process.argv[1];
2467
+ if (!argv1)
2468
+ return "";
2469
+ try {
2470
+ return realpathSync(argv1);
2471
+ }
2472
+ catch {
2473
+ return argv1;
2474
+ }
2475
+ })();
2476
+ // This module IS the command line — importing it means running it. There is no
2477
+ // entry test any more, and there must not be: the legacy `dist/geml.js` entry
2478
+ // reaches this file through a dynamic import, so argv[1] names *that* file, and
2479
+ // a test comparing it against this one would silently do nothing (it did).
2480
+ // `entry` is still computed above, because a couple of messages report it.
2481
+ {
2482
+ void entry;
2483
+ const argv = process.argv.slice(2);
2484
+ // The on-disk artifact is `.geml-code-graph/`, so people reconstruct the
2485
+ // command from the directory name — accept those spellings as `codemap`.
2486
+ const cmd = argv[0] === "codegraph" || argv[0] === "code-graph" ? "codemap" : argv[0];
2487
+ jsonMode = argv.includes("--json");
2488
+ const rest = argv.slice(1);
2489
+ if (cmd === "--help" || cmd === "-h") {
2490
+ console.log(USAGE);
2491
+ }
2492
+ else if (cmd === "--version" || cmd === "-V") {
2493
+ if (jsonMode)
2494
+ console.log(JSON.stringify({ parser: PARSER_VERSION, spec: VERSION }));
2495
+ else
2496
+ console.log(`geml ${PARSER_VERSION} (GEML spec ${VERSION})`);
2497
+ }
2498
+ else if (cmd === undefined) {
2499
+ console.error(USAGE);
2500
+ process.exit(2);
2501
+ }
2502
+ else if (SUBHELP[cmd] && (rest.includes("--help") || rest.includes("-h"))) {
2503
+ // `geml <cmd> --help` is a help request, not a usage error: usage to
2504
+ // stdout, exit 0 — never the `error:`-prefixed exit-2 path.
2505
+ console.log(SUBHELP[cmd]);
2506
+ }
2507
+ else if (cmd === "get") {
2508
+ runGet(argv.slice(1));
2509
+ }
2510
+ else if (cmd === "list") {
2511
+ runList(argv.slice(1));
2512
+ }
2513
+ else if (cmd === "find") {
2514
+ runFind(argv.slice(1));
2515
+ }
2516
+ else if (cmd === "set") {
2517
+ runSet(argv.slice(1));
2518
+ }
2519
+ else if (cmd === "replace") {
2520
+ runReplace(argv.slice(1));
2521
+ }
2522
+ else if (cmd === "add") {
2523
+ runAdd(argv.slice(1));
2524
+ }
2525
+ else if (cmd === "delete") {
2526
+ runDelete(argv.slice(1));
2527
+ }
2528
+ else if (cmd === "rename") {
2529
+ runRename(argv.slice(1));
2530
+ }
2531
+ else if (cmd === "revert") {
2532
+ runRevert(argv.slice(1));
2533
+ }
2534
+ else if (cmd === "history") {
2535
+ runHistory(argv.slice(1));
2536
+ }
2537
+ else if (cmd === "check") {
2538
+ runCheck(argv.slice(1));
2539
+ }
2540
+ else if (cmd === "codemap") {
2541
+ runCodemap(argv.slice(1));
2542
+ }
2543
+ else if (cmd === "mcp") {
2544
+ runMcp(argv.slice(1));
2545
+ }
2546
+ else if (cmd === "skill") {
2547
+ runSkill(argv.slice(1));
2548
+ }
2549
+ else if (cmd !== "-" && !/[.\/\\]/.test(cmd)) {
2550
+ // A bare word that is neither a known command nor a path is almost always
2551
+ // a mistyped command — say so, don't try to read it as a file. (The
2552
+ // reclaimed verbs render/export/fmt/convert land here too.)
2553
+ fail(`unknown command '${cmd}'. Run 'geml --help'.`);
2554
+ }
2555
+ else {
2556
+ // A file (or stdin via '-') is the transform entry: `--to`/`--from`/`-o`,
2557
+ // default `--to json`. The single door for every format conversion.
2558
+ runTransform(argv);
2559
+ }
2560
+ }