@lorekit/cli 1.29.0 → 1.29.2
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 +27 -1
- package/package.json +1 -1
- package/src/doctor.mjs +59 -2
- package/src/install.mjs +77 -9
- package/src/store/remote.mjs +54 -0
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_*`),
|
|
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
package/src/doctor.mjs
CHANGED
|
@@ -268,8 +268,11 @@ async function checkRemote(control, root, args, record) {
|
|
|
268
268
|
if (res.networkError) {
|
|
269
269
|
record('fail', 'connectivity', res.networkError);
|
|
270
270
|
} else if (res.ok) {
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
// Say what the probe actually proved. `ping()` hits the PUBLIC `/health`
|
|
272
|
+
// function and returns only `{ ok, httpStatus }` — never a tool list — so
|
|
273
|
+
// "reachable" is a statement about the network path alone. The token itself
|
|
274
|
+
// is judged by the `authentication` check below.
|
|
275
|
+
record('pass', 'connectivity', '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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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.
|
package/src/store/remote.mjs
CHANGED
|
@@ -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
|