@mnemahq/cli 0.3.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,7 +50,48 @@ 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.
74
+
75
+ ## Output, piping and colour
76
+
77
+ Every read command takes `--json`, so the CLI composes:
78
+
79
+ ```bash
80
+ mnema tasks --json | jq '.[] | select(.priority == "high") | .title'
81
+ ```
82
+
83
+ **Colour is emitted only when output is a terminal.** `mnema docs > out.txt` and
84
+ `mnema tasks | grep …` produce plain text with no escape sequences in it.
85
+
86
+ | variable | effect |
87
+ | --- | --- |
88
+ | `NO_COLOR` | disable colour, any value ([no-color.org](https://no-color.org)) |
89
+ | `FORCE_COLOR=1` | keep colour through a pipe, e.g. `mnema tasks \| less -R` |
90
+ | `COLUMNS` | override the width used for column layout |
91
+ | `MNEMA_WORKSPACE_ID` | default workspace, instead of `--workspace` |
92
+
93
+ `mnema doc <id>` is built for redirection: the markdown goes to stdout and the title
94
+ and path go to stderr, so `mnema doc <id> > note.md` gets the document alone.
54
95
 
55
96
  ## `.mnema/` repo artifacts
56
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.3.1",
3
+ "version": "0.7.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",
@@ -22,19 +22,23 @@
22
22
  "claude-code",
23
23
  "sessions"
24
24
  ],
25
- "license": "SEE LICENSE IN LICENSE",
25
+ "license": "SEE LICENSE IN LICENSE.md",
26
26
  "publishConfig": {
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 test/version.test.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, 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,
@@ -47,15 +52,39 @@ const VERSION = JSON.parse(
47
52
  readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
48
53
  ).version;
49
54
 
50
- function parseFlags(argv) {
55
+ /**
56
+ * Flags that take a value. Everything else is boolean.
57
+ *
58
+ * ⭐ THIS LIST IS THE FIX. The parser used to treat ANY flag as value-taking if
59
+ * the next argv entry did not start with `--`, so a boolean flag silently ate the
60
+ * following positional:
61
+ *
62
+ * mnema doc --json <id> -> flags.json = '<id>', rest = []
63
+ * -> "Usage: mnema doc <id>"
64
+ *
65
+ * `--json` only worked in trailing position, and the same trap hit `ask`,
66
+ * `search` and `graph`. Guessing from argv shape cannot distinguish "a boolean
67
+ * flag followed by an argument" from "a flag and its value" — the parser has to
68
+ * be told which is which, so it is.
69
+ */
70
+ const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget']);
71
+
72
+ export function parseFlags(argv) {
51
73
  const flags = {}; const rest = [];
52
74
  for (let i = 0; i < argv.length; i++) {
53
75
  const a = argv[i];
54
- if (a.startsWith('--')) {
55
- const key = a.slice(2);
56
- if (argv[i + 1] && !argv[i + 1].startsWith('--')) { flags[key] = argv[++i]; }
57
- else flags[key] = true;
58
- } else rest.push(a);
76
+ if (!a.startsWith('--')) { rest.push(a); continue; }
77
+
78
+ // `--limit=5` as well as `--limit 5`; the former is unambiguous and common.
79
+ const eq = a.indexOf('=');
80
+ if (eq > 2) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
81
+
82
+ const key = a.slice(2);
83
+ if (VALUE_FLAGS.has(key) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
84
+ flags[key] = argv[++i];
85
+ } else {
86
+ flags[key] = true;
87
+ }
59
88
  }
60
89
  return { flags, rest };
61
90
  }
@@ -211,24 +240,36 @@ async function cmdInit(flags) {
211
240
 
212
241
  async function cmdStatus(flags) {
213
242
  const { git, root, cfg, origin, workspaceId } = resolveContext(flags);
214
- console.log(c.bold('Mnema status'));
215
- console.log(` Workspace : ${workspaceId || c.red('not linked — run `mnema init`')}`);
216
- console.log(` Origin : ${origin}`);
217
- console.log(` Repo : ${canonicalRepo(git.remote) || c.dim('(no git remote)')}`);
218
- console.log(` Hook : ${hookInstalled() ? c.green('installed') : c.red('not installed')}`);
219
243
 
220
- 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;
221
251
  try {
222
252
  const r = await apiFetch(origin, '/install/mnema-hook.mjs');
223
- console.log(r.ok ? c.green('reachable') : c.yellow(`HTTP ${r.status}`));
224
- } 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' }; }
225
255
 
226
256
  const local = localSessionsForRepo(git.root, 1);
227
- if (local.length) {
228
- console.log(` Last local session: ${local[0].sessionId.slice(0, 8)}… ${c.dim(fmtAge(local[0].mtimeMs))}`);
229
- } else {
230
- console.log(` Last local session: ${c.dim('none found')}`);
231
- }
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
+ ]);
232
273
  void cfg;
233
274
  }
234
275
 
@@ -239,34 +280,49 @@ async function cmdSessions(flags) {
239
280
  const limit = Number(flags.limit) || 10;
240
281
 
241
282
  const local = localSessionsForRepo(git.root, limit);
242
- console.log(c.bold(`Local sessions for this repo (${local.length})`));
243
- if (!local.length) console.log(c.dim(' none found under ~/.claude/projects'));
244
- for (const s of local) {
245
- 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
+ })));
246
291
  }
