@lorekit/cli 1.28.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 +121 -6
- package/bin/lorekit.mjs +24 -7
- package/package.json +1 -1
- package/src/config.mjs +101 -15
- package/src/control.mjs +55 -2
- package/src/core/lessons.mjs +25 -6
- package/src/doctor.mjs +89 -39
- package/src/install.mjs +268 -25
- package/src/store/remote.mjs +54 -0
- package/src/store/ttl.mjs +78 -0
- package/src/write.mjs +52 -4
package/src/doctor.mjs
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
SKILLS,
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
skillInstallDir,
|
|
11
|
-
settingsPath,
|
|
12
11
|
CLAUDE_HOOK_EVENTS,
|
|
13
|
-
|
|
12
|
+
installedHookEvents,
|
|
13
|
+
hookModeFromEvents,
|
|
14
14
|
readLorekitServer,
|
|
15
15
|
readMcpConfig,
|
|
16
16
|
tokenKind,
|
|
@@ -66,6 +66,28 @@ export async function doctor(args) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// 2.4. Which hooks are actually wired, and where. Hooks are an install-time
|
|
70
|
+
// CHOICE (`--hooks all|read-only|none`, or the prompt), so "why does nothing
|
|
71
|
+
// get remembered?" is answered HERE — without a positive report the only way
|
|
72
|
+
// to tell a deliberate `none` from a broken install is to read settings.json.
|
|
73
|
+
{
|
|
74
|
+
const perScope = ['project', 'global']
|
|
75
|
+
.map((s) => ({ scope: s, events: installedHookEvents(root, s) }))
|
|
76
|
+
.filter((entry) => entry.events.length > 0);
|
|
77
|
+
if (perScope.length === 0) {
|
|
78
|
+
record(
|
|
79
|
+
'info',
|
|
80
|
+
'hooks',
|
|
81
|
+
'none wired — the skills work, but memory is model-invoked only. ' +
|
|
82
|
+
'Run `lorekit install --hooks all` to wire them.',
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
for (const { scope, events } of perScope) {
|
|
86
|
+
record('pass', `hooks ${scope}`, `${hookModeFromEvents(events)} — ${events.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
// 2.5. Duplicate-hook detection — warn when the same lorekit hook event is
|
|
70
92
|
// wired in both the project settings and the global settings. This causes
|
|
71
93
|
// Claude Code to fire the hook twice per event, producing doubled terminal
|
|
@@ -247,7 +269,10 @@ async function checkRemote(control, root, args, record) {
|
|
|
247
269
|
record('fail', 'connectivity', res.networkError);
|
|
248
270
|
} else if (res.ok) {
|
|
249
271
|
const tools = res.result && Array.isArray(res.result.tools) ? res.result.tools.length : null;
|
|
250
|
-
|
|
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)');
|
|
251
276
|
} else if (res.error && AUTH_CODES.has(res.error.code)) {
|
|
252
277
|
record('fail', 'connectivity', `auth rejected (${res.error.code}) — check your token`);
|
|
253
278
|
} else if (res.error) {
|
|
@@ -256,10 +281,64 @@ async function checkRemote(control, root, args, record) {
|
|
|
256
281
|
record('warn', 'connectivity', `unexpected response (HTTP ${res.httpStatus})`);
|
|
257
282
|
}
|
|
258
283
|
|
|
284
|
+
await checkRemoteAuth(store, record);
|
|
285
|
+
|
|
259
286
|
if (args.deep) await deepCheckRemote(store, root, record);
|
|
260
287
|
} else {
|
|
261
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;
|
|
262
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;
|
|
339
|
+
}
|
|
340
|
+
const detail = res.error ? res.error.message || res.error.code : `HTTP ${res.httpStatus}`;
|
|
341
|
+
record('warn', 'authentication', `inconclusive — server said: ${detail}`);
|
|
263
342
|
}
|
|
264
343
|
|
|
265
344
|
async function deepCheckRemote(store, root, record) {
|
|
@@ -349,43 +428,14 @@ async function deepCheckLocal(store, scope, record) {
|
|
|
349
428
|
// Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
|
|
350
429
|
// BOTH the project settings file (.claude/settings.json) and the global one
|
|
351
430
|
// (~/.claude/settings.json). An empty array means no duplicates — healthy.
|
|
431
|
+
//
|
|
432
|
+
// Both sides read through `installedHookEvents`, the SAME detection `install`
|
|
433
|
+
// uses to preselect its hook prompt, so the two surfaces can never disagree
|
|
434
|
+
// about what is wired.
|
|
352
435
|
function detectDuplicateHooks(root) {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
let projectHooks = {};
|
|
358
|
-
let globalHooks = {};
|
|
359
|
-
try {
|
|
360
|
-
const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
|
361
|
-
if (cfg && typeof cfg.hooks === 'object') projectHooks = cfg.hooks;
|
|
362
|
-
} catch { /* absent or unparseable — treat as empty */ }
|
|
363
|
-
try {
|
|
364
|
-
const cfg = JSON.parse(fs.readFileSync(globalFile, 'utf8'));
|
|
365
|
-
if (cfg && typeof cfg.hooks === 'object') globalHooks = cfg.hooks;
|
|
366
|
-
} catch { /* absent or unparseable — treat as empty */ }
|
|
367
|
-
|
|
368
|
-
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
369
|
-
const hasInProject = hooksForEvent(projectHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
370
|
-
const hasInGlobal = hooksForEvent(globalHooks, event).some((cmd) => LOREKIT_HOOK_RE.test(cmd));
|
|
371
|
-
if (hasInProject && hasInGlobal) dupes.push(event);
|
|
372
|
-
}
|
|
373
|
-
return dupes;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Extract the flat list of hook command strings for one event from a hooks
|
|
377
|
-
// object. Handles the nested-group shape Claude Code uses:
|
|
378
|
-
// { [event]: [ { hooks: [ { type, command } ] } ] }
|
|
379
|
-
function hooksForEvent(hooksObj, event) {
|
|
380
|
-
const groups = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
|
|
381
|
-
const commands = [];
|
|
382
|
-
for (const group of groups) {
|
|
383
|
-
const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
384
|
-
for (const h of inner) {
|
|
385
|
-
if (h && typeof h.command === 'string') commands.push(h.command);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return commands;
|
|
436
|
+
const inProject = new Set(installedHookEvents(root, 'project'));
|
|
437
|
+
const inGlobal = new Set(installedHookEvents(root, 'global'));
|
|
438
|
+
return CLAUDE_HOOK_EVENTS.filter((event) => inProject.has(event) && inGlobal.has(event));
|
|
389
439
|
}
|
|
390
440
|
|
|
391
441
|
async function checkBYODStorage(record) {
|
package/src/install.mjs
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
upsertMcpServer,
|
|
12
12
|
upsertClaudeHooks,
|
|
13
13
|
resolveHookRunner,
|
|
14
|
-
|
|
14
|
+
HOOK_MODES,
|
|
15
|
+
hookEventsForMode,
|
|
16
|
+
hookModeFromEvents,
|
|
17
|
+
installedHookEvents,
|
|
15
18
|
resolveConnection,
|
|
16
19
|
tokenKind,
|
|
17
20
|
homeDir,
|
|
@@ -20,7 +23,7 @@ import {
|
|
|
20
23
|
} from './config.mjs';
|
|
21
24
|
import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
|
|
22
25
|
import { deriveScope } from './scope.mjs';
|
|
23
|
-
import { log, heading, status, select, c } from './util.mjs';
|
|
26
|
+
import { log, heading, status, select, err, c } from './util.mjs';
|
|
24
27
|
|
|
25
28
|
// The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
|
|
26
29
|
const LOREKIT_MCP_ENDPOINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
|
|
@@ -30,6 +33,46 @@ function ask(question) {
|
|
|
30
33
|
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
|
|
31
34
|
}
|
|
32
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
|
+
|
|
33
76
|
// Detect whether lorekit is already installed for a given scope. Returns an
|
|
34
77
|
// object describing what is present so the caller can give precise feedback.
|
|
35
78
|
function detectInstalled(root, scope) {
|
|
@@ -60,11 +103,97 @@ function detectInstalled(root, scope) {
|
|
|
60
103
|
};
|
|
61
104
|
}
|
|
62
105
|
|
|
106
|
+
// The interactive hook choice, as data so it can be asserted on without a pty.
|
|
107
|
+
//
|
|
108
|
+
// Deliberately THREE options, not a yes/no: `SessionStart` is a pure read that
|
|
109
|
+
// injects existing lessons, while the other two only nudge. A single yes/no
|
|
110
|
+
// bundles them, so a user who declines because they don't want to be nudged
|
|
111
|
+
// also loses lesson injection — the thing LoreKit is for. Each hint says what
|
|
112
|
+
// the hooks DO (inject context, nudge); none of them writes memory, and copy
|
|
113
|
+
// that implied otherwise would ask for consent to something that never happens.
|
|
114
|
+
export const HOOK_PROMPT_OPTIONS = [
|
|
115
|
+
{
|
|
116
|
+
label: 'Yes, all of them',
|
|
117
|
+
value: 'all',
|
|
118
|
+
hint: 'inject lessons at session start; nudge on a tool failure and at end of turn',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
label: 'Read-only',
|
|
122
|
+
value: 'read-only',
|
|
123
|
+
hint: 'inject lessons at session start; never nudge',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
label: 'No hooks',
|
|
127
|
+
value: 'none',
|
|
128
|
+
hint: 'skills + MCP only; memory stays model-invoked',
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// Pure: which hook mode should the interactive prompt preselect?
|
|
133
|
+
//
|
|
134
|
+
// The default is the DETECTED state, not a constant — `install` is explicitly
|
|
135
|
+
// re-runnable (token refresh, `--force`, completing a partial install), so a
|
|
136
|
+
// constant "all" would silently resurrect hooks a user previously declined.
|
|
137
|
+
// A genuinely fresh install has nothing to detect, so it preselects `all`: that
|
|
138
|
+
// is the "opt in by default" the prompt is there to promote. A hand-wired subset
|
|
139
|
+
// that matches no preset (`custom`) preselects `all` too — there is no preset to
|
|
140
|
+
// re-offer, and the user still sees and chooses from the three options.
|
|
141
|
+
//
|
|
142
|
+
// That last clause is load-bearing and INTERACTIVE-ONLY: it is safe precisely
|
|
143
|
+
// because the user is then shown the list and picks. A `--yes` / non-TTY run has
|
|
144
|
+
// no such moment, so it must NOT take this value for a `custom` set — see
|
|
145
|
+
// `install`, which leaves a hand-wired wiring untouched instead.
|
|
146
|
+
export function defaultHookMode({ freshInstall, wiredEvents }) {
|
|
147
|
+
if (freshInstall) return 'all';
|
|
148
|
+
const detected = hookModeFromEvents(wiredEvents);
|
|
149
|
+
return detected === 'custom' ? 'all' : detected;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve the requested hook mode from the flags, or null when nothing was
|
|
153
|
+
// specified (the caller then prompts / falls back). `--hooks <mode>` is the
|
|
154
|
+
// explicit selector; `--no-hooks` is the pre-existing boolean and keeps its
|
|
155
|
+
// documented SKIP semantics — it never removes hooks that are already wired,
|
|
156
|
+
// which is why it maps to `none` here but is tracked separately below.
|
|
157
|
+
//
|
|
158
|
+
// A VALUELESS `--hooks` is a usage error, not an absent flag. `hooks` is not in
|
|
159
|
+
// `parseArgs`' `booleans` list, so `--hooks --yes` and a trailing `--hooks`
|
|
160
|
+
// both yield `true` and `--hooks=` yields `''`. Returning null for those would
|
|
161
|
+
// resolve to the DETECTED mode — the exact silent fallback the validation below
|
|
162
|
+
// exists to prevent — so they are surfaced as the sentinel `INVALID_HOOK_MODE`
|
|
163
|
+
// and rejected alongside `--hooks bogus`. Mirrors `write.mjs`'s bare
|
|
164
|
+
// `--ttl-days`, which feeds NaN to its validator for the same reason: an
|
|
165
|
+
// explicitly supplied flag is a caller assertion, so a malformed one must fail.
|
|
166
|
+
export const INVALID_HOOK_MODE = '(missing value)';
|
|
167
|
+
|
|
168
|
+
function requestedHookMode(args) {
|
|
169
|
+
const raw = args.hooks;
|
|
170
|
+
if (typeof raw === 'string' && raw.trim()) return raw.trim().toLowerCase();
|
|
171
|
+
if (raw !== undefined && raw !== false) return INVALID_HOOK_MODE;
|
|
172
|
+
if (args['no-hooks']) return 'none';
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
63
176
|
export async function install(args) {
|
|
64
177
|
const root = resolveProjectRoot(args.dir);
|
|
65
178
|
const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
|
|
66
179
|
const force = Boolean(args.force);
|
|
67
180
|
|
|
181
|
+
// Validate `--hooks` before touching anything on disk: a mistyped mode must
|
|
182
|
+
// fail loudly, never silently fall back to a different wiring than asked for.
|
|
183
|
+
const requestedMode = requestedHookMode(args);
|
|
184
|
+
if (requestedMode === INVALID_HOOK_MODE) {
|
|
185
|
+
err(`\n --hooks needs a mode. Valid modes: ${HOOK_MODES.join(' | ')}.`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
if (requestedMode !== null && !HOOK_MODES.includes(requestedMode)) {
|
|
189
|
+
err(`\n Unknown --hooks mode "${requestedMode}". Valid modes: ${HOOK_MODES.join(' | ')}.`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
// An explicit `--hooks` IS the intent to change the wiring, so it must reach
|
|
193
|
+
// the hook step even on an otherwise complete install (which normally short-
|
|
194
|
+
// circuits). `--no-hooks` is skip-only and never justifies that bypass.
|
|
195
|
+
const hooksFlagExplicit = typeof args.hooks === 'string' && args.hooks.trim() !== '';
|
|
196
|
+
|
|
68
197
|
heading('LoreKit install');
|
|
69
198
|
log(` project: ${c.dim(root)}`);
|
|
70
199
|
|
|
@@ -88,7 +217,9 @@ export async function install(args) {
|
|
|
88
217
|
const globalState = detectInstalled(root, 'global');
|
|
89
218
|
const currentState = scope === 'global' ? globalState : projectState;
|
|
90
219
|
|
|
91
|
-
|
|
220
|
+
const wiredEvents = installedHookEvents(root, scope);
|
|
221
|
+
|
|
222
|
+
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit) {
|
|
92
223
|
// Surface a clear, useful already-installed summary.
|
|
93
224
|
log('');
|
|
94
225
|
log(
|
|
@@ -124,10 +255,20 @@ export async function install(args) {
|
|
|
124
255
|
log(` ${c.yellow('Token: none configured — reads/writes will fail until a token is set')}`);
|
|
125
256
|
}
|
|
126
257
|
|
|
258
|
+
// Hooks are a user choice now, so an already-installed run must SAY which
|
|
259
|
+
// one is in effect — otherwise "why does nothing get remembered?" has no
|
|
260
|
+
// answer here and the user has to go read settings.json.
|
|
261
|
+
log(
|
|
262
|
+
wiredEvents.length > 0
|
|
263
|
+
? ` ${c.dim(`Hooks: ${wiredEvents.join(', ')}`)}`
|
|
264
|
+
: ` ${c.dim('Hooks: none wired — the skills work, but only when the model invokes them')}`,
|
|
265
|
+
);
|
|
266
|
+
|
|
127
267
|
log('');
|
|
128
268
|
log(` Run ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
|
|
269
|
+
log(` Change the hooks with ${c.cyan(`--hooks ${HOOK_MODES.join('|')}`)}.`);
|
|
129
270
|
log(` Pass ${c.cyan('--force')} to reinstall and overwrite existing files.`);
|
|
130
|
-
return 0;
|
|
271
|
+
return { exitCode: 0, 'lorekit.cli.hooks_mode': hookModeFromEvents(wiredEvents) };
|
|
131
272
|
}
|
|
132
273
|
|
|
133
274
|
// Partial install — note what's already there vs what will be added.
|
|
@@ -155,16 +296,44 @@ export async function install(args) {
|
|
|
155
296
|
const endpoint = fromArgs.endpoint || LOREKIT_MCP_ENDPOINT;
|
|
156
297
|
|
|
157
298
|
// Token resolution order: --token flag → env → existing config → prompt.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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;
|
|
163
311
|
log(` ${c.dim('Token: reusing existing token from config.')}`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
168
337
|
}
|
|
169
338
|
|
|
170
339
|
// 4. Install the skill files — every skill the CLI ships.
|
|
@@ -179,14 +348,67 @@ export async function install(args) {
|
|
|
179
348
|
const remoteUrl = buildRemoteUrl(endpoint, token);
|
|
180
349
|
const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
|
|
181
350
|
|
|
182
|
-
// 5b.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
351
|
+
// 5b. Hooks — the deterministic layer the Claude plugin adds on top of the
|
|
352
|
+
// skill, firing the shared `lorekit hook` engine (which reads the same
|
|
353
|
+
// config). NONE of them write memory: SessionStart injects existing
|
|
354
|
+
// lessons, PostToolUseFailure surfaces relevant ones plus a write nudge,
|
|
355
|
+
// Stop fires the friction-gated retrospective nudge. All three only emit
|
|
356
|
+
// context — the write is still the model calling `memory.write`. The
|
|
357
|
+
// prompt copy below says exactly that, because a user who declines
|
|
358
|
+
// "automatic memory writing" would be declining something that never
|
|
359
|
+
// happens and losing lesson injection, which is the product.
|
|
360
|
+
let hookMode = requestedMode;
|
|
361
|
+
// A hand-wired set matching no preset (`custom`) is the one state the three
|
|
362
|
+
// options cannot express. Interactively that is fine — `defaultHookMode`
|
|
363
|
+
// preselects `all` and the user still chooses. A `--yes` / non-TTY run never
|
|
364
|
+
// gets that moment, so taking the preselection there would WIRE `all` and
|
|
365
|
+
// silently re-add the events the user hand-removed — exactly what
|
|
366
|
+
// `hookModeFromEvents` tells callers not to do, and the opposite of the
|
|
367
|
+
// documented "otherwise whatever is already wired". So keep exactly that set
|
|
368
|
+
// — no event added or removed, though the command string is still refreshed
|
|
369
|
+
// below; `--hooks <mode>` remains the way to change it on purpose.
|
|
370
|
+
let preserveCustomHooks = false;
|
|
371
|
+
if (hookMode === null) {
|
|
372
|
+
const preselect = defaultHookMode({
|
|
373
|
+
freshInstall: !currentState.hasSkills && !currentState.hasMcp && wiredEvents.length === 0,
|
|
374
|
+
wiredEvents,
|
|
375
|
+
});
|
|
376
|
+
if (nonInteractive) {
|
|
377
|
+
preserveCustomHooks = hookModeFromEvents(wiredEvents) === 'custom';
|
|
378
|
+
hookMode = preserveCustomHooks ? 'custom' : preselect;
|
|
379
|
+
} else {
|
|
380
|
+
log('');
|
|
381
|
+
hookMode = await select('Install the LoreKit lifecycle hooks?', HOOK_PROMPT_OPTIONS, {
|
|
382
|
+
defaultIndex: Math.max(0, HOOK_PROMPT_OPTIONS.findIndex((o) => o.value === preselect)),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// `hookEventsForMode` maps any unknown mode to the full set, so `custom` must
|
|
388
|
+
// never reach it — the preserved wiring IS the event list here.
|
|
389
|
+
const hookEvents = preserveCustomHooks ? [...wiredEvents] : hookEventsForMode(hookMode);
|
|
390
|
+
// `--no-hooks` is skip-only by contract: it has always meant "don't wire
|
|
391
|
+
// them", never "take away the ones already there". An interactive `No hooks`
|
|
392
|
+
// (or an explicit `--hooks none`) is an unambiguous request to remove.
|
|
393
|
+
const skipHooksOnly = hookMode === 'none' && Boolean(args['no-hooks']) && !hooksFlagExplicit;
|
|
394
|
+
// Nothing to wire and nothing to remove ⇒ don't create a settings.json at all.
|
|
395
|
+
// `wiredEvents` reads as empty for an unparseable settings.json, so a `none`
|
|
396
|
+
// run against one is a silent no-op — correct, not a gap: Claude Code cannot
|
|
397
|
+
// parse that file either, so no lorekit hook is firing from it. Any mode that
|
|
398
|
+
// WIRES still goes through `upsertClaudeHooks`, whose throwing read surfaces
|
|
399
|
+
// the parse error rather than clobbering the file.
|
|
400
|
+
//
|
|
401
|
+
// Preserving a `custom` set does NOT mean skipping the write. Passing
|
|
402
|
+
// `hookEvents` (== `wiredEvents` here) keeps exactly that set — nothing is
|
|
403
|
+
// added, and the prune loop finds no lorekit entry on the events outside it,
|
|
404
|
+
// so `removed` is always 0 — while still REFRESHING a stale command string.
|
|
405
|
+
// Skipping the call instead left a `--force` re-install unable to repair a
|
|
406
|
+
// hook command pointing at an old runner, which is the one thing a re-install
|
|
407
|
+
// is for.
|
|
408
|
+
const touchHooks = !skipHooksOnly && (hookEvents.length > 0 || wiredEvents.length > 0);
|
|
187
409
|
let hooks = null;
|
|
188
|
-
if (
|
|
189
|
-
hooks = upsertClaudeHooks(root, scope, resolveHookRunner());
|
|
410
|
+
if (touchHooks) {
|
|
411
|
+
hooks = upsertClaudeHooks(root, scope, resolveHookRunner(), hookEvents);
|
|
190
412
|
}
|
|
191
413
|
|
|
192
414
|
// Show global paths relative to ~ (a repo-relative path would be a mess of
|
|
@@ -207,16 +429,34 @@ export async function install(args) {
|
|
|
207
429
|
}
|
|
208
430
|
status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
|
|
209
431
|
|
|
210
|
-
if (!
|
|
211
|
-
status(
|
|
432
|
+
if (!touchHooks) {
|
|
433
|
+
status(
|
|
434
|
+
'info',
|
|
435
|
+
'hooks',
|
|
436
|
+
skipHooksOnly
|
|
437
|
+
? 'skipped (--no-hooks) — the skills still work, but memory stays model-invoked'
|
|
438
|
+
: 'none — the skills still work, but memory stays model-invoked',
|
|
439
|
+
);
|
|
212
440
|
} else {
|
|
213
|
-
const n = hooks.added + hooks.updated;
|
|
441
|
+
const n = hooks.added + hooks.updated + hooks.removed;
|
|
214
442
|
const hookParts = [
|
|
215
443
|
hooks.added ? `${hooks.added} added` : '',
|
|
216
444
|
hooks.updated ? `${hooks.updated} updated` : '',
|
|
445
|
+
hooks.removed ? `${hooks.removed} removed` : '',
|
|
217
446
|
].filter(Boolean);
|
|
218
447
|
const hookState = n === 0 ? 'already wired' : hookParts.join(', ');
|
|
219
|
-
|
|
448
|
+
const wired = hookEvents.length > 0 ? ` (${hookEvents.join(', ')})` : '';
|
|
449
|
+
// The preserved-`custom` run DOES write, so it lands here rather than in the
|
|
450
|
+
// "left as-is" branch — but it is still the one state the three modes cannot
|
|
451
|
+
// express, so it keeps its own explanation of how to leave it.
|
|
452
|
+
const kept = preserveCustomHooks
|
|
453
|
+
? `; a hand-wired set matching no preset, pass --hooks ${HOOK_MODES.join('|')} to change it`
|
|
454
|
+
: '';
|
|
455
|
+
status(
|
|
456
|
+
n === 0 ? 'info' : 'pass',
|
|
457
|
+
`hooks ${hookMode}`,
|
|
458
|
+
`${hookState} → ${display(hooks.file)}${wired}${kept}`,
|
|
459
|
+
);
|
|
220
460
|
}
|
|
221
461
|
|
|
222
462
|
const kind = tokenKind(token);
|
|
@@ -249,5 +489,8 @@ export async function install(args) {
|
|
|
249
489
|
)}`,
|
|
250
490
|
);
|
|
251
491
|
}
|
|
252
|
-
|
|
492
|
+
// Bounded, non-PII: which of the three presets this run landed on. Counting
|
|
493
|
+
// the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
|
|
494
|
+
// user picks when actually asked, which is the whole point of the prompt.
|
|
495
|
+
return { exitCode: 0, 'lorekit.cli.hooks_mode': hookMode };
|
|
253
496
|
}
|
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
|