@lorekit/cli 1.35.0 → 1.37.0

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
@@ -74,6 +74,38 @@ lorekit install \
74
74
  lorekit install --global # set it up for every project
75
75
  ```
76
76
 
77
+ #### Claude Code on the web (`--mcp-json`)
78
+
79
+ Claude Code on the web clones the repo fresh into an ephemeral container, so the
80
+ only MCP config it can see is a **committed, repo-root `.mcp.json`** — a global
81
+ `~/.claude.json` lives on your machine and never travels there. `--mcp-json`
82
+ writes exactly that file, in a **committable** form: it authenticates via a
83
+ `${LOREKIT_TOKEN}` reference in an `mcp-remote --header` rather than embedding
84
+ the token, so there is no secret in the file.
85
+
86
+ ```bash
87
+ lorekit install --mcp-json --yes # web-ready project .mcp.json
88
+ lorekit install --global --mcp-json --yes # + the machine-wide CLI, skills, hooks
89
+ ```
90
+
91
+ Set **`LOREKIT_TOKEN`** as an environment secret in the web UI; the value is
92
+ expanded before `mcp-remote` is spawned. `--mcp-json` always writes the
93
+ **project-root** file regardless of `--project` / `--global`, and it composes
94
+ with either — pair it with `--global` to get the local CLI, skills, and hooks in
95
+ `~/.claude` **and** the committable web config in one command. On a `--project`
96
+ install it takes over `.mcp.json` with the committable form instead of the
97
+ embedded-token one.
98
+
99
+ **You have to commit the file, and `.mcp.json` is usually git-ignored** (the
100
+ default install embeds a token, so LoreKit's own `.gitignore` — and many
101
+ projects — ignore it). Un-ignore it before committing (drop the `.mcp.json` line
102
+ from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
103
+ `install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
104
+ fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
105
+ tracked, only run `install --mcp-json` in that repo — a plain `install --project`
106
+ would embed a live token in the now-committed file. See the
107
+ [Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
108
+
77
109
  In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
78
110
  Flags: `--project` / `--global` pick the scope non-interactively; `--yes` runs
79
111
  non-interactively (endpoint required via flag/env; scope defaults to project);
@@ -754,6 +786,7 @@ also returns their headroom against the plan's memory cap.
754
786
  | `-y, --yes` | Non-interactive / apply; never prompt |
755
787
  | `--hooks <mode>` | Lifecycle hooks to wire: `all` / `read-only` / `none` (`install`; `none` removes any already wired) |
756
788
  | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only. Leaves already-wired hooks alone (`install`) |
789
+ | `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
757
790
  | `--force` | Overwrite existing skill files (`install`) |
758
791
  | `--deep` | Write/read/delete round-trip (`doctor`) |
759
792
  | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
@@ -882,3 +915,12 @@ at release time and is never committed to git. See
882
915
 
883
916
  `install` writes your token into `.mcp.json`. Keep that file out of version
884
917
  control (LoreKit's root `.gitignore` already ignores `.mcp.json`).
918
+
919
+ The **one exception is `install --mcp-json`**: that file authenticates via a
920
+ `${LOREKIT_TOKEN}` reference instead of an embedded token, so it holds no secret
921
+ and **is** meant to be committed (that is how Claude Code on the web reads it
922
+ after a fresh clone). You'll need to un-ignore `.mcp.json` first, since it is
923
+ normally git-ignored for the embedded-token reason above. The token itself comes
924
+ from the `LOREKIT_TOKEN` environment variable at runtime — set it as an
925
+ environment secret, never commit it — and once the file is tracked, keep using
926
+ `--mcp-json` so a later plain install never writes a token into it.
package/bin/lorekit.mjs CHANGED
@@ -43,7 +43,8 @@ ${c.bold('Commands')}
43
43
  (~/.claude); --project / --global choose non-interactively.
44
44
  Also prompts whether to wire the hooks (all / read-only /
45
45
  none); --hooks <mode> chooses non-interactively and --no-hooks
46
- skips them (skills stay model-invoked only).
46
+ skips them (skills stay model-invoked only). --mcp-json also
47
+ writes a committable project .mcp.json for Claude Code on the web.
47
48
  uninstall Reverse install: remove the lorekit-memory + lorekit-setup skills,
48
49
  the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
49
50
  other servers, hooks, and settings are left untouched. Prompts
@@ -126,6 +127,8 @@ ${c.bold('Options')}
126
127
  -y, --yes Non-interactive / apply; never prompt
127
128
  --hooks <mode> Lifecycle hooks to wire: all | read-only | none (install)
128
129
  --no-hooks Skip wiring the lifecycle hooks (install)
130
+ --mcp-json Also write a committable project .mcp.json for Claude Code on
131
+ the web — auth via \${LOREKIT_TOKEN}, no embedded secret (install)
129
132
  --force Overwrite existing skill files (install)
130
133
  --deep Do a write→read→delete round-trip (doctor)
131
134
  --telemetry Verify the OTLP export credential works (doctor)
@@ -183,6 +186,14 @@ An interactive run preselects whatever is already wired, so re-running install
183
186
  never resurrects hooks you declined. Answering "No hooks" (or --hooks none)
184
187
  REMOVES hooks that are already there; --no-hooks only skips wiring new ones.
185
188
 
189
+ ${c.bold('Claude Code on the web')}
190
+ Add --mcp-json to write a committable project .mcp.json (repo root) that Claude
191
+ Code on the web can see after a fresh clone. It authenticates via a
192
+ \${LOREKIT_TOKEN} reference in an mcp-remote --header — NOT an embedded token —
193
+ so the file is safe to commit; set LOREKIT_TOKEN as an environment secret in the
194
+ web UI. Pair it with --global to get the local CLI, skills, and hooks in
195
+ ~/.claude AND the committable web config in one command.
196
+
186
197
  ${c.bold('Options')}
187
198
  -d, --dir <path> Target project root (default: current directory)
188
199
  --project Install into this project: .claude/skills + .mcp.json (default)
@@ -191,6 +202,8 @@ ${c.bold('Options')}
191
202
  -t, --token <token> LoreKit token: lk_rw_* read+write, lk_ro_* read-only, lk_wo_* write-only
192
203
  --hooks <mode> Wire the lifecycle hooks: all | read-only | none
193
204
  --no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
205
+ --mcp-json Also write a committable project .mcp.json (\${LOREKIT_TOKEN} auth)
206
+ for Claude Code on the web — always the repo-root file
194
207
  --force Overwrite existing skill files
195
208
  -y, --yes Non-interactive; never prompt (defaults to --project, and to the
196
209
  already-wired hooks — all on a fresh install)
@@ -198,6 +211,8 @@ ${c.bold('Options')}
198
211
  ${c.bold('Examples')}
199
212
  npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
200
213
  npx @lorekit/cli install --global
214
+ npx @lorekit/cli install --mcp-json --yes # web-ready project .mcp.json
215
+ npx @lorekit/cli install --global --mcp-json --yes # local CLI + committable web config
201
216
  npx @lorekit/cli install --hooks read-only --yes
202
217
  npx @lorekit/cli install --no-hooks --yes
203
218
  `,
@@ -631,7 +646,7 @@ ${c.bold('Options')}
631
646
  // typo like `--gloabl` should fail loudly, not quietly fall back to --project.
632
647
  const KNOWN_FLAGS = [
633
648
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
634
- 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
649
+ 'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
635
650
  'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
636
651
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
637
652
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
@@ -662,7 +677,7 @@ async function main() {
662
677
  const argv = process.argv.slice(2);
663
678
  const args = parseArgs(argv, {
664
679
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
665
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
680
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
666
681
  known: KNOWN_FLAGS,
667
682
  });
668
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
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/config.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
7
8
  // packages/cli/ — the installable package root (this file lives in src/).
@@ -366,6 +367,88 @@ export function upsertMcpServer(root, remoteUrl, scope = 'project') {
366
367
  return { file, existed };
367
368
  }
368
369
 
370
+ // The environment variable the committable (web) .mcp.json references for its
371
+ // token, instead of embedding a live secret. Kept as a constant so the writer,
372
+ // the install summary, and the docs can never name a different variable.
373
+ export const WEB_TOKEN_ENV_VAR = 'LOREKIT_TOKEN';
374
+
375
+ // Strip any credential from an endpoint URL before it goes into the committable
376
+ // web .mcp.json. The whole point of that file is to carry NO secret, so an
377
+ // `--endpoint` / LOREKIT_MCP_URL value that already embeds `?token=lk_…` (a
378
+ // perfectly ordinary thing to paste) must not be written verbatim — that would
379
+ // commit a live token in the file install tells you to commit. Mirrors
380
+ // `splitEndpoint`'s token removal (mcp.mjs) inline rather than importing it, so
381
+ // config.mjs keeps its no-mcp.mjs-import invariant (see resolveProjectConnection),
382
+ // and also clears userinfo for defense in depth. A non-URL string is left as-is;
383
+ // endpoint validity is the caller's concern, not this function's.
384
+ function stripEndpointCredentials(endpoint) {
385
+ try {
386
+ const u = new URL(endpoint);
387
+ u.searchParams.delete('token');
388
+ u.username = '';
389
+ u.password = '';
390
+ return u.toString();
391
+ } catch {
392
+ return endpoint;
393
+ }
394
+ }
395
+
396
+ // Merge a lorekit server entry into the PROJECT-root .mcp.json in the
397
+ // committable form Claude Code on the web needs: instead of embedding the token
398
+ // in the URL (a live secret, which is why the embedded form is git-ignored), it
399
+ // authenticates via an mcp-remote `--header` that references `${LOREKIT_TOKEN}`.
400
+ //
401
+ // WHY this exists as a distinct writer. Claude Code on the web clones the repo
402
+ // fresh into an ephemeral container, so the only MCP config it can see is a
403
+ // COMMITTED, repo-root `.mcp.json` — a global `~/.claude.json` never travels to
404
+ // the clone. And it must be committable, which the embedded-token form is not.
405
+ // So the web path needs exactly this: the project file (never the global one)
406
+ // with the token supplied at runtime from an environment secret. Callers set
407
+ // `LOREKIT_TOKEN` as an environment secret in the web UI; the value is expanded
408
+ // by Claude Code before mcp-remote is spawned. Preserves any other servers.
409
+ //
410
+ // The endpoint is credential-stripped first: it is the one field that could
411
+ // carry a `?token=` in from `--endpoint` / LOREKIT_MCP_URL, and writing that
412
+ // into a file meant to be committed would leak a live token.
413
+ export function upsertWebMcpServer(root, endpoint, envVar = WEB_TOKEN_ENV_VAR) {
414
+ const file = mcpJsonPath(root); // always the project file — the web clone only sees this one
415
+ const config = readJsonIfExists(file) || {};
416
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
417
+ config.mcpServers = {};
418
+ }
419
+ const existed = Boolean(config.mcpServers.lorekit);
420
+ config.mcpServers.lorekit = {
421
+ command: 'npx',
422
+ args: ['-y', 'mcp-remote', stripEndpointCredentials(endpoint), '--header', `Authorization:Bearer \${${envVar}}`],
423
+ };
424
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
425
+ return { file, existed };
426
+ }
427
+
428
+ // Is the project .mcp.json git-ignored in this repo? Tri-state so callers can
429
+ // distinguish "definitely ignored" from "can't tell" and stay quiet when unsure:
430
+ // true → ignored (git check-ignore matched) — the caller warns
431
+ // false → tracked / not ignored — nothing to say
432
+ // null → unknown (no git, not a repo, error) — say nothing
433
+ //
434
+ // This exists because the committable web .mcp.json (upsertWebMcpServer) only
435
+ // works if it is actually committed, and .mcp.json is COMMONLY git-ignored (the
436
+ // default embedded-token form is a secret, so LoreKit's own root .gitignore and
437
+ // many projects ignore it). Silently writing a file the repo will never commit
438
+ // is the trap this lets `install --mcp-json` warn about. `git check-ignore -q`
439
+ // exits 0 when ignored, 1 when not, 128 on error — anything but 0/1 is unknown.
440
+ export function isMcpJsonGitIgnored(root) {
441
+ try {
442
+ const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', '.mcp.json'], { stdio: 'ignore' });
443
+ if (r.error) return null; // git not found / spawn failed
444
+ if (r.status === 0) return true;
445
+ if (r.status === 1) return false;
446
+ return null; // 128 (not a repo) or anything unexpected
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+
369
452
  // Pull the configured lorekit remote URL out of the project .mcp.json, if
370
453
  // present. Non-throwing: returns null when the file is absent, invalid, or has
371
454
  // no lorekit server. Callers that need to distinguish those use readMcpConfig.
@@ -406,6 +489,37 @@ export function removeMcpServer(root, scope = 'project') {
406
489
  return { file, removed: true };
407
490
  }
408
491
 
492
+ // Is a lorekit server entry the committable WEB form — auth via a `--header`
493
+ // that references an `${ENV_VAR}` token, as written by `upsertWebMcpServer` —
494
+ // rather than an embedded-token or plain URL entry? Used to gate the global
495
+ // uninstall's project-file cleanup so it only removes what `--mcp-json` wrote.
496
+ export function isWebMcpServerEntry(server) {
497
+ const args = server && Array.isArray(server.args) ? server.args : [];
498
+ const i = args.indexOf('--header');
499
+ if (i === -1 || typeof args[i + 1] !== 'string') return false;
500
+ return /^Authorization:Bearer \$\{[^}]+\}$/.test(args[i + 1].trim());
501
+ }
502
+
503
+ // Remove the lorekit entry from the PROJECT .mcp.json ONLY when it is the
504
+ // committable web form. `uninstall --global` calls this to clean up the file
505
+ // `install --global --mcp-json` wrote — WITHOUT touching an unrelated
506
+ // embedded-token `install --project` entry a user set up separately (which a
507
+ // blind removeMcpServer(root, 'project') would delete). No-op otherwise.
508
+ export function removeWebMcpServer(root) {
509
+ const file = mcpJsonPath(root);
510
+ const config = readJsonIfExists(file);
511
+ const server =
512
+ config && config.mcpServers && typeof config.mcpServers === 'object'
513
+ ? config.mcpServers.lorekit
514
+ : null;
515
+ if (!server || !isWebMcpServerEntry(server)) return { file, removed: false };
516
+
517
+ delete config.mcpServers.lorekit;
518
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
519
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
520
+ return { file, removed: true };
521
+ }
522
+
409
523
  // Strip lorekit hook entries from every event, preserving non-lorekit hooks in
410
524
  // the same groups. Prunes groups left with no hooks and events left with no
411
525
  // groups. Returns the count removed; only writes when something changed.
@@ -475,18 +589,29 @@ export function tokenKind(token) {
475
589
  // install), then the `mcp.endpoint` field in .lorekit.json (committable URL
476
590
  // without token — safe for VCS), then env. `splitEndpoint` is passed in to
477
591
  // avoid a circular import with mcp.mjs.
592
+ //
593
+ // A source that STORES a token wins outright (project beats global — closest
594
+ // scope), but a TOKENLESS source must NOT shadow a later source that has one.
595
+ // That shadowing is exactly what `install --global --mcp-json` created: it
596
+ // writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
597
+ // AND the real token into ~/.claude.json. Returning early on the project entry
598
+ // left the local CLI, doctor, and hooks resolving `token: null` unless
599
+ // LOREKIT_TOKEN was exported — so a tokenless source is only remembered as an
600
+ // endpoint fallback, and the loop keeps looking for a stored token.
478
601
  export function resolveProjectConnection(root, splitEndpoint) {
479
602
  const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))];
603
+ let endpointOnly = null; // closest usable endpoint that carried no token (e.g. the web .mcp.json)
480
604
  for (const configured of sources) {
481
- if (configured && configured.url) {
482
- const { endpoint, token } = splitEndpoint(configured.url);
483
- if (endpoint && !endpoint.includes('<project-ref>')) {
484
- return {
485
- endpoint,
486
- token: token || process.env.LOREKIT_TOKEN || null,
487
- };
488
- }
489
- }
605
+ if (!configured || !configured.url) continue;
606
+ const { endpoint, token } = splitEndpoint(configured.url);
607
+ if (!endpoint || endpoint.includes('<project-ref>')) continue;
608
+ if (token) return { endpoint, token }; // a stored token wins; project (closest) beats global
609
+ if (!endpointOnly) endpointOnly = endpoint; // remember the closest tokenless source, keep looking
610
+ }
611
+ if (endpointOnly) {
612
+ // No source stored a token — the committable web .mcp.json is exactly this
613
+ // case. Fall back to the env token so the local CLI still authenticates.
614
+ return { endpoint: endpointOnly, token: process.env.LOREKIT_TOKEN || null };
490
615
  }
491
616
 
492
617
  // Fallback: `mcp.endpoint` in .lorekit.json — a committable URL without token.
package/src/install.mjs CHANGED
@@ -9,6 +9,9 @@ import {
9
9
  skillInstallDir,
10
10
  copyDir,
11
11
  upsertMcpServer,
12
+ upsertWebMcpServer,
13
+ isMcpJsonGitIgnored,
14
+ WEB_TOKEN_ENV_VAR,
12
15
  upsertClaudeHooks,
13
16
  resolveHookRunner,
14
17
  HOOK_MODES,
@@ -194,6 +197,12 @@ export async function install(args) {
194
197
  // circuits). `--no-hooks` is skip-only and never justifies that bypass.
195
198
  const hooksFlagExplicit = typeof args.hooks === 'string' && args.hooks.trim() !== '';
196
199
 
200
+ // `--mcp-json` writes a committable, env-var-referencing project .mcp.json for
201
+ // Claude Code on the web (see upsertWebMcpServer). It is an explicit intent to
202
+ // (re)write that file, so — like an explicit `--hooks` — it must reach the
203
+ // write step even when the scope is already fully installed.
204
+ const writeWebMcpJson = Boolean(args['mcp-json']);
205
+
197
206
  heading('LoreKit install');
198
207
  log(` project: ${c.dim(root)}`);
199
208
 
@@ -219,7 +228,7 @@ export async function install(args) {
219
228
 
220
229
  const wiredEvents = installedHookEvents(root, scope);
221
230
 
222
- if (currentState.isFullyInstalled && !force && !hooksFlagExplicit) {
231
+ if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson) {
223
232
  // Surface a clear, useful already-installed summary.
224
233
  log('');
225
234
  log(
@@ -345,8 +354,28 @@ export async function install(args) {
345
354
  });
346
355
 
347
356
  // 5. Wire the MCP config for the chosen scope.
357
+ // When `--mcp-json` is going to (re)write the PROJECT .mcp.json in its
358
+ // committable form and the scope target IS that same file (project scope),
359
+ // skip this write — otherwise we would write an embedded-token file only to
360
+ // overwrite it with the committable one a moment later. A global scope
361
+ // targets ~/.claude.json, a different file, so it always writes.
348
362
  const remoteUrl = buildRemoteUrl(endpoint, token);
349
- const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
363
+ const scopeWriteOwnedByWeb = writeWebMcpJson && scope === 'project';
364
+ let file = null;
365
+ let existed = false;
366
+ if (!scopeWriteOwnedByWeb) {
367
+ ({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
368
+ }
369
+
370
+ // 5a. `--mcp-json`: write the committable, env-var-referencing project
371
+ // .mcp.json for Claude Code on the web (see upsertWebMcpServer). Always
372
+ // the repo-root file, regardless of scope — that is the only MCP config a
373
+ // fresh web clone can see. The token is NOT embedded here: the file
374
+ // references `${LOREKIT_TOKEN}`, set as an environment secret in the web UI.
375
+ let webMcp = null;
376
+ if (writeWebMcpJson) {
377
+ webMcp = upsertWebMcpServer(root, endpoint, WEB_TOKEN_ENV_VAR);
378
+ }
350
379
 
351
380
  // 5b. Hooks — the deterministic layer the Claude plugin adds on top of the
352
381
  // skill, firing the shared `lorekit hook` engine (which reads the same
@@ -427,7 +456,30 @@ export async function install(args) {
427
456
  : 'already up to date';
428
457
  status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
429
458
  }
430
- status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
459
+ if (file) {
460
+ status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
461
+ }
462
+ if (webMcp) {
463
+ // The committable web form: reported separately so it is clear this file is
464
+ // meant to be committed (unlike the embedded-token form) and that the token
465
+ // comes from an environment secret rather than the file.
466
+ const webPath = path.relative(root, webMcp.file) || webMcp.file;
467
+ status(
468
+ 'pass',
469
+ '.mcp.json (web)',
470
+ `${webMcp.existed ? 'updated' : 'created'} committable lorekit server (auth via \${${WEB_TOKEN_ENV_VAR}}) → ${webPath}`,
471
+ );
472
+ // The web file only works if it is actually committed, and .mcp.json is
473
+ // commonly git-ignored (the embedded-token form is a secret). Warn when it
474
+ // is, so a fresh web clone silently missing the config is not a mystery.
475
+ if (isMcpJsonGitIgnored(root) === true) {
476
+ status(
477
+ 'warn',
478
+ '.mcp.json (git)',
479
+ 'git-ignored — un-ignore it (add `!.mcp.json` to .gitignore, or `git add -f .mcp.json`) or a fresh web clone will not see it',
480
+ );
481
+ }
482
+ }
431
483
 
432
484
  if (!touchHooks) {
433
485
  status(
@@ -465,8 +517,29 @@ export async function install(args) {
465
517
  }
466
518
 
467
519
  const kind = tokenKind(token);
468
- if (kind === 'none') {
469
- status('warn', 'token', 'none configured reads/writes will fail until a token is set');
520
+ if (scopeWriteOwnedByWeb) {
521
+ // `--project --mcp-json`: the committable web .mcp.json is the ONLY config
522
+ // written, and it references ${LOREKIT_TOKEN} rather than storing a token —
523
+ // so NOTHING persists a credential here. Reporting the token's tier would
524
+ // imply it was configured; instead say it is not stored (and that a passed
525
+ // --token was therefore not persisted), pointing at the environment secret.
526
+ status(
527
+ 'warn',
528
+ 'token',
529
+ `not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
530
+ token ? ' (any --token you passed is not persisted)' : ''
531
+ }`,
532
+ );
533
+ } else if (kind === 'none') {
534
+ // A global --mcp-json with no token still writes the web file; explain the
535
+ // runtime resolution rather than implying reads/writes will fail.
536
+ status(
537
+ 'warn',
538
+ 'token',
539
+ webMcp
540
+ ? `none stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret`
541
+ : 'none configured — reads/writes will fail until a token is set',
542
+ );
470
543
  } else if (kind === 'read-only') {
471
544
  status('warn', 'token', 'read-only (lk_ro_*) — the skill can read memories but not write them');
472
545
  } else if (kind === 'write-only') {
@@ -485,15 +558,26 @@ export async function install(args) {
485
558
  }
486
559
 
487
560
  log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
488
- if (token) {
561
+ // Where the token lives, and how to treat that file, depends on which MCP
562
+ // configs were written. The web `.mcp.json` never holds the token, so a
563
+ // project scope that was owned by `--mcp-json` gets the web guidance, not the
564
+ // "keep it out of version control" note that applies to the embedded form.
565
+ if (webMcp) {
489
566
  log(
490
567
  ` ${c.dim(
491
- scope === 'global'
492
- ? 'Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.'
493
- : 'Note: your token is stored in .mcp.json — keep it out of version control.',
568
+ `For Claude Code on the web: commit .mcp.json (it is often git-ignored — un-ignore it first), then set ${WEB_TOKEN_ENV_VAR} as an environment secret. The file references the token, so it never holds the secret itself.`,
494
569
  )}`,
495
570
  );
496
571
  }
572
+ if (token && scope === 'global') {
573
+ log(
574
+ ` ${c.dim('Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.')}`,
575
+ );
576
+ } else if (token && scope === 'project' && !scopeWriteOwnedByWeb) {
577
+ log(
578
+ ` ${c.dim('Note: your token is stored in .mcp.json — keep it out of version control.')}`,
579
+ );
580
+ }
497
581
  // Bounded, non-PII: which of the three presets this run landed on. Counting
498
582
  // the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
499
583
  // user picks when actually asked, which is the whole point of the prompt.
@@ -146,6 +146,17 @@ export const MEMORY_TOOL_DEFS = [
146
146
  description: 'Soft-archive a memory. Hidden from reads but restorable.',
147
147
  inputSchema: { type: 'object', required: ['scope', 'key'] },
148
148
  },
149
+ {
150
+ name: 'memory.scopes',
151
+ // The description tells the model WHEN to reach for this, not just what it
152
+ // returns: every other read tool needs a scope named up front, so this is
153
+ // the one that answers "what is there?" before you can ask "what is in it?".
154
+ description:
155
+ 'List every scope in the store with how many active memories it holds — '
156
+ + 'the inventory to consult when you do not already know which scope to read. '
157
+ + 'Takes no arguments and is store-wide, NOT limited to the current directory.',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ },
149
160
  ];
150
161
 
151
162
  // Org tools — always advertised regardless of memory mode. They always route
@@ -215,8 +226,117 @@ const MEMORY_DISPATCH = {
215
226
  'memory.search': (store, a) => store.search(a),
216
227
  'memory.delete': (store, a) => store.delete(a),
217
228
  'memory.archive': (store, a) => store.archive(a),
229
+ 'memory.scopes': (store) => listScopes(store),
218
230
  };
219
231
 
232
+ // `memory.scopes` — the store-wide inventory, normalised.
233
+ //
234
+ // This exists because an agent that cannot enumerate scopes cannot know what it
235
+ // does not know. `memory.list` and `memory.search` both need a scope (or a
236
+ // scope list) up front, so without this the only reachable lore is the lore
237
+ // whose scope the agent could already name — and the SessionStart injection is
238
+ // deliberately a bounded slice, not an index of the whole store. `GET
239
+ // /memories/scopes` and the `lorekit scopes` command have answered this since
240
+ // migration 00039; the MCP surface was the one caller that could not ask.
241
+ //
242
+ // THE TWO STORES ANSWER IN DIFFERENT SHAPES, and normalising here is the whole
243
+ // job of this function. `LocalStore`/`TwoTierStore.listScopes()` return a BARE
244
+ // ARRAY (`[{ scope, count }]`), while `RemoteStore.listScopes()` returns the
245
+ // standard `{ ok, scopes }` envelope — or `{ ok: false, error, networkError,
246
+ // unusable }`. A tool that passed either through verbatim would hand the model
247
+ // two different contracts for one tool name depending on a config value it
248
+ // cannot see.
249
+ //
250
+ // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
251
+ // unreachable remote as a short note at exit 0 rather than failing the run. An
252
+ // inventory that cannot be built is `{ scopes: [], note }` with `ok: true`, so
253
+ // `toolResult` does NOT mark it `isError`: "I could not enumerate" is a fact
254
+ // about the store, not a failed tool call, and a model that receives a
255
+ // tool-level error is liable to retry it rather than carry on with the lore it
256
+ // can already reach. The note says which, in bounded, non-PII terms.
257
+ export async function listScopes(store) {
258
+ let res;
259
+ try {
260
+ res = await store.listScopes();
261
+ } catch (e) {
262
+ // A store that cannot enumerate must not take the session down with it.
263
+ return { ok: true, scopes: [], note: `scope enumeration failed: ${errText(e)}` };
264
+ }
265
+
266
+ // Local/two-tier: the bare array form.
267
+ if (Array.isArray(res)) return { ok: true, scopes: sortScopes(res.map(shapeScope)) };
268
+
269
+ // Remote: the envelope form.
270
+ if (res && res.ok) {
271
+ return { ok: true, scopes: sortScopes((Array.isArray(res.scopes) ? res.scopes : []).map(shapeScope)) };
272
+ }
273
+
274
+ return { ok: true, scopes: [], note: scopeFailureNote(res) };
275
+ }
276
+
277
+ // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
278
+ // tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED surface
279
+ // gets that ordering from `lorekit_memory_scopes` (`order by m.scope asc`,
280
+ // migration 00039/00049), but `LocalStore`/`TwoTierStore.listScopes()` both
281
+ // return their `Map` insertion order — a walk order, not an ordering — so the
282
+ // stdio server owns it here rather than the two surfaces answering differently.
283
+ // Sorting BOTH shapes (not just the local one) makes the guarantee a property
284
+ // of this function instead of an assumption about the store it was handed.
285
+ // Codepoint comparison, deliberately not `localeCompare`: the ordering must not
286
+ // depend on the HOST's locale.
287
+ //
288
+ // That is ascending-by-scope, not byte-identical parity with the hosted path,
289
+ // and the difference is worth being precise about. `order by m.scope asc` sorts
290
+ // under the DATABASE's collation (`en_US.UTF-8` on a default Supabase project),
291
+ // which does not order like codepoint around punctuation — and a scope string
292
+ // is mostly punctuation (`::`, `/`, `-`), so `repo::a-b` and `repo::ab` can come
293
+ // out in the opposite relative order on the two surfaces. Case cannot differ
294
+ // (every scope segment is lowercased, see docs/scope-format.md). Closing the
295
+ // remaining gap means `collate "C"` on the RPC's `order by`, which changes the
296
+ // order `GET /memories/scopes` has always returned — a public contract change
297
+ // that belongs in its own migration, not here. Until then: both surfaces are
298
+ // sorted ascending, neither is unordered, and nothing should depend on the two
299
+ // agreeing on the exact position of a punctuated neighbour.
300
+ function sortScopes(rows) {
301
+ return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
302
+ }
303
+
304
+ // One inventory row. `last_activity` is passed through when the store supplied
305
+ // it (the hosted `GET /memories/scopes` has returned it since migration 00049)
306
+ // and OMITTED — never null — when it did not, so a client can tell "this store
307
+ // does not report freshness" from "this scope has no activity".
308
+ function shapeScope(s) {
309
+ const scope = String(s?.scope ?? '');
310
+ const count = Number(s?.count);
311
+ const row = { scope, count: Number.isFinite(count) ? count : 0 };
312
+ const last = s?.last_activity ?? s?.lastActivity;
313
+ return last ? { ...row, last_activity: last } : row;
314
+ }
315
+
316
+ // A short, bounded reason an enumeration produced nothing. Deliberately built
317
+ // here rather than reusing `lessons-view.mjs`'s `describeError`: that module
318
+ // carries the whole render/`util` stack, and this server has kept clear of it.
319
+ // The vocabulary matches what that helper reports, so the two read alike.
320
+ function scopeFailureNote(res) {
321
+ if (!res) return 'the store returned no result';
322
+ if (res.unusable) return 'no usable store is configured';
323
+ if (res.networkError) return `network error: ${String(res.networkError).slice(0, 200)}`;
324
+ // `httpStatus` is the ONLY field that carries a real status: `restFetch`'s
325
+ // error object is `{ message, code }`, and `code` is the response body's own
326
+ // application code on a JSON error, so rendering it as "HTTP <code>" would
327
+ // print a non-status. Read the top-level field first — that is the one
328
+ // `RemoteStore.listScopes()` passes through — and keep the nested read as a
329
+ // tolerance for any store that nests it instead.
330
+ const status = res.httpStatus ?? res.error?.httpStatus;
331
+ if (status) return `request failed with HTTP ${status}`;
332
+ if (res.error?.message) return String(res.error.message).slice(0, 200);
333
+ return 'the store could not enumerate its scopes';
334
+ }
335
+
336
+ function errText(e) {
337
+ return String(e?.message ?? e).slice(0, 200);
338
+ }
339
+
220
340
  // Provenance for a tool call: the caller's explicit values win, the working
221
341
  // directory and CI environment fill the rest. Best-effort — a failure to shell
222
342
  // out to git must never fail the write, so it degrades to no origin at all.
@@ -217,11 +217,43 @@ class RemoteStore {
217
217
  // is not relied upon (the server sorts by scope asc; the view re-sorts by
218
218
  // scope type). Failures use this store's standard `{ ok:false, error,
219
219
  // networkError }` envelope so the caller can degrade gracefully.
220
+ //
221
+ // `httpStatus` is carried through VERBATIM from `restFetch`, which is the ONLY
222
+ // place the real status lives: its error object holds `{ message, code }`,
223
+ // where `code` is the response body's own application code on a JSON error
224
+ // (a string like `permission_denied`) and only incidentally the status on a
225
+ // non-JSON one. A consumer that wants to say "HTTP 403" must therefore read
226
+ // `httpStatus`, never `error.code` — `mcp-server.mjs`'s `scopeFailureNote`
227
+ // does exactly that, and it had nothing to read until this field was passed
228
+ // through. Additive: `scopes.mjs`, `stats.mjs` and `lessons-view.mjs` all
229
+ // branch on `ok` / `unusable` / `networkError` and ignore the extra key.
220
230
  async listScopes() {
221
231
  const res = await this._rest('/memories/scopes');
222
- if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError, unusable: res.unusable };
232
+ if (!res.ok) {
233
+ return {
234
+ ok: false,
235
+ error: res.error,
236
+ httpStatus: res.httpStatus,
237
+ networkError: res.networkError,
238
+ unusable: res.unusable,
239
+ };
240
+ }
223
241
  const scopes = Array.isArray(res.data?.scopes) ? res.data.scopes : [];
224
- return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
242
+ // `last_activity` (migration 00049) is `max(created_at)` over exactly the
243
+ // counted rows — per-scope freshness without listing rows to reduce them,
244
+ // which is the row-cap trap this endpoint exists to avoid. It is passed
245
+ // through when present and OMITTED when absent (an older backend, or the
246
+ // offline store, which has no equivalent), so a consumer can tell "this
247
+ // store does not report freshness" from "this scope has none". Callers that
248
+ // read only `{ scope, count }` — `scopes.mjs`, `stats.mjs` — are unaffected.
249
+ return {
250
+ ok: true,
251
+ scopes: scopes.map((s) => ({
252
+ scope: s.scope,
253
+ count: Number(s.count) || 0,
254
+ ...(s.last_activity ? { last_activity: s.last_activity } : {}),
255
+ })),
256
+ };
225
257
  }
226
258
 
227
259
  // Authentication probe for doctor — does the configured token STILL work?
package/src/uninstall.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  resolveProjectRoot,
10
10
  removeSkill,
11
11
  removeMcpServer,
12
+ removeWebMcpServer,
12
13
  removeClaudeHooks,
13
14
  homeDir,
14
15
  } from './config.mjs';
@@ -56,6 +57,15 @@ export async function uninstall(args) {
56
57
  step: attempt(() => removeSkill(root, scope, skill.name)),
57
58
  }));
58
59
  const mcp = attempt(() => removeMcpServer(root, scope));
60
+ // `install --global --mcp-json` writes the committable project .mcp.json in
61
+ // ADDITION to ~/.claude.json, so a GLOBAL uninstall must also clear that
62
+ // entry or it orphans a lorekit server pointing at a now-removed setup. A
63
+ // project uninstall already targets .mcp.json via `mcp` above, so this is
64
+ // global-only. `removeWebMcpServer` removes ONLY the committable web form, so
65
+ // it never deletes an unrelated embedded-token `install --project` entry a
66
+ // user set up separately. Surgical and idempotent: no web file ⇒ a quiet
67
+ // "nothing to remove", other servers in the file are preserved.
68
+ const webMcp = scope === 'global' ? attempt(() => removeWebMcpServer(root)) : null;
59
69
  const hooks = attempt(() => removeClaudeHooks(root, scope));
60
70
 
61
71
  // Global paths shown relative to ~; project paths repo-relative.
@@ -74,15 +84,26 @@ export async function uninstall(args) {
74
84
  done: (r) => `lorekit server removed → ${display(r.file)}`,
75
85
  noop: 'no lorekit server entry — nothing to remove',
76
86
  });
87
+ if (webMcp) {
88
+ // Always the repo-relative project path, even under a global uninstall.
89
+ report(webMcp, '.mcp.json (web)', {
90
+ done: (r) => `lorekit server removed → ${path.relative(root, r.file) || r.file}`,
91
+ noop: 'no committable project .mcp.json — nothing to remove',
92
+ });
93
+ }
77
94
  report(hooks, 'hooks', {
78
95
  done: (r) => `${r.removed} removed → ${display(r.file)}`,
79
96
  noop: 'no lorekit hooks — nothing to remove',
80
97
  });
81
98
 
82
99
  const skillStepList = skillSteps.map((s) => s.step);
83
- const failed = [...skillStepList, mcp, hooks].some((s) => !s.ok);
100
+ const webSteps = webMcp ? [webMcp] : [];
101
+ const failed = [...skillStepList, mcp, ...webSteps, hooks].some((s) => !s.ok);
84
102
  const any =
85
- (skillStepList.some((s) => s.result?.removed) || mcp.result?.removed || hooks.result?.removed) && true;
103
+ (skillStepList.some((s) => s.result?.removed) ||
104
+ mcp.result?.removed ||
105
+ webMcp?.result?.removed ||
106
+ hooks.result?.removed) && true;
86
107
 
87
108
  if (failed) {
88
109
  log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);