@mnemahq/cli 0.3.0 → 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.3.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": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "node -e \"process.exit(0)\"",
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",
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",
38
38
  "test": "vitest run"
39
39
  }
40
40
  }
package/src/cli.mjs CHANGED
@@ -10,9 +10,9 @@
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, canAuthenticate, renderError } from './client.mjs';
15
+ import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
16
16
  import {
17
17
  cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
18
18
  } from './read-commands.mjs';
@@ -27,7 +27,25 @@ import {
27
27
  import { applyContext, scaffold } from './artifacts.mjs';
28
28
  import { execFileSync } from 'node:child_process';
29
29
 
30
- 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;
31
49
 
32
50
  function parseFlags(argv) {
33
51
  const flags = {}; const rest = [];
@@ -105,6 +123,26 @@ async function cmdInit(flags) {
105
123
  if (!workspaceId) { console.error(c.red('A workspace id is required.')); process.exit(1); }
106
124
 
107
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
+
108
146
  if (!hookToken) {
109
147
  // ⚠️ NOT "Settings → Developer". That page does not exist and never did in
110
148
  // this shape — the hook token moved to the Access page when tokens were
@@ -215,9 +253,8 @@ async function cmdSessions(flags) {
215
253
  const repo = canonicalRepo(git.remote);
216
254
  let rows = [];
217
255
  try {
218
- rows = await makeClient({ origin, workspaceId })
219
- .sessions.list({ limit, ...(repo ? { repo } : {}) })
220
- .all();
256
+ rows = await call({ origin, workspaceId }, (m) =>
257
+ m.sessions.list({ limit, ...(repo ? { repo } : {}) }).all());
221
258
  } catch (e) {
222
259
  // Non-fatal by design: the LOCAL list above is the useful half and has
223
260
  // already printed. Degrading here is deliberate, and it says which wall it
@@ -260,8 +297,8 @@ async function cmdSearch(flags, rest) {
260
297
 
261
298
  let results = [];
262
299
  try {
263
- results = await makeClient({ origin, workspaceId }).docs.search(query);
264
- } catch (e) { renderError(e, { context: 'search' }); }
300
+ results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
301
+ } catch (e) { renderError(e, { context: 'search', usedApiKey: hasApiKey(workspaceId) }); }
265
302
  if (!results.length) { console.log(c.dim('No results.')); return; }
266
303
  console.log(c.bold(`${results.length} result(s) for "${query}"`));
267
304
  for (const d of results) {
@@ -285,8 +322,8 @@ async function cmdPull(flags) {
285
322
  try {
286
323
  // `.context`, not the whole result — `.repo` is the URL the server matched.
287
324
  // 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' }); }
325
+ docs = (await call({ origin, workspaceId }, (m) => m.repos.context(repo))).context ?? [];
326
+ } catch (e) { renderError(e, { context: 'pull', usedApiKey: hasApiKey(workspaceId) }); }
290
327
 
291
328
  scaffold(root);
292
329
  const s = applyContext(root, docs);
@@ -329,6 +366,9 @@ async function cmdDoctor(flags) {
329
366
  const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
330
367
  if (apiKey) {
331
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.
332
372
  try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
333
373
  ok('API key valid', keyOk);
334
374
  } else {
package/src/client.mjs CHANGED
@@ -28,8 +28,8 @@ import { c, DEFAULT_APP_URL } from './util.mjs';
28
28
  * request — that is what makes a refresh mid-command work instead of failing on
29
29
  * a token that expired thirty seconds ago.
30
30
  */
31
- export function makeClient({ origin, workspaceId }) {
32
- const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null;
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
33
  return new Mnema(
34
34
  apiKey
35
35
  ? { apiKey, baseUrl: origin }
@@ -37,6 +37,45 @@ export function makeClient({ origin, workspaceId }) {
37
37
  );
38
38
  }
39
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
+
40
79
  /** True when an explicit API key is configured. Cheap; no network. */
41
80
  export function hasApiKey(workspaceId) {
42
81
  return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
@@ -73,14 +112,24 @@ export async function canAuthenticate(workspaceId) {
73
112
  * branch — turning a good message back into a bad one for the exact reason this
74
113
  * function exists.
75
114
  */
76
- export function renderError(e, { context = '' } = {}) {
115
+ export function renderError(e, { context = '', usedApiKey = false } = {}) {
116
+ const opts = { usedApiKey };
77
117
  const name = e?.constructor?.name ?? '';
78
118
  const where = context ? `${context}: ` : '';
79
119
 
80
120
  switch (name) {
81
121
  case 'AuthError':
82
122
  console.error(c.red(`${where}not authenticated.`));
83
- console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
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
+ }
84
133
  break;
85
134
 
86
135
  case 'PlanRequiredError':
@@ -126,3 +175,43 @@ export function renderError(e, { context = '' } = {}) {
126
175
  }
127
176
  process.exit(1);
128
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
+ }
@@ -20,7 +20,7 @@
20
20
  * returns the shape worth emitting, so it costs one line each.
21
21
  */
22
22
 
23
- import { makeClient, canAuthenticate, renderError } from './client.mjs';
23
+ import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
24
24
  import { c } from './util.mjs';
25
25
 
26
26
  /** Machine output is the whole object; humans get the formatted view. */
@@ -42,11 +42,11 @@ export async function cmdTasks(flags, ctx) {
42
42
  await guard(ctx.workspaceId);
43
43
  const limit = Number(flags.limit) || 20;
44
44
  try {
45
- const rows = await makeClient(ctx).tasks.list({
45
+ const rows = await call(ctx, (m) => m.tasks.list({
46
46
  limit,
47
47
  ...(flags.status ? { status: flags.status } : {}),
48
48
  ...(flags.project ? { project: flags.project } : {}),
49
- }).all();
49
+ }).all());
50
50
  emit(flags, rows, () => {
51
51
  if (!rows.length) return console.log(c.dim('No tasks match.'));
52
52
  console.log(c.bold(`${rows.length} task(s)`));
@@ -55,13 +55,13 @@ export async function cmdTasks(flags, ctx) {
55
55
  console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
56
56
  }
57
57
  });
58
- } catch (e) { renderError(e, { context: 'tasks' }); }
58
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
59
59
  }
60
60
 
61
61
  export async function cmdNext(flags, ctx) {
62
62
  await guard(ctx.workspaceId);
63
63
  try {
64
- const t = await makeClient(ctx).tasks.next();
64
+ const t = await call(ctx, (m) => m.tasks.next());
65
65
  emit(flags, t, () => {
66
66
  if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
67
67
  console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
@@ -73,7 +73,7 @@ export async function cmdNext(flags, ctx) {
73
73
  console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
74
74
  }
75
75
  });
76
- } catch (e) { renderError(e, { context: 'next' }); }
76
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'next' }); }
77
77
  }
78
78
 
79
79
  // ── docs ──────────────────────────────────────────────────────────────────────
@@ -81,7 +81,7 @@ export async function cmdDocs(flags, ctx) {
81
81
  await guard(ctx.workspaceId);
82
82
  const limit = Number(flags.limit) || 20;
83
83
  try {
84
- const rows = await makeClient(ctx).docs.list({ limit }).all();
84
+ const rows = await call(ctx, (m) => m.docs.list({ limit }).all());
85
85
  emit(flags, rows, () => {
86
86
  if (!rows.length) return console.log(c.dim('No docs.'));
87
87
  console.log(c.bold(`${rows.length} doc(s)`));
@@ -89,7 +89,7 @@ export async function cmdDocs(flags, ctx) {
89
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
90
  }
91
91
  });
92
- } catch (e) { renderError(e, { context: 'docs' }); }
92
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'docs' }); }
93
93
  }
94
94
 
95
95
  export async function cmdDoc(flags, ctx, rest) {
@@ -97,7 +97,7 @@ export async function cmdDoc(flags, ctx, rest) {
97
97
  const id = rest[0];
98
98
  if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
99
99
  try {
100
- const d = await makeClient(ctx).docs.get(id);
100
+ const d = await call(ctx, (m) => m.docs.get(id));
101
101
  // Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
102
102
  // the heading is not silently baked into the file.
103
103
  emit(flags, d, () => {
@@ -105,20 +105,20 @@ export async function cmdDoc(flags, ctx, rest) {
105
105
  console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
106
106
  if (d.markdown) console.log(d.markdown);
107
107
  });
108
- } catch (e) { renderError(e, { context: 'doc' }); }
108
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
109
109
  }
110
110
 
111
111
  // ── projects ──────────────────────────────────────────────────────────────────
112
112
  export async function cmdProjects(flags, ctx) {
113
113
  await guard(ctx.workspaceId);
114
114
  try {
115
- const rows = await makeClient(ctx).projects.list().all();
115
+ const rows = await call(ctx, (m) => m.projects.list().all());
116
116
  emit(flags, rows, () => {
117
117
  if (!rows.length) return console.log(c.dim('No projects.'));
118
118
  console.log(c.bold(`${rows.length} project(s)`));
119
119
  for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
120
120
  });
121
- } catch (e) { renderError(e, { context: 'projects' }); }
121
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'projects' }); }
122
122
  }
123
123
 
124
124
  // ── graph: ask + traverse ─────────────────────────────────────────────────────
@@ -127,7 +127,7 @@ export async function cmdAsk(flags, ctx, rest) {
127
127
  const q = rest.join(' ').trim();
128
128
  if (!q) { console.error(c.red('Usage: mnema ask "<question>"')); process.exit(1); }
129
129
  try {
130
- const a = await makeClient(ctx).graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) });
130
+ const a = await call(ctx, (m) => m.graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) }));
131
131
  emit(flags, a, () => {
132
132
  console.log(a.answer);
133
133
  // ⚠️ NEVER WITHOUT THE CONFIDENCE. The interpreter deliberately hedges
@@ -142,7 +142,7 @@ export async function cmdAsk(flags, ctx, rest) {
142
142
  for (const s of a.sources.slice(0, 5)) console.log(c.dim(` ${trunc(s.title, 70)}`));
143
143
  }
144
144
  });
145
- } catch (e) { renderError(e, { context: 'ask' }); }
145
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
146
146
  }
147
147
 
148
148
  export async function cmdGraph(flags, ctx, rest) {
@@ -150,7 +150,7 @@ export async function cmdGraph(flags, ctx, rest) {
150
150
  const [from, to] = rest;
151
151
  if (!from) { console.error(c.red('Usage: mnema graph <from> [to]')); process.exit(1); }
152
152
  try {
153
- const g = await makeClient(ctx).graph.traverse(from, to);
153
+ const g = await call(ctx, (m) => m.graph.traverse(from, to));
154
154
  emit(flags, g, () => {
155
155
  if (to) {
156
156
  // connected:false is an ANSWER, not a failure — say so plainly rather
@@ -166,14 +166,14 @@ export async function cmdGraph(flags, ctx, rest) {
166
166
  console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
167
167
  for (const n of g.nodes.slice(0, 25)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
168
168
  });
169
- } catch (e) { renderError(e, { context: 'graph' }); }
169
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
170
170
  }
171
171
 
172
172
  // ── briefing ──────────────────────────────────────────────────────────────────
173
173
  export async function cmdBriefing(flags, ctx) {
174
174
  await guard(ctx.workspaceId);
175
175
  try {
176
- const b = await makeClient(ctx).findings.briefing();
176
+ const b = await call(ctx, (m) => m.findings.briefing());
177
177
  emit(flags, b, () => {
178
178
  const p = b.pulse;
179
179
  console.log(c.bold('Pulse'));
@@ -192,5 +192,5 @@ export async function cmdBriefing(flags, ctx) {
192
192
  console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
193
193
  }
194
194
  });
195
- } catch (e) { renderError(e, { context: 'briefing' }); }
195
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
196
196
  }