@mnemahq/cli 0.4.0 → 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,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.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",
@@ -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
 
@@ -451,6 +506,7 @@ Commands:
451
506
  search "q" Search your workspace from the terminal
452
507
  doctor Diagnose install, hooks, auth, connectivity
453
508
  uninstall Remove hooks and stored secrets
509
+ tui Open the interactive briefing explicitly (Node 20+, a terminal)
454
510
 
455
511
  Read your workspace:
456
512
  tasks List tasks [--status --project --limit]
@@ -471,12 +527,14 @@ Options:
471
527
  --json Machine-readable output (every read command)
472
528
  --yes Non-interactive; skip optional prompts
473
529
  --purge uninstall: also delete .mnema/config.json
530
+ --no-tui Print help instead of opening the interactive UI
474
531
  --version, --help
475
532
 
476
533
  Environment:
477
534
  NO_COLOR Disable colour (any value)
478
535
  FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
479
536
  COLUMNS Override the terminal width used for layout
537
+ MNEMA_TUI never | always — force the interactive UI off or on
480
538
  MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
481
539
 
482
540
  Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
@@ -484,9 +542,48 @@ writes plain text.
484
542
  `);
485
543
  }
486
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
+
487
577
  export async function run(argv) {
488
578
  const { flags, rest } = parseFlags(argv);
489
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; }
490
587
  const cmd = rest.shift();
491
588
  switch (cmd) {
492
589
  case 'login': return cmdLogin(flags);
@@ -510,7 +607,21 @@ export async function run(argv) {
510
607
  case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
511
608
  case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
512
609
  case 'briefing': return cmdBriefing(flags, resolveContext(flags));
513
- 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);
514
625
  case 'help': return help();
515
626
  default:
516
627
  console.error(c.red(`Unknown command: ${cmd}`));
package/src/login.mjs CHANGED
@@ -13,7 +13,10 @@
13
13
  * then told the login "timed out". They didn't time out. They said no.
14
14
  */
15
15
 
16
- import { c, DEFAULT_ORIGIN } from './util.mjs';
16
+ import { c, mark, DEFAULT_ORIGIN } from './util.mjs';
17
+ import { spinner } from './render/wait.mjs';
18
+ import { heading, entries } from './render/layout.mjs';
19
+ import { openInBrowser } from './browser.mjs';
17
20
  import {
18
21
  backendName, clearState, deleteSecret, getSecret, readState, setSecret, writeState, NoKeychainError,
19
22
  } from './keychain.mjs';
@@ -61,52 +64,81 @@ export async function cmdLogin(flags = {}) {
61
64
  expires_in, interval,
62
65
  } = start.json;
63
66
 
67
+ // ⭐ ACTUALLY OPEN IT. `mnema --help` has promised "opens a browser" since 0.1.0
68
+ // and the CLI has never once done so — it printed the URL and waited. Opening is
69
+ // attempted BEFORE printing, so the "we opened it" line is only claimed when it
70
+ // is true; the URL is printed either way, because a headless box, an SSH session
71
+ // and a locked-down desktop all legitimately have nothing to open with.
72
+ const opened = await openInBrowser(verification_uri_complete || verification_uri, origin);
73
+
64
74
  console.log('');
65
- console.log(` Open ${c.cyan(verification_uri)}`);
66
- console.log(` Code ${c.bold(user_code)}`);
67
- console.log('');
68
- console.log(c.dim(` Or go straight there: ${verification_uri_complete}`));
69
- console.log(c.dim(` The code expires in ${Math.round((expires_in ?? 600) / 60)} minutes.`));
75
+ heading('Sign in');
76
+ // ⚠️ MEASURED, NOT HAND-PADDED. Writing `'Open '` with a trailing space to line
77
+ // up under `Code` is the same hand-counted gutter t-630 removed from status,
78
+ // doctor and pull — and it silently breaks the moment either word changes.
79
+ entries([
80
+ { label: 'code', value: user_code, style: c.bold },
81
+ { label: opened ? 'opened' : 'open', value: verification_uri, style: c.cyan },
82
+ ...(opened ? [] : [{ label: '', value: verification_uri_complete, dim: true }]),
83
+ { label: 'expires', value: `in ${Math.round((expires_in ?? 600) / 60)} minutes`, dim: true },
84
+ ]);
70
85
  console.log('');
71
- process.stdout.write(c.dim(' Waiting for approval…'));
72
86
 
73
87
  const deadline = Date.now() + (expires_in ?? 600) * 1000;
74
88
  let waitMs = (interval ?? 5) * 1000;
89
+ // ⚠️ ONE LINE THAT REDRAWS ITSELF, not a dot per poll. The old version wrote '.'
90
+ // every interval — up to 120 of them — which wraps and scrolls the user_code off
91
+ // the top of the screen. The code is the one thing they have to read.
92
+ const spin = spinner('Waiting for approval in your browser…');
75
93
 
76
- for (;;) {
77
- if (Date.now() > deadline) {
78
- console.log('');
79
- throw new Error('The code expired before it was approved. Run `mnema login` again.');
80
- }
81
- await sleep(waitMs);
82
-
83
- const poll = await post(origin, '/oauth/token', {
84
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
85
- device_code,
86
- client_id: CLIENT_ID,
87
- });
88
-
89
- if (poll.status === 200 && poll.json?.access_token) {
90
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
91
- return finish(origin, poll.json, store);
92
- }
93
-
94
- const err = poll.json?.error;
95
-
96
- if (err === 'authorization_pending') { process.stdout.write(c.dim('.')); continue; }
97
-
98
- if (err === 'slow_down') {
99
- // The server raised the interval and told us the new one. Honour it —
100
- // ignoring slow_down is how a CLI gets a client id rate-limited.
101
- waitMs = ((poll.json.interval ?? (waitMs / 1000) + 5)) * 1000;
102
- continue;
94
+ try {
95
+ for (;;) {
96
+ if (Date.now() > deadline) {
97
+ spin.stop();
98
+ throw new Error('The code expired before it was approved. Run `mnema login` again.');
99
+ }
100
+
101
+ const poll = await post(origin, '/oauth/token', {
102
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
103
+ device_code,
104
+ client_id: CLIENT_ID,
105
+ });
106
+
107
+ if (poll.status === 200 && poll.json?.access_token) {
108
+ spin.stop();
109
+ return finish(origin, poll.json, store);
110
+ }
111
+
112
+ const err = poll.json?.error;
113
+
114
+ if (err === 'authorization_pending') {
115
+ // ⚠️ SLEEP AFTER THE CHECK, not before it. The old loop slept first, so
116
+ // someone who approved instantly still sat through a full interval
117
+ // watching nothing happen.
118
+ await sleep(waitMs);
119
+ continue;
120
+ }
121
+
122
+ if (err === 'slow_down') {
123
+ // The server raised the interval and told us the new one. Honour it —
124
+ // ignoring slow_down is how a CLI gets a client id rate-limited — but SAY
125
+ // so, because obeying it silently just makes the wait mysteriously longer.
126
+ waitMs = ((poll.json.interval ?? (waitMs / 1000) + 5)) * 1000;
127
+ spin.update(`Waiting for approval — the server asked us to slow to ${Math.round(waitMs / 1000)}s…`);
128
+ await sleep(waitMs);
129
+ continue;
130
+ }
131
+
132
+ // ⭐ Everything below is terminal, and each says what actually happened.
133
+ spin.stop();
134
+ if (err === 'access_denied') throw new Error('The login was denied in the browser.');
135
+ if (err === 'expired_token') throw new Error('The code expired before it was approved. Run `mnema login` again.');
136
+ throw new Error(`Login failed: ${poll.json?.error_description || err || `HTTP ${poll.status}`}`);
103
137
  }
104
-
105
- // Everything below is terminal, and each says what actually happened.
106
- console.log('');
107
- if (err === 'access_denied') throw new Error('The login was denied in the browser.');
108
- if (err === 'expired_token') throw new Error('The code expired before it was approved. Run `mnema login` again.');
109
- throw new Error(`Login failed: ${poll.json?.error_description || err || `HTTP ${poll.status}`}`);
138
+ } finally {
139
+ // Belt and braces: a throw from post() must not leave a timer running and the
140
+ // user's cursor parked mid-line.
141
+ spin.stop();
110
142
  }
111
143
  }
112
144
 
@@ -134,11 +166,15 @@ async function finish(origin, tokens, store) {
134
166
  logged_in_at: new Date().toISOString(),
135
167
  });
136
168
 
137
- console.log(c.green(' Logged in.'));
138
- if (who?.email) console.log(` Account : ${who.email}`);
139
- if (who?.workspace_id) console.log(` Workspace : ${who.workspace_id}`);
140
- if (who?.plan) console.log(` Plan : ${who.plan}`);
141
- console.log(` Tokens : ${c.dim(store)}`);
169
+ // Hand-counted gutters ('Account :' is 10, 'Workspace :' is 10, 'Plan :'
170
+ // is 11) were the same defect t-630 removed everywhere else. One table.
171
+ heading('Signed in');
172
+ entries([
173
+ ...(who?.email ? [{ label: 'account', value: who.email }] : []),
174
+ ...(who?.workspace_id ? [{ label: 'workspace', value: who.workspace_id }] : []),
175
+ ...(who?.plan ? [{ label: 'plan', value: who.plan }] : []),
176
+ { label: 'tokens', value: store, dim: true },
177
+ ]);
142
178
  console.log('');
143
179
  return 0;
144
180
  }
@@ -185,6 +221,6 @@ export async function cmdLogout() {
185
221
  deleteSecret(`${ACCOUNT}:access`);
186
222
  deleteSecret(`${ACCOUNT}:refresh`);
187
223
  clearState();
188
- console.log(c.green('Logged out.') + c.dim(' Tokens removed from ' + backendName() + '.'));
224
+ console.log(`${mark.ok()} ${c.green('Logged out.')}${c.dim(` Tokens removed from ${backendName()}.`)}`);
189
225
  return 0;
190
226
  }