247
292
 
248
293
  if (!(await canAuthenticate(workspaceId))) {
249
- 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.'));
250
295
  return;
251
296
  }
252
297
 
253
298
  const repo = canonicalRepo(git.remote);
254
- let rows = [];
299
+ let page;
255
300
  try {
256
- rows = await call({ origin, workspaceId }, (m) =>
257
- m.sessions.list({ limit, ...(repo ? { repo } : {}) }).all());
301
+ page = await call({ origin, workspaceId }, (m) =>
302
+ take(m.sessions.list({ limit, ...(repo ? { repo } : {}) }), limit));
258
303
  } catch (e) {
259
304
  // Non-fatal by design: the LOCAL list above is the useful half and has
260
305
  // already printed. Degrading here is deliberate, and it says which wall it
261
306
  // hit rather than a bare HTTP number.
262
- console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
307
+ console.log(c.yellow(`\n Server sessions unavailable — ${e?.message ?? e}`));
263
308
  return;
264
309
  }
265
- console.log(c.bold(`\nServer sessions (${rows.length})`));
266
- for (const s of rows) {
267
- const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
268
- 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;
269
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}`);
270
326
  }
271
327
 
272
328
  // ── sweep ───────────────────────────────────────────────────────────────────────
@@ -299,11 +355,19 @@ async function cmdSearch(flags, rest) {
299
355
  try {
300
356
  results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
301
357
  } catch (e) { renderError(e, { context: 'search', usedApiKey: hasApiKey(workspaceId) }); }
302
- if (!results.length) { console.log(c.dim('No results.')); return; }
303
- console.log(c.bold(`${results.length} result(s) for "${query}"`));
358
+
359
+ // ⚠️ `search` DID NOT HONOUR --json, though help advertised it for "every read
360
+ // command". It sits in cli.mjs rather than read-commands.mjs, so it never got
361
+ // the shared emit() — a filing accident, not a decision.
362
+ if (flags.json) { console.log(JSON.stringify(results, null, 2)); return; }
363
+
364
+ heading('Results', results.length);
365
+ if (!results.length) { empty(`Nothing matched "${query}".`, 'Try fewer words, or a phrase from the document body.'); return; }
304
366
  for (const d of results) {
305
367
  console.log(` ${c.bold(d.title || d.path || d.id)}`);
306
- if (d.preview) console.log(` ${c.dim(String(d.preview).replace(/\s+/g, ' ').slice(0, 140))}`);
368
+ // truncate() adds an ellipsis; the old slice(0,140) cut mid-sentence with no
369
+ // marker, so a clipped preview looked like a document that simply ended.
370
+ if (d.preview) console.log(` ${c.dim(truncate(String(d.preview).replace(/\s+/g, ' '), width() - 6))}`);
307
371
  }
308
372
  }
309
373
 
@@ -328,16 +392,30 @@ async function cmdPull(flags) {
328
392
  scaffold(root);
329
393
  const s = applyContext(root, docs);
330
394
 
331
- const line = (label, arr, color) => { if (arr.length) console.log(` ${color(label)} ${arr.length}`); };
332
- console.log(c.bold(`Synced .mnema/context from ${repo}`));
333
- if (!docs.length) console.log(c.dim(' no docs are bound to this repo yet (add a project with this repo URL).'));
334
- line('written ', s.written, c.green);
335
- line('updated ', s.updated, c.green);
336
- line('up-to-date', s.upToDate, c.dim);
337
- line('kept local', s.kept, c.yellow);
338
- 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
+ }
339
416
  if (s.conflicts.length) {
340
- 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.
341
419
  for (const rel of s.conflicts) console.log(` ${rel} ${c.dim('vs')} ${rel.replace(/\.md$/, '.remote.md')}`);
342
420
  }
343
421
  console.log(c.dim('\n .mnema/context is committed and readable offline. Edit NOTABILITY.md to tune capture.'));
@@ -359,7 +437,11 @@ async function cmdDoctor(flags) {
359
437
  ok('hook token stored', !!(workspaceId && getSecret(workspaceId, 'hook-token')), `store: ${backendName()}`);
360
438
  ok('capture hook installed', hookInstalled());
361
439
 
362
- 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');
363
445
  let apiReach = false; try { apiReach = (await apiFetch(origin, '/install/mnema-hook.mjs')).ok; } catch { /* */ }
364
446
  ok('API reachable', apiReach, origin);
365
447
 
@@ -375,14 +457,19 @@ async function cmdDoctor(flags) {
375
457
  ok('API key stored', false, 'optional — needed for search/sessions');
376
458
  }
377
459
 
378
- console.log(c.bold('Mnema doctor '));
379
- for (const ch of checks) {
380
- const mark = ch.pass ? c.green('✓') : c.red('✗');
381
- console.log(` ${mark} ${ch.label.padEnd(26)} ${ch.note ? c.dim(ch.note) : ''}`);
382
- }
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);
383
467
  const failed = checks.filter((ch) => !ch.pass && ch.label !== 'API key stored');
384
- if (failed.length) { console.log(c.yellow(`\n ${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)); process.exit(1); }
385
- 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.')}`);
386
473
  void root;
