@mnemahq/cli 0.4.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,7 +50,27 @@ contents. See the [privacy details](https://mnema.theboringpeople.in/docs/connec
50
50
 
51
51
  `--origin <url>` (or `MNEMA_API_ORIGIN`) points the CLI at a self-hosted instance.
52
52
 
53
- Requires Node 18+ and `git`.
53
+ Requires Node 20+ and `git` — the same floor `@mnemahq/sdk` already declares.
54
+
55
+ ## The interactive briefing
56
+
57
+ Run `mnema` with no arguments in a terminal and it opens a navigable briefing:
58
+ pulse, then the ranked findings, with `enter` to drill into one.
59
+
60
+ ```
61
+ mnema the briefing (a terminal, Node 20+)
62
+ mnema tui the same, explicitly
63
+ mnema --no-tui print help instead
64
+ ```
65
+
66
+ It never opens when output is piped, under `CI`, on `TERM=dumb`, or where the
67
+ terminal cannot enter raw mode — in all of those `mnema` prints help exactly as it
68
+ always has. `MNEMA_TUI=never` turns it off for good; `MNEMA_TUI=always` forces it.
69
+
70
+ **Ink is loaded lazily**, so this costs one-shot commands nothing: `mnema --version`
71
+ measures the same before and after (~60ms), because `ink` is only imported once the
72
+ UI is actually opening. Installed size is ~22 MB, most of which is `es-toolkit`
73
+ arriving through Ink.
54
74
 
55
75
  ## Output, piping and colour
56
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.4.0",
3
+ "version": "0.9.0",
4
4
  "description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "LICENSE.md"
14
14
  ],
15
15
  "engines": {
16
- "node": ">=18"
16
+ "node": ">=20"
17
17
  },
18
18
  "keywords": [
19
19
  "mnema",
@@ -27,14 +27,18 @@
27
27
  "access": "public"
28
28
  },
29
29
  "devDependencies": {
30
+ "ink-testing-library": "^4.0.0",
30
31
  "vitest": "^3.2.7"
31
32
  },
32
33
  "dependencies": {
34
+ "htm": "^3.1.1",
35
+ "ink": "^6.8.0",
36
+ "react": "^19.2.7",
33
37
  "@mnemahq/sdk": "0.3.1"
34
38
  },
35
39
  "scripts": {
36
40
  "build": "node -e \"process.exit(0)\"",
37
- "typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs && node --check src/render/theme.mjs",
41
+ "typecheck": "find bin src -name '*.mjs' -exec node --check {} +",
38
42
  "test": "vitest run"
39
43
  }
