@mnemahq/cli 0.3.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/package.json +8 -4
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +207 -55
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +138 -58
- package/src/render/layout.mjs +117 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +255 -0
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +89 -0
- package/src/tui/components/list.mjs +57 -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 +53 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
- package/src/util.mjs +78 -19
package/README.md
CHANGED
|
@@ -50,7 +50,48 @@ contents. See the [privacy details](https://mnema.theboringpeople.in/docs/connec
|
|
|
50
50
|
|
|
51
51
|
`--origin <url>` (or `MNEMA_API_ORIGIN`) points the CLI at a self-hosted instance.
|
|
52
52
|
|
|
53
|
-
Requires Node
|
|
53
|
+
Requires Node 20+ and `git` — the same floor `@mnemahq/sdk` already declares.
|
|
54
|
+
|
|
55
|
+
## The interactive briefing
|
|
56
|
+
|
|
57
|
+
Run `mnema` with no arguments in a terminal and it opens a navigable briefing:
|
|
58
|
+
pulse, then the ranked findings, with `enter` to drill into one.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
mnema the briefing (a terminal, Node 20+)
|
|
62
|
+
mnema tui the same, explicitly
|
|
63
|
+
mnema --no-tui print help instead
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It never opens when output is piped, under `CI`, on `TERM=dumb`, or where the
|
|
67
|
+
terminal cannot enter raw mode — in all of those `mnema` prints help exactly as it
|
|
68
|
+
always has. `MNEMA_TUI=never` turns it off for good; `MNEMA_TUI=always` forces it.
|
|
69
|
+
|
|
70
|
+
**Ink is loaded lazily**, so this costs one-shot commands nothing: `mnema --version`
|
|
71
|
+
measures the same before and after (~60ms), because `ink` is only imported once the
|
|
72
|
+
UI is actually opening. Installed size is ~22 MB, most of which is `es-toolkit`
|
|
73
|
+
arriving through Ink.
|
|
74
|
+
|
|
75
|
+
## Output, piping and colour
|
|
76
|
+
|
|
77
|
+
Every read command takes `--json`, so the CLI composes:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
mnema tasks --json | jq '.[] | select(.priority == "high") | .title'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Colour is emitted only when output is a terminal.** `mnema docs > out.txt` and
|
|
84
|
+
`mnema tasks | grep …` produce plain text with no escape sequences in it.
|
|
85
|
+
|
|
86
|
+
| variable | effect |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `NO_COLOR` | disable colour, any value ([no-color.org](https://no-color.org)) |
|
|
89
|
+
| `FORCE_COLOR=1` | keep colour through a pipe, e.g. `mnema tasks \| less -R` |
|
|
90
|
+
| `COLUMNS` | override the width used for column layout |
|
|
91
|
+
| `MNEMA_WORKSPACE_ID` | default workspace, instead of `--workspace` |
|
|
92
|
+
|
|
93
|
+
`mnema doc <id>` is built for redirection: the markdown goes to stdout and the title
|
|
94
|
+
and path go to stderr, so `mnema doc <id> > note.md` gets the document alone.
|
|
54
95
|
|
|
55
96
|
## `.mnema/` repo artifacts
|
|
56
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"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": ">=
|
|
16
|
+
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"mnema",
|
|
@@ -22,19 +22,23 @@
|
|
|
22
22
|
"claude-code",
|
|
23
23
|
"sessions"
|
|
24
24
|
],
|
|
25
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
25
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"ink-testing-library": "^4.0.0",
|
|
30
31
|
"vitest": "^3.2.7"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
34
|
+
"htm": "^3.1.1",
|
|
35
|
+
"ink": "^6.8.0",
|
|
36
|
+
"react": "^19.2.7",
|
|
33
37
|
"@mnemahq/sdk": "0.3.1"
|
|
34
38
|
},
|
|
35
39
|
"scripts": {
|
|
36
40
|
"build": "node -e \"process.exit(0)\"",
|
|
37
|
-
"typecheck": "
|
|
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, 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,
|
|
@@ -47,15 +52,39 @@ const VERSION = JSON.parse(
|
|
|
47
52
|
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
48
53
|
).version;
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Flags that take a value. Everything else is boolean.
|
|
57
|
+
*
|
|
58
|
+
* ⭐ THIS LIST IS THE FIX. The parser used to treat ANY flag as value-taking if
|
|
59
|
+
* the next argv entry did not start with `--`, so a boolean flag silently ate the
|
|
60
|
+
* following positional:
|
|
61
|
+
*
|
|
62
|
+
* mnema doc --json <id> -> flags.json = '<id>', rest = []
|
|
63
|
+
* -> "Usage: mnema doc <id>"
|
|
64
|
+
*
|
|
65
|
+
* `--json` only worked in trailing position, and the same trap hit `ask`,
|
|
66
|
+
* `search` and `graph`. Guessing from argv shape cannot distinguish "a boolean
|
|
67
|
+
* flag followed by an argument" from "a flag and its value" — the parser has to
|
|
68
|
+
* be told which is which, so it is.
|
|
69
|
+
*/
|
|
70
|
+
const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget']);
|
|
71
|
+
|
|
72
|
+
export function parseFlags(argv) {
|
|
51
73
|
const flags = {}; const rest = [];
|
|
52
74
|
for (let i = 0; i < argv.length; i++) {
|
|
53
75
|
const a = argv[i];
|
|
54
|
-
if (a.startsWith('--')) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
if (!a.startsWith('--')) { rest.push(a); continue; }
|
|
77
|
+
|
|
78
|
+
// `--limit=5` as well as `--limit 5`; the former is unambiguous and common.
|
|
79
|
+
const eq = a.indexOf('=');
|
|
80
|
+
if (eq > 2) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
|
|
81
|
+
|
|
82
|
+
const key = a.slice(2);
|
|
83
|
+
if (VALUE_FLAGS.has(key) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
|
|
84
|
+
flags[key] = argv[++i];
|
|
85
|
+
} else {
|
|
86
|
+
flags[key] = true;
|
|
87
|
+
}
|
|
59
88
|
}
|
|
60
89
|
return { flags, rest };
|
|
61
90
|
}
|
|
@@ -211,24 +240,36 @@ async function cmdInit(flags) {
|
|
|
211
240
|
|
|
212
241
|
async function cmdStatus(flags) {
|
|
213
242
|
const { git, root, cfg, origin, workspaceId } = resolveContext(flags);
|
|
214
|
-
console.log(c.bold('Mnema status'));
|
|
215
|
-
console.log(` Workspace : ${workspaceId || c.red('not linked — run `mnema init`')}`);
|
|
216
|
-
console.log(` Origin : ${origin}`);
|
|
217
|
-
console.log(` Repo : ${canonicalRepo(git.remote) || c.dim('(no git remote)')}`);
|
|
218
|
-
console.log(` Hook : ${hookInstalled() ? c.green('installed') : c.red('not installed')}`);
|
|
219
243
|
|
|
220
|
-
|
|
244
|
+
// ⚠️ THE API PROBE RUNS BEFORE ANYTHING PRINTS, and that is the fix for the
|
|
245
|
+
// broken gutter. The old version wrote a hand-padded ' API : ' with
|
|
246
|
+
// process.stdout.write, awaited the network, then console.log'd the result —
|
|
247
|
+
// so the label column was aligned by counting spaces against four other lines,
|
|
248
|
+
// and any change to a label name silently knocked one row out of true. Gathering
|
|
249
|
+
// first means one table() measures every row against every other.
|
|
250
|
+
let api;
|
|
221
251
|
try {
|
|
222
252
|
const r = await apiFetch(origin, '/install/mnema-hook.mjs');
|
|
223
|
-
|
|
224
|
-
} catch {
|
|
253
|
+
api = r.ok ? { pass: true, note: 'reachable' } : { pass: false, note: `HTTP ${r.status}` };
|
|
254
|
+
} catch { api = { pass: false, note: 'unreachable' }; }
|
|
225
255
|
|
|
226
256
|
const local = localSessionsForRepo(git.root, 1);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
257
|
+
|
|
258
|
+
// ⚠️ ONE entries() CALL, not one per group — the label column only lines up if
|
|
259
|
+
// every row is measured against every other row. Three calls printed three
|
|
260
|
+
// different label widths, which is this PR's own bug in miniature.
|
|
261
|
+
heading('Status', workspaceId ? undefined : 'not linked');
|
|
262
|
+
entries([
|
|
263
|
+
{ label: 'workspace', value: workspaceId || c.red('not linked — run `mnema init`') },
|
|
264
|
+
{ label: 'origin', value: origin },
|
|
265
|
+
{ label: 'repo', value: canonicalRepo(git.remote) || c.dim('(no git remote)') },
|
|
266
|
+
{ label: 'capture hook', value: hookInstalled() ? 'installed' : 'run `mnema init`', state: hookInstalled() },
|
|
267
|
+
{ label: 'api', value: api.note, state: api.pass },
|
|
268
|
+
{
|
|
269
|
+
label: 'last session',
|
|
270
|
+
value: local.length ? `${local[0].sessionId.slice(0, 8)}… ${c.dim(fmtAge(local[0].mtimeMs))}` : c.dim('none found'),
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
232
273
|
void cfg;
|
|
233
274
|
}
|
|
234
275
|
|
|
@@ -239,34 +280,49 @@ async function cmdSessions(flags) {
|
|
|
239
280
|
const limit = Number(flags.limit) || 10;
|
|
240
281
|
|
|
241
282
|
const local = localSessionsForRepo(git.root, limit);
|
|
242
|
-
|
|
243
|
-
if (!local.length)
|
|
244
|
-
|
|
245
|
-
|
|
283
|
+
heading('Local sessions', local.length);
|
|
284
|
+
if (!local.length) {
|
|
285
|
+
empty('None found under ~/.claude/projects.', 'Start a Claude Code session in this repo.');
|
|
286
|
+
} else {
|
|
287
|
+
rows(local.map((s) => ({
|
|
288
|
+
cells: [`${s.sessionId.slice(0, 8)}…`, fmtAge(s.mtimeMs), `${(s.sizeBytes / 1024).toFixed(0)} KB`],
|
|
289
|
+
style: [undefined, undefined, c.dim],
|
|
290
|
+
})));
|
|
246
291
|
}
|
|
247
292
|
|
|
248
293
|
if (!(await canAuthenticate(workspaceId))) {
|
|
249
|
-
console.log(c.dim('\n
|
|
294
|
+
console.log(c.dim('\n Run `mnema login` to see server-side cost and status.'));
|
|
250
295
|
return;
|
|
251
296
|
}
|
|
252
297
|
|
|
253
298
|
const repo = canonicalRepo(git.remote);
|
|
254
|
-
let
|
|
299
|
+
let page;
|
|
255
300
|
try {
|
|
256
|
-
|
|
257
|
-
m.sessions.list({ limit, ...(repo ? { repo } : {}) })
|
|
301
|
+
page = await call({ origin, workspaceId }, (m) =>
|
|
302
|
+
take(m.sessions.list({ limit, ...(repo ? { repo } : {}) }), limit));
|
|
258
303
|
} catch (e) {
|
|
259
304
|
// Non-fatal by design: the LOCAL list above is the useful half and has
|
|
260
305
|
// already printed. Degrading here is deliberate, and it says which wall it
|
|
261
306
|
// hit rather than a bare HTTP number.
|
|
262
|
-
console.log(c.yellow(`\n
|
|
307
|
+
console.log(c.yellow(`\n Server sessions unavailable — ${e?.message ?? e}`));
|
|
263
308
|
return;
|
|
264
309
|
}
|
|
265
|
-
console.log(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
310
|
+
console.log('');
|
|
311
|
+
heading('Server sessions', page.rows.length);
|
|
312
|
+
if (!page.rows.length) {
|
|
313
|
+
empty('None recorded for this repo yet.');
|
|
314
|
+
return;
|
|
269
315
|
}
|
|
316
|
+
rows(page.rows.map((s) => ({
|
|
317
|
+
cells: [
|
|
318
|
+
s.developerId || '?',
|
|
319
|
+
String(s.status),
|
|
320
|
+
typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0',
|
|
321
|
+
s.model || '',
|
|
322
|
+
],
|
|
323
|
+
style: [undefined, undefined, undefined, c.dim],
|
|
324
|
+
})));
|
|
325
|
+
more(page.more, `mnema sessions --limit ${limit * 2}`);
|
|
270
326
|
}
|
|
271
327
|
|
|
272
328
|
// ── sweep ───────────────────────────────────────────────────────────────────────
|
|
@@ -299,11 +355,19 @@ async function cmdSearch(flags, rest) {
|
|
|
299
355
|
try {
|
|
300
356
|
results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
|
|
301
357
|
} catch (e) { renderError(e, { context: 'search', usedApiKey: hasApiKey(workspaceId) }); }
|
|
302
|
-
|
|
303
|
-
|
|
358
|
+
|
|
359
|
+
// ⚠️ `search` DID NOT HONOUR --json, though help advertised it for "every read
|
|
360
|
+
// command". It sits in cli.mjs rather than read-commands.mjs, so it never got
|
|
361
|
+
// the shared emit() — a filing accident, not a decision.
|
|
362
|
+
if (flags.json) { console.log(JSON.stringify(results, null, 2)); return; }
|
|
363
|
+
|
|
364
|
+
heading('Results', results.length);
|
|
365
|
+
if (!results.length) { empty(`Nothing matched "${query}".`, 'Try fewer words, or a phrase from the document body.'); return; }
|
|
304
366
|
for (const d of results) {
|
|
305
367
|
console.log(` ${c.bold(d.title || d.path || d.id)}`);
|
|
306
|
-
|
|
368
|
+
// truncate() adds an ellipsis; the old slice(0,140) cut mid-sentence with no
|
|
369
|
+
// marker, so a clipped preview looked like a document that simply ended.
|
|
370
|
+
if (d.preview) console.log(` ${c.dim(truncate(String(d.preview).replace(/\s+/g, ' '), width() - 6))}`);
|
|
307
371
|
}
|
|
308
372
|
}
|
|
309
373
|
|
|
@@ -328,16 +392,30 @@ async function cmdPull(flags) {
|
|
|
328
392
|
scaffold(root);
|
|
329
393
|
const s = applyContext(root, docs);
|
|
330
394
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
395
|
+
heading('Synced', repo);
|
|
396
|
+
if (!docs.length) {
|
|
397
|
+
empty('No docs are bound to this repo yet.', 'Add a project in the app with this repo URL.');
|
|
398
|
+
}
|
|
399
|
+
// ⚠️ THE LABELS USED TO BE PADDED BY HAND — 'written ' is 9 characters and
|
|
400
|
+
// 'up-to-date' is 10, so the count column stepped one place to the right on two
|
|
401
|
+
// of the five rows. Nobody notices reading it once; it is exactly the sort of
|
|
402
|
+
// thing that makes output feel unmade. table() measures them against each other.
|
|
403
|
+
const tally = [
|
|
404
|
+
['written', s.written, c.green],
|
|
405
|
+
['updated', s.updated, c.green],
|
|
406
|
+
['up-to-date', s.upToDate, c.dim],
|
|
407
|
+
['kept local', s.kept, c.yellow],
|
|
408
|
+
['orphaned', s.orphaned, c.dim],
|
|
409
|
+
].filter(([, arr]) => arr.length);
|
|
410
|
+
if (tally.length) {
|
|
411
|
+
rows(tally.map(([label, arr, colour]) => ({
|
|
412
|
+
cells: [label, String(arr.length)],
|
|
413
|
+
style: [colour, undefined],
|
|
414
|
+
})));
|
|
415
|
+
}
|
|
339
416
|
if (s.conflicts.length) {
|
|
340
|
-
console.log(
|
|
417
|
+
console.log(`\n ${mark.warn()} ${c.red(`${s.conflicts.length} conflict(s) — the server version is beside yours as *.remote.md`)}`);
|
|
418
|
+
// Paths are printed plain: these are the files the user is about to open.
|
|
341
419
|
for (const rel of s.conflicts) console.log(` ${rel} ${c.dim('vs')} ${rel.replace(/\.md$/, '.remote.md')}`);
|
|
342
420
|
}
|
|
343
421
|
console.log(c.dim('\n .mnema/context is committed and readable offline. Edit NOTABILITY.md to tune capture.'));
|
|
@@ -359,7 +437,11 @@ async function cmdDoctor(flags) {
|
|
|
359
437
|
ok('hook token stored', !!(workspaceId && getSecret(workspaceId, 'hook-token')), `store: ${backendName()}`);
|
|
360
438
|
ok('capture hook installed', hookInstalled());
|
|
361
439
|
|
|
362
|
-
|
|
440
|
+
// ⚠️ ONLY ON A TTY. This wrote unconditionally, so `mnema doctor > report.txt`
|
|
441
|
+
// and `mnema doctor | grep` both captured the progress text as if it were part
|
|
442
|
+
// of the report. A spinner is for a human watching; it is noise in a pipe.
|
|
443
|
+
const live = process.stdout.isTTY;
|
|
444
|
+
if (live) process.stdout.write(' … checking connectivity\r');
|
|
363
445
|
let apiReach = false; try { apiReach = (await apiFetch(origin, '/install/mnema-hook.mjs')).ok; } catch { /* */ }
|
|
364
446
|
ok('API reachable', apiReach, origin);
|
|
365
447
|
|
|
@@ -375,14 +457,19 @@ async function cmdDoctor(flags) {
|
|
|
375
457
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
376
458
|
}
|
|
377
459
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
460
|
+
// ⚠️ The old heading was the literal string 'Mnema doctor ' — twelve
|
|
461
|
+
// trailing spaces, left over from overwriting the '… checking connectivity\r'
|
|
462
|
+
// progress line by hand. It is invisible until you select the line, or diff the
|
|
463
|
+
// output, or pipe it. \x1b[K clears to end of line and cannot be off by a space.
|
|
464
|
+
if (live) process.stdout.write('\x1b[K');
|
|
465
|
+
heading('Doctor', origin);
|
|
466
|
+
checkList(checks);
|
|
383
467
|
const failed = checks.filter((ch) => !ch.pass && ch.label !== 'API key stored');
|
|
384
|
-
if (failed.length) {
|
|
385
|
-
|
|
468
|
+
if (failed.length) {
|
|
469
|
+
console.log(`\n ${mark.warn()} ${c.yellow(`${failed.length} issue(s). Run \`mnema init\` to (re)connect.`)}`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
console.log(`\n ${mark.ok()} ${c.green('All good.')}`);
|
|
386
473
|
void root;
|
|
387
474
|
}
|
|
388
475
|
|
|
@@ -419,6 +506,7 @@ Commands:
|
|
|
419
506
|
search "q" Search your workspace from the terminal
|
|
420
507
|
doctor Diagnose install, hooks, auth, connectivity
|
|
421
508
|
uninstall Remove hooks and stored secrets
|
|
509
|
+
tui Open the interactive briefing explicitly (Node 20+, a terminal)
|
|
422
510
|
|
|
423
511
|
Read your workspace:
|
|
424
512
|
tasks List tasks [--status --project --limit]
|
|
@@ -439,13 +527,63 @@ Options:
|
|
|
439
527
|
--json Machine-readable output (every read command)
|
|
440
528
|
--yes Non-interactive; skip optional prompts
|
|
441
529
|
--purge uninstall: also delete .mnema/config.json
|
|
530
|
+
--no-tui Print help instead of opening the interactive UI
|
|
442
531
|
--version, --help
|
|
532
|
+
|
|
533
|
+
Environment:
|
|
534
|
+
NO_COLOR Disable colour (any value)
|
|
535
|
+
FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
|
|
536
|
+
COLUMNS Override the terminal width used for layout
|
|
537
|
+
MNEMA_TUI never | always — force the interactive UI off or on
|
|
538
|
+
MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
|
|
539
|
+
|
|
540
|
+
Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
|
|
541
|
+
writes plain text.
|
|
443
542
|
`);
|
|
444
543
|
}
|
|
445
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Open the interactive briefing, or explain why not and print help.
|
|
547
|
+
*
|
|
548
|
+
* ⭐ THE ONLY DYNAMIC IMPORT OF THE TUI IN THE WHOLE PACKAGE. Everything under
|
|
549
|
+
* src/tui/ is reached exclusively through this line, which is why modules in there
|
|
550
|
+
* can use ordinary static imports. `import('ink')` measures 136–141ms against a
|
|
551
|
+
* 59–65ms `mnema --version`, and mnema runs from capture hooks and shell prompts —
|
|
552
|
+
* so this must stay the single seam. test/no-ink-in-oneshot.test.mjs enforces it,
|
|
553
|
+
* because `node --check` parses without resolving and would never notice a stray
|
|
554
|
+
* `import { Box } from 'ink'` in read-commands.mjs.
|
|
555
|
+
*/
|
|
556
|
+
async function openTuiOrHelp(flags) {
|
|
557
|
+
const gate = tuiEligibility(flags);
|
|
558
|
+
if (gate.ok) {
|
|
559
|
+
const { startTui } = await import('./tui/launch.mjs');
|
|
560
|
+
const ctx = resolveContext(flags);
|
|
561
|
+
return startTui({
|
|
562
|
+
...ctx,
|
|
563
|
+
call: (fn) => call(ctx, fn),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// ⚠️ TO STDERR, so `mnema | cat` still gets clean help on stdout. And nothing at
|
|
567
|
+
// all for `not-a-tty`: piping mnema to read its help is legitimate, and a nag
|
|
568
|
+
// there is noise in someone's data.
|
|
569
|
+
for (const line of explainUnavailable(gate.reason, gate.detail)) console.error(c.dim(line));
|
|
570
|
+
help();
|
|
571
|
+
// Exit 0: bare `mnema` has printed help and exited 0 since 0.1.0, and turning
|
|
572
|
+
// that into a failure for a runtime we still support is a regression wearing a
|
|
573
|
+
// feature's clothes.
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
|
|
446
577
|
export async function run(argv) {
|
|
447
578
|
const { flags, rest } = parseFlags(argv);
|
|
448
579
|
if (flags.version) { console.log(VERSION); return; }
|
|
580
|
+
// ⭐ BEFORE THE GATE, AND THIS ORDER IS THE WHOLE POINT. `mnema --help` leaves
|
|
581
|
+
// rest empty, so cmd === undefined — the SAME branch that opens the TUI. Worse,
|
|
582
|
+
// version.test.mjs runs the binary through execFileSync, where stdout is not a
|
|
583
|
+
// TTY, so the gate would fall through to help() and THE TEST WOULD PASS while
|
|
584
|
+
// every real user on a terminal got a UI instead of the help they asked for.
|
|
585
|
+
// That is this repo's characteristic bug aimed at its own test suite.
|
|
586
|
+
if (flags.help || flags.h) { help(); return; }
|
|
449
587
|
const cmd = rest.shift();
|
|
450
588
|
switch (cmd) {
|
|
451
589
|
case 'login': return cmdLogin(flags);
|
|
@@ -469,7 +607,21 @@ export async function run(argv) {
|
|
|
469
607
|
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
470
608
|
case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
|
|
471
609
|
case 'briefing': return cmdBriefing(flags, resolveContext(flags));
|
|
472
|
-
case
|
|
610
|
+
case 'tui':
|
|
611
|
+
// An EXPLICIT request that cannot be honoured fails loudly (exit 1); the
|
|
612
|
+
// implicit one below degrades to help and exits 0. That asymmetry is
|
|
613
|
+
// CLAUDE.md's no-silent-returns rule: `mnema tui` on Node 18 must not
|
|
614
|
+
// quietly print help as though that were what was asked for.
|
|
615
|
+
{
|
|
616
|
+
const gate = tuiEligibility(flags);
|
|
617
|
+
if (gate.ok) return openTuiOrHelp(flags);
|
|
618
|
+
const why = explainUnavailable(gate.reason, gate.detail);
|
|
619
|
+
console.error(c.red(why[0] ?? `The interactive UI is unavailable here (${gate.reason}).`));
|
|
620
|
+
for (const line of why.slice(1)) console.error(c.dim(line));
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
break;
|
|
624
|
+
case undefined: return openTuiOrHelp(flags);
|
|
473
625
|
case 'help': return help();
|
|
474
626
|
default:
|
|
475
627
|
console.error(c.red(`Unknown command: ${cmd}`));
|