@mnemahq/cli 0.10.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.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": {
@@ -34,7 +34,7 @@
34
34
  "htm": "^3.1.1",
35
35
  "ink": "^6.8.0",
36
36
  "react": "^19.2.7",
37
- "@mnemahq/sdk": "0.4.0"
37
+ "@mnemahq/sdk": "0.4.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node -e \"process.exit(0)\"",
package/src/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
15
15
  import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
16
16
  import {
17
17
  cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
18
+ cmdFlows, cmdFlow,
18
19
  } from './read-commands.mjs';
19
20
  import {
20
21
  DEFAULT_ORIGIN, DEFAULT_APP_URL, c, truncate, width, mark, gitInfo, canonicalRepo, readConfig, writeConfig,
@@ -510,17 +511,22 @@ Commands:
510
511
  uninstall Remove hooks and stored secrets
511
512
  tui Open the interactive briefing explicitly (Node 20+, a terminal)
512
513
 
514
+ On a terminal, the read commands below open a NAVIGABLE view — arrow keys to move,
515
+ enter to open, esc to go back. Piped or with --json they print as they always have.
516
+
513
517
  Read your workspace:
514
518
  tasks List tasks [--status --project --limit]
515
519
  next The next task to pick up, with a ready-made branch name
516
520
  docs List documents [--limit]
517
521
  doc [id] Print one document as markdown; no id opens a picker
518
522
  projects List projects
523
+ flows List flows
524
+ flow [slug] One flow; no slug opens a picker
519
525
  briefing What deserves attention — pulse, deltas, findings
520
526
 
521
527
  Ask the knowledge graph (paid feature):
522
528
  ask "q" A cited answer, with the confidence it deserves
523
- graph Neighbourhood of a node, or the path between two
529
+ graph Walk a node's neighbours; graph <a> <b> prints the path between two
524
530
 
525
531
  Examples:
526
532
  mnema the interactive briefing
@@ -565,6 +571,49 @@ writes plain text.
565
571
  * because `node --check` parses without resolving and would never notice a stray
566
572
  * `import { Box } from 'ink'` in read-commands.mjs.
567
573
  */
574
+ /**
575
+ * ⭐ THE COMMAND YOU TYPED SHOULD DO THE THING YOU NAMED.
576
+ *
577
+ * `mnema graph "Workspace Security & Management"` printed 25 rows and "… 176
578
+ * more" and stopped. The navigable walker existed, but it lived behind bare
579
+ * `mnema` and a `g` keybinding — so the obvious command gave a static dump and
580
+ * the answer was "run a different command and press a key". Nobody does that.
581
+ * They type the thing they want.
582
+ *
583
+ * ⚠️ THE ONE-SHOT PATH IS UNCHANGED. --json, a pipe, no TTY, --no-tui, CI: all
584
+ * still print exactly what they printed before, byte for byte. Scripts are the
585
+ * contract. This only changes what a HUMAN AT A TERMINAL gets.
586
+ *
587
+ * @returns true if the interactive view took over.
588
+ */
589
+ /**
590
+ * Which screen a read command should open on, or null to stay one-shot.
591
+ *
592
+ * Exported so the ROUTING is testable without spawning ink — the decision is the
593
+ * part that was wrong, and a test that re-implements it in the test file proves
594
+ * nothing about this function.
595
+ */
596
+ export function screenFor(cmd, flags, rest = []) {
597
+ if (flags.json) return null;
598
+ if (cmd === 'graph') {
599
+ // `graph <a> <b>` is a path query — one answer, not a place to walk.
600
+ if (rest.length > 1) return null;
601
+ return rest[0] ? { name: 'node', target: { label: rest[0] } } : { name: 'graph' };
602
+ }
603
+ if (cmd === 'tasks' || cmd === 'docs' || cmd === 'briefing' || cmd === 'flows') return { name: cmd };
604
+ return null;
605
+ }
606
+
607
+ async function maybeInteractive(flags, screen) {
608
+ if (!screen) return false;
609
+ if (flags.json) return false;
610
+ if (!tuiEligibility(flags).ok) return false;
611
+ const { startTui } = await import('./tui/launch.mjs');
612
+ const ctx = resolveContext(flags);
613
+ await startTui({ ...ctx, version: VERSION, call: (fn) => call(ctx, fn) }, screen);
614
+ return true;
615
+ }
616
+
568
617
  async function openTuiOrHelp(flags) {
569
618
  const gate = tuiEligibility(flags);
570
619
  if (gate.ok) {
@@ -612,14 +661,29 @@ export async function run(argv) {
612
661
 
613
662
  // Reads. Each resolves context once and hands the SDK client to a wrapper;
614
663
  // none of them knows a URL or an envelope key (§A2, t-623).
615
- case 'tasks': return cmdTasks(flags, resolveContext(flags));
664
+ case 'tasks':
665
+ if (await maybeInteractive(flags, screenFor('tasks', flags))) return 0;
666
+ return cmdTasks(flags, resolveContext(flags));
616
667
  case 'next': return cmdNext(flags, resolveContext(flags));
617
- case 'docs': return cmdDocs(flags, resolveContext(flags));
668
+ case 'docs':
669
+ if (await maybeInteractive(flags, screenFor('docs', flags))) return 0;
670
+ return cmdDocs(flags, resolveContext(flags));
618
671
  case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
619
672
  case 'projects': return cmdProjects(flags, resolveContext(flags));
673
+ case 'flows':
674
+ if (await maybeInteractive(flags, screenFor('flows', flags))) return 0;
675
+ return cmdFlows(flags, resolveContext(flags));
676
+ case 'flow': return cmdFlow(flags, resolveContext(flags), rest);
620
677
  case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
621
- case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
622
- case 'briefing': return cmdBriefing(flags, resolveContext(flags));
678
+ case 'graph':
679
+ // With a node named, open the walker AT it. With no argument, open the hub
680
+ // list. `graph <a> <b>` is a path query — a single answer, not a place to
681
+ // walk — so that one stays one-shot.
682
+ if (await maybeInteractive(flags, screenFor('graph', flags, rest))) return 0;
683
+ return cmdGraph(flags, resolveContext(flags), rest);
684
+ case 'briefing':
685
+ if (await maybeInteractive(flags, screenFor('briefing', flags))) return 0;
686
+ return cmdBriefing(flags, resolveContext(flags));
623
687
  case 'tui':
624
688
  // An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
625
689
  // implicit one below degrades to help and exits 0. That asymmetry is
@@ -27,7 +27,7 @@
27
27
  import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
28
28
  import { take } from './paging.mjs';
29
29
  import { c, truncate, link, mark } from './util.mjs';
30
- import { heading, empty, rows, more, copyable } from './render/layout.mjs';
30
+ import { heading, entries, empty, rows, more, copyable } from './render/layout.mjs';
31
31
  import { pick } from './render/pick.mjs';
32
32
 
33
33
  /** Machine output is the whole object; humans get the formatted view. */
@@ -274,3 +274,64 @@ export async function cmdBriefing(flags, ctx) {
274
274
  });
275
275
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
276
276
  }
277
+
278
+ // ── flows ─────────────────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * ⚠️ THE PUBLIC FLOW ENDPOINT RETURNS METADATA ONLY — no nodes, no steps, no run
282
+ * history. Rendering a "Steps" heading with nothing under it would be the exact
283
+ * silent-empty failure this codebase is named for: it would read as "this flow
284
+ * has no steps" when it means "this endpoint does not serve them". So the detail
285
+ * shows what is genuinely there and says where the rest lives.
286
+ */
287
+ export async function cmdFlows(flags, ctx) {
288
+ await guard(ctx.workspaceId);
289
+ const limit = limitOf(flags, 50);
290
+ try {
291
+ const page = await call(ctx, (m) => take(m.flows.list(), limit));
292
+ emit(flags, page.rows, () => {
293
+ heading('Flows', page.rows.length);
294
+ if (!page.rows.length) return empty('No flows yet.', 'Build one in the app and it shows up here.');
295
+ rows(page.rows.map((f) => ({
296
+ cells: [f.slug ?? '', f.name ?? ''],
297
+ style: [c.cyan, undefined],
298
+ })));
299
+ more(page.more, `mnema flows --limit ${limit * 2}`);
300
+ });
301
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'flows' }); }
302
+ }
303
+
304
+ export async function cmdFlow(flags, ctx, rest) {
305
+ await guard(ctx.workspaceId);
306
+ let slug = rest[0];
307
+ const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
308
+ if (!slug && !flags.json && interactive) {
309
+ const page = await call(ctx, (m) => take(m.flows.list(), 50)).catch(() => null);
310
+ if (page?.rows.length) {
311
+ const chosen = await pick(page.rows.map((f) => ({ label: `${f.name ?? f.slug}` })), { title: 'Which flow?' });
312
+ if (chosen === null) return;
313
+ slug = page.rows[chosen].slug;
314
+ }
315
+ }
316
+ if (!slug) {
317
+ console.error(c.red('Usage: mnema flow <slug>'));
318
+ console.error(c.dim(' Run it without a slug in a terminal to pick from a list.'));
319
+ process.exit(1);
320
+ }
321
+ try {
322
+ const f = await call(ctx, (m) => m.flows.get(slug));
323
+ emit(flags, f, () => {
324
+ heading(f.name ?? slug);
325
+ entries([
326
+ { label: 'slug', value: f.slug ?? slug },
327
+ { label: 'published', value: f.publishedVersionId ? 'yes' : 'draft', state: Boolean(f.publishedVersionId) },
328
+ ...(f.projectId ? [{ label: 'project', value: f.projectId }] : []),
329
+ { label: 'updated', value: String(f.updatedAt ?? '').slice(0, 10), dim: true },
330
+ ]);
331
+ if (f.description) console.log(`\n ${c.dim(f.description)}`);
332
+ // Saying this is the point: the absence of steps here is the endpoint's
333
+ // shape, not the flow's emptiness.
334
+ console.log(c.dim('\n Steps and runs are not on the public API — open the flow in the app to walk it.'));
335
+ });
336
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'flow' }); }
337
+ }
package/src/tui/app.mjs CHANGED
@@ -23,12 +23,13 @@ import { Header } from './components/header.mjs';
23
23
  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
