@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/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
- LOREKIT_HOOK_RE,
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
- 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)');
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 dupes = [];
354
- const projectFile = settingsPath(root, 'project');
355
- const globalFile = settingsPath(root, 'global');
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
- CLAUDE_HOOK_EVENTS,
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
- if (currentState.isFullyInstalled && !force) {
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
- let token = fromArgs.token;
159
- if (!token && currentState.existingToken) {
160
- // Reuse the token that's already in the config — don't make the user repeat
161
- // it just because they're running install again.
162
- 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;
163
311
  log(` ${c.dim('Token: reusing existing token from config.')}`);
164
- }
165
- if (!token && !nonInteractive) {
166
- token = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
167
- 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;
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. Wire the deterministic hooks (unless --no-hooks). This is the layer the
183
- // Claude plugin adds on top of the skill: lessons injected on every
184
- // SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
185
- // firing the shared `lorekit hook` engine, which reads the same config.
186
- const wireHooks = !args['no-hooks'];
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 (wireHooks) {
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 (!wireHooks) {
211
- status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but memories are model-invoked only');
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
- status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
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
- return 0;
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
  }
@@ -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