@mnemahq/cli 0.4.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/package.json +7 -3
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +176 -52
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +135 -70
- package/src/render/layout.mjs +117 -0
- package/src/render/mark-art.mjs +160 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +40 -12
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +127 -0
- package/src/tui/components/header.mjs +66 -0
- package/src/tui/components/list.mjs +91 -0
- package/src/tui/h.mjs +23 -0
- package/src/tui/launch.mjs +61 -0
- package/src/tui/screens/briefing.mjs +74 -0
- package/src/tui/screens/finding.mjs +89 -0
- package/src/tui/screens/node.mjs +106 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
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
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"LICENSE.md"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
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": "
|
|
41
|
+
"typecheck": "find bin src -name '*.mjs' -exec node --check {} +",
|
|
38
42
|
"test": "vitest run"
|
|
39
43
|
}
|
|
40
44
|
}
|
package/src/browser.mjs
ADDED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
} catch {
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
267
|
-
if (!local.length)
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
|
299
|
+
let page;
|
|
279
300
|
try {
|
|
280
|
-
|
|
281
|
-
m.sessions.list({ limit, ...(repo ? { repo } : {}) })
|
|
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
|
|
307
|
+
console.log(c.yellow(`\n Server sessions unavailable — ${e?.message ?? e}`));
|
|
287
308
|
return;
|
|
288
309
|
}
|
|
289
|
-
console.log(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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, ' '),
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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) {
|
|
417
|
-
|
|
468
|
+
if (failed.length) {
|
|
469
|
+
console.log(`\n ${mark.warn()} ${c.yellow(`${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)}`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
console.log(`\n ${mark.ok()} ${c.green('All good.')}`);
|
|
418
473
|
void root;
|
|
419
474
|
}
|
|
420
475
|
|
|
@@ -438,7 +493,9 @@ async function cmdUninstall(flags) {
|
|
|
438
493
|
function help() {
|
|
439
494
|
console.log(`mnema ${VERSION} — connect a repo to your Mnema workspace
|
|
440
495
|
|
|
441
|
-
Usage: mnema
|
|
496
|
+
Usage: mnema [command] [options]
|
|
497
|
+
|
|
498
|
+
mnema Open the interactive briefing (a terminal, Node 20+)
|
|
442
499
|
|
|
443
500
|
Commands:
|
|
444
501
|
login Sign in (opens a browser; tokens go to your OS keychain)
|
|
@@ -451,18 +508,29 @@ Commands:
|
|
|
451
508
|
search "q" Search your workspace from the terminal
|
|
452
509
|
doctor Diagnose install, hooks, auth, connectivity
|
|
453
510
|
uninstall Remove hooks and stored secrets
|
|
511
|
+
tui Open the interactive briefing explicitly (Node 20+, a terminal)
|
|
454
512
|
|
|
455
513
|
Read your workspace:
|
|
456
514
|
tasks List tasks [--status --project --limit]
|
|
457
515
|
next The next task to pick up, with a ready-made branch name
|
|
458
516
|
docs List documents [--limit]
|
|
459
|
-
doc
|
|
517
|
+
doc [id] Print one document as markdown; no id opens a picker
|
|
460
518
|
projects List projects
|
|
461
519
|
briefing What deserves attention — pulse, deltas, findings
|
|
462
520
|
|
|
463
521
|
Ask the knowledge graph (paid feature):
|
|
464
522
|
ask "q" A cited answer, with the confidence it deserves
|
|
465
|
-
graph
|
|
523
|
+
graph Neighbourhood of a node, or the path between two
|
|
524
|
+
|
|
525
|
+
Examples:
|
|
526
|
+
mnema the interactive briefing
|
|
527
|
+
mnema tasks --status in_progress
|
|
528
|
+
mnema doc pick a document from a list
|
|
529
|
+
mnema ask "why did we drop the queue?"
|
|
530
|
+
mnema graph "Workspace Security & Management"
|
|
531
|
+
|
|
532
|
+
⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before
|
|
533
|
+
mnema sees it, and zsh reports a parse error that looks like a broken CLI.
|
|
466
534
|
|
|
467
535
|
Options:
|
|
468
536
|
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
@@ -471,12 +539,14 @@ Options:
|
|
|
471
539
|
--json Machine-readable output (every read command)
|
|
472
540
|
--yes Non-interactive; skip optional prompts
|
|
473
541
|
--purge uninstall: also delete .mnema/config.json
|
|
542
|
+
--no-tui Print help instead of opening the interactive UI
|
|
474
543
|
--version, --help
|
|
475
544
|
|
|
476
545
|
Environment:
|
|
477
546
|
NO_COLOR Disable colour (any value)
|
|
478
547
|
FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
|
|
479
548
|
COLUMNS Override the terminal width used for layout
|
|
549
|
+
MNEMA_TUI never | always — force the interactive UI off or on
|
|
480
550
|
MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
|
|
481
551
|
|
|
482
552
|
Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
|
|
@@ -484,9 +554,49 @@ writes plain text.
|
|
|
484
554
|
`);
|
|
485
555
|
}
|
|
486
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Open the interactive briefing, or explain why not and print help.
|
|
559
|
+
*
|
|
560
|
+
* ⭐ THE ONLY DYNAMIC IMPORT OF THE TUI IN THE WHOLE PACKAGE. Everything under
|
|
561
|
+
* src/tui/ is reached exclusively through this line, which is why modules in there
|
|
562
|
+
* can use ordinary static imports. `import('ink')` measures 136–141ms against a
|
|
563
|
+
* 59–65ms `mnema --version`, and mnema runs from capture hooks and shell prompts —
|
|
564
|
+
* so this must stay the single seam. test/no-ink-in-oneshot.test.mjs enforces it,
|
|
565
|
+
* because `node --check` parses without resolving and would never notice a stray
|
|
566
|
+
* `import { Box } from 'ink'` in read-commands.mjs.
|
|
567
|
+
*/
|
|
568
|
+
async function openTuiOrHelp(flags) {
|
|
569
|
+
const gate = tuiEligibility(flags);
|
|
570
|
+
if (gate.ok) {
|
|
571
|
+
const { startTui } = await import('./tui/launch.mjs');
|
|
572
|
+
const ctx = resolveContext(flags);
|
|
573
|
+
return startTui({
|
|
574
|
+
...ctx,
|
|
575
|
+
version: VERSION,
|
|
576
|
+
call: (fn) => call(ctx, fn),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// ⚠️ TO STDERR, so `mnema | cat` still gets clean help on stdout. And nothing at
|
|
580
|
+
// all for `not-a-tty`: piping mnema to read its help is legitimate, and a nag
|
|
581
|
+
// there is noise in someone's data.
|
|
582
|
+
for (const line of explainUnavailable(gate.reason, gate.detail)) console.error(c.dim(line));
|
|
583
|
+
help();
|
|
584
|
+
// Exit 0: bare `mnema` has printed help and exited 0 since 0.1.0, and turning
|
|
585
|
+
// that into a failure for a runtime we still support is a regression wearing a
|
|
586
|
+
// feature's clothes.
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
487
590
|
export async function run(argv) {
|
|
488
591
|
const { flags, rest } = parseFlags(argv);
|
|
489
592
|
if (flags.version) { console.log(VERSION); return; }
|
|
593
|
+
// ⭐ BEFORE THE GATE, AND THIS ORDER IS THE WHOLE POINT. `mnema --help` leaves
|
|
594
|
+
// rest empty, so cmd === undefined — the SAME branch that opens the TUI. Worse,
|
|
595
|
+
// version.test.mjs runs the binary through execFileSync, where stdout is not a
|
|
596
|
+
// TTY, so the gate would fall through to help() and THE TEST WOULD PASS while
|
|
597
|
+
// every real user on a terminal got a UI instead of the help they asked for.
|
|
598
|
+
// That is this repo's characteristic bug aimed at its own test suite.
|
|
599
|
+
if (flags.help || flags.h) { help(); return; }
|
|
490
600
|
const cmd = rest.shift();
|
|
491
601
|
switch (cmd) {
|
|
492
602
|
case 'login': return cmdLogin(flags);
|
|
@@ -510,7 +620,21 @@ export async function run(argv) {
|
|
|
510
620
|
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
511
621
|
case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
|
|
512
622
|
case 'briefing': return cmdBriefing(flags, resolveContext(flags));
|
|
513
|
-
case
|
|
623
|
+
case 'tui':
|
|
624
|
+
// An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
|
|
625
|
+
// implicit one below degrades to help and exits 0. That asymmetry is
|
|
626
|
+
// CLAUDE.md's no-silent-returns rule: `mnema tui` on Node 18 must not
|
|
627
|
+
// quietly print help as though that were what was asked for.
|
|
628
|
+
{
|
|
629
|
+
const gate = tuiEligibility(flags);
|
|
630
|
+
if (gate.ok) return openTuiOrHelp(flags);
|
|
631
|
+
const why = explainUnavailable(gate.reason, gate.detail);
|
|
632
|
+
console.error(c.red(why[0] ?? `The interactive UI is unavailable here (${gate.reason}).`));
|
|
633
|
+
for (const line of why.slice(1)) console.error(c.dim(line));
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
break;
|
|
637
|
+
case undefined: return openTuiOrHelp(flags);
|
|
514
638
|
case 'help': return help();
|
|
515
639
|
default:
|
|
516
640
|
console.error(c.red(`Unknown command: ${cmd}`));
|