@mnemahq/cli 0.13.0 → 0.14.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
@@ -28,6 +28,29 @@ keychain, and writes a `.mnema/config.json` (safe to commit — it holds no secr
28
28
 
29
29
  Start a Claude Code session and it appears under **Sessions** with its cost.
30
30
 
31
+ ### Run from a checkout (contributors)
32
+
33
+ Hacking on the CLI inside the monorepo instead of installing the package? It's zero-build
34
+ Node ESM — run it straight from `bin/`:
35
+
36
+ ```bash
37
+ node packages/cli/bin/mnema.mjs init
38
+ node packages/cli/bin/mnema.mjs doctor # verify the install
39
+ ```
40
+
41
+ For a global `mnema` that tracks your working copy: `cd packages/cli && npm link`.
42
+
43
+ Non-interactive (CI or scripted setup) — pass the workspace id as a flag and the secrets as
44
+ env vars so nothing is prompted:
45
+
46
+ ```bash
47
+ MNEMA_HOOK_TOKEN=<hook-token> MNEMA_API_KEY=<api-key> \
48
+ node packages/cli/bin/mnema.mjs init --workspace <workspace-id> --yes
49
+ ```
50
+
51
+ `--origin` defaults to `https://api.theboringpeople.in`; point it (or `MNEMA_API_ORIGIN`) at
52
+ your own host for a self-hosted instance.
53
+
31
54
  ## Commands
32
55
 
33
56
  | Command | What it does |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The command catalogue — ONE source for `--help` and the interactive help.
3
+ *
4
+ * ⭐ WHY A STRUCTURE AND NOT TWO TEXTS. `mnema help` becoming navigable would
5
+ * otherwise mean a second hand-written list, and the two drift the first time a
6
+ * command is added to one of them. That failure already has a scar in this
7
+ * package: `--help` printed "mnema 0.1.0" and the old command list for three
8
+ * releases because the version was hand-written in a second place.
9
+ *
10
+ * ⚠️ `--help` OUTPUT IS TEST-CONSTRAINED and must not become interactive:
11
+ * `test/version.test.mjs` requires the version on line 1 and the literal words
12
+ * `tasks docs projects briefing ask graph next` in the body — that test exists so
13
+ * a stale global install is obvious. The flag stays plain text and pipe-safe;
14
+ * only the `help` SUBCOMMAND opens a view.
15
+ */
16
+
17
+ /** @typedef {{ name: string, usage?: string, blurb: string, detail?: string, flags?: string[], examples?: string[], paid?: boolean }} Cmd */
18
+
19
+ /** @type {{ title: string, note?: string, items: Cmd[] }[]} */
20
+ export const GROUPS = [
21
+ {
22
+ title: 'Set up',
23
+ items: [
24
+ {
25
+ name: 'login', blurb: 'Sign in (opens a browser; tokens go to your OS keychain)',
26
+ detail: 'Device-code flow. The browser opens on its own; the code stays on screen while it polls.',
27
+ },
28
+ { name: 'logout', blurb: 'Remove stored credentials' },
29
+ {
30
+ name: 'init', blurb: 'Link this repo to a workspace and install session capture',
31
+ detail: 'Writes .mnema/config.json, stores a hook token in the keychain, and installs the Claude Code hook plus the git hooks.\n\n⚠️ Minting a hook token ROTATES it — any other machine set up for this workspace must re-run init.',
32
+ flags: ['--workspace <id>', '--origin <url>', '--yes'],
33
+ },
34
+ { name: 'uninstall', blurb: 'Remove hooks and stored secrets', flags: ['--purge'] },
35
+ ],
36
+ },
37
+ {
38
+ title: 'Check',
39
+ items: [
40
+ { name: 'status', blurb: 'Show connection, hook, and last session' },
41
+ { name: 'doctor', blurb: 'Diagnose install, hooks, auth, connectivity' },
42
+ { name: 'sessions', blurb: 'List recent sessions for this repo (local + server)' },
43
+ { name: 'sweep', blurb: 'Backfill past local sessions (opt-in)' },
44
+ {
45
+ name: 'binding', blurb: 'Task-binding miss rate, measured locally',
46
+ detail: 'Reads the PreToolUse counters. Reports NO DATA as no data — "0% miss rate" and "the hook never ran" are the same number and mean opposite things.',
47
+ flags: ['--limit <days>'],
48
+ examples: ['mnema binding', 'mnema binding --limit 30'],
49
+ },
50
+ { name: 'pull', blurb: 'Export repo-bound docs into .mnema/context (server-is-truth)' },
51
+ ],
52
+ },
53
+ {
54
+ title: 'Read your workspace',
55
+ note: 'On a terminal these open a NAVIGABLE view — arrows to move, enter to open, esc to go back. Piped or with --json they print as they always have.',
56
+ items: [
57
+ { name: 'tasks', blurb: 'List tasks', flags: ['--status', '--project', '--limit'], examples: ['mnema tasks --status in_progress'] },
58
+ { name: 'next', blurb: 'The next task to pick up, with a ready-made branch name' },
59
+ { name: 'docs', blurb: 'List documents', flags: ['--limit'] },
60
+ { name: 'doc', usage: 'doc [id]', blurb: 'Print one document as markdown; no id opens a picker', examples: ['mnema doc'] },
61
+ { name: 'projects', blurb: 'List projects' },
62
+ { name: 'flows', blurb: 'List flows' },
63
+ { name: 'flow', usage: 'flow [slug]', blurb: 'One flow; no slug opens a picker' },
64
+ { name: 'briefing', blurb: 'What deserves attention — pulse, deltas, findings' },
65
+ { name: 'search', usage: 'search "q"', blurb: 'Search your workspace from the terminal' },
66
+ ],
67
+ },
68
+ {
69
+ title: 'Ask the knowledge graph',
70
+ note: 'Paid feature.',
71
+ items: [
72
+ { name: 'ask', usage: 'ask "q"', blurb: 'A cited answer, with the confidence it deserves', paid: true, examples: ['mnema ask "why did we drop the queue?"'] },
73
+ {
74
+ name: 'graph', usage: 'graph [a] [b]', blurb: "Walk a node's neighbours; two args prints the path between them", paid: true,
75
+ examples: ['mnema graph "Workspace Security & Management"'],
76
+ detail: '⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before mnema sees it, and zsh reports a parse error that looks like a broken CLI.',
77
+ },
78
+ ],
79
+ },
80
+ {
81
+ title: 'Interactive',
82
+ items: [
83
+ { name: 'tui', blurb: 'Open the interactive briefing explicitly (Node 20+, a terminal)' },
84
+ { name: 'help', blurb: 'This screen. `mnema --help` prints it as plain text instead.' },
85
+ ],
86
+ },
87
+ ];
88
+
89
+ export const OPTIONS = [
90
+ ['--workspace <id>', 'Workspace id (else prompted / MNEMA_WORKSPACE_ID)'],
91
+ ['--origin <url>', 'API origin'],
92
+ ['--limit <n>', 'Row limit'],
93
+ ['--json', 'Machine-readable output (every read command)'],
94
+ ['--yes', 'Non-interactive; skip optional prompts'],
95
+ ['--purge', 'uninstall: also delete .mnema/config.json'],
96
+ ['--no-tui', 'Print help instead of opening the interactive UI'],
97
+ ['--version, --help', ''],
98
+ ];
99
+
100
+ export const ENVIRONMENT = [
101
+ ['NO_COLOR', 'Disable colour (any value)'],
102
+ ['FORCE_COLOR=1', 'Keep colour when piping, e.g. into `less -R`'],
103
+ ['COLUMNS', 'Override the terminal width used for layout'],
104
+ ['MNEMA_TUI', 'never | always — force the interactive UI off or on'],
105
+ ['MNEMA_ENFORCE', 'warn | block | off — task-binding enforcement'],
106
+ ['MNEMA_WORKSPACE_ID', 'Default workspace, instead of --workspace'],
107
+ ];
108
+
109
+ /** Flat list, for the interactive picker and for tests. */
110
+ export function allCommands() {
111
+ return GROUPS.flatMap((g) => g.items.map((c) => ({ ...c, group: g.title })));
112
+ }
package/src/cli.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from './hook-install.mjs';
33
33
  import { installGitHooks, uninstallGitHooks } from './git-hooks.mjs';
34
34
  import { collectBindingStats, formatBindingStats } from './binding-stats.mjs';
35
+ import { GROUPS, OPTIONS, ENVIRONMENT } from './catalogue.mjs';
35
36
  import { applyContext, scaffold } from './artifacts.mjs';
36
37
  import { execFileSync } from 'node:child_process';
37
38
 
@@ -437,6 +438,70 @@ async function cmdPull(flags) {
437
438
 
438
439
  // ── doctor ───────────────────────────────────────────────────────────────────────
439
440
 
441
+ /**
442
+ * Turn a failed credential probe into something a person can act on.
443
+ *
444
+ * ⚠️ THE SERVER ALREADY DISTINGUISHES THESE and the CLI was throwing it away:
445
+ * `invalid_token` (a real credential, rejected — rotated, revoked or expired)
446
+ * reads completely differently from `missing_token` (nothing was sent) and from
447
+ * a network failure. All three rendered as one red tick with no note.
448
+ */
449
+ /**
450
+ * Probe the stored API key by fetching one page of docs.
451
+ *
452
+ * ⭐ THE OLD PROBE COULD NEVER PASS. It called `.pages().next()`, and `pages()`
453
+ * returns an async ITERABLE — `Symbol.asyncIterator`, no `.next()`. So it threw
454
+ * `TypeError: … .next is not a function` on every run, and `✗ API key valid`
455
+ * was red for a code bug regardless of the credential.
456
+ *
457
+ * ⚠️ IT WAS INVISIBLE BECAUSE OF THE BARE `catch { }` BESIDE IT. A swallowed
458
+ * error in the one command whose job is diagnosis hid a broken check for as long
459
+ * as it existed; the first thing that printed the reason found it in one run.
460
+ *
461
+ * Extracted so the call site is testable with a fake client — asserting that the
462
+ * doctor CALLS this is the guard, not that the function alone behaves.
463
+ */
464
+ export async function probeApiKey(makeClientFn) {
465
+ try {
466
+ // for-await, not .next(): the iterable is the contract the SDK exposes.
467
+ // eslint-disable-next-line no-unreachable-loop
468
+ for await (const _page of makeClientFn().docs.list({ limit: 1 }).pages()) break;
469
+ return { ok: true, why: undefined };
470
+ } catch (e) {
471
+ return { ok: false, why: diagnoseCredential(e) };
472
+ }
473
+ }
474
+
475
+ export function diagnoseCredential(e) {
476
+ // ⚠️ `body` IS AN OBJECT, NOT A STRING. My first version did
477
+ // `String(e.body ?? '')`, which yields "[object Object]" — so `invalid_token`
478
+ // never matched and every rejection fell through to the generic branch. The
479
+ // SDK throws typed errors (AuthError, with status/body/fix); read them.
480
+ const reason = typeof e?.body === 'object' && e?.body ? String(e.body.reason ?? '') : '';
481
+ const text = `${String(e?.message ?? '')} ${typeof e?.body === 'string' ? e.body : ''} ${reason}`;
482
+ const status = Number(e?.status);
483
+
484
+ if (reason === 'invalid_token' || /invalid_token/.test(text)) {
485
+ return 'rejected by the server — rotated, revoked or expired; mint a new one in Settings → Access';
486
+ }
487
+ if (reason === 'missing_token' || /missing_token/.test(text)) {
488
+ return 'stored but not sent — this is a CLI bug, please report it';
489
+ }
490
+ if (status === 403 || /insufficient_scope|forbidden/i.test(text)) {
491
+ return 'valid, but missing the docs:read scope — re-issue it with that box ticked';
492
+ }
493
+ if (/ENOTFOUND|ECONNREFUSED|fetch failed|network|ETIMEDOUT/i.test(text)) {
494
+ return 'could not reach the API — the key itself was never checked';
495
+ }
496
+ // ⭐ A TypeError HERE MEANS THE PROBE IS BROKEN, NOT THE KEY. That is not
497
+ // hypothetical: `.pages().next()` threw "not a function" on every run, and the
498
+ // bare catch beside it meant nobody could tell for as long as it existed.
499
+ if (e instanceof TypeError || /is not a function/.test(text)) {
500
+ return `the check itself failed, not the key — ${String(e?.message ?? '').slice(0, 70)}`;
501
+ }
502
+ return String(e?.message ?? '').slice(0, 90) || 'unknown error';
503
+ }
504
+
440
505
  async function cmdDoctor(flags) {
441
506
  const { git, root, origin, workspaceId } = resolveContext(flags);
442
507
  const checks = [];
@@ -462,11 +527,21 @@ async function cmdDoctor(flags) {
462
527
  const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
463
528
  if (apiKey) {
464
529
  let keyOk = false;
530
+ let keyWhy;
465
531
  // Probes the KEY specifically — no fallback — because that is the thing
466
532
  // doctor is reporting on. Using call() here would mask a dead key behind a
467
533
  // working login and print a green tick for a credential that does not work.
468
- try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
469
- ok('API key valid', keyOk);
534
+ //
535
+ // AND IT SAYS WHY IT FAILED. This was `catch { }` — a bare swallow that
536
+ // reported "✗ API key valid" and discarded the reason, in the one command
537
+ // whose entire job is diagnosis. Finding out that a real key was being
538
+ // REJECTED (invalid_token) rather than missing, expired-locally, or blocked
539
+ // by a scope took ten manual commands and a keychain dump. The error already
540
+ // carried the answer.
541
+ const probe = await probeApiKey(() => makeClient({ origin, workspaceId }));
542
+ keyOk = probe.ok;
543
+ keyWhy = probe.why;
544
+ ok('API key valid', keyOk, keyOk ? undefined : keyWhy);
470
545
  } else {
471
546
  ok('API key stored', false, 'optional — needed for search/sessions');
472
547
  }
@@ -507,73 +582,56 @@ async function cmdUninstall(flags) {
507
582
 
508
583
  // ── help ───────────────────────────────────────────────────────────────────────
509
584
 
585
+ /**
586
+ * The plain-text help. Rendered FROM `catalogue.mjs`, the same source the
587
+ * interactive screen uses.
588
+ *
589
+ * ⚠️ THIS OUTPUT IS TEST-CONSTRAINED. version.test.mjs requires the version on
590
+ * LINE 1 (so no banner above it) and the literal words
591
+ * `tasks docs projects briefing ask graph next` in the body — that test exists
592
+ * because `--help` once printed "mnema 0.1.0" and the old command list for three
593
+ * releases, making a stale global install undiagnosable.
594
+ */
510
595
  function help() {
511
- console.log(`mnema ${VERSION} connect a repo to your Mnema workspace
512
-
513
- Usage: mnema [command] [options]
514
-
515
- mnema Open the interactive briefing (a terminal, Node 20+)
516
-
517
- Commands:
518
- login Sign in (opens a browser; tokens go to your OS keychain)
519
- logout Remove stored credentials
520
- init Link this repo to a workspace and install session capture
521
- status Show connection, hook, and last session
522
- sessions List recent sessions for this repo (local + server)
523
- sweep Backfill past local sessions (opt-in)
524
- pull Export repo-bound docs into .mnema/context (server-is-truth)
525
- search "q" Search your workspace from the terminal
526
- doctor Diagnose install, hooks, auth, connectivity
527
- uninstall Remove hooks and stored secrets
528
- tui Open the interactive briefing explicitly (Node 20+, a terminal)
529
-
530
- On a terminal, the read commands below open a NAVIGABLE view — arrow keys to move,
531
- enter to open, esc to go back. Piped or with --json they print as they always have.
532
-
533
- Read your workspace:
534
- tasks List tasks [--status --project --limit]
535
- next The next task to pick up, with a ready-made branch name
536
- docs List documents [--limit]
537
- doc [id] Print one document as markdown; no id opens a picker
538
- projects List projects
539
- flows List flows
540
- flow [slug] One flow; no slug opens a picker
541
- briefing What deserves attention pulse, deltas, findings
542
-
543
- Ask the knowledge graph (paid feature):
544
- ask "q" A cited answer, with the confidence it deserves
545
- graph Walk a node's neighbours; graph <a> <b> prints the path between two
546
-
547
- Examples:
548
- mnema the interactive briefing
549
- mnema tasks --status in_progress
550
- mnema doc pick a document from a list
551
- mnema ask "why did we drop the queue?"
552
- mnema graph "Workspace Security & Management"
553
-
554
- ⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before
555
- mnema sees it, and zsh reports a parse error that looks like a broken CLI.
556
-
557
- Options:
558
- --workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
559
- --origin <url> API origin (default ${DEFAULT_ORIGIN})
560
- --limit <n> Row limit
561
- --json Machine-readable output (every read command)
562
- --yes Non-interactive; skip optional prompts
563
- --purge uninstall: also delete .mnema/config.json
564
- --no-tui Print help instead of opening the interactive UI
565
- --version, --help
566
-
567
- Environment:
568
- NO_COLOR Disable colour (any value)
569
- FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
570
- COLUMNS Override the terminal width used for layout
571
- MNEMA_TUI never | always — force the interactive UI off or on
572
- MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
573
-
574
- Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
575
- writes plain text.
576
- `);
596
+ const pad = (s2, n) => String(s2).padEnd(n);
597
+ const out = [];
598
+ out.push(`mnema ${VERSION} — connect a repo to your Mnema workspace`);
599
+ out.push('');
600
+ out.push('Usage: mnema [command] [options]');
601
+ out.push('');
602
+ out.push(' mnema Open the interactive briefing (a terminal, Node 20+)');
603
+ out.push(' mnema help The same command list, navigable');
604
+ for (const g of GROUPS) {
605
+ out.push('');
606
+ out.push(`${g.title}:`);
607
+ if (g.note) out.push(` ${g.note}`);
608
+ for (const c of g.items) {
609
+ const flags2 = c.flags?.length ? ` [${c.flags.join(' ')}]` : '';
610
+ out.push(` ${pad(c.usage ?? c.name, 12)} ${c.blurb}${flags2}`);
611
+ }
612
+ }
613
+ const examples = GROUPS.flatMap((g) => g.items.flatMap((c) => c.examples ?? []));
614
+ if (examples.length) {
615
+ out.push('');
616
+ out.push('Examples:');
617
+ out.push(' mnema the interactive briefing');
618
+ for (const e of examples) out.push(` ${e}`);
619
+ out.push('');
620
+ out.push(' ⚠️ Quote anything with spaces or & otherwise your SHELL eats it before');
621
+ out.push(' mnema sees it, and zsh reports a parse error that looks like a broken CLI.');
622
+ }
623
+ out.push('');
624
+ out.push('Options:');
625
+ for (const [flag, desc] of OPTIONS) {
626
+ out.push(` ${pad(flag, 18)} ${desc === '' ? '' : desc}`.replace(/\s+$/, ''));
627
+ }
628
+ out.push('');
629
+ out.push('Environment:');
630
+ for (const [k, v] of ENVIRONMENT) out.push(` ${pad(k, 18)} ${v}`);
631
+ out.push('');
632
+ out.push('Colour is off automatically when output is not a terminal, so `mnema tasks > f.txt`');
633
+ out.push('writes plain text.');
634
+ console.log(out.join('\n'));
577
635
  }
578
636
 
579
637
  /**
@@ -620,6 +678,22 @@ export function screenFor(cmd, flags, rest = []) {
620
678
  return null;
621
679
  }
622
680
 
681
+ /**
682
+ * `mnema help` — navigable on a terminal, plain text otherwise.
683
+ *
684
+ * ⚠️ `--help` THE FLAG IS NOT ROUTED HERE, and must not be. test/version.test.mjs
685
+ * requires the version on line 1 and the literal command words in the body — that
686
+ * test exists so a stale global install is obvious, and an Ink screen satisfies
687
+ * neither. The flag stays plain and pipe-safe; only the SUBCOMMAND opens a view.
688
+ *
689
+ * ⚠️ Falls back to the same plain text whenever the TUI is not eligible (piped,
690
+ * no TTY, --no-tui, old Node), so `mnema help | less` behaves.
691
+ */
692
+ async function cmdHelp(flags) {
693
+ if (await maybeInteractive(flags, { name: 'help' })) return;
694
+ return help();
695
+ }
696
+
623
697
  async function maybeInteractive(flags, screen) {
624
698
  if (!screen) return false;
625
699
  if (flags.json) return false;
@@ -727,7 +801,7 @@ function cmdBinding(flags) {
727
801
  }
728
802
  break;
729
803
  case undefined: return openTuiOrHelp(flags);
730
- case 'help': return help();
804
+ case 'help': return cmdHelp(flags);
731
805
  default:
732
806
  console.error(c.red(`Unknown command: ${cmd}`));
733
807
  help();
package/src/tui/app.mjs CHANGED
@@ -24,6 +24,7 @@ import { GraphHome } from './screens/graph-home.mjs';
24
24
  import { Tasks, Task } from './screens/tasks.mjs';
25
25
  import { Docs, Doc } from './screens/docs.mjs';
26
26
  import { Flows, Flow } from './screens/flows.mjs';
27
+ import { Help, HelpDetail } from './screens/help.mjs';
27
28
 
28
29
  const HOME = { name: 'briefing' };
29
30
 
@@ -73,9 +74,18 @@ export function App({ ctx, initial }) {
73
74
  if (input === 't') { setStack([{ name: 'tasks' }]); return; }
74
75
  if (input === 'd') { setStack([{ name: 'docs' }]); return; }
75
76
  if (input === 'f') { setStack([{ name: 'flows' }]); return; }
77
+ // ⚠️ `?` is the one jump that must work when SIGNED OUT — see the auth
78
+ // takeover below, which returns before the body renders. Help you cannot
79
+ // reach without a login is help you cannot reach when you need it.
80
+ if (input === '?') { setStack([{ name: 'help' }]); return; }
76
81
  });
77
82
 
78
- if (fatal?.kind === 'auth') {
83
+ // ⚠️ HELP OUTRANKS THE AUTH TAKEOVER. The takeover returns before the body,
84
+ // so without this exemption `?` would set the stack and still render "Not
85
+ // signed in" — help unreachable at exactly the moment someone needs it, which
86
+ // is the state they are in when they press `?`. The catalogue is static and
87
+ // needs no credential, so there is nothing to gate.
88
+ if (fatal?.kind === 'auth' && top.name !== 'help' && top.name !== 'helpDetail') {
79
89
  return html`
80
90
  <${Box} flexDirection="column" paddingX=${1}>
81
91
  <${Text} color="red" bold>Not signed in<//>
@@ -107,6 +117,12 @@ export function App({ ctx, initial }) {
107
117
  onOpen=${(f) => push({ name: 'flow', flow: f })} />`;
108
118
  } else if (top.name === 'flow') {
109
119
  body = html`<${Flow} ctx=${wrapped} flow=${top.flow} />`;
120
+ } else if (top.name === 'help') {
121
+ // ⚠️ Takes no ctx: the catalogue is static and this screen must work signed
122
+ // out. Help that requires a login is help you cannot reach when you need it.
123
+ body = html`<${Help} focused=${true} onOpen=${(c) => push({ name: 'helpDetail', cmd: c })} />`;
124
+ } else if (top.name === 'helpDetail') {
125
+ body = html`<${HelpDetail} cmd=${top.cmd} />`;
110
126
  } else if (top.name === 'briefing') {
111
127
  body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
112
128
  } else if (top.name === 'node') {
@@ -151,12 +167,24 @@ export function App({ ctx, initial }) {
151
167
  * which is worse than not mentioning it. The global jumps are appended once, in
152
168
  * one place, so they cannot drift per screen.
153
169
  */
154
- const JUMPS = 'b briefing · g graph · t tasks · d docs · f flows · q quit';
170
+ /**
171
+ * ⚠️ THE FOOTER HAS A HARD WIDTH BUDGET AND THIS LINE IS MOST OF IT. Adding
172
+ * `? help` to the previous wording pushed the total to 102 columns and ink
173
+ * CLIPPED `q quit` off the end — the quit key, invisible. tui-render.test.mjs
174
+ * caught it by asserting the LAST item is present, which is exactly why that
175
+ * assertion is on the last item and not the first.
176
+ *
177
+ * Separators cost 3 columns each and buy nothing at this density, so they are
178
+ * gone. 87 columns with the longest prefix, against ~98 of usable width.
179
+ */
180
+ const JUMPS = 'b brief g graph t tasks d docs f flows ? help q quit';
155
181
 
156
182
  function footerFor(top) {
157
183
  if (top.name === 'briefing') return `↑↓ move · enter open · r refresh · ${JUMPS}`;
158
184
  if (top.name === 'graph') return `↑↓ move · enter walk in · ${JUMPS}`;
159
185
  if (top.name === 'tasks' || top.name === 'docs' || top.name === 'flows') return `↑↓ move · enter open · ${JUMPS}`;
186
+ if (top.name === 'help') return `↑↓ move · enter detail · esc back · ${JUMPS}`;
187
+ if (top.name === 'helpDetail') return `esc back · ${JUMPS}`;
160
188
  if (top.name === 'task' || top.name === 'flow') return `esc back · ${JUMPS}`;
161
189
  if (top.name === 'doc') return `j/k scroll · esc back · ${JUMPS}`;
162
190
  if (top.name === 'node') return `↑↓ move · enter walk in · esc back · r refresh · ${JUMPS}`;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `mnema help` — the command list, navigable.
3
+ *
4
+ * ⭐ WHY THIS EXISTS. Every read command in this CLI opens a navigable view, and
5
+ * then `help` — the one command a confused person reaches for first — dumped
6
+ * sixty lines of text that scrolled off the top of the terminal. The thing you
7
+ * use when you are lost was the least usable thing in the tool.
8
+ *
9
+ * ⚠️ IT RENDERS FROM `catalogue.mjs`, THE SAME SOURCE AS `--help`. A second
10
+ * hand-written list drifts the first time a command is added to one of them, and
11
+ * this package already has that scar: `--help` reported "mnema 0.1.0" and the old
12
+ * command list for three releases because the version lived in a second place.
13
+ */
14
+
15
+ import { Box, Text } from 'ink';
16
+ import { html } from '../h.mjs';
17
+ import { List } from '../components/list.mjs';
18
+ import { GROUPS } from '../../catalogue.mjs';
19
+
20
+ /** Headers are rows but not destinations — List already knows to skip them. */
21
+ function items() {
22
+ const out = [];
23
+ for (const g of GROUPS) {
24
+ out.push({ key: `h:${g.title}`, header: true, label: g.title });
25
+ for (const c of g.items) {
26
+ out.push({ key: c.name, left: c.usage ?? c.name, label: c.blurb, cmd: c });
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export function Help({ focused, onOpen }) {
33
+ const rows = items();
34
+ return html`
35
+ <${Box} flexDirection="column" paddingX=${1}>
36
+ <${Box}>
37
+ <${Text} bold>Commands<//>
38
+ <${Text} dimColor> ${rows.filter((r) => !r.header).length} · enter for detail · esc back<//>
39
+ <//>
40
+ <${List}
41
+ items=${rows}
42
+ focused=${focused}
43
+ height=${14}
44
+ onSelect=${(i) => onOpen(rows[i]?.cmd ?? null)}
45
+ />
46
+ <//>`;
47
+ }
48
+
49
+ export function HelpDetail({ cmd }) {
50
+ if (!cmd) return html`<${Box} paddingX=${1}><${Text} dimColor>Pick a command.<//><//>`;
51
+ return html`
52
+ <${Box} flexDirection="column" paddingX=${1}>
53
+ <${Text} bold>mnema ${cmd.usage ?? cmd.name}<//>
54
+ <${Box} marginTop=${1}><${Text}>${cmd.blurb}<//><//>
55
+ ${cmd.paid ? html`<${Box} marginTop=${1}><${Text} color="yellow">Paid feature.<//><//>` : null}
56
+ ${cmd.detail
57
+ ? html`<${Box} marginTop=${1} flexDirection="column">
58
+ ${cmd.detail.split('\n\n').map((p, i) => html`<${Box} key=${i} marginBottom=${1}><${Text} dimColor>${p}<//><//>`)}
59
+ <//>`
60
+ : null}
61
+ ${cmd.flags?.length
62
+ ? html`<${Box} marginTop=${1} flexDirection="column">
63
+ <${Text} dimColor>flags<//>
64
+ ${cmd.flags.map((f) => html`<${Text} key=${f}> ${f}<//>`)}
65
+ <//>`
66
+ : null}
67
+ ${cmd.examples?.length
68
+ ? html`<${Box} marginTop=${1} flexDirection="column">
69
+ <${Text} dimColor>examples<//>
70
+ ${cmd.examples.map((e) => html`<${Text} key=${e} color="cyan"> ${e}<//>`)}
71
+ <//>`
72
+ : null}
73
+ <//>`;
74
+ }