@mnemahq/cli 0.1.1 → 0.3.1
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/package.json +5 -2
- package/src/cli.mjs +114 -29
- package/src/client.mjs +217 -0
- package/src/keychain.mjs +12 -1
- package/src/read-commands.mjs +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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 && node --check test/version.test.mjs",
|
|
35
38
|
"test": "vitest run"
|
|
36
39
|
}
|
|
37
40
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* mnema uninstall cleanly reverse everything
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
15
|
+
import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
|
|
16
|
+
import {
|
|
17
|
+
cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
|
|
18
|
+
} from './read-commands.mjs';
|
|
15
19
|
import {
|
|
16
20
|
DEFAULT_ORIGIN, DEFAULT_APP_URL, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
|
|
17
21
|
apiFetch, prompt, promptHidden, localSessionsForRepo,
|
|
@@ -23,7 +27,25 @@ import {
|
|
|
23
27
|
import { applyContext, scaffold } from './artifacts.mjs';
|
|
24
28
|
import { execFileSync } from 'node:child_process';
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
/**
|
|
31
|
+
* ⚠️ READ FROM package.json, NEVER HAND-WRITTEN. This was `const VERSION =
|
|
32
|
+
* '0.1.0'` and stayed that way through 0.1.1 and 0.3.0 — so every release of
|
|
33
|
+
* this CLI reported the same wrong number, and `mnema --version` was useless for
|
|
34
|
+
* the one job it has.
|
|
35
|
+
*
|
|
36
|
+
* The cost was not cosmetic. After 0.3.0 shipped with eight new commands,
|
|
37
|
+
* `mnema --help` printed "mnema 0.1.0" and the old command list, because the
|
|
38
|
+
* global install was stale and npm does not upgrade an installed global when you
|
|
39
|
+
* publish. The right fix was one line — `npm i -g @mnemahq/cli@latest` — but a
|
|
40
|
+
* binary that misreports itself makes that undiagnosable, and the honest reading
|
|
41
|
+
* of the evidence was "you shipped nothing".
|
|
42
|
+
*
|
|
43
|
+
* Resolved relative to import.meta.url, not cwd: the CLI runs from whatever
|
|
44
|
+
* directory the user happens to be in. package.json is always in the tarball.
|
|
45
|
+
*/
|
|
46
|
+
const VERSION = JSON.parse(
|
|
47
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
48
|
+
).version;
|
|
27
49
|
|
|
28
50
|
function parseFlags(argv) {
|
|
29
51
|
const flags = {}; const rest = [];
|
|
@@ -71,14 +93,13 @@ function fmtAge(mtimeMs) {
|
|
|
71
93
|
*/
|
|
72
94
|
async function workspaceFromSession(origin) {
|
|
73
95
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return body.workspace_id;
|
|
96
|
+
if (!(await accessToken())) return null;
|
|
97
|
+
// Via the SDK: /api/public/v1/me returns camelCase, and the SDK is the only
|
|
98
|
+
// thing that should know that.
|
|
99
|
+
const me = await makeClient({ origin }).me();
|
|
100
|
+
if (!me?.workspaceId) return null;
|
|
101
|
+
console.log(c.green(`\u2713 Workspace: ${me.workspaceName || me.workspaceId} (from your login)`));
|
|
102
|
+
return me.workspaceId;
|
|
82
103
|
} catch {
|
|
83
104
|
return null;
|
|
84
105
|
}
|
|
@@ -102,6 +123,26 @@ async function cmdInit(flags) {
|
|
|
102
123
|
if (!workspaceId) { console.error(c.red('A workspace id is required.')); process.exit(1); }
|
|
103
124
|
|
|
104
125
|
let hookToken = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
126
|
+
|
|
127
|
+
// ⚠️ ONLY MINT WHEN THERE IS NOTHING STORED. Minting ROTATES — the server keeps
|
|
128
|
+
// a hash, so `regenerate` is the only way to obtain a token and it invalidates
|
|
129
|
+
// the previous one. Re-running `init` on a second machine must not silently
|
|
130
|
+
// kill capture on the first.
|
|
131
|
+
if (!hookToken) {
|
|
132
|
+
process.stdout.write('Getting a hook token… ');
|
|
133
|
+
const minted = await mintHookToken({ origin, workspaceId });
|
|
134
|
+
if (minted) {
|
|
135
|
+
hookToken = minted;
|
|
136
|
+
console.log(c.green('done'));
|
|
137
|
+
console.log(c.dim(' A new token was issued. Any OTHER machine set up for this workspace'));
|
|
138
|
+
console.log(c.dim(' must re-run `mnema init` — the previous token no longer works.'));
|
|
139
|
+
} else {
|
|
140
|
+
// Not logged in, not the owner (the endpoint is owner-only), or offline.
|
|
141
|
+
// Fall through to asking, with the right pointer.
|
|
142
|
+
console.log(c.dim('not available — enter one manually'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
if (!hookToken) {
|
|
106
147
|
// ⚠️ NOT "Settings → Developer". That page does not exist and never did in
|
|
107
148
|
// this shape — the hook token moved to the Access page when tokens were
|
|
@@ -204,15 +245,23 @@ async function cmdSessions(flags) {
|
|
|
204
245
|
console.log(` ${s.sessionId.slice(0, 8)}… ${fmtAge(s.mtimeMs).padStart(8)} ${c.dim((s.sizeBytes / 1024).toFixed(0) + ' KB')}`);
|
|
205
246
|
}
|
|
206
247
|
|
|
207
|
-
|
|
208
|
-
|
|
248
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
249
|
+
console.log(c.dim('\n (run `mnema login` — or add an API key — to see server-side cost + status)'));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
209
252
|
|
|
210
253
|
const repo = canonicalRepo(git.remote);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
254
|
+
let rows = [];
|
|
255
|
+
try {
|
|
256
|
+
rows = await call({ origin, workspaceId }, (m) =>
|
|
257
|
+
m.sessions.list({ limit, ...(repo ? { repo } : {}) }).all());
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// Non-fatal by design: the LOCAL list above is the useful half and has
|
|
260
|
+
// already printed. Degrading here is deliberate, and it says which wall it
|
|
261
|
+
// hit rather than a bare HTTP number.
|
|
262
|
+
console.log(c.yellow(`\n server sessions unavailable — ${e?.message ?? e}`));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
216
265
|
console.log(c.bold(`\nServer sessions (${rows.length})`));
|
|
217
266
|
for (const s of rows) {
|
|
218
267
|
const cost = typeof s.totalCostUsd === 'number' ? `$${s.totalCostUsd.toFixed(4)}` : '$0';
|
|
@@ -241,12 +290,15 @@ async function cmdSearch(flags, rest) {
|
|
|
241
290
|
const { origin, workspaceId } = resolveContext(flags);
|
|
242
291
|
const query = rest.join(' ').trim();
|
|
243
292
|
if (!query) { console.error(c.red('Usage: mnema search "<query>"')); process.exit(1); }
|
|
244
|
-
|
|
245
|
-
|
|
293
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
294
|
+
console.error(c.red('Search needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
246
297
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
298
|
+
let results = [];
|
|
299
|
+
try {
|
|
300
|
+
results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
|
|
301
|
+
} catch (e) { renderError(e, { context: 'search', usedApiKey: hasApiKey(workspaceId) }); }
|
|
250
302
|
if (!results.length) { console.log(c.dim('No results.')); return; }
|
|
251
303
|
console.log(c.bold(`${results.length} result(s) for "${query}"`));
|
|
252
304
|
for (const d of results) {
|
|
@@ -261,12 +313,17 @@ async function cmdPull(flags) {
|
|
|
261
313
|
const { git, root, origin, workspaceId } = resolveContext(flags);
|
|
262
314
|
const repo = flags.repo || canonicalRepo(git.remote);
|
|
263
315
|
if (!repo) { console.error(c.red('No git remote to identify this repo. Set one, or pass --repo <url>.')); process.exit(1); }
|
|
264
|
-
|
|
265
|
-
|
|
316
|
+
if (!(await canAuthenticate(workspaceId))) {
|
|
317
|
+
console.error(c.red('`mnema pull` needs a credential — run `mnema login`, or `mnema init` with an API key.'));
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
266
320
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
321
|
+
let docs = [];
|
|
322
|
+
try {
|
|
323
|
+
// `.context`, not the whole result — `.repo` is the URL the server matched.
|
|
324
|
+
// Reading the wrong one of these two is the bug this rewire found in the SDK.
|
|
325
|
+
docs = (await call({ origin, workspaceId }, (m) => m.repos.context(repo))).context ?? [];
|
|
326
|
+
} catch (e) { renderError(e, { context: 'pull', usedApiKey: hasApiKey(workspaceId) }); }
|
|
270
327
|
|
|
271
328
|
scaffold(root);
|
|
272
329
|
const s = applyContext(root, docs);
|
|
@@ -308,7 +365,11 @@ async function cmdDoctor(flags) {
|
|
|
308
365
|
|
|
309
366
|
const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
|
|
310
367
|
if (apiKey) {
|
|
311
|
-
let keyOk = false;
|
|
368
|
+
let keyOk = false;
|
|
369
|
+
// Probes the KEY specifically — no fallback — because that is the thing
|
|
370
|
+
// doctor is reporting on. Using call() here would mask a dead key behind a
|
|
371
|
+
// working login and print a green tick for a credential that does not work.
|
|
372
|
+
try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
|
|
312
373
|
ok('API key valid', keyOk);
|
|
313
374
|
} else {
|
|
314
375
|
ok('API key stored', false, 'optional — needed for search/sessions');
|
|
@@ -359,10 +420,23 @@ Commands:
|
|
|
359
420
|
doctor Diagnose install, hooks, auth, connectivity
|
|
360
421
|
uninstall Remove hooks and stored secrets
|
|
361
422
|
|
|
423
|
+
Read your workspace:
|
|
424
|
+
tasks List tasks [--status --project --limit]
|
|
425
|
+
next The next task to pick up, with a ready-made branch name
|
|
426
|
+
docs List documents [--limit]
|
|
427
|
+
doc <id> Print one document as markdown
|
|
428
|
+
projects List projects
|
|
429
|
+
briefing What deserves attention — pulse, deltas, findings
|
|
430
|
+
|
|
431
|
+
Ask the knowledge graph (paid feature):
|
|
432
|
+
ask "q" A cited answer, with the confidence it deserves
|
|
433
|
+
graph <a> [b] Neighbourhood of a, or the shortest path from a to b
|
|
434
|
+
|
|
362
435
|
Options:
|
|
363
436
|
--workspace <id> Workspace id (else prompted / MNEMA_WORKSPACE_ID)
|
|
364
437
|
--origin <url> API origin (default ${DEFAULT_ORIGIN})
|
|
365
|
-
--limit <n> Row limit
|
|
438
|
+
--limit <n> Row limit
|
|
439
|
+
--json Machine-readable output (every read command)
|
|
366
440
|
--yes Non-interactive; skip optional prompts
|
|
367
441
|
--purge uninstall: also delete .mnema/config.json
|
|
368
442
|
--version, --help
|
|
@@ -384,6 +458,17 @@ export async function run(argv) {
|
|
|
384
458
|
case 'search': return cmdSearch(flags, rest);
|
|
385
459
|
case 'doctor': return cmdDoctor(flags);
|
|
386
460
|
case 'uninstall': return cmdUninstall(flags);
|
|
461
|
+
|
|
462
|
+
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
463
|
+
// none of them knows a URL or an envelope key (§A2, t-623).
|
|
464
|
+
case 'tasks': return cmdTasks(flags, resolveContext(flags));
|
|
465
|
+
case 'next': return cmdNext(flags, resolveContext(flags));
|
|
466
|
+
case 'docs': return cmdDocs(flags, resolveContext(flags));
|
|
467
|
+
case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
|
|
468
|
+
case 'projects': return cmdProjects(flags, resolveContext(flags));
|
|
469
|
+
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
470
|
+
case 'graph': return cmdGraph(flags, resolveContext(flags), rest);
|
|
471
|
+
case 'briefing': return cmdBriefing(flags, resolveContext(flags));
|
|
387
472
|
case undefined:
|
|
388
473
|
case 'help': return help();
|
|
389
474
|
default:
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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, forceLogin = false }) {
|
|
32
|
+
const apiKey = forceLogin ? null : ((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
|
+
/**
|
|
41
|
+
* Run an SDK call, and if a STORED API KEY is rejected, retry once with the login.
|
|
42
|
+
*
|
|
43
|
+
* ⭐ THE TRAP THIS REMOVES. makeClient prefers an explicit API key, which is right
|
|
44
|
+
* — a key is scoped and deliberate. But a DEAD key then shadows a perfectly good
|
|
45
|
+
* login forever, and the failure told the user to do the one thing that could not
|
|
46
|
+
* help:
|
|
47
|
+
*
|
|
48
|
+
* $ mnema docs -> not authenticated. Run `mnema login`.
|
|
49
|
+
* $ mnema login -> ✓ Logged in.
|
|
50
|
+
* $ mnema docs -> not authenticated. Run `mnema login`.
|
|
51
|
+
*
|
|
52
|
+
* The login was never the credential in play. An instruction that cannot work is
|
|
53
|
+
* worse than none: it spends the reader's remaining trust in the tool.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ THE SDK CANNOT DO THIS FOR US. packages/core resolves `Bearer apiKey`
|
|
56
|
+
* whenever apiKey is set and never consults getToken, so `onUnauthorized` can
|
|
57
|
+
* ask for a retry but cannot change which credential the retry uses. The swap has
|
|
58
|
+
* to happen out here, by building a second client.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ AND IT IS ANNOUNCED, not silent. Quietly using a different identity than the
|
|
61
|
+
* one configured is its own confusion — the next question would be "which
|
|
62
|
+
* credential ran this?", and the answer should not require reading source.
|
|
63
|
+
*/
|
|
64
|
+
export async function call(ctx, fn) {
|
|
65
|
+
try {
|
|
66
|
+
return await fn(makeClient(ctx));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const isAuth = e?.constructor?.name === 'AuthError';
|
|
69
|
+
if (!isAuth || ctx.forceLogin || !hasApiKey(ctx.workspaceId)) throw e;
|
|
70
|
+
let loggedIn = false;
|
|
71
|
+
try { loggedIn = Boolean(await accessToken()); } catch { /* no login */ }
|
|
72
|
+
if (!loggedIn) throw e;
|
|
73
|
+
console.error(c.yellow(' stored API key was rejected — using your login instead.'));
|
|
74
|
+
console.error(c.dim(' Run `mnema init` to replace the key, or ignore this if you meant to use the login.'));
|
|
75
|
+
return await fn(makeClient({ ...ctx, forceLogin: true }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when an explicit API key is configured. Cheap; no network. */
|
|
80
|
+
export function hasApiKey(workspaceId) {
|
|
81
|
+
return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True when this machine can authenticate AT ALL — by key or by login.
|
|
86
|
+
*
|
|
87
|
+
* ⭐ THE COMMANDS USED TO DEMAND A KEY, and that was a leftover from before the
|
|
88
|
+
* public API accepted both. `/api/public/v1` has taken an OAuth access token
|
|
89
|
+
* since the login flow shipped, so "Search needs an API key" was refusing work
|
|
90
|
+
* the server would happily have done for someone who had already run
|
|
91
|
+
* `mnema login`. Asking a person to go generate a credential they do not need is
|
|
92
|
+
* the kind of friction that makes a tool feel broken.
|
|
93
|
+
*/
|
|
94
|
+
export async function canAuthenticate(workspaceId) {
|
|
95
|
+
if (hasApiKey(workspaceId)) return true;
|
|
96
|
+
try { return Boolean(await accessToken()); } catch { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn a thrown SDK error into something a person can act on, and exit.
|
|
101
|
+
*
|
|
102
|
+
* ⭐ THIS IS THE POINT OF THE REWIRE, not a nicety. Every failure used to print
|
|
103
|
+
* `HTTP 402` and a raw body — which tells you a wall exists and nothing about
|
|
104
|
+
* which one. The hierarchy already knows: whether you need to log in, upgrade,
|
|
105
|
+
* top up, wait, or accept that this server does not have the feature at all.
|
|
106
|
+
* Four of those five have different fixes and the fifth cannot be fixed by
|
|
107
|
+
* paying.
|
|
108
|
+
*
|
|
109
|
+
* Errors are matched by NAME rather than instanceof: the CLI may load a
|
|
110
|
+
* different copy of @mnemahq/core than the SDK did (npm dedupes, pnpm does not
|
|
111
|
+
* always), and a cross-realm instanceof silently falls through to the generic
|
|
112
|
+
* branch — turning a good message back into a bad one for the exact reason this
|
|
113
|
+
* function exists.
|
|
114
|
+
*/
|
|
115
|
+
export function renderError(e, { context = '', usedApiKey = false } = {}) {
|
|
116
|
+
const opts = { usedApiKey };
|
|
117
|
+
const name = e?.constructor?.name ?? '';
|
|
118
|
+
const where = context ? `${context}: ` : '';
|
|
119
|
+
|
|
120
|
+
switch (name) {
|
|
121
|
+
case 'AuthError':
|
|
122
|
+
console.error(c.red(`${where}not authenticated.`));
|
|
123
|
+
// ⚠️ NAME THE CREDENTIAL THAT FAILED. The SDK's generic fix is "Run `mnema
|
|
124
|
+
// login`", which is exactly wrong when the thing that was rejected is a
|
|
125
|
+
// stored API KEY and a valid login already exists — the user runs it,
|
|
126
|
+
// succeeds, and nothing changes.
|
|
127
|
+
if (opts.usedApiKey) {
|
|
128
|
+
console.error(' The stored API key was rejected.');
|
|
129
|
+
console.error(' Run `mnema init` to replace it, or `mnema login` to use a login instead.');
|
|
130
|
+
} else {
|
|
131
|
+
console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case 'PlanRequiredError':
|
|
136
|
+
console.error(c.yellow(`${where}your ${e.plan ?? 'current'} plan does not include ${e.feature ?? 'this'}.`));
|
|
137
|
+
console.error(` Needs the ${e.required ?? 'a paid'} plan.`);
|
|
138
|
+
console.error(` ${c.dim(e.upgradeUrl ?? `${DEFAULT_APP_URL}/app/settings/billing`)}`);
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case 'FeatureUnavailableError':
|
|
142
|
+
// No upgrade link here on purpose. There is nothing to buy: this server's
|
|
143
|
+
// build does not contain the feature, and pointing at billing would sell
|
|
144
|
+
// someone a plan that cannot change their binary.
|
|
145
|
+
console.error(c.yellow(`${where}${e.feature ?? 'that feature'} is not available on this server.`));
|
|
146
|
+
console.error(` ${c.dim(`This is a ${e.edition ?? 'core'} build — the enterprise modules are not installed.`)}`);
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case 'InsufficientCreditError': {
|
|
150
|
+
const need = typeof e.topUpNeededCents === 'number' ? `$${(e.topUpNeededCents / 100).toFixed(2)}` : 'more credit';
|
|
151
|
+
console.error(c.yellow(`${where}not enough credit for ${e.action ?? 'this action'}.`));
|
|
152
|
+
console.error(` Top up ${need}. ${c.dim(`${DEFAULT_APP_URL}/app/settings/wallet`)}`);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'RateLimitError': {
|
|
157
|
+
const secs = typeof e.retryAfterMs === 'number' ? Math.ceil(e.retryAfterMs / 1000) : null;
|
|
158
|
+
console.error(c.yellow(`${where}rate limited${secs ? ` — retry in ${secs}s` : ''}.`));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'ValidationError':
|
|
163
|
+
console.error(c.red(`${where}${e.message}`));
|
|
164
|
+
if (e.fields) for (const [f, msgs] of Object.entries(e.fields)) console.error(` ${f}: ${[].concat(msgs).join(', ')}`);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'NetworkError':
|
|
168
|
+
console.error(c.red(`${where}could not reach the server.`));
|
|
169
|
+
console.error(` ${c.dim(e.message)}`);
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
default:
|
|
173
|
+
console.error(c.red(`${where}${e?.message ?? String(e)}`));
|
|
174
|
+
if (e?.status) console.error(` ${c.dim(`HTTP ${e.status}`)}`);
|
|
175
|
+
}
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Mint a hook token for this workspace, using the login (t-627).
|
|
181
|
+
*
|
|
182
|
+
* ⭐ WHY THIS EXISTS. Setting up a repo meant fetching three things from two
|
|
183
|
+
* settings pages. The workspace id now comes from /api/me and the API key is
|
|
184
|
+
* optional — this was the last piece of copy-paste, and it never needed to be one:
|
|
185
|
+
* the endpoint has existed all along and has accepted a bearer token since t-613.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ THE HOOK TOKEN CANNOT SIMPLY BE THE LOGIN, and that is deliberate rather than
|
|
188
|
+
* unfinished. installHook writes it in PLAINTEXT to ~/.claude/mnema-hook.json,
|
|
189
|
+
* where an unattended process reads it after every session. A narrow,
|
|
190
|
+
* single-purpose token belongs in a file like that; an OAuth refresh token — which
|
|
191
|
+
* can mint access tokens for the whole account — does not.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ AND MINTING ROTATES. The server keeps only a HASH, so there is no way to read
|
|
194
|
+
* an existing token back; `regenerate` is the only path and it invalidates the old
|
|
195
|
+
* one. Callers MUST check for a stored token first — minting unconditionally would
|
|
196
|
+
* silently break capture on every other machine the user has set up. That check
|
|
197
|
+
* lives in cmdInit, not here, because this function's job is to mint and it should
|
|
198
|
+
* not pretend otherwise.
|
|
199
|
+
*
|
|
200
|
+
* Returns null rather than throwing: not logged in, not the owner (the endpoint is
|
|
201
|
+
* owner-only), or offline are all ordinary, and the caller falls back to asking.
|
|
202
|
+
*/
|
|
203
|
+
export async function mintHookToken({ origin, workspaceId }) {
|
|
204
|
+
try {
|
|
205
|
+
const token = await accessToken();
|
|
206
|
+
if (!token) return null;
|
|
207
|
+
const res = await fetch(`${origin}/api/workspaces/${workspaceId}/regenerate-hook-token`, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) return null;
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
return body?.hookToken ?? null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
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 { call, hasApiKey, 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 call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cmdNext(flags, ctx) {
|
|
62
|
+
await guard(ctx.workspaceId);
|
|
63
|
+
try {
|
|
64
|
+
const t = await call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), 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 call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), 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 call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── projects ──────────────────────────────────────────────────────────────────
|
|
112
|
+
export async function cmdProjects(flags, ctx) {
|
|
113
|
+
await guard(ctx.workspaceId);
|
|
114
|
+
try {
|
|
115
|
+
const rows = await call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), 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 call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), 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 call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── briefing ──────────────────────────────────────────────────────────────────
|
|
173
|
+
export async function cmdBriefing(flags, ctx) {
|
|
174
|
+
await guard(ctx.workspaceId);
|
|
175
|
+
try {
|
|
176
|
+
const b = await call(ctx, (m) => m.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, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
|
|
196
|
+
}
|