@mnemahq/cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +1 -1
- package/src/binding-stats.mjs +93 -0
- package/src/catalogue.mjs +112 -0
- package/src/cli.mjs +171 -69
- package/src/git-hooks.mjs +174 -0
- package/src/hook-install.mjs +5 -1
- 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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev Mode Phase 5b — the miss-rate report.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ ASSERT 5's FIRST BOX: "warn mode logs a real miss-rate over 7 days — paste
|
|
5
|
+
* the number." The PreToolUse hook accumulates counts into
|
|
6
|
+
* `~/.claude/hooks/state/<sessionId>.binding.json` — a local file append, never a
|
|
7
|
+
* request, because it runs before EVERY tool call. This reads them back.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT REPORTS ZERO AS ZERO, WITH A REASON. "0% miss rate" and "the hook never
|
|
10
|
+
* ran" produce the same number and mean opposite things, so a run with no files
|
|
11
|
+
* says so instead of printing a flattering percentage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
export function stateDir() { return join(homedir(), '.claude', 'hooks', 'state'); }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aggregate the per-session counters.
|
|
22
|
+
*
|
|
23
|
+
* @param sinceDays only files modified within this window (0 = all)
|
|
24
|
+
*/
|
|
25
|
+
export function collectBindingStats(sinceDays = 7, dir = stateDir()) {
|
|
26
|
+
const out = {
|
|
27
|
+
sessions: 0, hits: 0, misses: 0, branches: [],
|
|
28
|
+
missRate: null, windowDays: sinceDays, reason: null,
|
|
29
|
+
};
|
|
30
|
+
let names = [];
|
|
31
|
+
try { names = readdirSync(dir).filter((f) => f.endsWith('.binding.json')); } catch {
|
|
32
|
+
out.reason = 'no hook state directory — the PreToolUse hook has never run on this machine';
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
if (names.length === 0) {
|
|
36
|
+
out.reason = 'no binding counters — PreToolUse is not installed, or no tool call has run since it was';
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cutoff = sinceDays > 0 ? Date.now() - sinceDays * 86400000 : 0;
|
|
41
|
+
const branches = new Set();
|
|
42
|
+
for (const n of names) {
|
|
43
|
+
const p = join(dir, n);
|
|
44
|
+
try {
|
|
45
|
+
if (cutoff && statSync(p).mtimeMs < cutoff) continue;
|
|
46
|
+
const s = JSON.parse(readFileSync(p, 'utf8'));
|
|
47
|
+
out.sessions += 1;
|
|
48
|
+
out.hits += Number(s.hits) || 0;
|
|
49
|
+
out.misses += Number(s.misses) || 0;
|
|
50
|
+
for (const b of s.branches || []) branches.add(b);
|
|
51
|
+
} catch { /* a partially-written counter is skipped, not fatal */ }
|
|
52
|
+
}
|
|
53
|
+
out.branches = [...branches].sort();
|
|
54
|
+
|
|
55
|
+
const total = out.hits + out.misses;
|
|
56
|
+
if (total === 0) {
|
|
57
|
+
out.reason = `${out.sessions} counter file(s) in the window, but no tool calls recorded in them`;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
out.missRate = out.misses / total;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatBindingStats(s) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(` Task-binding miss rate — last ${s.windowDays || 'all'} day(s)`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
if (s.missRate === null) {
|
|
70
|
+
// ⚠️ Never a percentage here. There is no number, and inventing 0% would
|
|
71
|
+
// read as "everything is bound" when it means "nothing was measured".
|
|
72
|
+
lines.push(` NO DATA — ${s.reason}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
const pct = (s.missRate * 100).toFixed(1);
|
|
77
|
+
lines.push(` sessions measured ${s.sessions}`);
|
|
78
|
+
lines.push(` tool calls on a task ${s.hits}`);
|
|
79
|
+
lines.push(` tool calls with none ${s.misses}`);
|
|
80
|
+
lines.push(` MISS RATE ${pct}%`);
|
|
81
|
+
if (s.branches.length) {
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(' branches with no task:');
|
|
84
|
+
for (const b of s.branches.slice(0, 15)) lines.push(` ${b}`);
|
|
85
|
+
if (s.branches.length > 15) lines.push(` … ${s.branches.length - 15} more`);
|
|
86
|
+
}
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push(s.missRate > 0.5
|
|
89
|
+
? ' ⚠️ Above 50%. Do NOT flip enforce=block yet — it would stop most sessions.'
|
|
90
|
+
: ' Below 50%. Flipping enforce=block is defensible; do it on one install first.');
|
|
91
|
+
lines.push('');
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command catalogue — ONE source for `--help` and the interactive help.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHY A STRUCTURE AND NOT TWO TEXTS. `mnema help` becoming navigable would
|
|
5
|
+
* otherwise mean a second hand-written list, and the two drift the first time a
|
|
6
|
+
* command is added to one of them. That failure already has a scar in this
|
|
7
|
+
* package: `--help` printed "mnema 0.1.0" and the old command list for three
|
|
8
|
+
* releases because the version was hand-written in a second place.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ `--help` OUTPUT IS TEST-CONSTRAINED and must not become interactive:
|
|
11
|
+
* `test/version.test.mjs` requires the version on line 1 and the literal words
|
|
12
|
+
* `tasks docs projects briefing ask graph next` in the body — that test exists so
|
|
13
|
+
* a stale global install is obvious. The flag stays plain text and pipe-safe;
|
|
14
|
+
* only the `help` SUBCOMMAND opens a view.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ name: string, usage?: string, blurb: string, detail?: string, flags?: string[], examples?: string[], paid?: boolean }} Cmd */
|
|
18
|
+
|
|
19
|
+
/** @type {{ title: string, note?: string, items: Cmd[] }[]} */
|
|
20
|
+
export const GROUPS = [
|
|
21
|
+
{
|
|
22
|
+
title: 'Set up',
|
|
23
|
+
items: [
|
|
24
|
+
{
|
|
25
|
+
name: 'login', blurb: 'Sign in (opens a browser; tokens go to your OS keychain)',
|
|
26
|
+
detail: 'Device-code flow. The browser opens on its own; the code stays on screen while it polls.',
|
|
27
|
+
},
|
|
28
|
+
{ name: 'logout', blurb: 'Remove stored credentials' },
|
|
29
|
+
{
|
|
30
|
+
name: 'init', blurb: 'Link this repo to a workspace and install session capture',
|
|
31
|
+
detail: 'Writes .mnema/config.json, stores a hook token in the keychain, and installs the Claude Code hook plus the git hooks.\n\n⚠️ Minting a hook token ROTATES it — any other machine set up for this workspace must re-run init.',
|
|
32
|
+
flags: ['--workspace <id>', '--origin <url>', '--yes'],
|
|
33
|
+
},
|
|
34
|
+
{ name: 'uninstall', blurb: 'Remove hooks and stored secrets', flags: ['--purge'] },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: 'Check',
|
|
39
|
+
items: [
|
|
40
|
+
{ name: 'status', blurb: 'Show connection, hook, and last session' },
|
|
41
|
+
{ name: 'doctor', blurb: 'Diagnose install, hooks, auth, connectivity' },
|
|
42
|
+
{ name: 'sessions', blurb: 'List recent sessions for this repo (local + server)' },
|
|
43
|
+
{ name: 'sweep', blurb: 'Backfill past local sessions (opt-in)' },
|
|
44
|
+
{
|
|
45
|
+
name: 'binding', blurb: 'Task-binding miss rate, measured locally',
|
|
46
|
+
detail: 'Reads the PreToolUse counters. Reports NO DATA as no data — "0% miss rate" and "the hook never ran" are the same number and mean opposite things.',
|
|
47
|
+
flags: ['--limit <days>'],
|
|
48
|
+
examples: ['mnema binding', 'mnema binding --limit 30'],
|
|
49
|
+
},
|
|
50
|
+
{ name: 'pull', blurb: 'Export repo-bound docs into .mnema/context (server-is-truth)' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
title: 'Read your workspace',
|
|
55
|
+
note: 'On a terminal these open a NAVIGABLE view — arrows to move, enter to open, esc to go back. Piped or with --json they print as they always have.',
|
|
56
|
+
items: [
|
|
57
|
+
{ name: 'tasks', blurb: 'List tasks', flags: ['--status', '--project', '--limit'], examples: ['mnema tasks --status in_progress'] },
|
|
58
|
+
{ name: 'next', blurb: 'The next task to pick up, with a ready-made branch name' },
|
|
59
|
+
{ name: 'docs', blurb: 'List documents', flags: ['--limit'] },
|
|
60
|
+
{ name: 'doc', usage: 'doc [id]', blurb: 'Print one document as markdown; no id opens a picker', examples: ['mnema doc'] },
|
|
61
|
+
{ name: 'projects', blurb: 'List projects' },
|
|
62
|
+
{ name: 'flows', blurb: 'List flows' },
|
|
63
|
+
{ name: 'flow', usage: 'flow [slug]', blurb: 'One flow; no slug opens a picker' },
|
|
64
|
+
{ name: 'briefing', blurb: 'What deserves attention — pulse, deltas, findings' },
|
|
65
|
+
{ name: 'search', usage: 'search "q"', blurb: 'Search your workspace from the terminal' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
title: 'Ask the knowledge graph',
|
|
70
|
+
note: 'Paid feature.',
|
|
71
|
+
items: [
|
|
72
|
+
{ name: 'ask', usage: 'ask "q"', blurb: 'A cited answer, with the confidence it deserves', paid: true, examples: ['mnema ask "why did we drop the queue?"'] },
|
|
73
|
+
{
|
|
74
|
+
name: 'graph', usage: 'graph [a] [b]', blurb: "Walk a node's neighbours; two args prints the path between them", paid: true,
|
|
75
|
+
examples: ['mnema graph "Workspace Security & Management"'],
|
|
76
|
+
detail: '⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before mnema sees it, and zsh reports a parse error that looks like a broken CLI.',
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
title: 'Interactive',
|
|
82
|
+
items: [
|
|
83
|
+
{ name: 'tui', blurb: 'Open the interactive briefing explicitly (Node 20+, a terminal)' },
|
|
84
|
+
{ name: 'help', blurb: 'This screen. `mnema --help` prints it as plain text instead.' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const OPTIONS = [
|
|
90
|
+
['--workspace <id>', 'Workspace id (else prompted / MNEMA_WORKSPACE_ID)'],
|
|
91
|
+
['--origin <url>', 'API origin'],
|
|
92
|
+
['--limit <n>', 'Row limit'],
|
|
93
|
+
['--json', 'Machine-readable output (every read command)'],
|
|
94
|
+
['--yes', 'Non-interactive; skip optional prompts'],
|
|
95
|
+
['--purge', 'uninstall: also delete .mnema/config.json'],
|
|
96
|
+
['--no-tui', 'Print help instead of opening the interactive UI'],
|
|
97
|
+
['--version, --help', ''],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export const ENVIRONMENT = [
|
|
101
|
+
['NO_COLOR', 'Disable colour (any value)'],
|
|
102
|
+
['FORCE_COLOR=1', 'Keep colour when piping, e.g. into `less -R`'],
|
|
103
|
+
['COLUMNS', 'Override the terminal width used for layout'],
|
|
104
|
+
['MNEMA_TUI', 'never | always — force the interactive UI off or on'],
|
|
105
|
+
['MNEMA_ENFORCE', 'warn | block | off — task-binding enforcement'],
|
|
106
|
+
['MNEMA_WORKSPACE_ID', 'Default workspace, instead of --workspace'],
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/** Flat list, for the interactive picker and for tests. */
|
|
110
|
+
export function allCommands() {
|
|
111
|
+
return GROUPS.flatMap((g) => g.items.map((c) => ({ ...c, group: g.title })));
|
|
112
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -30,6 +30,9 @@ import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from
|
|
|
30
30
|
import {
|
|
31
31
|
installHook, uninstallHook, hookInstalled, hookConfigPath, defaultDeveloperId, sweepScriptPath,
|
|
32
32
|
} from './hook-install.mjs';
|
|
33
|
+
import { installGitHooks, uninstallGitHooks } from './git-hooks.mjs';
|
|
34
|
+
import { collectBindingStats, formatBindingStats } from './binding-stats.mjs';
|
|
35
|
+
import { GROUPS, OPTIONS, ENVIRONMENT } from './catalogue.mjs';
|
|
33
36
|
import { applyContext, scaffold } from './artifacts.mjs';
|
|
34
37
|
import { execFileSync } from 'node:child_process';
|
|
35
38
|
|
|
@@ -196,6 +199,17 @@ async function cmdInit(flags) {
|
|
|
196
199
|
try {
|
|
197
200
|
await installHook({ origin, workspaceId, hookToken, developerId: defaultDeveloperId() });
|
|
198
201
|
console.log(c.green('done'));
|
|
202
|
+
|
|
203
|
+
// Phase 5f — the portable layer. These fire for Cursor, Codex, a human, a
|
|
204
|
+
// script; the Claude hook only fires inside an agent that supports hooks.
|
|
205
|
+
// ⚠️ Repo-local and non-blocking. An existing foreign hook is never
|
|
206
|
+
// overwritten — it is reported and left alone.
|
|
207
|
+
const g = installGitHooks();
|
|
208
|
+
if (g.ok && g.installed.length) console.log(` git hooks: ${g.installed.join(', ')} (warn only)`);
|
|
209
|
+
if (g.ok && g.skipped.length) {
|
|
210
|
+
for (const s2 of g.skipped) console.log(c.yellow(` git hook ${s2.name} SKIPPED — ${s2.reason}`));
|
|
211
|
+
}
|
|
212
|
+
if (!g.ok) console.log(c.dim(` git hooks: skipped — ${g.reason}`));
|
|
199
213
|
} catch (e) {
|
|
200
214
|
console.log(c.red('failed'));
|
|
201
215
|
console.error(` ${e.message}`);
|
|
@@ -424,6 +438,70 @@ async function cmdPull(flags) {
|
|
|
424
438
|
|
|
425
439
|
// ── doctor ───────────────────────────────────────────────────────────────────────
|
|
426
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Turn a failed credential probe into something a person can act on.
|
|
443
|
+
*
|
|
444
|
+
* ⚠️ THE SERVER ALREADY DISTINGUISHES THESE and the CLI was throwing it away:
|
|
445
|
+
* `invalid_token` (a real credential, rejected — rotated, revoked or expired)
|
|
446
|
+
* reads completely differently from `missing_token` (nothing was sent) and from
|
|
447
|
+
* a network failure. All three rendered as one red tick with no note.
|
|
448
|
+
*/
|
|
449
|
+
/**
|
|
450
|
+
* Probe the stored API key by fetching one page of docs.
|
|
451
|
+
*
|
|
452
|
+
* ⭐ THE OLD PROBE COULD NEVER PASS. It called `.pages().next()`, and `pages()`
|
|
453
|
+
* returns an async ITERABLE — `Symbol.asyncIterator`, no `.next()`. So it threw
|
|
454
|
+
* `TypeError: … .next is not a function` on every run, and `✗ API key valid`
|
|
455
|
+
* was red for a code bug regardless of the credential.
|
|
456
|
+
*
|
|
457
|
+
* ⚠️ IT WAS INVISIBLE BECAUSE OF THE BARE `catch { }` BESIDE IT. A swallowed
|
|
458
|
+
* error in the one command whose job is diagnosis hid a broken check for as long
|
|
459
|
+
* as it existed; the first thing that printed the reason found it in one run.
|
|
460
|
+
*
|
|
461
|
+
* Extracted so the call site is testable with a fake client — asserting that the
|
|
462
|
+
* doctor CALLS this is the guard, not that the function alone behaves.
|
|
463
|
+
*/
|
|
464
|
+
export async function probeApiKey(makeClientFn) {
|
|
465
|
+
try {
|
|
466
|
+
// for-await, not .next(): the iterable is the contract the SDK exposes.
|
|
467
|
+
// eslint-disable-next-line no-unreachable-loop
|
|
468
|
+
for await (const _page of makeClientFn().docs.list({ limit: 1 }).pages()) break;
|
|
469
|
+
return { ok: true, why: undefined };
|
|
470
|
+
} catch (e) {
|
|
471
|
+
return { ok: false, why: diagnoseCredential(e) };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function diagnoseCredential(e) {
|
|
476
|
+
// ⚠️ `body` IS AN OBJECT, NOT A STRING. My first version did
|
|
477
|
+
// `String(e.body ?? '')`, which yields "[object Object]" — so `invalid_token`
|
|
478
|
+
// never matched and every rejection fell through to the generic branch. The
|
|
479
|
+
// SDK throws typed errors (AuthError, with status/body/fix); read them.
|
|
480
|
+
const reason = typeof e?.body === 'object' && e?.body ? String(e.body.reason ?? '') : '';
|
|
481
|
+
const text = `${String(e?.message ?? '')} ${typeof e?.body === 'string' ? e.body : ''} ${reason}`;
|
|
482
|
+
const status = Number(e?.status);
|
|
483
|
+
|
|
484
|
+
if (reason === 'invalid_token' || /invalid_token/.test(text)) {
|
|
485
|
+
return 'rejected by the server — rotated, revoked or expired; mint a new one in Settings → Access';
|
|
486
|
+
}
|
|
487
|
+
if (reason === 'missing_token' || /missing_token/.test(text)) {
|
|
488
|
+
return 'stored but not sent — this is a CLI bug, please report it';
|
|
489
|
+
}
|
|
490
|
+
if (status === 403 || /insufficient_scope|forbidden/i.test(text)) {
|
|
491
|
+
return 'valid, but missing the docs:read scope — re-issue it with that box ticked';
|
|
492
|
+
}
|
|
493
|
+
if (/ENOTFOUND|ECONNREFUSED|fetch failed|network|ETIMEDOUT/i.test(text)) {
|
|
494
|
+
return 'could not reach the API — the key itself was never checked';
|
|
495
|
+
}
|
|
496
|
+
// ⭐ A TypeError HERE MEANS THE PROBE IS BROKEN, NOT THE KEY. That is not
|
|
497
|
+
// hypothetical: `.pages().next()` threw "not a function" on every run, and the
|
|
498
|
+
// bare catch beside it meant nobody could tell for as long as it existed.
|
|
499
|
+
if (e instanceof TypeError || /is not a function/.test(text)) {
|
|
500
|
+
return `the check itself failed, not the key — ${String(e?.message ?? '').slice(0, 70)}`;
|
|
501
|
+
}
|
|
502
|
+
return String(e?.message ?? '').slice(0, 90) || 'unknown error';
|
|
503
|
+
}
|
|
504
|
+
|
|
427
505
|
async function cmdDoctor(flags) {
|
|
428
506
|
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
429
507
|
const checks = [];
|
|
@@ -449,11 +527,21 @@ async function cmdDoctor(flags) {
|
|
|
449
527
|
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
450
528
|
if (apiKey) {
|
|
451
529
|
let keyOk = false;
|
|
530
|
+
let keyWhy;
|
|
452
531
|
// Probes the KEY specifically — no fallback — because that is the thing
|
|
453
532
|
// doctor is reporting on. Using call() here would mask a dead key behind a
|
|
454
533
|
// working login and print a green tick for a credential that does not work.
|
|
455
|
-
|
|
456
|
-
|
|
534
|
+
//
|
|
535
|
+
// ⭐ AND IT SAYS WHY IT FAILED. This was `catch { }` — a bare swallow that
|
|
536
|
+
// reported "✗ API key valid" and discarded the reason, in the one command
|
|
537
|
+
// whose entire job is diagnosis. Finding out that a real key was being
|
|
538
|
+
// REJECTED (invalid_token) rather than missing, expired-locally, or blocked
|
|
539
|
+
// by a scope took ten manual commands and a keychain dump. The error already
|
|
540
|
+
// carried the answer.
|
|
541
|
+
const probe = await probeApiKey(() => makeClient({ origin, workspaceId }));
|
|
542
|
+
keyOk = probe.ok;
|
|
543
|
+
keyWhy = probe.why;
|
|
544
|
+
ok('API key valid', keyOk, keyOk ? undefined : keyWhy);
|
|
457
545
|
} else {
|
|
458
546
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
459
547
|
}
|
|
@@ -479,6 +567,9 @@ async function cmdDoctor(flags) {
|
|
|
479
567
|
async function cmdUninstall(flags) {
|
|
480
568
|
const { root, workspaceId } = resolveContext(flags);
|
|
481
569
|
uninstallHook();
|
|
570
|
+
// Only removes hooks carrying our marker — a foreign pre-push is left alone.
|
|
571
|
+
const gone = uninstallGitHooks();
|
|
572
|
+
if (gone.removed.length) console.log(` git hooks removed: ${gone.removed.join(', ')}`);
|
|
482
573
|
if (workspaceId) deleteSecrets(workspaceId);
|
|
483
574
|
let purge = flags.purge === true;
|
|
484
575
|
if (!purge && process.stdin.isTTY) {
|
|
@@ -491,73 +582,56 @@ async function cmdUninstall(flags) {
|
|
|
491
582
|
|
|
492
583
|
// ── help ───────────────────────────────────────────────────────────────────────
|
|
493
584
|
|
|
585
|
+
/**
|
|
586
|
+
* The plain-text help. Rendered FROM `catalogue.mjs`, the same source the
|
|
587
|
+
* interactive screen uses.
|
|
588
|
+
*
|
|
589
|
+
* ⚠️ THIS OUTPUT IS TEST-CONSTRAINED. version.test.mjs requires the version on
|
|
590
|
+
* LINE 1 (so no banner above it) and the literal words
|
|
591
|
+
* `tasks docs projects briefing ask graph next` in the body — that test exists
|
|
592
|
+
* because `--help` once printed "mnema 0.1.0" and the old command list for three
|
|
593
|
+
* releases, making a stale global install undiagnosable.
|
|
594
|
+
*/
|
|
494
595
|
function help() {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
mnema doc pick a document from a list
|
|
535
|
-
mnema ask "why did we drop the queue?"
|
|
536
|
-
mnema graph "Workspace Security & Management"
|
|
537
|
-
|
|
538
|
-
⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before
|
|
539
|
-
mnema sees it, and zsh reports a parse error that looks like a broken CLI.
|
|
540
|
-
|
|
541
|
-
Options:
|
|
542
|
-
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
543
|
-
--origin <url> API origin (default ${DEFAULT_ORIGIN})
|
|
544
|
-
--limit <n> Row limit
|
|
545
|
-
--json Machine-readable output (every read command)
|
|
546
|
-
--yes Non-interactive; skip optional prompts
|
|
547
|
-
--purge uninstall: also delete .mnema/config.json
|
|
548
|
-
--no-tui Print help instead of opening the interactive UI
|
|
549
|
-
--version, --help
|
|
550
|
-
|
|
551
|
-
Environment:
|
|
552
|
-
NO_COLOR Disable colour (any value)
|
|
553
|
-
FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
|
|
554
|
-
COLUMNS Override the terminal width used for layout
|
|
555
|
-
MNEMA_TUI never | always — force the interactive UI off or on
|
|
556
|
-
MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
|
|
557
|
-
|
|
558
|
-
Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
|
|
559
|
-
writes plain text.
|
|
560
|
-
`);
|
|
596
|
+
const pad = (s2, n) => String(s2).padEnd(n);
|
|
597
|
+
const out = [];
|
|
598
|
+
out.push(`mnema ${VERSION} — connect a repo to your Mnema workspace`);
|
|
599
|
+
out.push('');
|
|
600
|
+
out.push('Usage: mnema [command] [options]');
|
|
601
|
+
out.push('');
|
|
602
|
+
out.push(' mnema Open the interactive briefing (a terminal, Node 20+)');
|
|
603
|
+
out.push(' mnema help The same command list, navigable');
|
|
604
|
+
for (const g of GROUPS) {
|
|
605
|
+
out.push('');
|
|
606
|
+
out.push(`${g.title}:`);
|
|
607
|
+
if (g.note) out.push(` ${g.note}`);
|
|
608
|
+
for (const c of g.items) {
|
|
609
|
+
const flags2 = c.flags?.length ? ` [${c.flags.join(' ')}]` : '';
|
|
610
|
+
out.push(` ${pad(c.usage ?? c.name, 12)} ${c.blurb}${flags2}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const examples = GROUPS.flatMap((g) => g.items.flatMap((c) => c.examples ?? []));
|
|
614
|
+
if (examples.length) {
|
|
615
|
+
out.push('');
|
|
616
|
+
out.push('Examples:');
|
|
617
|
+
out.push(' mnema the interactive briefing');
|
|
618
|
+
for (const e of examples) out.push(` ${e}`);
|
|
619
|
+
out.push('');
|
|
620
|
+
out.push(' ⚠️ Quote anything with spaces or & — otherwise your SHELL eats it before');
|
|
621
|
+
out.push(' mnema sees it, and zsh reports a parse error that looks like a broken CLI.');
|
|
622
|
+
}
|
|
623
|
+
out.push('');
|
|
624
|
+
out.push('Options:');
|
|
625
|
+
for (const [flag, desc] of OPTIONS) {
|
|
626
|
+
out.push(` ${pad(flag, 18)} ${desc === '' ? '' : desc}`.replace(/\s+$/, ''));
|
|
627
|
+
}
|
|
628
|
+
out.push('');
|
|
629
|
+
out.push('Environment:');
|
|
630
|
+
for (const [k, v] of ENVIRONMENT) out.push(` ${pad(k, 18)} ${v}`);
|
|
631
|
+
out.push('');
|
|
632
|
+
out.push('Colour is off automatically when output is not a terminal, so `mnema tasks > f.txt`');
|
|
633
|
+
out.push('writes plain text.');
|
|
634
|
+
console.log(out.join('\n'));
|
|
561
635
|
}
|
|
562
636
|
|
|
563
637
|
/**
|
|
@@ -604,6 +678,22 @@ export function screenFor(cmd, flags, rest = []) {
|
|
|
604
678
|
return null;
|
|
605
679
|
}
|
|
606
680
|
|
|
681
|
+
/**
|
|
682
|
+
* `mnema help` — navigable on a terminal, plain text otherwise.
|
|
683
|
+
*
|
|
684
|
+
* ⚠️ `--help` THE FLAG IS NOT ROUTED HERE, and must not be. test/version.test.mjs
|
|
685
|
+
* requires the version on line 1 and the literal command words in the body — that
|
|
686
|
+
* test exists so a stale global install is obvious, and an Ink screen satisfies
|
|
687
|
+
* neither. The flag stays plain and pipe-safe; only the SUBCOMMAND opens a view.
|
|
688
|
+
*
|
|
689
|
+
* ⚠️ Falls back to the same plain text whenever the TUI is not eligible (piped,
|
|
690
|
+
* no TTY, --no-tui, old Node), so `mnema help | less` behaves.
|
|
691
|
+
*/
|
|
692
|
+
async function cmdHelp(flags) {
|
|
693
|
+
if (await maybeInteractive(flags, { name: 'help' })) return;
|
|
694
|
+
return help();
|
|
695
|
+
}
|
|
696
|
+
|
|
607
697
|
async function maybeInteractive(flags, screen) {
|
|
608
698
|
if (!screen) return false;
|
|
609
699
|
if (flags.json) return false;
|
|
@@ -647,6 +737,17 @@ export async function run(argv) {
|
|
|
647
737
|
// That is this repo's characteristic bug aimed at its own test suite.
|
|
648
738
|
if (flags.help || flags.h) { help(); return; }
|
|
649
739
|
const cmd = rest.shift();
|
|
740
|
+
/**
|
|
741
|
+
* Phase 5b — print the measured task-binding miss rate.
|
|
742
|
+
*
|
|
743
|
+
* ⭐ This is ASSERT 5's first box. It exists so the decision to flip
|
|
744
|
+
* enforce=block is made against a number rather than a hunch.
|
|
745
|
+
*/
|
|
746
|
+
function cmdBinding(flags) {
|
|
747
|
+
const days = flags.limit ? Number(flags.limit) : 7;
|
|
748
|
+
console.log(formatBindingStats(collectBindingStats(Number.isFinite(days) ? days : 7)));
|
|
749
|
+
}
|
|
750
|
+
|
|
650
751
|
switch (cmd) {
|
|
651
752
|
case 'login': return cmdLogin(flags);
|
|
652
753
|
case 'logout': return cmdLogout();
|
|
@@ -657,6 +758,7 @@ export async function run(argv) {
|
|
|
657
758
|
case 'pull': return cmdPull(flags);
|
|
658
759
|
case 'search': return cmdSearch(flags, rest);
|
|
659
760
|
case 'doctor': return cmdDoctor(flags);
|
|
761
|
+
case 'binding': return cmdBinding(flags);
|
|
660
762
|
case 'uninstall': return cmdUninstall(flags);
|
|
661
763
|
|
|
662
764
|
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
@@ -699,7 +801,7 @@ export async function run(argv) {
|
|
|
699
801
|
}
|
|
700
802
|
break;
|
|
701
803
|
case undefined: return openTuiOrHelp(flags);
|
|
702
|
-
case 'help': return
|
|
804
|
+
case 'help': return cmdHelp(flags);
|
|
703
805
|
default:
|
|
704
806
|
console.error(c.red(`Unknown command: ${cmd}`));
|
|
705
807
|
help();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev Mode Phase 5f — the portable git-hook layer.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHY GIT HOOKS AS WELL AS CLAUDE HOOKS. The PreToolUse warning only fires
|
|
5
|
+
* inside an agent that supports hooks. These two fire for ANYONE committing in
|
|
6
|
+
* the repo — Cursor, Codex, a human, a script — which is what makes task binding
|
|
7
|
+
* a property of the repository rather than of one tool. Audit C5: no husky, no
|
|
8
|
+
* lefthook, no pre-commit, so this is a clean install with nothing to merge.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ REPO-LOCAL, NEVER GLOBAL. `core.hooksPath` is not touched: setting it would
|
|
11
|
+
* silently redirect hooks for every repo on the machine, and someone would spend
|
|
12
|
+
* a day finding out why. These are written into THIS repo's .git/hooks.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NEITHER HOOK BLOCKS ON DAY ONE. `prepare-commit-msg` only ADDS a trailer;
|
|
15
|
+
* `pre-push` warns to stderr and exits 0. 80% of branches carry no task (audit
|
|
16
|
+
* B3), so blocking would stop nearly every push. `pre-push` reads
|
|
17
|
+
* MNEMA_ENFORCE=block to become blocking, the same switch 5c uses.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `--no-verify` BYPASSES BOTH, AND THAT IS FINE — it is git's own escape
|
|
20
|
+
* hatch and pretending otherwise invites someone to disable the hooks entirely.
|
|
21
|
+
* The bypass is documented in the hook body so it is discoverable rather than
|
|
22
|
+
* folklore.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { writeFileSync, existsSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const MARKER = '# mnema-task-binding';
|
|
30
|
+
|
|
31
|
+
/** The repo root, or null when not inside a git work tree. */
|
|
32
|
+
export function repoRoot(cwd = process.cwd()) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
35
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim() || null;
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gitHooksDir(cwd = process.cwd()) {
|
|
41
|
+
const root = repoRoot(cwd);
|
|
42
|
+
return root ? join(root, '.git', 'hooks') : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Injects `Task: t-<n>` as a trailer when the branch names a task and the message
|
|
47
|
+
* does not already carry one.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ Same token-boundary rule as everywhere else: `revert-t-5-fix` names nothing.
|
|
50
|
+
* A trailer is additive — it never rewrites what the author wrote.
|
|
51
|
+
*/
|
|
52
|
+
export const PREPARE_COMMIT_MSG = `#!/bin/sh
|
|
53
|
+
${MARKER}
|
|
54
|
+
# Adds a "Task: t-<n>" trailer when the branch names a task. Never blocks.
|
|
55
|
+
# Bypass: git commit --no-verify
|
|
56
|
+
msg_file="$1"
|
|
57
|
+
src="$2"
|
|
58
|
+
[ "$src" = "merge" ] && exit 0
|
|
59
|
+
[ "$src" = "squash" ] && exit 0
|
|
60
|
+
|
|
61
|
+
# ⚠️ symbolic-ref, NOT rev-parse. Before the first commit, rev-parse
|
|
62
|
+
# --abbrev-ref HEAD FAILS and prints the literal "HEAD", which the exempt
|
|
63
|
+
# list below then swallows — so the hook silently did nothing on the very
|
|
64
|
+
# first commit in a repo. symbolic-ref answers correctly with no commits, and
|
|
65
|
+
# fails cleanly (empty) in detached HEAD, which is what we want to skip.
|
|
66
|
+
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
|
|
67
|
+
case "$branch" in
|
|
68
|
+
main|master|HEAD|"") exit 0 ;;
|
|
69
|
+
esac
|
|
70
|
+
|
|
71
|
+
# t-<n> must sit on a token boundary: start, or after / _ .
|
|
72
|
+
# ⚠️ grep -Eo, NOT sed. The first version used \`sed -n 's/...\\|.../'\` and returned
|
|
73
|
+
# EMPTY FOR EVERY BRANCH: \`\\|\` alternation is a GNU extension BSD sed does not have,
|
|
74
|
+
# and \`^\` inside a group is not an anchor. It was found by RUNNING it, not reading it.
|
|
75
|
+
task=$(printf '%s' "$branch" | grep -Eo '(^|[/_.])t-[0-9]+([-/_.]|$)' | head -1 | grep -Eo 't-[0-9]+' | head -1)
|
|
76
|
+
[ -z "$task" ] && exit 0
|
|
77
|
+
|
|
78
|
+
grep -qi "^Task: " "$msg_file" && exit 0
|
|
79
|
+
printf '\\nTask: %s\\n' "$task" >> "$msg_file"
|
|
80
|
+
exit 0
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Warns (or blocks, under MNEMA_ENFORCE=block) when pushing a branch that names
|
|
85
|
+
* no task.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ Never fires on main/master — pushing the default branch is not task-bound
|
|
88
|
+
* work, and warning there teaches people to ignore the warning.
|
|
89
|
+
*/
|
|
90
|
+
export const PRE_PUSH = `#!/bin/sh
|
|
91
|
+
${MARKER}
|
|
92
|
+
# Warns when the branch names no task. Blocks only when MNEMA_ENFORCE=block.
|
|
93
|
+
# Bypass: git push --no-verify
|
|
94
|
+
# ⚠️ symbolic-ref, NOT rev-parse. Before the first commit, rev-parse
|
|
95
|
+
# --abbrev-ref HEAD FAILS and prints the literal "HEAD", which the exempt
|
|
96
|
+
# list below then swallows — so the hook silently did nothing on the very
|
|
97
|
+
# first commit in a repo. symbolic-ref answers correctly with no commits, and
|
|
98
|
+
# fails cleanly (empty) in detached HEAD, which is what we want to skip.
|
|
99
|
+
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
|
|
100
|
+
case "$branch" in
|
|
101
|
+
main|master|HEAD|"") exit 0 ;;
|
|
102
|
+
esac
|
|
103
|
+
|
|
104
|
+
# ⚠️ grep -Eo, NOT sed. The first version used \`sed -n 's/...\\|.../'\` and returned
|
|
105
|
+
# EMPTY FOR EVERY BRANCH: \`\\|\` alternation is a GNU extension BSD sed does not have,
|
|
106
|
+
# and \`^\` inside a group is not an anchor. It was found by RUNNING it, not reading it.
|
|
107
|
+
task=$(printf '%s' "$branch" | grep -Eo '(^|[/_.])t-[0-9]+([-/_.]|$)' | head -1 | grep -Eo 't-[0-9]+' | head -1)
|
|
108
|
+
[ -n "$task" ] && exit 0
|
|
109
|
+
|
|
110
|
+
printf '\\n [mnema] Pushing a branch that names no task: %s\\n' "$branch" >&2
|
|
111
|
+
printf ' Nothing on the board will link to this work.\\n' >&2
|
|
112
|
+
if [ "$MNEMA_ENFORCE" = "block" ]; then
|
|
113
|
+
printf ' Enforcement is set to block. Rename the branch, or push with --no-verify.\\n\\n' >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
printf ' Not blocked. Rename it t-<n>-<slug> to link it.\\n\\n' >&2
|
|
117
|
+
exit 0
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
const HOOKS = [
|
|
121
|
+
['prepare-commit-msg', PREPARE_COMMIT_MSG],
|
|
122
|
+
['pre-push', PRE_PUSH],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install both hooks into this repo.
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ AN EXISTING HOOK THAT IS NOT OURS IS NEVER OVERWRITTEN. It is reported and
|
|
129
|
+
* skipped — silently clobbering someone's pre-push is the kind of "helpful"
|
|
130
|
+
* install that loses trust permanently. Ours carries a marker line, so re-running
|
|
131
|
+
* upgrades our own and only our own.
|
|
132
|
+
*/
|
|
133
|
+
export function installGitHooks(cwd = process.cwd()) {
|
|
134
|
+
const dir = gitHooksDir(cwd);
|
|
135
|
+
if (!dir) return { ok: false, reason: 'not a git repository', installed: [], skipped: [] };
|
|
136
|
+
|
|
137
|
+
const installed = [];
|
|
138
|
+
const skipped = [];
|
|
139
|
+
for (const [name, body] of HOOKS) {
|
|
140
|
+
const p = join(dir, name);
|
|
141
|
+
if (existsSync(p)) {
|
|
142
|
+
let existing = '';
|
|
143
|
+
try { existing = readFileSync(p, 'utf8'); } catch { /* unreadable */ }
|
|
144
|
+
if (!existing.includes(MARKER)) { skipped.push({ name, reason: 'a different hook is already installed' }); continue; }
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(p, body, { mode: 0o755 });
|
|
148
|
+
chmodSync(p, 0o755);
|
|
149
|
+
installed.push(name);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
skipped.push({ name, reason: String(e && e.message ? e.message : e) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, reason: null, installed, skipped };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Remove only the hooks we wrote — identified by the marker, never by name. */
|
|
158
|
+
export function uninstallGitHooks(cwd = process.cwd()) {
|
|
159
|
+
const dir = gitHooksDir(cwd);
|
|
160
|
+
if (!dir) return { removed: [], kept: [] };
|
|
161
|
+
const removed = [];
|
|
162
|
+
const kept = [];
|
|
163
|
+
for (const [name] of HOOKS) {
|
|
164
|
+
const p = join(dir, name);
|
|
165
|
+
if (!existsSync(p)) continue;
|
|
166
|
+
let body = '';
|
|
167
|
+
try { body = readFileSync(p, 'utf8'); } catch { /* ignore */ }
|
|
168
|
+
if (!body.includes(MARKER)) { kept.push(name); continue; }
|
|
169
|
+
try { rmSync(p); removed.push(name); } catch { kept.push(name); }
|
|
170
|
+
}
|
|
171
|
+
return { removed, kept };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { MARKER as GIT_HOOK_MARKER };
|
package/src/hook-install.mjs
CHANGED
|
@@ -8,7 +8,11 @@ import { homedir, userInfo, hostname } from 'node:os';
|
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
9
9
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, chmodSync } from 'node:fs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// ⚠️ PreToolUse (Phase 5a) is the ONLY event that runs before a tool, so it is the
|
|
12
|
+
// only one whose cost is felt. It is local-only — a git call and a file append,
|
|
13
|
+
// no API round-trip — and it exits 0 always. The installer merges idempotently,
|
|
14
|
+
// so adding it here upgrades existing installs on the next `mnema hook install`.
|
|
15
|
+
const HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'Stop', 'PostToolUse', 'PostToolUseFailure', 'PreToolUse'];
|
|
12
16
|
|
|
13
17
|
export function claudeDir() { return join(homedir(), '.claude'); }
|
|
14
18
|
export function hooksDir() { return join(claudeDir(), 'hooks'); }
|
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
|
+
}
|