387
474
  }
388
475
 
@@ -419,6 +506,7 @@ Commands:
419
506
  search "q" Search your workspace from the terminal
420
507
  doctor Diagnose install, hooks, auth, connectivity
421
508
  uninstall Remove hooks and stored secrets
509
+ tui Open the interactive briefing explicitly (Node 20+, a terminal)
422
510
 
423
511
  Read your workspace:
424
512
  tasks List tasks [--status --project --limit]
@@ -439,13 +527,63 @@ Options:
439
527
  --json Machine-readable output (every read command)
440
528
  --yes Non-interactive; skip optional prompts
441
529
  --purge uninstall: also delete .mnema/config.json
530
+ --no-tui Print help instead of opening the interactive UI
442
531
  --version, --help
532
+
533
+ Environment:
534
+ NO_COLOR Disable colour (any value)
535
+ FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
536
+ COLUMNS Override the terminal width used for layout
537
+ MNEMA_TUI never | always — force the interactive UI off or on
538
+ MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
539
+
540
+ Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
541
+ writes plain text.
443
542
  `);
444
543
  }
445
544
 
545
+ /**
546
+ * Open the interactive briefing, or explain why not and print help.
547
+ *
548
+ * ⭐ THE ONLY DYNAMIC IMPORT OF THE TUI IN THE WHOLE PACKAGE. Everything under
549
+ * src/tui/ is reached exclusively through this line, which is why modules in there
550
+ * can use ordinary static imports. `import('ink')` measures 136–141ms against a
551
+ * 59–65ms `mnema --version`, and mnema runs from capture hooks and shell prompts —
552
+ * so this must stay the single seam. test/no-ink-in-oneshot.test.mjs enforces it,
553
+ * because `node --check` parses without resolving and would never notice a stray
554
+ * `import { Box } from 'ink'` in read-commands.mjs.
555
+ */
556
+ async function openTuiOrHelp(flags) {
557
+ const gate = tuiEligibility(flags);
558
+ if (gate.ok) {
559
+ const { startTui } = await import('./tui/launch.mjs');
560
+ const ctx = resolveContext(flags);
561
+ return startTui({
562
+ ...ctx,
563
+ call: (fn) => call(ctx, fn),
564
+ });
565
+ }
566
+ // ⚠️ TO STDERR, so `mnema | cat` still gets clean help on stdout. And nothing at
567
+ // all for `not-a-tty`: piping mnema to read its help is legitimate, and a nag
568
+ // there is noise in someone's data.
569
+ for (const line of explainUnavailable(gate.reason, gate.detail)) console.error(c.dim(line));
570
+ help();
571
+ // Exit 0: bare `mnema` has printed help and exited 0 since 0.1.0, and turning
572
+ // that into a failure for a runtime we still support is a regression wearing a
573
+ // feature's clothes.
574
+ return 0;
575
+ }
576
+
446
577
  export async function run(argv) {
447
578
  const { flags, rest } = parseFlags(argv);
448
579
  if (flags.version) { console.log(VERSION); return; }
580
+ // ⭐ BEFORE THE GATE, AND THIS ORDER IS THE WHOLE POINT. `mnema --help` leaves
581
+ // rest empty, so cmd === undefined — the SAME branch that opens the TUI. Worse,
582
+ // version.test.mjs runs the binary through execFileSync, where stdout is not a
583
+ // TTY, so the gate would fall through to help() and THE TEST WOULD PASS while
584
+ // every real user on a terminal got a UI instead of the help they asked for.
585
+ // That is this repo's characteristic bug aimed at its own test suite.
586
+ if (flags.help || flags.h) { help(); return; }
449
587
  const cmd = rest.shift();
450
588
  switch (cmd) {
451
589
  case 'login': return cmdLogin(flags);
@@ -469,7 +607,21 @@ export async function run(argv) {
469
607
  case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
470
608
  case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
471
609
  case 'briefing': return cmdBriefing(flags, resolveContext(flags));
472
- case undefined:
610
+ case 'tui':
611
+ // An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
612
+ // implicit one below degrades to help and exits 0. That asymmetry is
613
+ // CLAUDE.md's no-silent-returns rule: `mnema tui` on Node 18 must not
614
+ // quietly print help as though that were what was asked for.
615
+ {
616
+ const gate = tuiEligibility(flags);
617
+ if (gate.ok) return openTuiOrHelp(flags);
618
+ const why = explainUnavailable(gate.reason, gate.detail);
619
+ console.error(c.red(why[0] ?? `The interactive UI is unavailable here (${gate.reason}).`));
620
+ for (const line of why.slice(1)) console.error(c.dim(line));
621
+ process.exit(1);
622
+ }
623
+ break;
624
+ case undefined: return openTuiOrHelp(flags);
473
625
  case 'help': return help();
474
626
  default:
475
627
  console.error(c.red(`Unknown command: ${cmd}`));