@lorekit/cli 1.29.0 → 1.29.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/README.md CHANGED
@@ -110,6 +110,14 @@ command is still refreshed. Pass `--hooks <mode>` to choose explicitly.
110
110
  wiring new ones. `lorekit doctor` reports which events are wired, and in which
111
111
  scope.
112
112
 
113
+ **Replacing a token.** A plain re-run reuses the token already in your config.
114
+ An interactive `lorekit install --force` instead asks what to do with it —
115
+ **keep**, **replace** (paste a new one), or **remove** — so a revoked token can
116
+ be swapped without hand-editing `.mcp.json` / `~/.claude.json`. The stored token
117
+ is only ever shown masked (`lk_rw_…ijkl`). Non-interactive runs (`--yes`, or no
118
+ TTY) never prompt and keep reusing the stored token; pass `--token` to replace
119
+ it in a script.
120
+
113
121
  > The hook command uses a global `lorekit` when one is on your `PATH` (fast),
114
122
  > otherwise `npx -y @lorekit/cli`. Installing the CLI globally
115
123
  > (`npm i -g @lorekit/cli`) is recommended so hooks fire without an npx
@@ -127,7 +135,9 @@ Verifies the setup and prints a status report:
127
135
  gitignored
128
136
  - for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not
129
137
  the `<project-ref>` placeholder), the token and its permission tier
130
- (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), and that the endpoint is reachable
138
+ (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), that the endpoint is reachable, and —
139
+ the `authentication` check — that the token is **still accepted by the
140
+ server**
131
141
  - for `off`: a note that memory is disabled
132
142
  - the git-derived read/write scopes for the current directory
133
143
 
@@ -138,6 +148,22 @@ lorekit doctor --deep # also does a write → read → delete round-trip (ne
138
148
 
139
149
  Exit code is non-zero if any check fails, so it fits CI gates.
140
150
 
151
+ **`connectivity` and `authentication` are different questions.** `connectivity`
152
+ probes the public `/health` function: it proves the network path and says
153
+ nothing about your credential. `authentication` makes one authenticated,
154
+ side-effect-free request and reports what the server said about the token
155
+ itself:
156
+
157
+ | Result | Meaning |
158
+ | --- | --- |
159
+ | `PASS — token accepted` | the token is live (read access confirmed) |
160
+ | `PASS — no read permission` | accepted, but it is a write-only `lk_wo_*` token |
161
+ | `FAIL — token REJECTED (HTTP 401)` | revoked, deleted, or never valid — every remote read and write is broken |
162
+ | `WARN` | rate limited, unreachable, or an inconclusive answer — never reported as "revoked" |
163
+
164
+ A revoked token is a **failure**, not a warning: fix it by creating a new token
165
+ and running `lorekit install --force`, which offers to replace the stored one.
166
+
141
167
  ### `lorekit list` (alias `ls`)
142
168
 
143
169
  Shows the lessons that apply to **where you are** — the scopes `deriveScope`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.29.0",