+ import { Flows, Flow } from './screens/flows.mjs';
26
27
 
27
28
  const HOME = { name: 'briefing' };
28
29
 
29
- export function App({ ctx }) {
30
+ export function App({ ctx, initial }) {
30
31
  const { exit } = useApp();
31
- const [stack, setStack] = useState([HOME]);
32
+ const [stack, setStack] = useState([initial ?? HOME]);
32
33
  const [fatal, setFatal] = useState(null);
33
34
 
34
35
  // ⭐ THE AUTH TAKEOVER HAS TO BE WIRED, not merely rendered. Every screen fetches
@@ -71,6 +72,7 @@ export function App({ ctx }) {
71
72
  if (input === 'g') { setStack([{ name: 'graph' }]); return; }
72
73
  if (input === 't') { setStack([{ name: 'tasks' }]); return; }
73
74
  if (input === 'd') { setStack([{ name: 'docs' }]); return; }
75
+ if (input === 'f') { setStack([{ name: 'flows' }]); return; }
74
76
  });
75
77
 
76
78
  if (fatal?.kind === 'auth') {
@@ -100,6 +102,11 @@ export function App({ ctx }) {
100
102
  onOpen=${(d) => push({ name: 'doc', doc: d })} />`;
101
103
  } else if (top.name === 'doc') {
102
104
  body = html`<${Doc} ctx=${wrapped} doc=${top.doc} focused=${true} />`;
105
+ } else if (top.name === 'flows') {
106
+ body = html`<${Flows} ctx=${wrapped} focused=${true}
107
+ onOpen=${(f) => push({ name: 'flow', flow: f })} />`;
108
+ } else if (top.name === 'flow') {
109
+ body = html`<${Flow} ctx=${wrapped} flow=${top.flow} />`;
103
110
  } else if (top.name === 'briefing') {
104
111
  body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
105
112
  } else if (top.name === 'node') {
@@ -144,13 +151,13 @@ export function App({ ctx }) {
144
151
  * which is worse than not mentioning it. The global jumps are appended once, in
145
152
  * one place, so they cannot drift per screen.
146
153
  */
147
- const JUMPS = 'b briefing · g graph · t tasks · d docs · q quit';
154
+ const JUMPS = 'b briefing · g graph · t tasks · d docs · f flows · q quit';
148
155
 
149
156
  function footerFor(top) {
150
157
  if (top.name === 'briefing') return `↑↓ move · enter open · r refresh · ${JUMPS}`;
151
158
  if (top.name === 'graph') return `↑↓ move · enter walk in · ${JUMPS}`;
152
- if (top.name === 'tasks' || top.name === 'docs') return `↑↓ move · enter open · ${JUMPS}`;
153
- if (top.name === 'task') return `esc back · ${JUMPS}`;
159
+ if (top.name === 'tasks' || top.name === 'docs' || top.name === 'flows') return `↑↓ move · enter open · ${JUMPS}`;
160
+ if (top.name === 'task' || top.name === 'flow') return `esc back · ${JUMPS}`;
154
161
  if (top.name === 'doc') return `j/k scroll · esc back · ${JUMPS}`;
155
162
  if (top.name === 'node') return `↑↓ move · enter walk in · esc back · r refresh · ${JUMPS}`;
156
163
  if (top.name === 'finding') {
@@ -28,8 +28,14 @@ import { App } from './app.mjs';
28
28
 
29
29
  const SHOW_CURSOR = '\x1b[?25h';
30
30
 
31
- export async function startTui(ctx) {
32
- const instance = render(html`<${App} ctx=${ctx} />`, {
31
+ /**
32
+ * @param initial the screen to open on. `mnema graph "X"` opens the walker AT X
33
+ * rather than at the briefing, because the command someone typed
34
+ * should do the thing they named — not drop them at a home screen
35
+ * with a keybinding to hunt for.
36
+ */
37
+ export async function startTui(ctx, initial) {
38
+ const instance = render(html`<${App} ctx=${ctx} initial=${initial} />`, {
33
39
  // Ink's own exitOnCtrlC would leave our own key handling out of the loop.
34
40
  exitOnCtrlC: true,
35
41
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Flows (t-640).
3
+ *
4
+ * ⚠️ THE PUBLIC ENDPOINT SERVES METADATA ONLY — no nodes, no steps, no runs. A
5
+ * "Steps" heading with nothing under it would read as "this flow is empty" when
6
+ * it means "this endpoint does not serve them", which is exactly the silent-empty
7
+ * failure this codebase is named for. So the detail shows what is genuinely there
8
+ * and names where the rest lives.
9
+ */
10
+
11
+ import { Box, Text } from 'ink';
12
+ import { html } from '../h.mjs';
13
+ import { useResource, ago } from '../store.mjs';
14
+ import { List } from '../components/list.mjs';
15
+ import { take } from '../../paging.mjs';
16
+
17
+ export function Flows({ ctx, focused, onOpen }) {
18
+ const { status, data, error, elapsed, at } = useResource('flows', () =>
19
+ ctx.call((m) => take(m.flows.list(), 60)));
20
+
21
+ if (status === 'loading') {
22
+ return html`<${Box} paddingX=${1}><${Text} dimColor>Loading flows… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
23
+ }
24
+ if (error && !data) {
25
+ return html`<${Box} paddingX=${1}><${Text} color="red">${error.message ?? String(error)}<//><//>`;
26
+ }
27
+
28
+ const rows = data?.rows ?? [];
29
+ return html`
30
+ <${Box} flexDirection="column" paddingX=${1}>
31
+ <${Box}>
32
+ <${Text} bold>Flows<//>
33
+ <${Text} dimColor> ${rows.length}${data?.more ? '+' : ''} · ${ago(at)}<//>
34
+ <//>
35
+ ${rows.length === 0
36
+ ? html`<${Text} dimColor>No flows yet.<//>`
37
+ : html`<${List}
38
+ items=${rows.map((f) => ({ key: f.id ?? f.slug, left: f.slug ?? '', label: f.name ?? '' }))}
39
+ focused=${focused}
40
+ height=${12}
41
+ onSelect=${(i) => onOpen(rows[i])}
42
+ />`}
43
+ <//>`;
44
+ }
45
+
46
+ export function Flow({ ctx, flow }) {
47
+ const { status, data, error, elapsed } = useResource(`flow:${flow?.slug}`, () =>
48
+ ctx.call((m) => m.flows.get(flow.slug)));
49
+
50
+ if (status === 'loading') {
51
+ return html`<${Box} paddingX=${1}><${Text} dimColor>Opening ${flow?.name ?? ''}… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
52
+ }
53
+ if (error) {
54
+ return html`<${Box} flexDirection="column" paddingX=${1}>
55
+ <${Text} color="red">${error.message ?? String(error)}<//>
56
+ <${Text} dimColor>esc to go back<//>
57
+ <//>`;
58
+ }
59
+ const f = data ?? flow ?? {};
60
+
61
+ return html`
62
+ <${Box} flexDirection="column" paddingX=${1}>
63
+ <${Text} bold>${f.name ?? f.slug ?? '(unnamed)'}<//>
64
+ <${Box} flexDirection="column" marginTop=${1}>
65
+ <${Box}><${Text} dimColor>slug <//><${Text} color="cyan">${f.slug ?? ''}<//><//>
66
+ <${Box}><${Text} dimColor>published <//>
67
+ <${Text} color=${f.publishedVersionId ? 'green' : undefined}>${f.publishedVersionId ? 'yes' : 'draft'}<//><//>
68
+ <${Box}><${Text} dimColor>updated <//><${Text}>${String(f.updatedAt ?? '').slice(0, 10)}<//><//>
69
+ <//>
70
+ ${f.description
71
+ ? html`<${Box} marginTop=${1}><${Text} dimColor>${f.description}<//><//>`
72
+ : null}
73
+ <${Box} marginTop=${1}>
74
+ <${Text} dimColor>Steps and runs are not on the public API — open the flow in the app to walk it.<//>
75
+ <//>
76
+ <//>`;
77
+ }