@mnemahq/cli 0.1.0 → 0.3.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 +9 -2
- package/package.json +5 -2
- package/src/cli.mjs +108 -23
- package/src/client.mjs +128 -0
- package/src/keychain.mjs +12 -1
- package/src/read-commands.mjs +196 -0
- package/src/util.mjs +10 -0
package/README.md
CHANGED
|
@@ -15,8 +15,15 @@ The package is `@mnemahq/cli`; the command it installs is **`mnema`**. (The unsc
|
|
|
15
15
|
name `mnema` is blocked on npm — its typosquat filter rejects it as too close to the
|
|
16
16
|
existing package `mem`.)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Run `mnema login` first and the workspace is filled in automatically. Otherwise you'll be asked
|
|
19
|
+
for two values, which live on **two different settings pages**:
|
|
20
|
+
|
|
21
|
+
| value | where |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| Workspace id | **Settings → Workspace** |
|
|
24
|
+
| Hook token | **Settings → Access** → *Session capture* → **Generate token** |
|
|
25
|
+
|
|
26
|
+
Optionally an **API key** for search. `init` installs the capture hook, stores your secrets in the OS
|
|
20
27
|
keychain, and writes a `.mnema/config.json` (safe to commit — it holds no secrets).
|
|
21
28
|
|
|
22
29
|
Start a Claude Code session and it appears under **Sessions** with its cost.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"vitest": "^3.2.7"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@mnemahq/sdk": "0.3.1"
|
|
34
|
+
},
|
|
32
35
|
"scripts": {
|
|
33
36
|
"build": "node -e \"process.exit(0)\"",
|
|
34
|
-
"typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs",
|
|
37
|
+
"typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs",
|
|
35
38
|
"test": "vitest run"
|
|
36
39
|
}
|
|
37
40
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
|
-
import { cmdLogin, cmdLogout } from './login.mjs';
|
|
14
|
+
import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
15
|
+
import { makeClient, canAuthenticate, renderError } from './client.mjs';
|
|
15
16
|
import {
|
|
16
|
-
|
|
17
|
+
cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
|
|
18
|
+
} from './read-commands.mjs';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_ORIGIN, DEFAULT_APP_URL, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
|
|
17
21
|
apiFetch, prompt, promptHidden, localSessionsForRepo,
|
|
18
22
|
} from './util.mjs';
|
|
19
23
|
import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from './secrets.mjs';
|
|
@@ -57,6 +61,32 @@ function fmtAge(mtimeMs) {
|
|
|
57
61
|
|
|
58
62
|
// ── init ───────────────────────────────────────────────────────────────────────
|
|
59
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The workspace this login already belongs to.
|
|
66
|
+
*
|
|
67
|
+
* ⭐ THE CLI WAS ASKING FOR SOMETHING IT COULD LOOK UP. `init` prompted "Workspace
|
|
68
|
+
* id:" with no hint, and the id lives on a different settings page from the hook
|
|
69
|
+
* token — so the first thing a new user did was go hunting for a UUID the tool
|
|
70
|
+
* could have read from their own session. If they have run `mnema login`, /api/me
|
|
71
|
+
* answers it.
|
|
72
|
+
*
|
|
73
|
+
* Returns null on any failure. This is a convenience, never a requirement: not
|
|
74
|
+
* logged in, offline, or an unexpected body all fall through to the prompt.
|
|
75
|
+
*/
|
|
76
|
+
async function workspaceFromSession(origin) {
|
|
77
|
+
try {
|
|
78
|
+
if (!(await accessToken())) return null;
|
|
79
|
+
// Via the SDK: /api/public/v1/me returns camelCase, and the SDK is the only
|
|
80
|
+
// thing that should know that.
|
|
81
|
+
const me = await makeClient({ origin }).me();
|
|
82
|
+
if (!me?.workspaceId) return null;
|
|
83
|
+
console.log(c.green(`\u2713 Workspace: ${me.workspaceName || me.workspaceId} (from your login)`));
|
|
84
|
+
return me.workspaceId;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
60
90
|
async function cmdInit(flags) {
|
|
61
91
|
const git = gitInfo();
|
|
62
92
|
const root = git.root || process.cwd();
|
|
@@ -66,11 +96,24 @@ async function cmdInit(flags) {
|
|
|
66
96
|
const existing = readConfig(root);
|
|
67
97
|
|
|
68
98
|
let workspaceId = flags.workspace || process.env.MNEMA_WORKSPACE_ID || existing?.workspaceId;
|
|
69
|
-
if (!workspaceId) workspaceId = await
|
|
99
|
+
if (!workspaceId) workspaceId = await workspaceFromSession(origin);
|
|
100
|
+
if (!workspaceId) {
|
|
101
|
+
console.log(` Workspace id — ${c.dim('Settings \u2192 Workspace')} ${c.dim(`${DEFAULT_APP_URL}/app/settings/workspace`)}`);
|
|
102
|
+
console.log(` ${c.dim('(or run `mnema login` first and this is filled in for you)')}`);
|
|
103
|
+
workspaceId = await prompt('Workspace id: ');
|
|
104
|
+
}
|
|
70
105
|
if (!workspaceId) { console.error(c.red('A workspace id is required.')); process.exit(1); }
|
|
71
106
|
|
|
72
107
|
let hookToken = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
73
|
-
if (!hookToken)
|
|
108
|
+
if (!hookToken) {
|
|
109
|
+
// ⚠️ NOT "Settings → Developer". That page does not exist and never did in
|
|
110
|
+
// this shape — the hook token moved to the Access page when tokens were
|
|
111
|
+
// consolidated there, and the id lives on Workspace. The old prompt sent
|
|
112
|
+
// every new user looking for a page that was not in the app.
|
|
113
|
+
console.log(` Hook token — ${c.dim('Settings \u2192 Access \u2192 "Session capture" \u2192 Generate token')}`);
|
|
114
|
+
console.log(` ${c.dim(`${DEFAULT_APP_URL}/app/settings/access`)}`);
|
|
115
|
+
hookToken = await promptHidden('Hook token: ');
|
|
116
|
+
}
|
|
74
117
|
if (!hookToken) { console.error(c.red('A hook token is required.')); process.exit(1); }
|
|
75
118
|
|
|
76
119
|
let apiKey = process.env.MNEMA_API_KEY || getSecret(workspaceId, 'api-key') || '';
|
|
@@ -164,15 +207,24 @@ async function cmdSessions(flags) {
|
|
|
164
207
|
console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
|
|
165
208
|
}
|
|
166
209
|
|
|
167
|
-
|
|
168
|
-
|
|
210
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
211
|
+
console.log(c.dim('\n (run `mnema login` — or add an API key — to see server-side cost + status)'));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
169
214
|
|
|
170
215
|
const repo = canonicalRepo(git.remote);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
216
|
+
let rows = [];
|
|
217
|
+
try {
|
|
218
|
+
rows = await makeClient({ origin, workspaceId })
|
|
219
|
+
.sessions.list({ limit, ...(repo ? { repo } : {}) })
|
|
220
|
+
.all();
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// Non-fatal by design: the LOCAL list above is the useful half and has
|
|
223
|
+
// already printed. Degrading here is deliberate, and it says which wall it
|
|
224
|
+
// hit rather than a bare HTTP number.
|
|
225
|
+
console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
176
228
|
console.log(c.bold(`\nServer sessions (${rows.length})`));
|
|
177
229
|
for (const s of rows) {
|
|
178
230
|
const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
|
|
@@ -201,12 +253,15 @@ async function cmdSearch(flags, rest) {
|
|
|
201
253
|
const { origin, workspaceId } = resolveContext(flags);
|
|
202
254
|
const query = rest.join(' ').trim();
|
|
203
255
|
if (!query) { console.error(c.red('Usage: mnema search "<query>"')); process.exit(1); }
|
|
204
|
-
|
|
205
|
-
|
|
256
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
257
|
+
console.error(c.red('Search needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
206
260
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
261
|
+
let results = [];
|
|
262
|
+
try {
|
|
263
|
+
results = await makeClient({ origin, workspaceId }).docs.search(query);
|
|
264
|
+
} catch (e) { renderError(e, { context: 'search' }); }
|
|
210
265
|
if (!results.length) { console.log(c.dim('No results.')); return; }
|
|
211
266
|
console.log(c.bold(`${results.length} result(s) for "${query}"`));
|
|
212
267
|
for (const d of results) {
|
|
@@ -221,12 +276,17 @@ async function cmdPull(flags) {
|
|
|
221
276
|
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
222
277
|
const repo = flags.repo || canonicalRepo(git.remote);
|
|
223
278
|
if (!repo) { console.error(c.red('No git remote to identify this repo. Set one, or pass --repo <url>.')); process.exit(1); }
|
|
224
|
-
|
|
225
|
-
|
|
279
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
280
|
+
console.error(c.red('`mnema pull` needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
226
283
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
284
|
+
let docs = [];
|
|
285
|
+
try {
|
|
286
|
+
// `.context`, not the whole result — `.repo` is the URL the server matched.
|
|
287
|
+
// Reading the wrong one of these two is the bug this rewire found in the SDK.
|
|
288
|
+
docs = (await makeClient({ origin, workspaceId }).repos.context(repo)).context ?? [];
|
|
289
|
+
} catch (e) { renderError(e, { context: 'pull' }); }
|
|
230
290
|
|
|
231
291
|
scaffold(root);
|
|
232
292
|
const s = applyContext(root, docs);
|
|
@@ -268,7 +328,8 @@ async function cmdDoctor(flags) {
|
|
|
268
328
|
|
|
269
329
|
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
270
330
|
if (apiKey) {
|
|
271
|
-
let keyOk = false;
|
|
331
|
+
let keyOk = false;
|
|
332
|
+
try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
|
|
272
333
|
ok('API key valid', keyOk);
|
|
273
334
|
} else {
|
|
274
335
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
@@ -319,10 +380,23 @@ Commands:
|
|
|
319
380
|
doctor Diagnose install, hooks, auth, connectivity
|
|
320
381
|
uninstall Remove hooks and stored secrets
|
|
321
382
|
|
|
383
|
+
Read your workspace:
|
|
384
|
+
tasks List tasks [--status --project --limit]
|
|
385
|
+
next The next task to pick up, with a ready-made branch name
|
|
386
|
+
docs List documents [--limit]
|
|
387
|
+
doc <id> Print one document as markdown
|
|
388
|
+
projects List projects
|
|
389
|
+
briefing What deserves attention — pulse, deltas, findings
|
|
390
|
+
|
|
391
|
+
Ask the knowledge graph (paid feature):
|
|
392
|
+
ask "q" A cited answer, with the confidence it deserves
|
|
393
|
+
graph <a> [b] Neighbourhood of a, or the shortest path from a to b
|
|
394
|
+
|
|
322
395
|
Options:
|
|
323
396
|
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
324
397
|
--origin <url> API origin (default ${DEFAULT_ORIGIN})
|
|
325
|
-
--limit <n> Row limit
|
|
398
|
+
--limit <n> Row limit
|
|
399
|
+
--json Machine-readable output (every read command)
|
|
326
400
|
--yes Non-interactive; skip optional prompts
|
|
327
401
|
--purge uninstall: also delete .mnema/config.json
|
|
328
402
|
--version, --help
|
|
@@ -344,6 +418,17 @@ export async function run(argv) {
|
|
|
344
418
|
case 'search': return cmdSearch(flags, rest);
|
|
345
419
|
case 'doctor': return cmdDoctor(flags);
|
|
346
420
|
case 'uninstall': return cmdUninstall(flags);
|
|
421
|
+
|
|
422
|
+
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
423
|
+
// none of them knows a URL or an envelope key (§A2, t-623).
|
|
424
|
+
case 'tasks': return cmdTasks(flags, resolveContext(flags));
|
|
425
|
+
case 'next': return cmdNext(flags, resolveContext(flags));
|
|
426
|
+
case 'docs': return cmdDocs(flags, resolveContext(flags));
|
|
427
|
+
case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
|
|
428
|
+
case 'projects': return cmdProjects(flags, resolveContext(flags));
|
|
429
|
+
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
430
|
+
case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
|
|
431
|
+
case 'briefing': return cmdBriefing(flags, resolveContext(flags));
|
|
347
432
|
case undefined:
|
|
348
433
|
case 'help': return help();
|
|
349
434
|
default:
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the CLI talks to Mnema — through @mnemahq/sdk (§A2).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHY THE RULE EXISTS, DEMONSTRATED THE HOUR IT WAS FOLLOWED. Rewiring the
|
|
5
|
+
* first command onto the SDK immediately turned up that `repos.context()` had
|
|
6
|
+
* been unwrapping the wrong envelope key since it shipped — it returned the repo
|
|
7
|
+
* URL string instead of the documents. The CLI's own raw fetch read the right key
|
|
8
|
+
* and had been right all along. An SDK its own CLI routes around has no first
|
|
9
|
+
* user, so its mistakes are found by everyone else instead.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ NOT EVERY REQUEST BELONGS HERE. `/install/mnema-hook.mjs` is a static asset,
|
|
12
|
+
* not an API call, and stays on plain fetch in hook-install.mjs. Routing it
|
|
13
|
+
* through a typed API client would imply an envelope and a credential that the
|
|
14
|
+
* endpoint neither sends nor wants.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Mnema } from '@mnemahq/sdk';
|
|
18
|
+
import { getSecret } from './secrets.mjs';
|
|
19
|
+
import { accessToken } from './login.mjs';
|
|
20
|
+
import { c, DEFAULT_APP_URL } from './util.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A client carrying whichever credential this machine actually has.
|
|
24
|
+
*
|
|
25
|
+
* Order is deliberate: an API key is explicit, scoped, and what CI uses, so it
|
|
26
|
+
* wins. The OAuth token from `mnema login` is the human fallback. `getToken` is
|
|
27
|
+
* passed as a FUNCTION rather than a resolved string so the SDK re-reads it per
|
|
28
|
+
* request — that is what makes a refresh mid-command work instead of failing on
|
|
29
|
+
* a token that expired thirty seconds ago.
|
|
30
|
+
*/
|
|
31
|
+
export function makeClient({ origin, workspaceId }) {
|
|
32
|
+
const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null;
|
|
33
|
+
return new Mnema(
|
|
34
|
+
apiKey
|
|
35
|
+
? { apiKey, baseUrl: origin }
|
|
36
|
+
: { getToken: accessToken, baseUrl: origin },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when an explicit API key is configured. Cheap; no network. */
|
|
41
|
+
export function hasApiKey(workspaceId) {
|
|
42
|
+
return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when this machine can authenticate AT ALL — by key or by login.
|
|
47
|
+
*
|
|
48
|
+
* ⭐ THE COMMANDS USED TO DEMAND A KEY, and that was a leftover from before the
|
|
49
|
+
* public API accepted both. `/api/public/v1` has taken an OAuth access token
|
|
50
|
+
* since the login flow shipped, so "Search needs an API key" was refusing work
|
|
51
|
+
* the server would happily have done for someone who had already run
|
|
52
|
+
* `mnema login`. Asking a person to go generate a credential they do not need is
|
|
53
|
+
* the kind of friction that makes a tool feel broken.
|
|
54
|
+
*/
|
|
55
|
+
export async function canAuthenticate(workspaceId) {
|
|
56
|
+
if (hasApiKey(workspaceId)) return true;
|
|
57
|
+
try { return Boolean(await accessToken()); } catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turn a thrown SDK error into something a person can act on, and exit.
|
|
62
|
+
*
|
|
63
|
+
* ⭐ THIS IS THE POINT OF THE REWIRE, not a nicety. Every failure used to print
|
|
64
|
+
* `HTTP 402` and a raw body — which tells you a wall exists and nothing about
|
|
65
|
+
* which one. The hierarchy already knows: whether you need to log in, upgrade,
|
|
66
|
+
* top up, wait, or accept that this server does not have the feature at all.
|
|
67
|
+
* Four of those five have different fixes and the fifth cannot be fixed by
|
|
68
|
+
* paying.
|
|
69
|
+
*
|
|
70
|
+
* Errors are matched by NAME rather than instanceof: the CLI may load a
|
|
71
|
+
* different copy of @mnemahq/core than the SDK did (npm dedupes, pnpm does not
|
|
72
|
+
* always), and a cross-realm instanceof silently falls through to the generic
|
|
73
|
+
* branch — turning a good message back into a bad one for the exact reason this
|
|
74
|
+
* function exists.
|
|
75
|
+
*/
|
|
76
|
+
export function renderError(e, { context = '' } = {}) {
|
|
77
|
+
const name = e?.constructor?.name ?? '';
|
|
78
|
+
const where = context ? `${context}: ` : '';
|
|
79
|
+
|
|
80
|
+
switch (name) {
|
|
81
|
+
case 'AuthError':
|
|
82
|
+
console.error(c.red(`${where}not authenticated.`));
|
|
83
|
+
console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'PlanRequiredError':
|
|
87
|
+
console.error(c.yellow(`${where}your ${e.plan ?? 'current'} plan does not include ${e.feature ?? 'this'}.`));
|
|
88
|
+
console.error(` Needs the ${e.required ?? 'a paid'} plan.`);
|
|
89
|
+
console.error(` ${c.dim(e.upgradeUrl ?? `${DEFAULT_APP_URL}/app/settings/billing`)}`);
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
case 'FeatureUnavailableError':
|
|
93
|
+
// No upgrade link here on purpose. There is nothing to buy: this server's
|
|
94
|
+
// build does not contain the feature, and pointing at billing would sell
|
|
95
|
+
// someone a plan that cannot change their binary.
|
|
96
|
+
console.error(c.yellow(`${where}${e.feature ?? 'that feature'} is not available on this server.`));
|
|
97
|
+
console.error(` ${c.dim(`This is a ${e.edition ?? 'core'} build — the enterprise modules are not installed.`)}`);
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case 'InsufficientCreditError': {
|
|
101
|
+
const need = typeof e.topUpNeededCents === 'number' ? `$${(e.topUpNeededCents / 100).toFixed(2)}` : 'more credit';
|
|
102
|
+
console.error(c.yellow(`${where}not enough credit for ${e.action ?? 'this action'}.`));
|
|
103
|
+
console.error(` Top up ${need}. ${c.dim(`${DEFAULT_APP_URL}/app/settings/wallet`)}`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'RateLimitError': {
|
|
108
|
+
const secs = typeof e.retryAfterMs === 'number' ? Math.ceil(e.retryAfterMs / 1000) : null;
|
|
109
|
+
console.error(c.yellow(`${where}rate limited${secs ? ` — retry in ${secs}s` : ''}.`));
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'ValidationError':
|
|
114
|
+
console.error(c.red(`${where}${e.message}`));
|
|
115
|
+
if (e.fields) for (const [f, msgs] of Object.entries(e.fields)) console.error(` ${f}: ${[].concat(msgs).join(', ')}`);
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'NetworkError':
|
|
119
|
+
console.error(c.red(`${where}could not reach the server.`));
|
|
120
|
+
console.error(` ${c.dim(e.message)}`);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
console.error(c.red(`${where}${e?.message ?? String(e)}`));
|
|
125
|
+
if (e?.status) console.error(` ${c.dim(`HTTP ${e.status}`)}`);
|
|
126
|
+
}
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
package/src/keychain.mjs
CHANGED
|
@@ -181,8 +181,19 @@ export function deleteSecret(account) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/** Where the CLI keeps non-secret login state (origin, workspace, expiry). */
|
|
184
|
+
/**
|
|
185
|
+
* Where the CLI keeps non-secret login state.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ MNEMA_STATE_DIR EXISTS BECAUSE THE TEST SUITE ATE A REAL LOGIN. keychain's
|
|
188
|
+
* own test calls writeState({ origin: 'https://api.example', … }) to check the
|
|
189
|
+
* file round-trips — and with the path hard-wired to homedir() that wrote over
|
|
190
|
+
* the developer's actual ~/.mnema/auth.json. Running `pnpm test` silently
|
|
191
|
+
* repointed a working CLI at a fake host; the next command failed with
|
|
192
|
+
* `getaddrinfo api.example`, which reads like a network fault and is nothing of
|
|
193
|
+
* the kind. Tests must not be able to reach a real home directory.
|
|
194
|
+
*/
|
|
184
195
|
export function statePath() {
|
|
185
|
-
const dir = join(homedir(), '.mnema');
|
|
196
|
+
const dir = process.env.MNEMA_STATE_DIR || join(homedir(), '.mnema');
|
|
186
197
|
mkdirSync(dir, { recursive: true });
|
|
187
198
|
return join(dir, 'auth.json');
|
|
188
199
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read commands — tasks, docs, projects, ask, graph, briefing (t-624).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ EVERY ONE OF THESE IS A WRAPPER, NOT A CLIENT. They exist because t-623 put
|
|
5
|
+
* the CLI on @mnemahq/sdk; each is four lines of SDK call plus formatting, and
|
|
6
|
+
* none of them knows a URL, an envelope key or a header. That is the payoff §A2
|
|
7
|
+
* was arguing for: the surface grew without the transport growing with it.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ THE FORMATTING IS THE PART WITH JUDGEMENT IN IT, and two of these can lie by
|
|
10
|
+
* omission if it is done carelessly:
|
|
11
|
+
*
|
|
12
|
+
* ask the interpreter HEDGES below its confidence threshold. Printing the
|
|
13
|
+
* sentence without the number turns a hedge into a claim, which is
|
|
14
|
+
* worse than printing nothing.
|
|
15
|
+
* briefing a short list has three quite different causes and only one is good
|
|
16
|
+
* news. On a core build six of seven finding families cannot produce
|
|
17
|
+
* anything at all, and `coverage.notice` is the only thing that says so.
|
|
18
|
+
*
|
|
19
|
+
* --json everywhere: a CLI you cannot pipe is half a CLI, and the SDK already
|
|
20
|
+
* returns the shape worth emitting, so it costs one line each.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { makeClient, canAuthenticate, renderError } from './client.mjs';
|
|
24
|
+
import { c } from './util.mjs';
|
|
25
|
+
|
|
26
|
+
/** Machine output is the whole object; humans get the formatted view. */
|
|
27
|
+
function emit(flags, value, render) {
|
|
28
|
+
if (flags.json) { console.log(JSON.stringify(value, null, 2)); return; }
|
|
29
|
+
render();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function guard(workspaceId) {
|
|
33
|
+
if (await canAuthenticate(workspaceId)) return;
|
|
34
|
+
console.error(c.red('Not authenticated — run `mnema login`, or `mnema init` with an API key.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trunc = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : (s ?? ''));
|
|
39
|
+
|
|
40
|
+
// ── tasks ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
export async function cmdTasks(flags, ctx) {
|
|
42
|
+
await guard(ctx.workspaceId);
|
|
43
|
+
const limit = Number(flags.limit) || 20;
|
|
44
|
+
try {
|
|
45
|
+
const rows = await makeClient(ctx).tasks.list({
|
|
46
|
+
limit,
|
|
47
|
+
...(flags.status ? { status: flags.status } : {}),
|
|
48
|
+
...(flags.project ? { project: flags.project } : {}),
|
|
49
|
+
}).all();
|
|
50
|
+
emit(flags, rows, () => {
|
|
51
|
+
if (!rows.length) return console.log(c.dim('No tasks match.'));
|
|
52
|
+
console.log(c.bold(`${rows.length} task(s)`));
|
|
53
|
+
for (const t of rows) {
|
|
54
|
+
const id = (t.publicId || t.id.slice(0, 8)).padEnd(8);
|
|
55
|
+
console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
} catch (e) { renderError(e, { context: 'tasks' }); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cmdNext(flags, ctx) {
|
|
62
|
+
await guard(ctx.workspaceId);
|
|
63
|
+
try {
|
|
64
|
+
const t = await makeClient(ctx).tasks.next();
|
|
65
|
+
emit(flags, t, () => {
|
|
66
|
+
if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
|
|
67
|
+
console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
|
|
68
|
+
if (t.description) console.log(c.dim(trunc(t.description.replace(/\s+/g, ' '), 300)));
|
|
69
|
+
// The branch name is the load-bearing part of this project's dev loop, so
|
|
70
|
+
// the CLI hands it over ready to paste rather than making it be derived.
|
|
71
|
+
if (t.publicId) {
|
|
72
|
+
const slug = t.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
|
|
73
|
+
console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} catch (e) { renderError(e, { context: 'next' }); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── docs ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
export async function cmdDocs(flags, ctx) {
|
|
81
|
+
await guard(ctx.workspaceId);
|
|
82
|
+
const limit = Number(flags.limit) || 20;
|
|
83
|
+
try {
|
|
84
|
+
const rows = await makeClient(ctx).docs.list({ limit }).all();
|
|
85
|
+
emit(flags, rows, () => {
|
|
86
|
+
if (!rows.length) return console.log(c.dim('No docs.'));
|
|
87
|
+
console.log(c.bold(`${rows.length} doc(s)`));
|
|
88
|
+
for (const d of rows) {
|
|
89
|
+
console.log(` ${c.dim(d.id.slice(0, 8))} ${trunc(d.title, 60).padEnd(60)} ${c.dim(String(d.updatedAt).slice(0, 10))}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} catch (e) { renderError(e, { context: 'docs' }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function cmdDoc(flags, ctx, rest) {
|
|
96
|
+
await guard(ctx.workspaceId);
|
|
97
|
+
const id = rest[0];
|
|
98
|
+
if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
|
|
99
|
+
try {
|
|
100
|
+
const d = await makeClient(ctx).docs.get(id);
|
|
101
|
+
// Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
|
|
102
|
+
// the heading is not silently baked into the file.
|
|
103
|
+
emit(flags, d, () => {
|
|
104
|
+
console.error(c.bold(d.title));
|
|
105
|
+
console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
|
|
106
|
+
if (d.markdown) console.log(d.markdown);
|
|
107
|
+
});
|
|
108
|
+
} catch (e) { renderError(e, { context: 'doc' }); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── projects ──────────────────────────────────────────────────────────────────
|
|
112
|
+
export async function cmdProjects(flags, ctx) {
|
|
113
|
+
await guard(ctx.workspaceId);
|
|
114
|
+
try {
|
|
115
|
+
const rows = await makeClient(ctx).projects.list().all();
|
|
116
|
+
emit(flags, rows, () => {
|
|
117
|
+
if (!rows.length) return console.log(c.dim('No projects.'));
|
|
118
|
+
console.log(c.bold(`${rows.length} project(s)`));
|
|
119
|
+
for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
|
|
120
|
+
});
|
|
121
|
+
} catch (e) { renderError(e, { context: 'projects' }); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── graph: ask + traverse ─────────────────────────────────────────────────────
|
|
125
|
+
export async function cmdAsk(flags, ctx, rest) {
|
|
126
|
+
await guard(ctx.workspaceId);
|
|
127
|
+
const q = rest.join(' ').trim();
|
|
128
|
+
if (!q) { console.error(c.red('Usage: mnema ask "<question>"')); process.exit(1); }
|
|
129
|
+
try {
|
|
130
|
+
const a = await makeClient(ctx).graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) });
|
|
131
|
+
emit(flags, a, () => {
|
|
132
|
+
console.log(a.answer);
|
|
133
|
+
// ⚠️ NEVER WITHOUT THE CONFIDENCE. The interpreter deliberately hedges
|
|
134
|
+
// rather than asserts when the graph is thin; a terminal that prints only
|
|
135
|
+
// the sentence promotes a hedge to a fact.
|
|
136
|
+
const pct = Math.round((a.confidence ?? 0) * 100);
|
|
137
|
+
const why = a.confidenceReason ? ` (${a.confidenceReason})` : '';
|
|
138
|
+
const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
|
|
139
|
+
console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
|
|
140
|
+
if (a.sources?.length) {
|
|
141
|
+
console.log(c.dim(`\nsources (${a.sources.length}):`));
|
|
142
|
+
for (const s of a.sources.slice(0, 5)) console.log(c.dim(` ${trunc(s.title, 70)}`));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
} catch (e) { renderError(e, { context: 'ask' }); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function cmdGraph(flags, ctx, rest) {
|
|
149
|
+
await guard(ctx.workspaceId);
|
|
150
|
+
const [from, to] = rest;
|
|
151
|
+
if (!from) { console.error(c.red('Usage: mnema graph <from> [to]')); process.exit(1); }
|
|
152
|
+
try {
|
|
153
|
+
const g = await makeClient(ctx).graph.traverse(from, to);
|
|
154
|
+
emit(flags, g, () => {
|
|
155
|
+
if (to) {
|
|
156
|
+
// connected:false is an ANSWER, not a failure — say so plainly rather
|
|
157
|
+
// than printing an empty list and letting it read as an error.
|
|
158
|
+
if (!g.connected) return console.log(c.yellow(`No route between "${from}" and "${to}".`));
|
|
159
|
+
console.log(c.bold(`${g.hopCount} hop(s) from "${from}" to "${to}"`));
|
|
160
|
+
for (const id of g.path) {
|
|
161
|
+
const n = g.nodes.find((x) => x.id === id);
|
|
162
|
+
console.log(` ${n ? trunc(n.label, 60) : id}${n?.type ? c.dim(` [${n.type}]`) : ''}`);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
|
|
167
|
+
for (const n of g.nodes.slice(0, 25)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
|
|
168
|
+
});
|
|
169
|
+
} catch (e) { renderError(e, { context: 'graph' }); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── briefing ──────────────────────────────────────────────────────────────────
|
|
173
|
+
export async function cmdBriefing(flags, ctx) {
|
|
174
|
+
await guard(ctx.workspaceId);
|
|
175
|
+
try {
|
|
176
|
+
const b = await makeClient(ctx).findings.briefing();
|
|
177
|
+
emit(flags, b, () => {
|
|
178
|
+
const p = b.pulse;
|
|
179
|
+
console.log(c.bold('Pulse'));
|
|
180
|
+
console.log(` ${p.activeProjects} active project(s) · ${p.problemsInActiveProjects} problem(s) · $${p.cost7dUsd} last 7d`);
|
|
181
|
+
|
|
182
|
+
// ⚠️ THE CAVEATS COME BEFORE THE LIST, on purpose. A short list has three
|
|
183
|
+
// causes and only one is good news; printing the findings first invites the
|
|
184
|
+
// reader to conclude "all clear" before reaching the reason it is short.
|
|
185
|
+
if (b.neverComputed) console.log(c.yellow('\n The findings engine has never run for this workspace.'));
|
|
186
|
+
if (b.coverage?.degraded) console.log(c.yellow(`\n ${b.coverage.notice}`));
|
|
187
|
+
|
|
188
|
+
console.log(c.bold(`\nFindings (${b.findings.length})`));
|
|
189
|
+
if (!b.findings.length) console.log(c.dim(' nothing surfaced'));
|
|
190
|
+
for (const f of b.findings) {
|
|
191
|
+
const count = f.grouped ? c.dim(` ×${f.count}`) : '';
|
|
192
|
+
console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
} catch (e) { renderError(e, { context: 'briefing' }); }
|
|
196
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -18,6 +18,16 @@ import { createInterface } from 'node:readline';
|
|
|
18
18
|
// capture hook uses.
|
|
19
19
|
export const DEFAULT_ORIGIN = process.env.MNEMA_API_ORIGIN || 'https://api.theboringpeople.in';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* ⭐ THE APP, NOT THE API — a SEPARATE host, and the reason this constant exists.
|
|
23
|
+
* Everything the CLI asks a human to fetch lives in the web app at
|
|
24
|
+
* mnema.theboringpeople.in, while every request it makes goes to
|
|
25
|
+
* api.theboringpeople.in. Deriving one from the other is not possible, and
|
|
26
|
+
* printing the API origin in a "go here and copy this" message sends people to a
|
|
27
|
+
* host with no UI on it.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_APP_URL = process.env.MNEMA_APP_URL || 'https://mnema.theboringpeople.in';
|
|
30
|
+
|
|
21
31
|
export const c = {
|
|
22
32
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
23
33
|
green: (s) => `\x1b[32m${s}\x1b[0m`,
|