mellos-mapping 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -12,37 +12,106 @@
12
12
  * directory, which is exactly the contract dist/server.mjs already expects.
13
13
  * The registered path is absolute and version-specific, so re-run after
14
14
  * every plugin update.
15
+ *
16
+ * Two Windows facts shape the process handling below:
17
+ * - A missing `codex` is NOT a spawn error there. The call goes through
18
+ * cmd.exe (only a shell resolves both codex.exe and an npm .cmd shim), so
19
+ * "not recognized as an internal or external command" arrives as an
20
+ * ordinary non-zero exit, and a check for spawnSync's `error` field can
21
+ * never fire. PATH is probed up front instead.
22
+ * - The child writes its diagnostics in the console's OEM code page.
23
+ * Decoding those bytes as UTF-8 turns every non-English message into
24
+ * mojibake, so they are passed through as bytes and the terminal decodes
25
+ * them as it decodes everything else it prints.
26
+ *
27
+ * Pure helpers are exported for the spec; registration runs only as an entry
28
+ * point, so importing this file is inert.
15
29
  */
16
30
  import { spawnSync } from 'node:child_process';
31
+ import { realpathSync } from 'node:fs';
17
32
  import { dirname, join } from 'node:path';
18
33
  import { fileURLToPath } from 'node:url';
19
34
 