3
+ "version": "1.29.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/doctor.mjs CHANGED
@@ -269,7 +269,10 @@ async function checkRemote(control, root, args, record) {
269
269
  record('fail', 'connectivity', res.networkError);
270
270
  } else if (res.ok) {
271
271
  const tools = res.result && Array.isArray(res.result.tools) ? res.result.tools.length : null;
272
- record('pass', 'connectivity', tools !== null ? `reachable, ${tools} tools` : 'reachable');
272
+ // Say what the probe actually proved. `/health` is public, so "reachable"
273
+ // is a statement about the network path only — the token is judged by the
274
+ // `authentication` check below.
275
+ record('pass', 'connectivity', tools !== null ? `reachable, ${tools} tools` : 'reachable (public health probe — token not checked)');
273
276
  } else if (res.error && AUTH_CODES.has(res.error.code)) {
274
277
  record('fail', 'connectivity', `auth rejected (${res.error.code}) — check your token`);
275
278
  } else if (res.error) {
@@ -278,10 +281,64 @@ async function checkRemote(control, root, args, record) {
278
281
  record('warn', 'connectivity', `unexpected response (HTTP ${res.httpStatus})`);
279
282
  }
280
283
 
284
+ await checkRemoteAuth(store, record);
285
+
281
286
  if (args.deep) await deepCheckRemote(store, root, record);
282
287
  } else {
283
288
  record('warn', 'connectivity', 'skipped — need a valid endpoint and token');
289
+ record('warn', 'authentication', 'skipped — need a valid endpoint and token');
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Does the configured token STILL work?
295
+ *
296
+ * The `token` check above only reads the PREFIX (`lk_rw_`/`lk_ro_`/`lk_wo_`)
297
+ * and `connectivity` probes the PUBLIC `/health` function, so both stay green
298
+ * for a token that has been revoked in the dashboard — which is precisely the
299
+ * state a user runs doctor in. This check makes one authenticated,
300
+ * side-effect-free request and reports what the server said about the
301
+ * credential itself.
302
+ *
303
+ * A revoked token is a FAIL (doctor exits non-zero): every remote read and
304
+ * write is broken, which is not a warning-level condition. A token that is
305
+ * accepted but lacks read permission is a PASS — that is the healthy state of a
306
+ * write-only token, and the `token` check already describes the tradeoff.
307
+ */
308
+ async function checkRemoteAuth(store, record) {
309
+ const res = await store.verifyAuth();
310
+
311
+ if (res.networkError) {
312
+ record('warn', 'authentication', `could not verify — ${res.networkError}`);
313
+ return;
314
+ }
315
+ if (res.unusable) {
316
+ record('warn', 'authentication', 'skipped — need a valid endpoint and token');
317
+ return;
318
+ }
319
+ if (res.authenticated === false) {
320
+ record(
321
+ 'fail',
322
+ 'authentication',
323
+ 'token REJECTED by the server (HTTP 401) — it has been revoked, deleted, or was never valid. ' +
324
+ 'Create a new one at https://lorekit.io/settings, then run `lorekit install --force` to replace it.',
325
+ );
326
+ return;
327
+ }
328
+ if (res.rateLimited) {
329
+ record('warn', 'authentication', 'could not verify — the request was rate limited (HTTP 429) before it reached the route; retry shortly');
330
+ return;
331
+ }
332
+ if (res.authenticated === true) {
333
+ record(
334
+ 'pass',
335
+ 'authentication',
336
+ res.permitted ? 'token accepted — read access confirmed' : 'token accepted — no read permission (write-only token)',
337
+ );
338
+ return;
284
339
  }
340
+ const detail = res.error ? res.error.message || res.error.code : `HTTP ${res.httpStatus}`;
341
+ record('warn', 'authentication', `inconclusive — server said: ${detail}`);
285
342
  }
286
343
 
287
344
  async function deepCheckRemote(store, root, record) {
package/src/install.mjs CHANGED
@@ -33,6 +33,46 @@ function ask(question) {
33
33
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
34
34
  }
35
35
 
36
+ // Show enough of a token to recognise it, never enough to use it: the
37
+ // permission prefix plus the last four characters.
38
+ export function maskToken(token) {
39
+ if (!token) return 'none';
40
+ const s = String(token);
41
+ const m = /^(lk_(?:rw|ro|wo)_)/.exec(s);
42
+ const prefix = m ? m[1] : '';
43
+ const tail = s.slice(-4);
44
+ return s.length <= prefix.length + 4 ? `${prefix}…` : `${prefix}…${tail}`;
45
+ }
46
+
47
+ /**
48
+ * How `install` should arrive at the token it writes — the pure decision, so
49
+ * the rule is testable without a pseudo-TTY.
50
+ *
51
+ * 'flag' → an explicit --token / LOREKIT_TOKEN wins outright.
52
+ * 'choose' → a token is already configured AND this is an interactive
53
+ * `--force`: ask whether to keep / replace / remove it.
54
+ * 'reuse' → a token is already configured: reuse it silently.
55
+ * 'prompt' → nothing configured and someone is there to ask.
56
+ * 'none' → nothing configured and nobody to ask.
57
+ *
58
+ * WHY 'choose' exists: `--force` is what a user runs precisely BECAUSE the
59
+ * current setup is wrong, and the most common way for it to be wrong is a
60
+ * revoked token — which doctor's `authentication` check now names, telling them
61
+ * to come here. Reusing the stored token silently made `--force` incapable of
62
+ * fixing the one thing it was reached for, and no other command could either.
63
+ * It stays interactive-only: a non-interactive run has nobody to answer, so it
64
+ * keeps the old reuse behaviour and `--token` remains the way to replace a
65
+ * token in a script.
66
+ */
67
+ export function tokenPlan({ flagToken, existingToken, force, nonInteractive } = {}) {
68
+ if (flagToken) return { action: 'flag', token: flagToken };
69
+ if (existingToken) {
70
+ if (force && !nonInteractive) return { action: 'choose', token: existingToken };
71
+ return { action: 'reuse', token: existingToken };
72
+ }
73
+ return nonInteractive ? { action: 'none', token: null } : { action: 'prompt', token: null };
74
+ }
75
+
36
76
  // Detect whether lorekit is already installed for a given scope. Returns an
37
77
  // object describing what is present so the caller can give precise feedback.
38
78
  function detectInstalled(root, scope) {
@@ -256,16 +296,44 @@ export async function install(args) {
256
296
  const endpoint = fromArgs.endpoint || LOREKIT_MCP_ENDPOINT;
257
297
 
258
298
  // Token resolution order: --token flag → env → existing config → prompt.
259
- let token = fromArgs.token;
260
- if (!token && currentState.existingToken) {
261
- // Reuse the token that's already in the config — don't make the user repeat
262
- // it just because they're running install again.
263
- token = currentState.existingToken;
299
+ const plan = tokenPlan({
300
+ flagToken: fromArgs.token,
301
+ existingToken: currentState.existingToken,
302
+ force,
303
+ nonInteractive,
304
+ });
305
+
306
+ let token = null;
307
+ if (plan.action === 'flag') {
308
+ token = plan.token;
309
+ } else if (plan.action === 'reuse') {
310
+ token = plan.token;
264
311
  log(` ${c.dim('Token: reusing existing token from config.')}`);
265
- }
266
- if (!token && !nonInteractive) {
267
- token = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
268
- token = token || null;
312
+ } else if (plan.action === 'choose') {
313
+ const choice = await select(
314
+ `A token is already configured (${maskToken(currentState.existingToken)}). What should this reinstall do?`,
315
+ [
316
+ { label: 'Keep the existing token', value: 'keep', hint: 'reuse what is in the config' },
317
+ { label: 'Replace it with a new token', value: 'replace', hint: 'paste a fresh lk_… token (e.g. after revoking one)' },
318
+ { label: 'Remove the token', value: 'remove', hint: 'leave the server unauthenticated' },
319
+ ],
320
+ );
321
+ if (choice === 'replace') {
322
+ const entered = await ask(' New LoreKit token (lk_rw_… to allow writes, blank to keep the existing one): ');
323
+ token = entered || currentState.existingToken;
324
+ log(` ${c.dim(entered ? 'Token: replaced with the token you entered.' : 'Token: nothing entered — keeping the existing token.')}`);
325
+ } else if (choice === 'remove') {
326
+ // Deliberately NOT followed by the fresh-install prompt below: someone who
327
+ // just chose "remove" must not be immediately asked for a token again.
328
+ token = null;
329
+ log(` ${c.yellow('Token: removed — reads/writes will fail until a token is set.')}`);
330
+ } else {
331
+ token = currentState.existingToken;
332
+ log(` ${c.dim('Token: reusing existing token from config.')}`);
333
+ }
334
+ } else if (plan.action === 'prompt') {
335
+ const entered = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
336
+ token = entered || null;
269
337
  }
270
338
 
271
339
  // 4. Install the skill files — every skill the CLI ships.
@@ -197,8 +197,62 @@ class RemoteStore {
197
197
  return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
198
198
  }
199
199
 
200
+ // Authentication probe for doctor — does the configured token STILL work?
201
+ //
202
+ // `ping()` deliberately hits the PUBLIC `/health` function, so it stays green
203
+ // for a revoked, deleted or mistyped token: it proves the network path, and
204
+ // nothing about the credential. This probe is the missing half. It makes one
205
+ // authenticated, side-effect-free request (`GET /memories?limit=1`) and
206
+ // classifies the answer:
207
+ //
208
+ // 200 → the token was accepted AND may read.
209
+ // 401 → the token was REJECTED (revoked, deleted, or never valid). This is
210
+ // `resolveRestAuth` finding no `api_tokens` row for the hash
211
+ // (supabase/functions/_shared/api/auth.ts).
212
+ // 403 → the token was ACCEPTED, but lacks read permission — the normal,
213
+ // healthy answer for a write-only `lk_wo_*` token, so it must never
214
+ // be reported as an auth failure.
215
+ // 429 → rate limited, and it says NOTHING about the credential. The only
216
+ // `tooManyRequests()` call sites on the whole REST surface are
217
+ // `memories/handlers/create.ts` and `purge.ts` — both write paths.
218
+ // `GET /memories` (`handleList`) has no rate-limit check at all, so a
219
+ // 429 here is emitted by the platform edge AHEAD of the function,
220
+ // before `resolveRestAuth` ever runs. `rateLimited` is still reported
221
+ // so the caller can say "retry shortly" instead of "inconclusive".
222
+ //
223
+ // Returns { ok, authenticated, permitted, rateLimited, httpStatus, error,
224
+ // networkError, unusable }. `authenticated` is null when the answer does not
225
+ // settle the question — the caller must not turn "don't know" into "broken".
226
+ async verifyAuth() {
227
+ if (!this.usable()) return { ok: false, unusable: true, authenticated: null };
228
+ if (!this.restBase) {
229
+ return { ok: false, authenticated: null, error: { message: `Endpoint is not a valid URL: ${this.endpoint}` } };
230
+ }
231
+ // limit=1 keeps the probe cheap; the rows themselves are never read.
232
+ const res = await this._rest('/memories?limit=1');
233
+ if (res.networkError) return { ok: false, authenticated: null, networkError: res.networkError };
234
+ if (res.ok) return { ok: true, authenticated: true, permitted: true, httpStatus: res.httpStatus };
235
+
236
+ const httpStatus = res.httpStatus ?? null;
237
+ if (httpStatus === 401) {
238
+ return { ok: false, authenticated: false, permitted: false, httpStatus, error: res.error };
239
+ }
240
+ if (httpStatus === 403) {
241
+ return { ok: true, authenticated: true, permitted: false, httpStatus, error: res.error };
242
+ }
243
+ if (httpStatus === 429) {
244
+ return { ok: true, authenticated: null, permitted: null, rateLimited: true, httpStatus, error: res.error };
245
+ }
246
+ return { ok: false, authenticated: null, httpStatus, error: res.error };
247
+ }
248
+
200
249
  // Connectivity probe for doctor — a transport check, not a memory op.
201
250
  //
251
+ // NOTE: this is deliberately UNAUTHENTICATED (the `/health` function is
252
+ // public), so a green result says the endpoint is reachable and says NOTHING
253
+ // about the token. `verifyAuth()` above is what answers that; doctor runs
254
+ // both and reports them as separate checks.
255
+ //
202
256
  // There is no MCP fallback: a `restBase` we could not derive means the
203
257
  // configured endpoint is not a URL, and a JSON-RPC POST to that same
204
258
  // unparseable string could only fail in a less legible way. Report the