@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.1.1",
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
- const VERSION = '0.1.0';
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
- const token = await accessToken();
75
- if (!token) return null;
76
- const res = await fetch(`${origin}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
77
- if (!res.ok) return null;
78
- const body = await res.json();
79
- if (!body?.workspace_id) return null;
80
- console.log(c.green(`\u2713 Workspace: ${body.workspace_name || body.workspace_id} (from your login)`));
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
- const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
208
- if (!apiKey) { console.log(c.dim('\n (add an API key via `mnema init` to see server-side cost + status)')); return; }
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
- const q = new URLSearchParams({ limit: String(limit) });
212
- if (repo) q.set('repo', repo);
213
- const r = await apiFetch(origin, `/api/public/v1/sessions?${q}`, { token: apiKey });
214
- if (!r.ok) { console.log(c.yellow(`\n server sessions unavailable (HTTP ${r.status})`)); return; }
215
- const rows = r.json?.data?.sessions ?? [];
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
- const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : (process.env.MNEMA_API_KEY || null);
245
- if (!apiKey) { console.error(c.red('Search needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
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
- const r = await apiFetch(origin, `/api/public/v1/docs/search?q=${encodeURIComponent(query)}`, { token: apiKey });
248
- if (!r.ok) { console.error(c.red(`Search failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
249
- const results = r.json?.data?.results ?? [];
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
- const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY;
265
- if (!apiKey) { console.error(c.red('`mnema pull` needs an API key. Run `mnema init` and provide one.')); process.exit(1); }
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
- const r = await apiFetch(origin, `/api/public/v1/repo-context?repo=${encodeURIComponent(repo)}`, { token: apiKey });
268
- if (!r.ok) { console.error(c.red(`Pull failed (HTTP ${r.status}): ${r.json?.error?.message || r.text || ''}`)); process.exit(1); }
269
- const docs = r.json?.data?.context ?? [];
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; try { keyOk = (await apiFetch(origin, '/api/public/v1/docs?limit=1', { token: apiKey })).ok; } catch { /* */ }
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 for sessions
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
+ }