20
- const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
21
- const serverPath = join(pluginRoot, 'dist', 'server.mjs');
35
+ /** cmd.exe's exit code for a command it cannot resolve on PATH. */
36
+ export const COMMAND_NOT_FOUND_EXIT_CODE = 9009;
37
+
38
+ /** Quote one argument for a cmd.exe command line. */
39
+ export function quoteForShell(arg) {
40
+ return /[\s"]/.test(arg) ? `"${arg}"` : arg;
41
+ }
42
+
43
+ /**
44
+ * What went wrong, in the user's terms.
45
+ * @param status - the child's exit code (null = it never ran).
46
+ * @returns the line to print; a missing CLI is named as such on every
47
+ * platform, because that is the failure a first-time reader actually hits.
48
+ */
49
+ export function describeFailure(status) {
50
+ return status === COMMAND_NOT_FOUND_EXIT_CODE || status === null
51
+ ? 'codex CLI not found on PATH — install Codex first, then re-run this script.'
52
+ : `codex mcp add failed (exit ${status}).`;
53
+ }
54
+
55
+ /** Whether `codex` resolves on PATH. */
56
+ function codexOnPath() {
57
+ const probe =
58
+ process.platform === 'win32'
59
+ ? spawnSync('where', ['codex'], { stdio: 'ignore', windowsHide: true })
60
+ : spawnSync('command', ['-v', 'codex'], { shell: true, stdio: 'ignore' });
61
+ return probe.error === undefined && probe.status === 0;
62
+ }
22
63
 
64
+ /**
65
+ * Run the codex CLI. Output is captured as BYTES (no `encoding`), so the
66
+ * child's console-code-page diagnostics reach the terminal unaltered.
67
+ */
23
68
  function codex(args) {
24
69
  if (process.platform !== 'win32') {
25
- return spawnSync('codex', args, { stdio: 'pipe', encoding: 'utf8' });
70
+ return spawnSync('codex', args, { stdio: 'pipe' });
26
71
  }
27
72
  // Windows: codex may be an .exe or an npm .cmd shim; only a shell resolves
28
73
  // both. The shell needs explicit quoting for paths with spaces.
29
- const line = ['codex', ...args].map((a) => (/[\s"]/.test(a) ? `"${a}"` : a)).join(' ');
30
- return spawnSync(line, { shell: true, stdio: 'pipe', encoding: 'utf8' });
74
+ const line = ['codex', ...args].map(quoteForShell).join(' ');
75
+ return spawnSync(line, { shell: true, stdio: 'pipe', windowsHide: true });
31
76
  }
32
77
 
33
- // Idempotent: drop any previous (possibly stale, version-specific) entry.
34
- codex(['mcp', 'remove', 'mellos-mapping']);
35
-
36
- const added = codex(['mcp', 'add', 'mellos-mapping', '--', 'node', serverPath]);
37
- if (added.error !== undefined || added.status !== 0) {
38
- process.stderr.write(added.stderr ?? '');
39
- console.error(
40
- added.error !== undefined
41
- ? 'codex CLI not found on PATH — install Codex first, then re-run this script.'
42
- : `codex mcp add failed (exit ${added.status}).`,
43
- );
44
- process.exit(1);
78
+ function main() {
79
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
80
+ const serverPath = join(pluginRoot, 'dist', 'server.mjs');
81
+
82
+ if (!codexOnPath()) {
83
+ console.error(describeFailure(COMMAND_NOT_FOUND_EXIT_CODE));
84
+ process.exit(1);
85
+ }
86
+
87
+ // Idempotent: drop any previous (possibly stale, version-specific) entry.
88
+ codex(['mcp', 'remove', 'mellos-mapping']);
89
+
90
+ const added = codex(['mcp', 'add', 'mellos-mapping', '--', 'node', serverPath]);
91
+ if (added.status !== 0) {
92
+ if (added.stderr) process.stderr.write(added.stderr);
93
+ console.error(describeFailure(added.status));
94
+ process.exit(1);
95
+ }
96
+ if (added.stdout) process.stdout.write(added.stdout);
97
+ console.log(`mellos-mapping MCP registered with Codex: node ${serverPath}`);
98
+ console.log('State files resolve to each session’s working directory (.mellos/map.json).');
45
99
  }
46
- process.stdout.write(added.stdout ?? '');
47
- console.log(`mellos-mapping MCP registered with Codex: node ${serverPath}`);
48
- console.log('State files resolve to each session’s working directory (.mellos/map.json).');
100
+
101
+ /**
102
+ * Run only as an entry point, so the spec can import the helpers above
103
+ * without registering anything. Each script in this directory carries its own
104
+ * copy: package.json ships them file-by-file, so a shared scripts/ module
105
+ * would simply be missing from an npm install.
106
+ */
107
+ function launchedAsEntry() {
108
+ const entry = process.argv[1];
109
+ if (entry === undefined) return false;
110
+ try {
111
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ if (launchedAsEntry()) main();
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Put `mmap` on the PATH of a plugin install.
4
+ *
5
+ * node scripts/install-mmap-command.mjs [--uninstall] [--json]
6
+ *
7
+ * npm users get `mmap` for free — package.json declares it in `bin`. Claude
8
+ * Code installs this plugin by CLONING the repo, with no npm install and no
9
+ * bin shims, so the one command a human is meant to type has nowhere to be
10
+ * typed from. This script is that missing step, and nothing more: it writes
11
+ * two tiny shims into `%LOCALAPPDATA%\mellos-mapping\bin` — `mmap.cmd` for
12
+ * cmd/PowerShell, `mmap` for git-bash — and adds that one directory to the
13
+ * USER PATH.
14
+ *
15
+ * Nobody has to run it by hand: the SessionStart hook
16
+ * (src/hook/session-start.ts) notices a missing or stale shim when a session
17
+ * starts and runs this script with `--json`, which prints the outcome as one
18
+ * JSON line instead of prose — that outcome object is the contract between
19
+ * the two files. The script stays a standalone command for the cases the
20
+ * hook does not cover: `--uninstall`, and re-adding a PATH entry the user
21
+ * removed while the shims still exist (the hook checks only the shims).
22
+ *
23
+ * What it will NEVER do, and says so instead:
24
+ *
25
+ * - Touch the SYSTEM PATH. This is a per-user tool.
26
+ * - Rewrite a user PATH that `setx` cannot carry safely. `setx` writes a
27
+ * plain string and truncates past SETX_VALUE_MAX, so a PATH holding
28
+ * `%USERPROFILE%`-style references would come back with the references
29
+ * flattened, and a long one would come back SHORTER. Both are damage that
30
+ * outlives this tool, so in either case the shims are still installed and
31
+ * the exact line to add by hand is printed.
32
+ *
33
+ * Windows-first, deliberately: this plugin's pane is Windows Terminal. On
34
+ * other platforms `mmap` is `npm i -g mellos-mapping`, or a shell alias.
35
+ *
36
+ * Pure helpers are exported for the spec; the installer runs only as an entry
37
+ * point, so importing this file is inert.
38
+ */
39
+ import { spawnSync } from 'node:child_process';
40
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+
43
+ import { launchedAsEntry, pluginRootOf } from './pane-core.mjs';
44
+
45
+ export const USAGE = 'usage: node scripts/install-mmap-command.mjs [--uninstall] [--json]';
46
+
47
+ /**
48
+ * Longest value `setx` stores without truncating it, in characters. Documented
49
+ * by the tool itself ("the maximum size is 1024 characters") and the reason
50
+ * this script would rather refuse than shorten someone's PATH.
51
+ */
52
+ export const SETX_VALUE_MAX = 1024;
53
+
54
+ /** The one directory this installer owns, given a `%LOCALAPPDATA%`. */
55
+ export function binDirIn(localAppData) {
56
+ return join(localAppData, 'mellos-mapping', 'bin');
57
+ }
58
+
59
+ /** The cmd/PowerShell shim: forwards every argument, prints nothing of its own. */
60
+ export function cmdShim(mmapPath) {
61
+ return ['@echo off', `node "${mmapPath}" %*`, ''].join('\r\n');
62
+ }
63
+
64
+ /**
65
+ * The git-bash shim. The target is written with forward slashes: a Windows
66
+ * path inside sh double quotes keeps its backslashes verbatim, and `\U` in
67
+ * `C:\Users` is one escape away from being someone else's bug.
68
+ */
69
+ export function shShim(mmapPath) {
70
+ return ['#!/bin/sh', `exec node "${mmapPath.replaceAll('\\', '/')}" "$@"`, ''].join('\n');
71
+ }
72
+
73
+ /** PATH entries as written, empties dropped — the unit every rule below works on. */
74
+ function entriesOf(rawPath) {
75
+ return rawPath.split(';').filter((e) => e.trim() !== '');
76
+ }
77
+
78
+ /** Two PATH entries naming the same directory: case and a trailing slash do not count. */
79
+ function samePathEntry(a, b) {
80
+ const norm = (s) => s.trim().replace(/^"|"$/g, '').replace(/[\\/]+$/, '').toLowerCase();
81
+ return norm(a) === norm(b);
82
+ }
83
+
84
+ /** Is `dir` already on this PATH? Then installing it again must change nothing. */
85
+ export function pathContains(rawPath, dir) {
86
+ return entriesOf(rawPath).some((e) => samePathEntry(e, dir));
87
+ }
88
+
89
+ /** `rawPath` with `dir` appended — unchanged when it is already there. */
90
+ export function pathWith(rawPath, dir) {
91
+ return pathContains(rawPath, dir) ? rawPath : [...entriesOf(rawPath), dir].join(';');
92
+ }
93
+
94
+ /** `rawPath` with every mention of `dir` removed. */
95
+ export function pathWithout(rawPath, dir) {
96
+ return entriesOf(rawPath)
97
+ .filter((e) => !samePathEntry(e, dir))
98
+ .join(';');
99
+ }
100
+
101
+ /**
102
+ * Why `setx` must not be used to write `nextValue`, or undefined when it can.
103
+ *
104
+ * @param rawValue - the USER PATH exactly as the registry holds it.
105
+ * @param nextValue - what this script would write.
106
+ * @returns a sentence naming the damage that would be done, for the caller to
107
+ * print beside the manual instructions.
108
+ */
109
+ export function setxRefusal(rawValue, nextValue) {
110
+ if (rawValue.includes('%')) {
111
+ return 'your user PATH contains %VARIABLE% references, and setx would store them flattened to whatever they mean right now';
112
+ }
113
+ if (nextValue.length > SETX_VALUE_MAX) {
114
+ return `your user PATH would be ${nextValue.length} characters and setx truncates past ${SETX_VALUE_MAX}`;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /**
120
+ * What bringing the PATH from `raw` to `wanted` takes — decided, not done.
121
+ * The single place the write/refuse/no-op judgment lives; both the prose
122
+ * path and the `--json` path execute exactly this plan.
123
+ * @returns {{action: 'unchanged'} | {action: 'write'} | {action: 'refused', reason: string}}
124
+ */
125
+ export function planPath(raw, wanted) {
126
+ if (wanted === raw) return { action: 'unchanged' };
127
+ const reason = setxRefusal(raw, wanted);
128
+ return reason === undefined ? { action: 'write' } : { action: 'refused', reason };
129
+ }
130
+
131
+ /**
132
+ * The USER PATH exactly as the registry holds it — unexpanded, so a
133
+ * `%USERPROFILE%` in it is still a `%USERPROFILE%` here.
134
+ *
135
+ * node's own `process.env.Path` is the merged, expanded, per-process value and
136
+ * writing THAT back would fold the machine's PATH into the user's.
137
+ * @returns the raw value, or '' when the user has no PATH of their own.
138
+ */
139
+ function readUserPath() {
140
+ const r = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf8', windowsHide: true });
141
+ const m = (r.stdout ?? '').match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*)$/m);
142
+ return m === null ? '' : m[1].trimEnd();
143
+ }
144
+
145
+ /** Ask Windows to store the user's PATH, and to tell running shells about it. */
146
+ function writeUserPath(value) {
147
+ const r = spawnSync('setx', ['PATH', value], { encoding: 'utf8', windowsHide: true });
148
+ return r.status === 0 ? { ok: true, value: undefined } : { ok: false, error: (r.stderr || r.stdout || '').trim() };
149
+ }
150
+
151
+ /**
152
+ * Bring the USER PATH to where it should be, saying out loud what changes
153
+ * before anything does.
154
+ * @param wanted - the PATH value this run wants.
155
+ * @param raw - the PATH value as it stands.
156
+ */
157
+ function applyPath(raw, wanted) {
158
+ const plan = planPath(raw, wanted);
159
+ if (plan.action === 'unchanged') {
160
+ console.log('Your user PATH already says what it should — leaving it alone.');
161
+ return;
162
+ }
163
+ if (plan.action === 'refused') {
164
+ console.log(`NOT touching your user PATH: ${plan.reason}.`);
165
+ console.log('Add (or remove) this entry yourself, in Settings > "Edit environment variables for your account":');
166
+ console.log(` ${wanted}`);
167
+ return;
168
+ }
169
+ console.log('Changing ONE thing in your Windows user environment — your user PATH becomes:');
170
+ console.log(` ${wanted}`);
171
+ const written = writeUserPath(wanted);
172
+ if (!written.ok) {
173
+ console.error(`setx refused: ${written.error}`);
174
+ process.exit(1);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The install itself, with the narration stripped out: write the shims, bring
180
+ * the PATH in line, report what happened as data. This outcome object IS the
181
+ * `--json` output and the contract the SessionStart hook reads — change a
182
+ * field here and src/hook/session-start.ts must follow.
183
+ *
184
+ * @returns one of
185
+ * {kind: 'not-built', missing} — dist/mmap.mjs absent, nothing written;
186
+ * {kind: 'installed', binDir, cmdPath, shPath, path, wanted, reason?} —
187
+ * shims written; `path` says what happened to the USER PATH:
188
+ * 'unchanged' — the entry was already there,
189
+ * 'updated' — the entry was appended,
190
+ * 'refused' — setx would damage this PATH (`reason` says how); the
191
+ * entry in `wanted` must be added by hand,
192
+ * 'error' — setx itself failed (`reason` is its message).
193
+ */
194
+ export function install(localAppData, pluginRoot) {
195
+ const mmapPath = join(pluginRoot, 'dist', 'mmap.mjs');
196
+ if (!existsSync(mmapPath)) return { kind: 'not-built', missing: mmapPath };
197
+
198
+ const binDir = binDirIn(localAppData);
199
+ const cmdPath = join(binDir, 'mmap.cmd');
200
+ const shPath = join(binDir, 'mmap');
201
+ mkdirSync(binDir, { recursive: true });
202
+ writeFileSync(cmdPath, cmdShim(mmapPath));
203
+ writeFileSync(shPath, shShim(mmapPath));
204
+ try {
205
+ chmodSync(shPath, 0o755); // git-bash honors the execute bit it can see
206
+ } catch {
207
+ // A filesystem without POSIX modes still runs the shim through `sh`;
208
+ // refusing to install over a chmod would be theatre.
209
+ }
210
+
211
+ const raw = readUserPath();
212
+ const wanted = pathWith(raw, binDir);
213
+ const plan = planPath(raw, wanted);
214
+ if (plan.action === 'unchanged') return { kind: 'installed', binDir, cmdPath, shPath, path: 'unchanged', wanted };
215
+ if (plan.action === 'refused') {
216
+ return { kind: 'installed', binDir, cmdPath, shPath, path: 'refused', wanted, reason: plan.reason };
217
+ }
218
+ const written = writeUserPath(wanted);
219
+ return written.ok
220
+ ? { kind: 'installed', binDir, cmdPath, shPath, path: 'updated', wanted }
221
+ : { kind: 'installed', binDir, cmdPath, shPath, path: 'error', wanted, reason: written.error };
222
+ }
223
+
224
+ function main() {
225
+ const argv = process.argv.slice(2);
226
+ const uninstall = argv.includes('--uninstall');
227
+ const json = argv.includes('--json');
228
+ const unknown = argv.find((a) => a !== '--uninstall' && a !== '--json');
229
+ if (unknown !== undefined) {
230
+ console.error(`unknown argument "${unknown}"\n${USAGE}`);
231
+ process.exit(1);
232
+ }
233
+ if (uninstall && json) {
234
+ console.error(`--uninstall talks to a human; it has no --json mode\n${USAGE}`);
235
+ process.exit(1);
236
+ }
237
+ if (process.platform !== 'win32') {
238
+ console.error('This installer is for Windows. Elsewhere, `npm i -g mellos-mapping` provides the same `mmap` command.');
239
+ process.exit(1);
240
+ }
241
+ const localAppData = process.env['LOCALAPPDATA'];
242
+ if (localAppData === undefined || localAppData === '') {
243
+ console.error('LOCALAPPDATA is not set — there is no per-user place to install into.');
244
+ process.exit(1);
245
+ }
246
+
247
+ if (uninstall) {
248
+ const binDir = binDirIn(localAppData);
249
+ const raw = readUserPath();
250
+ for (const path of [join(binDir, 'mmap.cmd'), join(binDir, 'mmap')]) {
251
+ rmSync(path, { force: true });
252
+ console.log(`removed ${path}`);
253
+ }
254
+ applyPath(raw, pathWithout(raw, binDir));
255
+ console.log('Close your terminal app entirely and reopen it for the change to take effect — a new tab keeps the old PATH.');
256
+ return;
257
+ }
258
+
259
+ const outcome = install(localAppData, pluginRootOf(import.meta.url));
260
+ if (json) {
261
+ console.log(JSON.stringify(outcome));
262
+ return;
263
+ }
264
+ if (outcome.kind === 'not-built') {
265
+ console.error(`the plugin is not built — run "npm run build" (missing ${outcome.missing})`);
266
+ process.exit(1);
267
+ }
268
+ console.log(`wrote ${outcome.cmdPath}`);
269
+ console.log(`wrote ${outcome.shPath}`);
270
+ switch (outcome.path) {
271
+ case 'unchanged':
272
+ console.log('Your user PATH already says what it should — leaving it alone.');
273
+ break;
274
+ case 'updated':
275
+ console.log('Changed ONE thing in your Windows user environment — your user PATH is now:');
276
+ console.log(` ${outcome.wanted}`);
277
+ break;
278
+ case 'refused':
279
+ console.log(`NOT touching your user PATH: ${outcome.reason}.`);
280
+ console.log('Add this entry yourself, in Settings > "Edit environment variables for your account":');
281
+ console.log(` ${outcome.binDir}`);
282
+ break;
283
+ case 'error':
284
+ console.error(`setx refused: ${outcome.reason}`);
285
+ process.exit(1);
286
+ }
287
+ console.log(
288
+ 'Close Windows Terminal entirely and reopen it — a running terminal, new tabs included, keeps the old PATH.',
289
+ );
290
+ console.log('Then type `mmap` in any project: it opens the map pane, or closes the open one.');
291
+ }
292
+
293
+ if (launchedAsEntry(process.argv[1], import.meta.url)) main();
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `mmap` — the human's entrance to the map pane. A TOGGLE.
4
+ *
5
+ * mmap open the pane for this project, or close the one
6
+ * that is already open
7
+ * mmap <slug> open the pane ON that page, or retarget the pane
8
+ * that is already open. Never closes anything:
9
+ * naming a page is asking to SEE it
10
+ * mmap [--window] [--ascii] [--no-color] [--no-mouse] [--no-follow]
11
+ * [--interval <ms>] [--force]
12
+ *
13
+ * Two things make this a different command from scripts/open-pane.mjs rather
14
+ * than a flag on it:
15
+ *
16
+ * 1. It has no project argument. A human types `mmap` wherever they happen
17
+ * to be standing, so the project is DISCOVERED — walk up from the cwd to
18
+ * the nearest directory holding the store, the way git finds its root.
19
+ * 2. It closes. `mmap` with a pane open writes the store's one-shot quit
20
+ * request and the pane exits within a poll tick. The agent's launcher
21
+ * deliberately cannot do that.
22
+ *
23
+ * Everything else — the window probe, the already-running check, the `wt`
24
+ * payload — is ./pane-core.mjs, shared verbatim with open-pane.mjs.
25
+ *
26
+ * Pure helpers are exported for the spec; the command runs only as an entry
27
+ * point, so importing this file is inert.
28
+ */
29
+ import { existsSync } from 'node:fs';
30
+ import { dirname, join, resolve } from 'node:path';
31
+
32
+ import {
33
+ DEDICATED_WINDOW_NAME,
34
+ PANE_MODE,
35
+ launchedAsEntry,
36
+ loadPluginPaths,
37
+ placePane,
38
+ pluginRootOf,
39
+ takeWatcherFlag,
40
+ paneIsOpen,
41
+ writeFocusRequest,
42
+ writeQuitRequest,
43
+ } from './pane-core.mjs';
44
+
45
+ export const USAGE =
46
+ 'usage: mmap [<page-slug>] [--window] [--force] [--ascii] [--no-color] [--no-mouse]' +
47
+ ' [--no-follow] [--interval <ms>]\n' +
48
+ ' bare `mmap` toggles: it opens the map pane for this project, or closes the open one.';
49
+
50
+ /**
51
+ * Parse the toggle's command line.
52
+ *
53
+ * @param argv - arguments after the script path.
54
+ * @param idRule - the store's page-slug grammar (ID_RULE), passed in rather
55
+ * than restated, so this parser cannot drift from the ids the store accepts.
56
+ * @returns ok(config) or err(message). One positional argument at most, and it
57
+ * is a PAGE SLUG — not a directory: `mmap` is typed inside the project it
58
+ * means. An unknown flag is refused rather than dropped, exactly as the
59
+ * watcher and the agent launcher refuse one.
60
+ */
61
+ export function parseMmapArgs(argv, idRule) {
62
+ const positional = [];
63
+ const watcherFlags = [];
64
+ let mode = PANE_MODE.split;
65
+ let force = false;
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ const watcher = takeWatcherFlag(argv, i);
69
+ if (watcher.kind === 'bad-value') return { ok: false, error: `${watcher.message}\n${USAGE}` };
70
+ if (watcher.kind === 'taken') {
71
+ watcherFlags.push(...watcher.flags);
72
+ i = watcher.next;
73
+ } else if (a === '--window') {
74
+ mode = PANE_MODE.window;
75
+ } else if (a === '--force') {
76
+ force = true;
77
+ } else if (a === '--page') {
78
+ // The slug is the positional here; accepting both spellings would make
79
+ // `mmap api --page other` a question with no answer.
80
+ return { ok: false, error: `mmap takes the page slug as its argument, not --page\n${USAGE}` };
81
+ } else if (a.startsWith('--')) {
82
+ return { ok: false, error: `unknown flag "${a}"\n${USAGE}` };
83
+ } else {
84
+ positional.push(a);
85
+ }
86
+ }
87
+ if (positional.length > 1) return { ok: false, error: `mmap takes at most one page slug\n${USAGE}` };
88
+ const pageSlug = positional[0];
89
+ if (pageSlug !== undefined && !idRule.test(pageSlug)) {
90
+ return { ok: false, error: `a page is a kebab-case slug (got "${pageSlug}")\n${USAGE}` };
91
+ }
92
+ return { ok: true, value: { pageSlug, mode, force, watcherFlags } };
93
+ }
94
+
95
+ /**
96
+ * The directories to look in for a project's store, nearest first: the
97
+ * starting directory, then every ancestor up to and including the filesystem
98
+ * root.
99
+ *
100
+ * Pure on purpose — the caller asks the filesystem which of these actually
101
+ * holds a store — so the walk itself is specifiable without a project tree.
102
+ */
103
+ export function storeSearchPath(startDir) {
104
+ const dirs = [];
105
+ let dir = resolve(startDir);
106
+ for (;;) {
107
+ dirs.push(dir);
108
+ const parent = dirname(dir);
109
+ if (parent === dir) return dirs; // the filesystem root is its own parent
110
+ dir = parent;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The directory whose presence marks a project, taken from the store's own
116
+ * default-page path (its first segment). Derived rather than restated: this
117
+ * script must not carry a second copy of the store's layout.
118
+ */
119
+ export function storeMarkerOf(storeRelativePath) {
120
+ return storeRelativePath.split(/[\\/]/)[0];
121
+ }
122
+
123
+ /**
124
+ * Which project `mmap` was typed in: the NEAREST candidate that holds a store,
125
+ * the way git discovers its root.
126
+ *
127
+ * @param candidates - storeSearchPath's answer, nearest first.
128
+ * @param holdsStore - whether each candidate holds the store directory, in the
129
+ * same order. A parallel array of answers rather than a predicate to call:
130
+ * the filesystem question belongs to the entry point, and this rule is then
131
+ * a fold over values that a spec can state in full.
132
+ * @returns the project root, and whether a store was actually found —
133
+ * `found: false` means the starting directory is being used because nothing
134
+ * above it has a map yet, which is a legitimate first open, not an error.
135
+ */
136
+ export function nearestProject(candidates, holdsStore) {
137
+ const at = holdsStore.indexOf(true);
138
+ return at >= 0 ? { root: candidates[at], found: true } : { root: candidates[0], found: false };
139
+ }
140
+
141
+ /**
142
+ * What a `mmap` invocation means, given whether a pane is already up.
143
+ *
144
+ * The toggle rule in one place: a BARE `mmap` against a running pane closes
145
+ * it; naming a page never does — asking to see something is not asking to
146
+ * lose it — and retargets the running pane instead. With no pane running,
147
+ * every form opens one.
148
+ *
149
+ * @param running - a watcher for this project's map file is alive.
150
+ * @param pageSlug - the page named on the command line, if any.
151
+ * @param force - `--force`: open another pane regardless.
152
+ */
153
+ export function toggleAction(running, pageSlug, force = false) {
154
+ if (!running || force) return { kind: 'open' };
155
+ return pageSlug === undefined ? { kind: 'quit' } : { kind: 'focus', page: pageSlug };
156
+ }
157
+
158
+ async function main() {
159
+ const loaded = await loadPluginPaths(pluginRootOf(import.meta.url));
160
+ if (!loaded.ok) {
161
+ console.error(loaded.error);
162
+ process.exit(1);
163
+ }
164
+ const { store, watchPath } = loaded.value;
165
+
166
+ const parsed = parseMmapArgs(process.argv.slice(2), store.ID_RULE);
167
+ if (!parsed.ok) {
168
+ console.error(parsed.error);
169
+ process.exit(1);
170
+ }
171
+ if (process.platform !== 'win32') {
172
+ console.error('mmap opens the pane through Windows Terminal — on other platforms run the watcher yourself:');
173
+ console.error(` node "${watchPath}" --file "<project>/${store.STATE_FILE_RELATIVE_PATH}"`);
174
+ process.exit(1);
175
+ }
176
+
177
+ const candidates = storeSearchPath(process.cwd());
178
+ const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
179
+ const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
180
+ const cfg = { ...parsed.value, projectDir: project.root };
181
+ const mapFile = join(project.root, store.STATE_FILE_RELATIVE_PATH);
182
+ const action = toggleAction(paneIsOpen(store, mapFile), cfg.pageSlug, cfg.force);
183
+
184
+ if (action.kind === 'quit') {
185
+ writeQuitRequest(store.quitFilePath(mapFile));
186
+ console.log(`Closing the map pane for ${project.root}.`);
187
+ return;
188
+ }
189
+ if (action.kind === 'focus') {
190
+ writeFocusRequest(store.focusFilePath(mapFile), action.page);
191
+ console.log(`The map pane for ${project.root} is already open — showing page "${action.page}".`);
192
+ return;
193
+ }
194
+
195
+ const placed = placePane(cfg, watchPath, mapFile);
196
+ if (!placed.ok) {
197
+ console.error(placed.error);
198
+ process.exit(1);
199
+ }
200
+ const where =
201
+ placed.value.mode === PANE_MODE.window
202
+ ? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})`
203
+ : 'beside this terminal (vertical split)';
204
+ console.log(`Map opened for ${project.root} ${where}.`);
205
+ if (!project.found) {
206
+ console.log(
207
+ 'This project has no map yet, so the pane will sit on its standby screen ' +
208
+ 'until the assistant declares one.',
209
+ );
210
+ }
211
+ }
212
+
213
+ if (launchedAsEntry(process.argv[1], import.meta.url)) await main();