@mnemahq/cli 0.13.0 → 0.15.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 +23 -0
- package/package.json +2 -2
- package/src/catalogue.mjs +115 -0
- package/src/cli.mjs +320 -71
- package/src/codex-rollout.mjs +138 -0
- package/src/gemini-chat.mjs +132 -0
- package/src/prd.mjs +97 -0
- package/src/tui/app.mjs +30 -2
- package/src/tui/screens/help.mjs +74 -0
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.
|
|
3
|
+
"version": "0.15.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.
|
|
37
|
+
"@mnemahq/sdk": "0.5.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node -e \"process.exit(0)\"",
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
{ name: 'codex-sweep', blurb: 'Backfill OpenAI Codex CLI sessions from ~/.codex rollouts' },
|
|
45
|
+
{ name: 'gemini-sweep', blurb: 'Backfill Google Gemini CLI sessions from ~/.gemini chats' },
|
|
46
|
+
{
|
|
47
|
+
name: 'binding', blurb: 'Task-binding miss rate, measured locally',
|
|
48
|
+
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.',
|
|
49
|
+
flags: ['--limit <days>'],
|
|
50
|
+
examples: ['mnema binding', 'mnema binding --limit 30'],
|
|
51
|
+
},
|
|
52
|
+
{ name: 'pull', blurb: 'Export repo-bound docs into .mnema/context (server-is-truth)' },
|
|
53
|
+
{ name: 'prd init', blurb: 'Write a docs/prd.md draft from what Mnema observed (never commits)' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
title: 'Read your workspace',
|
|
58
|
+
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.',
|
|
59
|
+
items: [
|
|
60
|
+
{ name: 'tasks', blurb: 'List tasks', flags: ['--status', '--project', '--limit'], examples: ['mnema tasks --status in_progress'] },
|
|
61
|
+
{ name: 'next', blurb: 'The next task to pick up, with a ready-made branch name' },
|
|
62
|
+
{ name: 'docs', blurb: 'List documents', flags: ['--limit'] },
|
|
63
|
+
{ name: 'doc', usage: 'doc [id]', blurb: 'Print one document as markdown; no id opens a picker', examples: ['mnema doc'] },
|
|
64
|
+
{ name: 'projects', blurb: 'List projects' },
|
|
65
|
+
{ name: 'flows', blurb: 'List flows' },
|
|
66
|
+
{ name: 'flow', usage: 'flow [slug]', blurb: 'One flow; no slug opens a picker' },
|
|
67
|
+
{ name: 'briefing', blurb: 'What deserves attention — pulse, deltas, findings' },
|
|
68
|
+
{ name: 'search', usage: 'search "q"', blurb: 'Search your workspace from the terminal' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
title: 'Ask the knowledge graph',
|
|
73
|
+
note: 'Paid feature.',
|
|
74
|
+
items: [
|
|
75
|
+
{ 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?"'] },
|
|
76
|
+
{
|
|
77
|
+
name: 'graph', usage: 'graph [a] [b]', blurb: "Walk a node's neighbours; two args prints the path between them", paid: true,
|
|
78
|
+
examples: ['mnema graph "Workspace Security & Management"'],
|
|
79
|
+
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.',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
title: 'Interactive',
|
|
85
|
+
items: [
|
|
86
|
+
{ name: 'tui', blurb: 'Open the interactive briefing explicitly (Node 20+, a terminal)' },
|
|
87
|
+
{ name: 'help', blurb: 'This screen. `mnema --help` prints it as plain text instead.' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export const OPTIONS = [
|
|
93
|
+
['--workspace <id>', 'Workspace id (else prompted / MNEMA_WORKSPACE_ID)'],
|
|
94
|
+
['--origin <url>', 'API origin'],
|
|
95
|
+
['--limit <n>', 'Row limit'],
|
|
96
|
+
['--json', 'Machine-readable output (every read command)'],
|
|
97
|
+
['--yes', 'Non-interactive; skip optional prompts'],
|
|
98
|
+
['--purge', 'uninstall: also delete .mnema/config.json'],
|
|
99
|
+
['--no-tui', 'Print help instead of opening the interactive UI'],
|
|
100
|
+
['--version, --help', ''],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
export const ENVIRONMENT = [
|
|
104
|
+
['NO_COLOR', 'Disable colour (any value)'],
|
|
105
|
+
['FORCE_COLOR=1', 'Keep colour when piping, e.g. into `less -R`'],
|
|
106
|
+
['COLUMNS', 'Override the terminal width used for layout'],
|
|
107
|
+
['MNEMA_TUI', 'never | always — force the interactive UI off or on'],
|
|
108
|
+
['MNEMA_ENFORCE', 'warn | block | off — task-binding enforcement'],
|
|
109
|
+
['MNEMA_WORKSPACE_ID', 'Default workspace, instead of --workspace'],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** Flat list, for the interactive picker and for tests. */
|
|
113
|
+
export function allCommands() {
|
|
114
|
+
return GROUPS.flatMap((g) => g.items.map((c) => ({ ...c, group: g.title })));
|
|
115
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* mnema uninstall cleanly reverse everything
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { applyWrites, chooseProject, describeResult, planWrites } from './prd.mjs';
|
|
17
|
+
import { parseCodexRollout } from './codex-rollout.mjs';
|
|
18
|
+
import { parseGeminiChat } from './gemini-chat.mjs';
|
|
14
19
|
import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
15
20
|
import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
|
|
16
21
|
import {
|
|
@@ -32,6 +37,7 @@ import {
|
|
|
32
37
|
} from './hook-install.mjs';
|
|
33
38
|
import { installGitHooks, uninstallGitHooks } from './git-hooks.mjs';
|
|
34
39
|
import { collectBindingStats, formatBindingStats } from './binding-stats.mjs';
|
|
40
|
+
import { GROUPS, OPTIONS, ENVIRONMENT } from './catalogue.mjs';
|
|
35
41
|
import { applyContext, scaffold } from './artifacts.mjs';
|
|
36
42
|
import { execFileSync } from 'node:child_process';
|
|
37
43
|
|
|
@@ -70,7 +76,7 @@ const VERSION = JSON.parse(
|
|
|
70
76
|
* flag followed by an argument" from "a flag and its value" — the parser has to
|
|
71
77
|
* be told which is which, so it is.
|
|
72
78
|
*/
|
|
73
|
-
const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget']);
|
|
79
|
+
const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget', 'days']);
|
|
74
80
|
|
|
75
81
|
export function parseFlags(argv) {
|
|
76
82
|
const flags = {}; const rest = [];
|
|
@@ -354,6 +360,113 @@ function cmdSweep() {
|
|
|
354
360
|
}
|
|
355
361
|
}
|
|
356
362
|
|
|
363
|
+
// ── codex-sweep ────────────────────────────────────────────────────────────────
|
|
364
|
+
// Backfill OpenAI Codex CLI sessions: read the local rollout JSONL traces under
|
|
365
|
+
// CODEX_HOME/sessions, parse each into a normalised session (see codex-rollout.mjs),
|
|
366
|
+
// and POST to /api/hooks/codex. Cost is computed server-side from tokens × pricing.
|
|
367
|
+
|
|
368
|
+
function codexHome() {
|
|
369
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function findRollouts(dir, out = []) {
|
|
373
|
+
let entries;
|
|
374
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
375
|
+
for (const e of entries) {
|
|
376
|
+
const p = join(dir, e.name);
|
|
377
|
+
if (e.isDirectory()) findRollouts(p, out);
|
|
378
|
+
else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function cmdCodexSweep(flags) {
|
|
384
|
+
const { origin, workspaceId } = resolveContext(flags);
|
|
385
|
+
if (!workspaceId) { console.error(c.red('Not linked — run `mnema init` first.')); process.exit(1); }
|
|
386
|
+
const token = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
387
|
+
if (!token) { console.error(c.red('No hook token stored. Run `mnema init` first.')); process.exit(1); }
|
|
388
|
+
|
|
389
|
+
const sessionsDir = join(codexHome(), 'sessions');
|
|
390
|
+
const files = findRollouts(sessionsDir);
|
|
391
|
+
if (!files.length) {
|
|
392
|
+
console.log(c.dim(`No Codex rollouts found under ${sessionsDir}`));
|
|
393
|
+
console.log(c.dim(' (set CODEX_HOME if your Codex data lives elsewhere.)'));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const limit = Number(flags.limit) || files.length;
|
|
398
|
+
const developerId = defaultDeveloperId();
|
|
399
|
+
const chosen = files.slice(-limit); // newest by path (…/YYYY/MM/DD/rollout-<ISO>-…)
|
|
400
|
+
let sent = 0, skipped = 0;
|
|
401
|
+
process.stdout.write(`Sweeping ${chosen.length} Codex session(s)… `);
|
|
402
|
+
for (const file of chosen) {
|
|
403
|
+
let text;
|
|
404
|
+
try { text = readFileSync(file, 'utf8'); } catch { skipped++; continue; }
|
|
405
|
+
let payload;
|
|
406
|
+
try { payload = parseCodexRollout(text, { developerId }); } catch { payload = null; }
|
|
407
|
+
if (!payload || !payload.session_id) { skipped++; continue; }
|
|
408
|
+
try {
|
|
409
|
+
const r = await apiFetch(origin, '/api/hooks/codex', { method: 'POST', token, body: payload });
|
|
410
|
+
if (r.ok || r.status === 202) sent++; else skipped++;
|
|
411
|
+
} catch { skipped++; }
|
|
412
|
+
}
|
|
413
|
+
console.log(c.green('done'));
|
|
414
|
+
console.log(c.green(`✓ ${sent} session(s) sent`) + (skipped ? c.dim(`, ${skipped} skipped`) : ''));
|
|
415
|
+
console.log(c.dim(' They appear under Sessions shortly, with cost computed from tokens × model pricing.'));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── gemini-sweep ─────────────────────────────────────────────────────────────
|
|
419
|
+
// Backfill Google Gemini CLI sessions: read the chat recordings under
|
|
420
|
+
// ~/.gemini/tmp/<project_hash>/chats/, parse each (see gemini-chat.mjs), and POST
|
|
421
|
+
// to /api/hooks/gemini. Gemini CLI records real per-turn tokens, so cost computes.
|
|
422
|
+
|
|
423
|
+
function geminiChatFiles(root, out = []) {
|
|
424
|
+
let entries;
|
|
425
|
+
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
|
|
426
|
+
for (const e of entries) {
|
|
427
|
+
const p = join(root, e.name);
|
|
428
|
+
// Recurse into tmp/<hash>/chats; collect the chat files (json/jsonl) within.
|
|
429
|
+
if (e.isDirectory()) geminiChatFiles(p, out);
|
|
430
|
+
else if (e.isFile() && /\.(jsonl?|json)$/i.test(e.name) && /chats?[\\/]/.test(p)) out.push(p);
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function cmdGeminiSweep(flags) {
|
|
436
|
+
const { origin, workspaceId } = resolveContext(flags);
|
|
437
|
+
if (!workspaceId) { console.error(c.red('Not linked — run `mnema init` first.')); process.exit(1); }
|
|
438
|
+
const token = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
439
|
+
if (!token) { console.error(c.red('No hook token stored. Run `mnema init` first.')); process.exit(1); }
|
|
440
|
+
|
|
441
|
+
const geminiHome = process.env.GEMINI_HOME || join(homedir(), '.gemini');
|
|
442
|
+
const files = geminiChatFiles(join(geminiHome, 'tmp'));
|
|
443
|
+
if (!files.length) {
|
|
444
|
+
console.log(c.dim(`No Gemini chats found under ${join(geminiHome, 'tmp')}`));
|
|
445
|
+
console.log(c.dim(' (set GEMINI_HOME if your Gemini CLI data lives elsewhere.)'));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const limit = Number(flags.limit) || files.length;
|
|
450
|
+
const developerId = defaultDeveloperId();
|
|
451
|
+
const chosen = files.slice(-limit);
|
|
452
|
+
let sent = 0, skipped = 0;
|
|
453
|
+
process.stdout.write(`Sweeping ${chosen.length} Gemini session(s)… `);
|
|
454
|
+
for (const file of chosen) {
|
|
455
|
+
let text;
|
|
456
|
+
try { text = readFileSync(file, 'utf8'); } catch { skipped++; continue; }
|
|
457
|
+
let payload;
|
|
458
|
+
try { payload = parseGeminiChat(text, { developerId }); } catch { payload = null; }
|
|
459
|
+
if (!payload || !payload.session_id) { skipped++; continue; }
|
|
460
|
+
try {
|
|
461
|
+
const r = await apiFetch(origin, '/api/hooks/gemini', { method: 'POST', token, body: payload });
|
|
462
|
+
if (r.ok || r.status === 202) sent++; else skipped++;
|
|
463
|
+
} catch { skipped++; }
|
|
464
|
+
}
|
|
465
|
+
console.log(c.green('done'));
|
|
466
|
+
console.log(c.green(`✓ ${sent} session(s) sent`) + (skipped ? c.dim(`, ${skipped} skipped`) : ''));
|
|
467
|
+
console.log(c.dim(' They appear under Sessions shortly, with cost computed from tokens × model pricing.'));
|
|
468
|
+
}
|
|
469
|
+
|
|
357
470
|
// ── search ───────────────────────────────────────────────────────────────────────
|
|
358
471
|
|
|
359
472
|
async function cmdSearch(flags, rest) {
|
|
@@ -437,6 +550,70 @@ async function cmdPull(flags) {
|
|
|
437
550
|
|
|
438
551
|
// ── doctor ───────────────────────────────────────────────────────────────────────
|
|
439
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Turn a failed credential probe into something a person can act on.
|
|
555
|
+
*
|
|
556
|
+
* ⚠️ THE SERVER ALREADY DISTINGUISHES THESE and the CLI was throwing it away:
|
|
557
|
+
* `invalid_token` (a real credential, rejected — rotated, revoked or expired)
|
|
558
|
+
* reads completely differently from `missing_token` (nothing was sent) and from
|
|
559
|
+
* a network failure. All three rendered as one red tick with no note.
|
|
560
|
+
*/
|
|
561
|
+
/**
|
|
562
|
+
* Probe the stored API key by fetching one page of docs.
|
|
563
|
+
*
|
|
564
|
+
* ⭐ THE OLD PROBE COULD NEVER PASS. It called `.pages().next()`, and `pages()`
|
|
565
|
+
* returns an async ITERABLE — `Symbol.asyncIterator`, no `.next()`. So it threw
|
|
566
|
+
* `TypeError: … .next is not a function` on every run, and `✗ API key valid`
|
|
567
|
+
* was red for a code bug regardless of the credential.
|
|
568
|
+
*
|
|
569
|
+
* ⚠️ IT WAS INVISIBLE BECAUSE OF THE BARE `catch { }` BESIDE IT. A swallowed
|
|
570
|
+
* error in the one command whose job is diagnosis hid a broken check for as long
|
|
571
|
+
* as it existed; the first thing that printed the reason found it in one run.
|
|
572
|
+
*
|
|
573
|
+
* Extracted so the call site is testable with a fake client — asserting that the
|
|
574
|
+
* doctor CALLS this is the guard, not that the function alone behaves.
|
|
575
|
+
*/
|
|
576
|
+
export async function probeApiKey(makeClientFn) {
|
|
577
|
+
try {
|
|
578
|
+
// for-await, not .next(): the iterable is the contract the SDK exposes.
|
|
579
|
+
// eslint-disable-next-line no-unreachable-loop
|
|
580
|
+
for await (const _page of makeClientFn().docs.list({ limit: 1 }).pages()) break;
|
|
581
|
+
return { ok: true, why: undefined };
|
|
582
|
+
} catch (e) {
|
|
583
|
+
return { ok: false, why: diagnoseCredential(e) };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function diagnoseCredential(e) {
|
|
588
|
+
// ⚠️ `body` IS AN OBJECT, NOT A STRING. My first version did
|
|
589
|
+
// `String(e.body ?? '')`, which yields "[object Object]" — so `invalid_token`
|
|
590
|
+
// never matched and every rejection fell through to the generic branch. The
|
|
591
|
+
// SDK throws typed errors (AuthError, with status/body/fix); read them.
|
|
592
|
+
const reason = typeof e?.body === 'object' && e?.body ? String(e.body.reason ?? '') : '';
|
|
593
|
+
const text = `${String(e?.message ?? '')} ${typeof e?.body === 'string' ? e.body : ''} ${reason}`;
|
|
594
|
+
const status = Number(e?.status);
|
|
595
|
+
|
|
596
|
+
if (reason === 'invalid_token' || /invalid_token/.test(text)) {
|
|
597
|
+
return 'rejected by the server — rotated, revoked or expired; mint a new one in Settings → Access';
|
|
598
|
+
}
|
|
599
|
+
if (reason === 'missing_token' || /missing_token/.test(text)) {
|
|
600
|
+
return 'stored but not sent — this is a CLI bug, please report it';
|
|
601
|
+
}
|
|
602
|
+
if (status === 403 || /insufficient_scope|forbidden/i.test(text)) {
|
|
603
|
+
return 'valid, but missing the docs:read scope — re-issue it with that box ticked';
|
|
604
|
+
}
|
|
605
|
+
if (/ENOTFOUND|ECONNREFUSED|fetch failed|network|ETIMEDOUT/i.test(text)) {
|
|
606
|
+
return 'could not reach the API — the key itself was never checked';
|
|
607
|
+
}
|
|
608
|
+
// ⭐ A TypeError HERE MEANS THE PROBE IS BROKEN, NOT THE KEY. That is not
|
|
609
|
+
// hypothetical: `.pages().next()` threw "not a function" on every run, and the
|
|
610
|
+
// bare catch beside it meant nobody could tell for as long as it existed.
|
|
611
|
+
if (e instanceof TypeError || /is not a function/.test(text)) {
|
|
612
|
+
return `the check itself failed, not the key — ${String(e?.message ?? '').slice(0, 70)}`;
|
|
613
|
+
}
|
|
614
|
+
return String(e?.message ?? '').slice(0, 90) || 'unknown error';
|
|
615
|
+
}
|
|
616
|
+
|
|
440
617
|
async function cmdDoctor(flags) {
|
|
441
618
|
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
442
619
|
const checks = [];
|
|
@@ -462,11 +639,21 @@ async function cmdDoctor(flags) {
|
|
|
462
639
|
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
463
640
|
if (apiKey) {
|
|
464
641
|
let keyOk = false;
|
|
642
|
+
let keyWhy;
|
|
465
643
|
// Probes the KEY specifically — no fallback — because that is the thing
|
|
466
644
|
// doctor is reporting on. Using call() here would mask a dead key behind a
|
|
467
645
|
// working login and print a green tick for a credential that does not work.
|
|
468
|
-
|
|
469
|
-
|
|
646
|
+
//
|
|
647
|
+
// ⭐ AND IT SAYS WHY IT FAILED. This was `catch { }` — a bare swallow that
|
|
648
|
+
// reported "✗ API key valid" and discarded the reason, in the one command
|
|
649
|
+
// whose entire job is diagnosis. Finding out that a real key was being
|
|
650
|
+
// REJECTED (invalid_token) rather than missing, expired-locally, or blocked
|
|
651
|
+
// by a scope took ten manual commands and a keychain dump. The error already
|
|
652
|
+
// carried the answer.
|
|
653
|
+
const probe = await probeApiKey(() => makeClient({ origin, workspaceId }));
|
|
654
|
+
keyOk = probe.ok;
|
|
655
|
+
keyWhy = probe.why;
|
|
656
|
+
ok('API key valid', keyOk, keyOk ? undefined : keyWhy);
|
|
470
657
|
} else {
|
|
471
658
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
472
659
|
}
|
|
@@ -507,73 +694,56 @@ async function cmdUninstall(flags) {
|
|
|
507
694
|
|
|
508
695
|
// ── help ───────────────────────────────────────────────────────────────────────
|
|
509
696
|
|
|
697
|
+
/**
|
|
698
|
+
* The plain-text help. Rendered FROM `catalogue.mjs`, the same source the
|
|
699
|
+
* interactive screen uses.
|
|
700
|
+
*
|
|
701
|
+
* ⚠️ THIS OUTPUT IS TEST-CONSTRAINED. version.test.mjs requires the version on
|
|
702
|
+
* LINE 1 (so no banner above it) and the literal words
|
|
703
|
+
* `tasks docs projects briefing ask graph next` in the body — that test exists
|
|
704
|
+
* because `--help` once printed "mnema 0.1.0" and the old command list for three
|
|
705
|
+
* releases, making a stale global install undiagnosable.
|
|
706
|
+
*/
|
|
510
707
|
function help() {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
`);
|
|
708
|
+
const pad = (s2, n) => String(s2).padEnd(n);
|
|
709
|
+
const out = [];
|
|
710
|
+
out.push(`mnema ${VERSION} — connect a repo to your Mnema workspace`);
|
|
711
|
+
out.push('');
|
|
712
|
+
out.push('Usage: mnema [command] [options]');
|
|
713
|
+
out.push('');
|
|
714
|
+
out.push(' mnema Open the interactive briefing (a terminal, Node 20+)');
|
|
715
|
+
out.push(' mnema help The same command list, navigable');
|
|
716
|
+
for (const g of GROUPS) {
|
|
717
|
+
out.push('');
|
|
718
|
+
out.push(`${g.title}:`);
|
|
719
|
+
if (g.note) out.push(` ${g.note}`);
|
|
720
|
+
for (const c of g.items) {
|
|
721
|
+
const flags2 = c.flags?.length ? ` [${c.flags.join(' ')}]` : '';
|
|
722
|
+
out.push(` ${pad(c.usage ?? c.name, 12)} ${c.blurb}${flags2}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const examples = GROUPS.flatMap((g) => g.items.flatMap((c) => c.examples ?? []));
|
|
726
|
+
if (examples.length) {
|
|
727
|
+
out.push('');
|
|
728
|
+
out.push('Examples:');
|
|
729
|
+
out.push(' mnema the interactive briefing');
|
|
730
|
+
for (const e of examples) out.push(` ${e}`);
|
|
731
|
+
out.push('');
|
|
732
|
+
out.push(' ⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before');
|
|
733
|
+
out.push(' mnema sees it, and zsh reports a parse error that looks like a broken CLI.');
|
|
734
|
+
}
|
|
735
|
+
out.push('');
|
|
736
|
+
out.push('Options:');
|
|
737
|
+
for (const [flag, desc] of OPTIONS) {
|
|
738
|
+
out.push(` ${pad(flag, 18)} ${desc === '' ? '' : desc}`.replace(/\s+$/, ''));
|
|
739
|
+
}
|
|
740
|
+
out.push('');
|
|
741
|
+
out.push('Environment:');
|
|
742
|
+
for (const [k, v] of ENVIRONMENT) out.push(` ${pad(k, 18)} ${v}`);
|
|
743
|
+
out.push('');
|
|
744
|
+
out.push('Colour is off automatically when output is not a terminal, so `mnema tasks > f.txt`');
|
|
745
|
+
out.push('writes plain text.');
|
|
746
|
+
console.log(out.join('\n'));
|
|
577
747
|
}
|
|
578
748
|
|
|
579
749
|
/**
|
|
@@ -620,6 +790,22 @@ export function screenFor(cmd, flags, rest = []) {
|
|
|
620
790
|
return null;
|
|
621
791
|
}
|
|
622
792
|
|
|
793
|
+
/**
|
|
794
|
+
* `mnema help` — navigable on a terminal, plain text otherwise.
|
|
795
|
+
*
|
|
796
|
+
* ⚠️ `--help` THE FLAG IS NOT ROUTED HERE, and must not be. test/version.test.mjs
|
|
797
|
+
* requires the version on line 1 and the literal command words in the body — that
|
|
798
|
+
* test exists so a stale global install is obvious, and an Ink screen satisfies
|
|
799
|
+
* neither. The flag stays plain and pipe-safe; only the SUBCOMMAND opens a view.
|
|
800
|
+
*
|
|
801
|
+
* ⚠️ Falls back to the same plain text whenever the TUI is not eligible (piped,
|
|
802
|
+
* no TTY, --no-tui, old Node), so `mnema help | less` behaves.
|
|
803
|
+
*/
|
|
804
|
+
async function cmdHelp(flags) {
|
|
805
|
+
if (await maybeInteractive(flags, { name: 'help' })) return;
|
|
806
|
+
return help();
|
|
807
|
+
}
|
|
808
|
+
|
|
623
809
|
async function maybeInteractive(flags, screen) {
|
|
624
810
|
if (!screen) return false;
|
|
625
811
|
if (flags.json) return false;
|
|
@@ -674,6 +860,66 @@ function cmdBinding(flags) {
|
|
|
674
860
|
console.log(formatBindingStats(collectBindingStats(Number.isFinite(days) ? days : 7)));
|
|
675
861
|
}
|
|
676
862
|
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* `mnema prd init` — fetch the declaration draft and write it into this repo.
|
|
866
|
+
*
|
|
867
|
+
* ⛔ Writes into the WORKING TREE only. Never commits, never pushes. The
|
|
868
|
+
* declaration is authored by a person; that seam is the point.
|
|
869
|
+
*/
|
|
870
|
+
async function cmdPrd(flags, rest) {
|
|
871
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
872
|
+
if (sub !== 'init') {
|
|
873
|
+
console.error(c.red(`Unknown prd subcommand: ${sub || '(none)'}`));
|
|
874
|
+
console.error(c.dim('Usage: mnema prd init [--project <name|id>] [--days 365] [--force]'));
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const { root, origin, workspaceId } = resolveContext(flags);
|
|
879
|
+
if (!workspaceId) {
|
|
880
|
+
console.error(c.red('Not linked — run `mnema init` first.'));
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
let projects = [];
|
|
885
|
+
try {
|
|
886
|
+
projects = (await call({ origin, workspaceId }, (m) => m.projects.list({ limit: 100 }).all())) ?? [];
|
|
887
|
+
} catch (e) { renderError(e, { context: 'prd init', usedApiKey: hasApiKey(workspaceId) }); return; }
|
|
888
|
+
|
|
889
|
+
const choice = chooseProject(projects, flags.project);
|
|
890
|
+
if (!choice.ok) {
|
|
891
|
+
// ⚠️ Never guess. Print what there is to choose from — a wrong project would
|
|
892
|
+
// write another project's features into this repo's PRD.
|
|
893
|
+
const why = choice.reason === 'none-given'
|
|
894
|
+
? 'Which project is this repo?'
|
|
895
|
+
: choice.reason === 'ambiguous' ? 'That matched more than one project.' : 'No project matched that.';
|
|
896
|
+
console.error(c.yellow(why));
|
|
897
|
+
console.error(c.dim(' mnema prd init --project <name or id>'));
|
|
898
|
+
console.error('');
|
|
899
|
+
for (const p of (choice.projects || []).slice(0, 25)) {
|
|
900
|
+
console.error(` ${c.dim(p.id)} ${p.name}`);
|
|
901
|
+
}
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
let draft;
|
|
906
|
+
try {
|
|
907
|
+
draft = await call({ origin, workspaceId }, (m) =>
|
|
908
|
+
m.prd.draft(choice.project.id, flags.days ? Number(flags.days) : undefined));
|
|
909
|
+
} catch (e) { renderError(e, { context: 'prd init', usedApiKey: hasApiKey(workspaceId) }); return; }
|
|
910
|
+
|
|
911
|
+
if (!draft || draft.available === false) {
|
|
912
|
+
// A normal answer, not a failure: say why and stop.
|
|
913
|
+
console.error(c.yellow(draft?.reason || 'No draft available for that project.'));
|
|
914
|
+
process.exit(1);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const plan = planWrites(root, draft.files, { force: Boolean(flags.force) });
|
|
918
|
+
applyWrites(plan);
|
|
919
|
+
console.log('');
|
|
920
|
+
console.log(describeResult(draft, plan));
|
|
921
|
+
}
|
|
922
|
+
|
|
677
923
|
switch (cmd) {
|
|
678
924
|
case 'login': return cmdLogin(flags);
|
|
679
925
|
case 'logout': return cmdLogout();
|
|
@@ -681,7 +927,10 @@ function cmdBinding(flags) {
|
|
|
681
927
|
case 'status': return cmdStatus(flags);
|
|
682
928
|
case 'sessions': return cmdSessions(flags);
|
|
683
929
|
case 'sweep': return cmdSweep();
|
|
930
|
+
case 'codex-sweep': return cmdCodexSweep(flags);
|
|
931
|
+
case 'gemini-sweep': return cmdGeminiSweep(flags);
|
|
684
932
|
case 'pull': return cmdPull(flags);
|
|
933
|
+
case 'prd': return cmdPrd(flags, rest);
|
|
685
934
|
case 'search': return cmdSearch(flags, rest);
|
|
686
935
|
case 'doctor': return cmdDoctor(flags);
|
|
687
936
|
case 'binding': return cmdBinding(flags);
|
|
@@ -727,7 +976,7 @@ function cmdBinding(flags) {
|
|
|
727
976
|
}
|
|
728
977
|
break;
|
|
729
978
|
case undefined: return openTuiOrHelp(flags);
|
|
730
|
-
case 'help': return
|
|
979
|
+
case 'help': return cmdHelp(flags);
|
|
731
980
|
default:
|
|
732
981
|
console.error(c.red(`Unknown command: ${cmd}`));
|
|
733
982
|
help();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an OpenAI Codex CLI "rollout" JSONL trace into a normalised Mnema session
|
|
3
|
+
* payload (the same shape the Claude Code hook POSTs, so the server adapter + worker
|
|
4
|
+
* are shared).
|
|
5
|
+
*
|
|
6
|
+
* Rollout files live at CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ISO>-<UUID>.jsonl
|
|
7
|
+
* (CODEX_HOME defaults to ~/.codex). Each line is `{timestamp, type, payload}`.
|
|
8
|
+
* The fields we extract (verified against the documented format — PR openai/codex#1583
|
|
9
|
+
* added token counts, timestamps + model to rollouts; ccusage parses the same events):
|
|
10
|
+
* - session_meta → session id, cwd, git info, (sometimes) model
|
|
11
|
+
* - turn_context → the active model (latest wins)
|
|
12
|
+
* - token_count → total_token_usage {input, cached_input, output, reasoning_output}
|
|
13
|
+
* - function_call → tool count; apply_patch args → files touched
|
|
14
|
+
*
|
|
15
|
+
* Version-tolerant: the payload nesting has changed across Codex releases, so every
|
|
16
|
+
* lookup is defensive and unknown lines are skipped, never thrown on. Cost is NOT in
|
|
17
|
+
* the rollout — the server computes it from `model` + `usage` via model_pricing.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: token_count has historically been absent from non-interactive `codex exec`
|
|
20
|
+
* sessions (openai/codex#9660); such a session yields no `usage` and the server records
|
|
21
|
+
* it with zero cost rather than a wrong one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function num(v) {
|
|
25
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pull file paths out of an apply_patch tool call's argument string. */
|
|
29
|
+
export function extractPatchFiles(argsRaw) {
|
|
30
|
+
const out = [];
|
|
31
|
+
let text = argsRaw;
|
|
32
|
+
if (typeof text !== 'string') {
|
|
33
|
+
try { text = JSON.stringify(argsRaw ?? ''); } catch { return out; }
|
|
34
|
+
}
|
|
35
|
+
// apply_patch envelopes use "*** Add File: path" / "Update File" / "Delete File".
|
|
36
|
+
const re = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = re.exec(text)) !== null) {
|
|
39
|
+
const p = m[1].trim();
|
|
40
|
+
if (p) out.push(p);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Codex tags events on TWO levels, so we match on both:
|
|
47
|
+
* - session_meta / turn_context are top-level `type`, data in `payload` (or flat).
|
|
48
|
+
* - token_count / function_call are the INNER `payload.type`, wrapped by an outer
|
|
49
|
+
* `event_msg` / `response_item` type. Matching only `obj.type` misses them.
|
|
50
|
+
*/
|
|
51
|
+
function classify(obj) {
|
|
52
|
+
const inner = obj.payload && typeof obj.payload === 'object' ? obj.payload : obj;
|
|
53
|
+
const topType = typeof obj.type === 'string' ? obj.type : undefined;
|
|
54
|
+
const innerType = typeof inner.type === 'string' ? inner.type : topType;
|
|
55
|
+
return { inner, topType, innerType };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} jsonlText full contents of one rollout-*.jsonl file
|
|
60
|
+
* @param {{ sessionId?: string, developerId?: string }} [opts]
|
|
61
|
+
* @returns {object|null} normalised session payload, or null if no session id found
|
|
62
|
+
*/
|
|
63
|
+
export function parseCodexRollout(jsonlText, opts = {}) {
|
|
64
|
+
const lines = String(jsonlText).split(/\r?\n/);
|
|
65
|
+
let sessionId = opts.sessionId ?? null;
|
|
66
|
+
let model = null;
|
|
67
|
+
let cwd = null;
|
|
68
|
+
let gitRoot = null;
|
|
69
|
+
let gitBranch = null;
|
|
70
|
+
let gitRemote = null;
|
|
71
|
+
let usage = null;
|
|
72
|
+
let startTs = null;
|
|
73
|
+
let endTs = null;
|
|
74
|
+
let toolCount = 0;
|
|
75
|
+
const files = new Set();
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed) continue;
|
|
80
|
+
let obj;
|
|
81
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
82
|
+
|
|
83
|
+
if (typeof obj.timestamp === 'string') {
|
|
84
|
+
if (!startTs) startTs = obj.timestamp;
|
|
85
|
+
endTs = obj.timestamp;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { inner, topType, innerType } = classify(obj);
|
|
89
|
+
|
|
90
|
+
if (topType === 'session_meta' || innerType === 'session_meta') {
|
|
91
|
+
sessionId = sessionId ?? inner.id ?? inner.session_id ?? null;
|
|
92
|
+
cwd = cwd ?? inner.cwd ?? null;
|
|
93
|
+
model = model ?? inner.model ?? null;
|
|
94
|
+
const git = inner.git ?? inner.git_info ?? {};
|
|
95
|
+
gitRoot = gitRoot ?? git.repository_root ?? git.root ?? null;
|
|
96
|
+
gitBranch = gitBranch ?? git.branch ?? null;
|
|
97
|
+
gitRemote = gitRemote ?? git.remote_url ?? git.origin_url ?? git.remote ?? null;
|
|
98
|
+
} else if (topType === 'turn_context' || innerType === 'turn_context') {
|
|
99
|
+
if (typeof inner.model === 'string') model = inner.model; // latest wins
|
|
100
|
+
cwd = cwd ?? inner.cwd ?? null;
|
|
101
|
+
} else if (innerType === 'token_count') {
|
|
102
|
+
const tot = inner.total_token_usage ?? inner.info?.total_token_usage ?? inner.total ?? null;
|
|
103
|
+
if (tot && typeof tot === 'object') {
|
|
104
|
+
usage = {
|
|
105
|
+
input_tokens: num(tot.input_tokens),
|
|
106
|
+
output_tokens: num(tot.output_tokens) + num(tot.reasoning_output_tokens),
|
|
107
|
+
cache_read_tokens: num(tot.cached_input_tokens),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
} else if (innerType === 'function_call') {
|
|
111
|
+
toolCount += 1;
|
|
112
|
+
const name = inner.name ?? '';
|
|
113
|
+
if (name === 'apply_patch' || name === 'shell' || name === 'local_shell') {
|
|
114
|
+
for (const f of extractPatchFiles(inner.arguments)) files.add(f);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!sessionId) return null;
|
|
120
|
+
|
|
121
|
+
const payload = {
|
|
122
|
+
session_id: sessionId,
|
|
123
|
+
hook_event_name: 'SessionEnd',
|
|
124
|
+
usage_cumulative: true,
|
|
125
|
+
developer_id: opts.developerId,
|
|
126
|
+
files_changed: [...files].map((path) => ({ path, added: null, removed: null })),
|
|
127
|
+
};
|
|
128
|
+
if (model) payload.model = model;
|
|
129
|
+
if (usage) payload.usage = usage;
|
|
130
|
+
if (cwd) payload.cwd = cwd;
|
|
131
|
+
if (gitRoot) payload.git_root = gitRoot;
|
|
132
|
+
if (gitBranch) payload.git_branch = gitBranch;
|
|
133
|
+
if (gitRemote) payload.git_remote = gitRemote;
|
|
134
|
+
if (toolCount > 0) payload.tool_count = toolCount;
|
|
135
|
+
if (startTs) payload.started_at = startTs;
|
|
136
|
+
if (endTs) payload.ended_at = endTs;
|
|
137
|
+
return payload;
|
|
138
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Google Gemini CLI chat-recording file into a normalised Mnema session
|
|
3
|
+
* payload (the same shape the Claude Code hook + Codex reader POST, so the server
|
|
4
|
+
* adapter + worker are shared).
|
|
5
|
+
*
|
|
6
|
+
* Gemini CLI's chatRecordingService writes conversations under
|
|
7
|
+
* ~/.gemini/tmp/<project_hash>/chats/
|
|
8
|
+
* as a ConversationRecord — { sessionId, projectHash, model, messages[] } — where
|
|
9
|
+
* each message carries per-turn token usage (Gemini's usageMetadata:
|
|
10
|
+
* promptTokenCount / candidatesTokenCount / cachedContentTokenCount / total).
|
|
11
|
+
* Unlike Codex's cumulative token_count, Gemini logs usage PER TURN, so the
|
|
12
|
+
* session total is the SUM across messages.
|
|
13
|
+
*
|
|
14
|
+
* Format-tolerant: the file may be one JSON object (the whole ConversationRecord)
|
|
15
|
+
* or JSONL (a header line + one message per line), and field names have drifted
|
|
16
|
+
* (usageMetadata vs tokens; camelCase vs snake_case). Every lookup is defensive and
|
|
17
|
+
* malformed input is skipped, never thrown on.
|
|
18
|
+
*
|
|
19
|
+
* Gemini CLI records real tokens (this is why it was chosen over Antigravity CLI,
|
|
20
|
+
* which does not) — but no USD; the server computes cost from model + usage.
|
|
21
|
+
*
|
|
22
|
+
* NOTE: verified against the DOCUMENTED shape (chatRecordingTypes.ts + Google's
|
|
23
|
+
* session docs), not yet a live ~/.gemini file — validate against a real chat
|
|
24
|
+
* before trusting cost numbers in prod.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function num(v) {
|
|
28
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a usageMetadata-ish object off a message, tolerating field-name variants. */
|
|
32
|
+
function readUsage(m) {
|
|
33
|
+
const u = m.usageMetadata ?? m.usage ?? m.tokens ?? m.tokenUsage ?? null;
|
|
34
|
+
if (!u || typeof u !== 'object') return null;
|
|
35
|
+
const input = num(u.promptTokenCount ?? u.inputTokens ?? u.input_tokens ?? u.prompt_tokens);
|
|
36
|
+
const output = num(u.candidatesTokenCount ?? u.outputTokens ?? u.output_tokens ?? u.candidates_tokens);
|
|
37
|
+
const cached = num(u.cachedContentTokenCount ?? u.cachedTokens ?? u.cache_read_tokens ?? u.cached_tokens);
|
|
38
|
+
if (input === 0 && output === 0 && cached === 0) return null;
|
|
39
|
+
return { input, output, cached };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pull edited file paths out of a message's tool calls. */
|
|
43
|
+
function filesFromMessage(m, into) {
|
|
44
|
+
const calls = m.toolCalls ?? m.tool_calls ?? m.functionCalls ?? [];
|
|
45
|
+
if (!Array.isArray(calls)) return;
|
|
46
|
+
for (const call of calls) {
|
|
47
|
+
const name = call.name ?? call.tool ?? call.functionName ?? '';
|
|
48
|
+
if (!/write_file|replace|edit|create_file|apply/i.test(String(name))) continue;
|
|
49
|
+
const args = call.args ?? call.arguments ?? call.input ?? {};
|
|
50
|
+
const path = args.file_path ?? args.absolute_path ?? args.path ?? args.filePath ?? args.filename;
|
|
51
|
+
if (typeof path === 'string' && path.trim()) into.add(path.trim());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Coerce a file's contents into an array of records (whole-object or JSONL). */
|
|
56
|
+
function toRecords(text) {
|
|
57
|
+
const trimmed = String(text).trim();
|
|
58
|
+
if (!trimmed) return [];
|
|
59
|
+
// Try the whole file as one JSON value first (the common ConversationRecord case).
|
|
60
|
+
try {
|
|
61
|
+
const whole = JSON.parse(trimmed);
|
|
62
|
+
return Array.isArray(whole) ? whole : [whole];
|
|
63
|
+
} catch { /* fall through to JSONL */ }
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
66
|
+
const t = line.trim();
|
|
67
|
+
if (!t) continue;
|
|
68
|
+
try { out.push(JSON.parse(t)); } catch { /* skip malformed line */ }
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} text contents of one Gemini chat file
|
|
75
|
+
* @param {{ sessionId?: string, developerId?: string }} [opts]
|
|
76
|
+
* @returns {object|null} normalised session payload, or null if no session id
|
|
77
|
+
*/
|
|
78
|
+
export function parseGeminiChat(text, opts = {}) {
|
|
79
|
+
const records = toRecords(text);
|
|
80
|
+
if (records.length === 0) return null;
|
|
81
|
+
|
|
82
|
+
let sessionId = opts.sessionId ?? null;
|
|
83
|
+
let model = null;
|
|
84
|
+
let startTs = null;
|
|
85
|
+
let endTs = null;
|
|
86
|
+
const usage = { input: 0, output: 0, cached: 0 };
|
|
87
|
+
let sawUsage = false;
|
|
88
|
+
const files = new Set();
|
|
89
|
+
let turns = 0;
|
|
90
|
+
|
|
91
|
+
// A record is either a header (sessionId/projectHash/model + maybe messages[])
|
|
92
|
+
// or a single message. Handle both, and recurse into an embedded messages[].
|
|
93
|
+
const messages = [];
|
|
94
|
+
for (const rec of records) {
|
|
95
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
96
|
+
sessionId = sessionId ?? rec.sessionId ?? rec.session_id ?? rec.id ?? null;
|
|
97
|
+
if (typeof rec.model === 'string') model = rec.model;
|
|
98
|
+
if (rec.startTime ?? rec.start_time) startTs = startTs ?? (rec.startTime ?? rec.start_time);
|
|
99
|
+
if (rec.lastUpdated ?? rec.last_updated) endTs = rec.lastUpdated ?? rec.last_updated;
|
|
100
|
+
if (Array.isArray(rec.messages)) messages.push(...rec.messages);
|
|
101
|
+
else if (rec.role || rec.content || rec.usageMetadata || rec.tokens) messages.push(rec);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const m of messages) {
|
|
105
|
+
if (!m || typeof m !== 'object') continue;
|
|
106
|
+
turns += 1;
|
|
107
|
+
if (typeof m.model === 'string' && !model) model = m.model;
|
|
108
|
+
const ts = m.timestamp ?? m.time ?? m.created_at ?? m.createdAt;
|
|
109
|
+
if (ts) { startTs = startTs ?? ts; endTs = ts; }
|
|
110
|
+
const u = readUsage(m);
|
|
111
|
+
if (u) { usage.input += u.input; usage.output += u.output; usage.cached += u.cached; sawUsage = true; }
|
|
112
|
+
filesFromMessage(m, files);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!sessionId) return null;
|
|
116
|
+
|
|
117
|
+
const payload = {
|
|
118
|
+
session_id: sessionId,
|
|
119
|
+
hook_event_name: 'SessionEnd',
|
|
120
|
+
usage_cumulative: true, // we already summed to the session total
|
|
121
|
+
developer_id: opts.developerId,
|
|
122
|
+
files_changed: [...files].map((path) => ({ path, added: null, removed: null })),
|
|
123
|
+
};
|
|
124
|
+
if (model) payload.model = model;
|
|
125
|
+
if (sawUsage) {
|
|
126
|
+
payload.usage = { input_tokens: usage.input, output_tokens: usage.output, cache_read_tokens: usage.cached };
|
|
127
|
+
}
|
|
128
|
+
if (turns > 0) payload.tool_count = turns;
|
|
129
|
+
if (startTs) payload.started_at = String(startTs);
|
|
130
|
+
if (endTs) payload.ended_at = String(endTs);
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
package/src/prd.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mnema prd init` — write the declaration bootstrap into this repo (t-934).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THIS COMMAND WAS ADVERTISED BEFORE IT EXISTED. The app's bootstrap prompt
|
|
5
|
+
* has told people to run `npx mnema prd init` since it shipped. There was no
|
|
6
|
+
* `prd` command in any published version, so every reader who followed the
|
|
7
|
+
* instruction got "Unknown command: prd". The generator behind the promise was
|
|
8
|
+
* real — it had no route a CLI could reach, and no CLI caller.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ MNEMA WRITES INTO A WORKING TREE, NEVER INTO HISTORY. It does not commit,
|
|
11
|
+
* and it does not push. The declaration is authored in the repo BY A PERSON;
|
|
12
|
+
* Mnema reading its own output back as though a human had written it is the seam
|
|
13
|
+
* the whole declaration build rests on. So: write the files, tell the reader to
|
|
14
|
+
* review them, stop.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pick the project to draft for.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ NO GUESSING FROM THE GIT REMOTE. The projects API returns `repoId`, not a
|
|
23
|
+
* repo name, so matching a remote would mean a second lookup per project and a
|
|
24
|
+
* confident wrong answer when two projects share a repo. An explicit choice, or
|
|
25
|
+
* a list to choose from, is the honest interface.
|
|
26
|
+
*/
|
|
27
|
+
export function chooseProject(projects, wanted) {
|
|
28
|
+
if (!wanted) return { ok: false, reason: 'none-given', projects };
|
|
29
|
+
const want = String(wanted).trim().toLowerCase();
|
|
30
|
+
const byId = projects.find((p) => p.id.toLowerCase() === want);
|
|
31
|
+
if (byId) return { ok: true, project: byId };
|
|
32
|
+
const byName = projects.filter((p) => p.name.toLowerCase() === want);
|
|
33
|
+
if (byName.length === 1) return { ok: true, project: byName[0] };
|
|
34
|
+
if (byName.length > 1) return { ok: false, reason: 'ambiguous', projects: byName };
|
|
35
|
+
const partial = projects.filter((p) => p.name.toLowerCase().includes(want));
|
|
36
|
+
if (partial.length === 1) return { ok: true, project: partial[0] };
|
|
37
|
+
if (partial.length > 1) return { ok: false, reason: 'ambiguous', projects: partial };
|
|
38
|
+
return { ok: false, reason: 'no-match', projects };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide what to do with each file the server returned.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ NEVER OVERWRITE SILENTLY. `docs/prd.md` is the declaration — a human wrote
|
|
45
|
+
* it, and it is the source of truth for what every feature claims. Replacing one
|
|
46
|
+
* without asking would destroy exactly the authorship this design protects.
|
|
47
|
+
* Identical content is a no-op, not a write, so re-running is safe.
|
|
48
|
+
*/
|
|
49
|
+
export function planWrites(root, files, { force = false } = {}) {
|
|
50
|
+
return Object.entries(files).map(([rel, content]) => {
|
|
51
|
+
const abs = join(root, rel);
|
|
52
|
+
if (!existsSync(abs)) return { rel, abs, content, action: 'create' };
|
|
53
|
+
const current = readFileSync(abs, 'utf8');
|
|
54
|
+
if (current === content) return { rel, abs, content, action: 'unchanged' };
|
|
55
|
+
return { rel, abs, content, action: force ? 'overwrite' : 'skip-exists' };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Apply a plan. Returns the plan, annotated with what happened. */
|
|
60
|
+
export function applyWrites(plan) {
|
|
61
|
+
for (const item of plan) {
|
|
62
|
+
if (item.action === 'create' || item.action === 'overwrite') {
|
|
63
|
+
mkdirSync(dirname(item.abs), { recursive: true });
|
|
64
|
+
writeFileSync(item.abs, item.content, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return plan;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The report a reader needs after a draft is written. */
|
|
71
|
+
export function describeResult(draft, plan) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const wrote = plan.filter((p) => p.action === 'create' || p.action === 'overwrite');
|
|
74
|
+
const skipped = plan.filter((p) => p.action === 'skip-exists');
|
|
75
|
+
const same = plan.filter((p) => p.action === 'unchanged');
|
|
76
|
+
|
|
77
|
+
for (const p of wrote) lines.push(` wrote ${p.rel}`);
|
|
78
|
+
for (const p of same) lines.push(` unchanged ${p.rel}`);
|
|
79
|
+
for (const p of skipped) lines.push(` kept ${p.rel} (already exists — pass --force to replace)`);
|
|
80
|
+
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(` ${draft.kept.length} feature${draft.kept.length === 1 ? '' : 's'} drafted from ` +
|
|
83
|
+
`${draft.observedScopes} observed scope${draft.observedScopes === 1 ? '' : 's'} ` +
|
|
84
|
+
`over ${draft.windowDays} days`);
|
|
85
|
+
|
|
86
|
+
const rejected = Array.isArray(draft.rejected) ? draft.rejected.length : 0;
|
|
87
|
+
if (rejected > 0) {
|
|
88
|
+
// ⚠️ Reported, not hidden. A scope screened out is a feature the draft does
|
|
89
|
+
// NOT claim — leaving that silent would let someone believe the file covers
|
|
90
|
+
// everything.
|
|
91
|
+
lines.push(` ${rejected} scope${rejected === 1 ? '' : 's'} screened out (too little evidence to declare)`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(' Review it, correct it, and commit it. Mnema does not commit — the');
|
|
95
|
+
lines.push(' declaration is yours, and that is what makes it worth trusting.');
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|