40
44
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Open a URL in the user's browser (t-631).
3
+ *
4
+ * ⭐ `mnema --help` HAS CLAIMED THIS SINCE 0.1.0 — "login Sign in (opens a
5
+ * browser; tokens go to your OS keychain)" — and the CLI has never once opened a
6
+ * browser. cmdLogin printed the URL and waited. The help text was describing a
7
+ * feature that did not exist, which is the same class of untruth as a docs pointer
8
+ * to a settings page that was never built (t-629): nothing breaks, no test goes
9
+ * red, the sentence is just not true.
10
+ *
11
+ * ⚠️ THE URL COMES FROM A SERVER RESPONSE, so it is INPUT, not a constant. The
12
+ * device-authorization body hands back `verification_uri_complete` and we are
13
+ * about to hand it to the operating system's "open this" facility. Two rules
14
+ * follow, and neither is optional:
15
+ *
16
+ * 1. VALIDATE IT. http/https only, and the host must match the API origin we
17
+ * chose to talk to. Without the scheme check a `file://` or `javascript:`
18
+ * value would be handed straight to the OS; without the host check, an
19
+ * origin-confused or tampered response could send the user's browser
20
+ * somewhere else entirely while they are in the mood to approve a login.
21
+ * 2. NEVER THROUGH A SHELL. execFile with the URL as an argv entry, so no amount
22
+ * of quoting, backticks or semicolons in it can become a command. `shell:true`
23
+ * here would be a straightforward injection.
24
+ *
25
+ * And it must never be load-bearing: a headless box, an SSH session and a locked
26
+ * down desktop all legitimately have nothing to open with. Failure returns false
27
+ * and the printed URL — which is still printed — remains the real path.
28
+ */
29
+
30
+ import { execFile } from 'node:child_process';
31
+
32
+ /**
33
+ * Is this URL safe to hand to the OS, given the origin we are talking to?
34
+ *
35
+ * Exported for the tests, because the interesting cases are the ones nobody
36
+ * reaches by hand.
37
+ */
38
+ export function isSafeToOpen(url, origin) {
39
+ let target;
40
+ let base;
41
+ try {
42
+ target = new URL(String(url));
43
+ base = new URL(String(origin));
44
+ } catch {
45
+ return false;
46
+ }
47
+ // Not a denylist. Anything that is not plainly http(s) is refused, so
48
+ // `javascript:`, `data:`, `file:` and every scheme nobody has thought of yet
49
+ // are all covered by the same line.
50
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') return false;
51
+ // Same host as the API we are already trusting. `endsWith` would accept
52
+ // `evil-theboringpeople.in`, so this is an exact comparison.
53
+ return target.hostname === base.hostname;
54
+ }
55
+
56
+ /** Platform command + args. Windows' `start` is a cmd builtin, hence the shim. */
57
+ function opener(url) {
58
+ if (process.platform === 'darwin') return ['open', [url]];
59
+ if (process.platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
60
+ return ['xdg-open', [url]];
61
+ }
62
+
63
+ /**
64
+ * Try to open `url`. Resolves true only if the opener actually started.
65
+ *
66
+ * ⚠️ NEVER THROWS AND NEVER REJECTS. The caller's next line prints the URL for the
67
+ * user to open themselves; turning a missing xdg-open into a failed login would be
68
+ * absurd.
69
+ */
70
+ export function openInBrowser(url, origin) {
71
+ if (!isSafeToOpen(url, origin)) return Promise.resolve(false);
72
+ if (process.env.MNEMA_NO_BROWSER) return Promise.resolve(false);
73
+ // Nothing to open onto, and on a headless box the opener can hang rather than
74
+ // fail — so do not even start it.
75
+ if (!process.stdout.isTTY || process.env.CI) return Promise.resolve(false);
76
+
77
+ const [cmd, args] = opener(url);
78
+ return new Promise((resolve) => {
79
+ let settled = false;
80
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
81
+ try {
82
+ const child = execFile(cmd, args, { windowsHide: true }, (err) => done(!err));
83
+ child.on('error', () => done(false));
84
+ // xdg-open on a misconfigured desktop can sit there indefinitely. The login
85
+ // must not wait on it: give it two seconds, then carry on with the printed
86
+ // URL either way.
87
+ const t = setTimeout(() => done(false), 2000);
88
+ if (typeof t.unref === 'function') t.unref();
89
+ } catch {
90
+ done(false);
91
+ }
92
+ });
93
+ }
package/src/cli.mjs CHANGED
@@ -17,9 +17,14 @@ import {
17
17
  cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
18
18
  } from './read-commands.mjs';
19
19
  import {
20
- DEFAULT_ORIGIN, DEFAULT_APP_URL, c, truncate, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
21
- apiFetch, prompt, promptHidden, localSessionsForRepo,
20
+ DEFAULT_ORIGIN, DEFAULT_APP_URL, c, truncate, width, mark, gitInfo, canonicalRepo, readConfig, writeConfig,
21
+ removeConfigDir, apiFetch, prompt, promptHidden, localSessionsForRepo,
22
22
  } from './util.mjs';
23
+ // `checks` is aliased because cmdDoctor already has a local of that name — the
24
+ // collision is worth keeping visible rather than renaming the well-named local.
25
+ import { heading, entries, checks as checkList, rows, empty, more } from './render/layout.mjs';
26
+ import { take } from './paging.mjs';
27
+ import { tuiEligibility, explainUnavailable } from './tui-gate.mjs';
23
28
  import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from './secrets.mjs';
24
29
  import {
25
30
  installHook, uninstallHook, hookInstalled, hookConfigPath, defaultDeveloperId, sweepScriptPath,
@@ -235,24 +240,36 @@ async function cmdInit(flags) {
235
240
 
236
241
  async function cmdStatus(flags) {
237
242
  const { git, root, cfg, origin, workspaceId } = resolveContext(flags);
238
- console.log(c.bold('Mnema status'));
239
- console.log(` Workspace : ${workspaceId || c.red('not linked — run `mnema init`')}`);
240
- console.log(` Origin : ${origin}`);
241
- console.log(` Repo : ${canonicalRepo(git.remote) || c.dim('(no git remote)')}`);
242
- console.log(` Hook : ${hookInstalled() ? c.green('installed') : c.red('not installed')}`);
243
243
 
244
- process.stdout.write(' API : ');
244
+ // ⚠️ THE API PROBE RUNS BEFORE ANYTHING PRINTS, and that is the fix for the
245
+ // broken gutter. The old version wrote a hand-padded ' API : ' with
246
+ // process.stdout.write, awaited the network, then console.log'd the result —
247
+ // so the label column was aligned by counting spaces against four other lines,
248
+ // and any change to a label name silently knocked one row out of true. Gathering
249
+ // first means one table() measures every row against every other.
250
+ let api;
245
251
  try {
246
252
  const r = await apiFetch(origin, '/install/mnema-hook.mjs');
247
- console.log(r.ok ? c.green('reachable') : c.yellow(`HTTP ${r.status}`));
248
- } catch { console.log(c.red('unreachable')); }
253
+ api = r.ok ? { pass: true, note: 'reachable' } : { pass: false, note: `HTTP ${r.status}` };
254
+ } catch { api = { pass: false, note: 'unreachable' }; }
249
255
 
250
256
  const local = localSessionsForRepo(git.root, 1);
251
- if (local.length) {
252
- console.log(` Last local session: ${local[0].sessionId.slice(0, 8)}… ${c.dim(fmtAge(local[0].mtimeMs))}`);
253
- } else {
254
- console.log(` Last local session: ${c.dim('none found')}`);
255
- }
257
+
258
+ // ⚠️ ONE entries() CALL, not one per group — the label column only lines up if
259
+ // every row is measured against every other row. Three calls printed three
260
+ // different label widths, which is this PR's own bug in miniature.
261
+ heading('Status', workspaceId ? undefined : 'not linked');
262
+ entries([
263
+ { label: 'workspace', value: workspaceId || c.red('not linked — run `mnema init`') },
264
+ { label: 'origin', value: origin },
265
+ { label: 'repo', value: canonicalRepo(git.remote) || c.dim('(no git remote)') },
266
+ { label: 'capture hook', value: hookInstalled() ? 'installed' : 'run `mnema init`', state: hookInstalled() },
267
+ { label: 'api', value: api.note, state: api.pass },
268
+ {
269
+ label: 'last session',
270
+ value: local.length ? `${local[0].sessionId.slice(0, 8)}… ${c.dim(fmtAge(local[0].mtimeMs))}` : c.dim('none found'),
271
+ },
272
+ ]);
256
273
  void cfg;
257
274
  }
258
275
 
@@ -263,34 +280,49 @@ async function cmdSessions(flags) {
263
280
  const limit = Number(flags.limit) || 10;
264
281
 
265
282
  const local = localSessionsForRepo(git.root, limit);
266
- console.log(c.bold(`Local sessions for this repo (${local.length})`));
267
- if (!local.length) console.log(c.dim(' none found under ~/.claude/projects'));
268
- for (const s of local) {
269
- console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
283
+ heading('Local sessions', local.length);
284
+ if (!local.length) {
285
+ empty('None found under ~/.claude/projects.', 'Start a Claude Code session in this repo.');
286
+ } else {
287
+ rows(local.map((s) => ({
288
+ cells: [`${s.sessionId.slice(0, 8)}…`, fmtAge(s.mtimeMs), `${(s.sizeBytes / 1024).toFixed(0)} KB`],
289
+ style: [undefined, undefined, c.dim],
290
+ })));
270
291
  }
271
292
 
272
293
  if (!(await canAuthenticate(workspaceId))) {
273
- console.log(c.dim('\n (run `mnema login` — or add an API key — to see server-side cost + status)'));
294
+ console.log(c.dim('\n Run `mnema login` to see server-side cost and status.'));
274
295
  return;
275
296
  }
276
297
 
277
298
  const repo = canonicalRepo(git.remote);
278
- let rows = [];
299
+ let page;
279
300
  try {
280
- rows = await call({ origin, workspaceId }, (m) =>
281
- m.sessions.list({ limit, ...(repo ? { repo } : {}) }).all());
301
+ page = await call({ origin, workspaceId }, (m) =>
302
+ take(m.sessions.list({ limit, ...(repo ? { repo } : {}) }), limit));
282
303
  } catch (e) {
283
304
  // Non-fatal by design: the LOCAL list above is the useful half and has
284
305
  // already printed. Degrading here is deliberate, and it says which wall it
285
306
  // hit rather than a bare HTTP number.
286
- console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
307
+ console.log(c.yellow(`\n Server sessions unavailable — ${e?.message ?? e}`));
287
308
  return;
288
309
  }
289
- console.log(c.bold(`\nServer sessions (${rows.length})`));
290
- for (const s of rows) {
291
- const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
292
- console.log(` ${(s.developerId || '?').padEnd(16)} ${String(s.status).padEnd(10)} ${cost.padStart(10)} ${c.dim(s.model || '')}`);
310
+ console.log('');
311
+ heading('Server sessions', page.rows.length);
312
+ if (!page.rows.length) {
313
+ empty('None recorded for this repo yet.');
314
+ return;
293
315
  }
316
+ rows(page.rows.map((s) => ({
317
+ cells: [
318
+ s.developerId || '?',
319
+ String(s.status),
320
+ typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0',
321
+ s.model || '',
322
+ ],
323
+ style: [undefined, undefined, undefined, c.dim],
324
+ })));
325
+ more(page.more, `mnema sessions --limit ${limit * 2}`);
294
326
  }
295
327
 
296
328
  // ── sweep ───────────────────────────────────────────────────────────────────────
@@ -329,13 +361,13 @@ async function cmdSearch(flags, rest) {
329
361
  // the shared emit() — a filing accident, not a decision.
330
362
  if (flags.json) { console.log(JSON.stringify(results, null, 2)); return; }
331
363
 
332
- if (!results.length) { console.log(c.dim('No results.')); return; }
333
- console.log(c.bold(`${results.length} result(s) for "${query}"`));
364
+ heading('Results', results.length);
365
+ if (!results.length) { empty(`Nothing matched "${query}".`, 'Try fewer words, or a phrase from the document body.'); return; }
334
366
  for (const d of results) {
335
367
  console.log(` ${c.bold(d.title || d.path || d.id)}`);
336
368
  // truncate() adds an ellipsis; the old slice(0,140) cut mid-sentence with no
337
369
  // marker, so a clipped preview looked like a document that simply ended.
338
- if (d.preview) console.log(` ${c.dim(truncate(String(d.preview).replace(/\s+/g, ' '), 140))}`);
370
+ if (d.preview) console.log(` ${c.dim(truncate(String(d.preview).replace(/\s+/g, ' '), width() - 6))}`);
339
371
  }
340
372
  }
341
373
 
@@ -360,16 +392,30 @@ async function cmdPull(flags) {
360
392
  scaffold(root);
361
393
  const s = applyContext(root, docs);
362
394
 
363
- const line = (label, arr, color) => { if (arr.length) console.log(` ${color(label)} ${arr.length}`); };
364
- console.log(c.bold(`Synced .mnema/context from ${repo}`));
365
- if (!docs.length) console.log(c.dim(' no docs are bound to this repo yet (add a project with this repo URL).'));
366
- line('written ', s.written, c.green);
367
- line('updated ', s.updated, c.green);
368
- line('up-to-date', s.upToDate, c.dim);
369
- line('kept local', s.kept, c.yellow);
370
- line('orphaned ', s.orphaned, c.dim);
395
+ heading('Synced', repo);
396
+ if (!docs.length) {
397
+ empty('No docs are bound to this repo yet.', 'Add a project in the app with this repo URL.');
398
+ }
399
+ // ⚠️ THE LABELS USED TO BE PADDED BY HAND — 'written ' is 9 characters and
400
+ // 'up-to-date' is 10, so the count column stepped one place to the right on two
401
+ // of the five rows. Nobody notices reading it once; it is exactly the sort of
402
+ // thing that makes output feel unmade. table() measures them against each other.
403
+ const tally = [
404
+ ['written', s.written, c.green],
405
+ ['updated', s.updated, c.green],
406
+ ['up-to-date', s.upToDate, c.dim],
407
+ ['kept local', s.kept, c.yellow],
408
+ ['orphaned', s.orphaned, c.dim],
409
+ ].filter(([, arr]) => arr.length);
410
+ if (tally.length) {
411
+ rows(tally.map(([label, arr, colour]) => ({
412
+ cells: [label, String(arr.length)],
413
+ style: [colour, undefined],
414
+ })));
415
+ }
371
416
  if (s.conflicts.length) {
372
- console.log(c.red(` conflicts ${s.conflicts.length} — server version written beside your file as *.remote.md:`));
417
+ console.log(`\n ${mark.warn()} ${c.red(`${s.conflicts.length} conflict(s) the server version is beside yours as *.remote.md`)}`);
418
+ // Paths are printed plain: these are the files the user is about to open.
373
419
  for (const rel of s.conflicts) console.log(` ${rel} ${c.dim('vs')} ${rel.replace(/\.md$/, '.remote.md')}`);
374
420
  }
375
421
  console.log(c.dim('\n .mnema/context is committed and readable offline. Edit NOTABILITY.md to tune capture.'));
@@ -391,7 +437,11 @@ async function cmdDoctor(flags) {
391
437
  ok('hook token stored', !!(workspaceId && getSecret(workspaceId, 'hook-token')), `store: ${backendName()}`);
392
438
  ok('capture hook installed', hookInstalled());
393
439
 
394
- process.stdout.write(' … checking connectivity\r');
440
+ // ⚠️ ONLY ON A TTY. This wrote unconditionally, so `mnema doctor > report.txt`
441
+ // and `mnema doctor | grep` both captured the progress text as if it were part
442
+ // of the report. A spinner is for a human watching; it is noise in a pipe.
443
+ const live = process.stdout.isTTY;
444
+ if (live) process.stdout.write(' … checking connectivity\r');
395
445
  let apiReach = false; try { apiReach = (await apiFetch(origin, '/install/mnema-hook.mjs')).ok; } catch { /* */ }
396
446
  ok('API reachable', apiReach, origin);
397
447
 
@@ -407,14 +457,19 @@ async function cmdDoctor(flags) {
407
457
  ok('API key stored', false, 'optional — needed for search/sessions');
408
458
  }
409
459
 
410
- console.log(c.bold('Mnema doctor '));
411
- for (const ch of checks) {
412
- const mark = ch.pass ? c.green('✓') : c.red('✗');
413
- console.log(` ${mark} ${ch.label.padEnd(26)} ${ch.note ? c.dim(ch.note) : ''}`);
414
- }
460
+ // ⚠️ The old heading was the literal string 'Mnema doctor ' — twelve
461
+ // trailing spaces, left over from overwriting the '… checking connectivity\r'
462
+ // progress line by hand. It is invisible until you select the line, or diff the
463
+ // output, or pipe it. \x1b[K clears to end of line and cannot be off by a space.
464
+ if (live) process.stdout.write('\x1b[K');
465
+ heading('Doctor', origin);
466
+ checkList(checks);
415
467
  const failed = checks.filter((ch) => !ch.pass && ch.label !== 'API key stored');
416
- if (failed.length) { console.log(c.yellow(`\n ${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)); process.exit(1); }
417
- console.log(c.green('\n All good.'));
468
+ if (failed.length) {
469
+ console.log(`\n ${mark.warn()} ${c.yellow(`${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)}`);
470
+ process.exit(1);
471
+ }
472
+ console.log(`\n ${mark.ok()} ${c.green('All good.')}`);
418
473
  void root;
419
474
  }
420
475
 
@@ -438,7 +493,9 @@ async function cmdUninstall(flags) {
438
493
  function help() {
439
494
  console.log(`mnema ${VERSION} — connect a repo to your Mnema workspace
440
495
 
441
- Usage: mnema <command> [options]
496
+ Usage: mnema [command] [options]
497
+
498
+ mnema Open the interactive briefing (a terminal, Node 20+)
442
499
 
443
500
  Commands:
444
501
  login Sign in (opens a browser; tokens go to your OS keychain)
@@ -451,18 +508,29 @@ Commands:
451
508
  search "q" Search your workspace from the terminal
452
509
  doctor Diagnose install, hooks, auth, connectivity
453
510
  uninstall Remove hooks and stored secrets
511
+ tui Open the interactive briefing explicitly (Node 20+, a terminal)
454
512
 
455
513
  Read your workspace:
456
514
  tasks List tasks [--status --project --limit]
457
515
  next The next task to pick up, with a ready-made branch name
458
516
  docs List documents [--limit]
459
- doc <id> Print one document as markdown
517
+ doc [id] Print one document as markdown; no id opens a picker
460
518
  projects List projects
461
519
  briefing What deserves attention — pulse, deltas, findings
462
520
 
463
521
  Ask the knowledge graph (paid feature):
464
522
  ask "q" A cited answer, with the confidence it deserves
465
- graph <a> [b] Neighbourhood of a, or the shortest path from a to b
523
+ graph Neighbourhood of a node, or the path between two
524
+
525
+ Examples:
526
+ mnema the interactive briefing
527
+ mnema tasks --status in_progress
528
+ mnema doc pick a document from a list
529
+ mnema ask "why did we drop the queue?"
530
+ mnema graph "Workspace Security & Management"
531
+
532
+ ⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before
533
+ mnema sees it, and zsh reports a parse error that looks like a broken CLI.
466
534
 
467
535
  Options:
468
536
  --workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
@@ -471,12 +539,14 @@ Options:
471
539
  --json Machine-readable output (every read command)
472
540
  --yes Non-interactive; skip optional prompts
473
541
  --purge uninstall: also delete .mnema/config.json
542
+ --no-tui Print help instead of opening the interactive UI
474
543
  --version, --help
475
544
 
476
545
  Environment:
477
546
  NO_COLOR Disable colour (any value)
478
547
  FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
479
548
  COLUMNS Override the terminal width used for layout
549
+ MNEMA_TUI never | always — force the interactive UI off or on
480
550
  MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
481
551
 
482
552
  Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
@@ -484,9 +554,49 @@ writes plain text.
484
554
  `);
485
555
  }
486
556
 
557
+ /**
558
+ * Open the interactive briefing, or explain why not and print help.
559
+ *
560
+ * ⭐ THE ONLY DYNAMIC IMPORT OF THE TUI IN THE WHOLE PACKAGE. Everything under
561
+ * src/tui/ is reached exclusively through this line, which is why modules in there
562
+ * can use ordinary static imports. `import('ink')` measures 136–141ms against a
563
+ * 59–65ms `mnema --version`, and mnema runs from capture hooks and shell prompts —
564
+ * so this must stay the single seam. test/no-ink-in-oneshot.test.mjs enforces it,
565
+ * because `node --check` parses without resolving and would never notice a stray
566
+ * `import { Box } from 'ink'` in read-commands.mjs.
567
+ */
568
+ async function openTuiOrHelp(flags) {
569
+ const gate = tuiEligibility(flags);
570
+ if (gate.ok) {
571
+ const { startTui } = await import('./tui/launch.mjs');
572
+ const ctx = resolveContext(flags);
573
+ return startTui({
574
+ ...ctx,
575
+ version: VERSION,
576
+ call: (fn) => call(ctx, fn),
577
+ });
578
+ }
579
+ // ⚠️ TO STDERR, so `mnema | cat` still gets clean help on stdout. And nothing at
580
+ // all for `not-a-tty`: piping mnema to read its help is legitimate, and a nag
581
+ // there is noise in someone's data.
582
+ for (const line of explainUnavailable(gate.reason, gate.detail)) console.error(c.dim(line));
583
+ help();
584
+ // Exit 0: bare `mnema` has printed help and exited 0 since 0.1.0, and turning
585
+ // that into a failure for a runtime we still support is a regression wearing a
586
+ // feature's clothes.
587
+ return 0;
588
+ }
589
+
487
590
  export async function run(argv) {
488
591
  const { flags, rest } = parseFlags(argv);
489
592
  if (flags.version) { console.log(VERSION); return; }
593
+ // ⭐ BEFORE THE GATE, AND THIS ORDER IS THE WHOLE POINT. `mnema --help` leaves
594
+ // rest empty, so cmd === undefined — the SAME branch that opens the TUI. Worse,
595
+ // version.test.mjs runs the binary through execFileSync, where stdout is not a
596
+ // TTY, so the gate would fall through to help() and THE TEST WOULD PASS while
597
+ // every real user on a terminal got a UI instead of the help they asked for.
598
+ // That is this repo's characteristic bug aimed at its own test suite.
599
+ if (flags.help || flags.h) { help(); return; }
490
600
  const cmd = rest.shift();
491
601
  switch (cmd) {
492
602
  case 'login': return cmdLogin(flags);
@@ -510,7 +620,21 @@ export async function run(argv) {
510
620
  case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
511
621
  case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
512
622
  case 'briefing': return cmdBriefing(flags, resolveContext(flags));
513
- case undefined:
623
+ case 'tui':
624
+ // An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
625
+ // implicit one below degrades to help and exits 0. That asymmetry is
626
+ // CLAUDE.md's no-silent-returns rule: `mnema tui` on Node 18 must not
627
+ // quietly print help as though that were what was asked for.
628
+ {
629
+ const gate = tuiEligibility(flags);
630
+ if (gate.ok) return openTuiOrHelp(flags);
631
+ const why = explainUnavailable(gate.reason, gate.detail);
632
+ console.error(c.red(why[0] ?? `The interactive UI is unavailable here (${gate.reason}).`));
633
+ for (const line of why.slice(1)) console.error(c.dim(line));
634
+ process.exit(1);
635
+ }
636
+ break;
637
+ case undefined: return openTuiOrHelp(flags);
514
638
  case 'help': return help();
515
639
  default:
516
640
  console.error(c.red(`Unknown command: ${cmd}`));