@lorekit/cli 1.0.1 → 1.3.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
@@ -37,19 +37,44 @@ Linux, and Windows (npm creates the `lorekit` shim on every platform).
37
37
 
38
38
  ### `lorekit install`
39
39
 
40
- Scaffolds the `lorekit-memory` skill into `.claude/skills/lorekit-memory/` and
41
- adds (or updates) a `lorekit` server in the project's `.mcp.json`, preserving
42
- any other MCP servers already configured.
40
+ Sets up the full memory loop the same three parts as the Claude plugin,
41
+ without needing a marketplace:
42
+
43
+ 1. **Skill** (`lorekit-memory`) — the model-invoked authoring judgment.
44
+ 2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
45
+ MCP config (preserving any other servers).
46
+ 3. **Hooks** — the *deterministic* layer: lessons injected on every
47
+ `SessionStart`, a nudge on tool failure (`PostToolUseFailure`), and a
48
+ retrospective nudge on `Stop`. These fire the shared `lorekit hook` engine
49
+ and are merged into `settings.json` (existing hooks preserved).
50
+
51
+ It first asks **where** to install:
52
+
53
+ - **project** (default) — `.claude/skills/`, `.mcp.json`, `.claude/settings.json`.
54
+ Scoped to this repo; commit it to share with your team.
55
+ - **global** — `~/.claude/skills/`, `~/.claude.json`, `~/.claude/settings.json`.
56
+ Applies to every project you open. Your token lands in `~/.claude.json`, so
57
+ keep that file private.
43
58
 
44
59
  ```bash
45
60
  lorekit install \
46
61
  --endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp \
47
62
  --token lk_rw_your_token
63
+
64
+ lorekit install --global # set it up for every project
48
65
  ```
49
66
 
50
- If run in a TTY without `--endpoint` / `--token`, it prompts for them.
51
- Use `--yes` for non-interactive environments (endpoint required via flag/env).
52
- Use `--force` to overwrite an existing skill copy.
67
+ In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
68
+ Flags: `--project` / `--global` pick the scope non-interactively; `--no-hooks`
69
+ installs the skill + MCP only (memory stays model-invoked); `--yes` runs
70
+ non-interactively (endpoint required via flag/env; scope defaults to project);
71
+ `--force` overwrites an existing skill copy. Re-running is idempotent — the hook
72
+ entries are updated in place, never duplicated.
73
+
74
+ > The hook command uses a global `lorekit` when one is on your `PATH` (fast),
75
+ > otherwise `npx -y @lorekit/cli`. Installing the CLI globally
76
+ > (`npm i -g @lorekit/cli`) is recommended so hooks fire without an npx
77
+ > resolution each time.
53
78
 
54
79
  ### `lorekit doctor`
55
80
 
@@ -63,7 +88,7 @@ Verifies the setup and prints a status report:
63
88
  gitignored
64
89
  - for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not
65
90
  the `<project-ref>` placeholder), the token and its permission tier
66
- (`lk_rw_*` vs `lk_ro_*`), and that the endpoint is reachable
91
+ (`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), and that the endpoint is reachable
67
92
  - for `off`: a note that memory is disabled
68
93
  - the git-derived read/write scopes for the current directory
69
94
 
@@ -243,6 +268,8 @@ active deny constraints.
243
268
  | Flag | Meaning |
244
269
  |------|---------|
245
270
  | `-d, --dir <path>` | Target project root (default: cwd) |
271
+ | `--project` | Install into this repo: `.claude/skills` + `.mcp.json` (`install`; default) |
272
+ | `--global` | Install for every project: `~/.claude/skills` + `~/.claude.json` (`install`) |
246
273
  | `-e, --endpoint <url>` | LoreKit MCP endpoint |
247
274
  | `-t, --token <token>` | LoreKit token |
248
275
  | `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
@@ -251,6 +278,7 @@ active deny constraints.
251
278
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
252
279
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
253
280
  | `-y, --yes` | Non-interactive / apply; never prompt |
281
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
254
282
  | `--force` | Overwrite existing skill files (`install`) |
255
283
  | `--deep` | Write/read/delete round-trip (`doctor`) |
256
284
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
@@ -269,6 +297,10 @@ active deny constraints.
269
297
  | `LOREKIT_MCP_URL` / `LOREKIT_ENDPOINT` | endpoint fallback |
270
298
  | `LOREKIT_TOKEN` | token fallback |
271
299
  | `NO_COLOR` | disable colored output |
300
+ | `LOREKIT_TELEMETRY` | set to `0` / `off` / `false` to disable usage telemetry |
301
+ | `DO_NOT_TRACK` | `1` also disables usage telemetry (cross-vendor standard) |
302
+ | `LOREKIT_TELEMETRY_TOKEN` | bearer token for telemetry export (overrides the baked-in default) |
303
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` | override the telemetry OTLP endpoint / headers |
272
304
 
273
305
  ## What the skill does
274
306
 
@@ -331,6 +363,24 @@ forever. This reduces manual validation to a single capture pass per tool.
331
363
  > the real Claude CLI accepts the wiring; for a true live check, install the
332
364
  > plugin and start one session per tool.
333
365
 
366
+ ## Usage telemetry
367
+
368
+ The human-facing commands (`install`, `uninstall`, `doctor`, `migrate`) emit one
369
+ OpenTelemetry span + one counter point per run so the maintainers can see which
370
+ commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
371
+ deliberately narrow — it carries only the command name, a bounded set of boolean
372
+ flags (`--global`, `--deep`, …), the CLI/runtime/OS identity, and the outcome.
373
+ **No path, token, endpoint, repo, or scope is ever sent.** The `hook` and `mcp`
374
+ commands are never instrumented.
375
+
376
+ Opt out any time with `LOREKIT_TELEMETRY=0` (or `off` / `false` / `no`) or the
377
+ cross-vendor `DO_NOT_TRACK=1`. Point it at your own collector with
378
+ `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS`, or set just the
379
+ bearer via `LOREKIT_TELEMETRY_TOKEN`. Export is a no-op when no token is
380
+ configured. The published package's default token is injected from a CI secret
381
+ at release time and is never committed to git. See
382
+ [docs/otel.md](../../docs/otel.md) for the attribute list and setup.
383
+
334
384
  ## Security note
335
385
 
336
386
  `install` writes your token into `.mcp.json`. Keep that file out of version
package/bin/lorekit.mjs CHANGED
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  // LoreKit CLI — install the shared-memory skill and run health checks.
3
3
  import process from 'node:process';
4
+ import { readFileSync } from 'node:fs';
4
5
  import { parseArgs, log, err, c } from '../src/util.mjs';
5
6
  import { install } from '../src/install.mjs';
7
+ import { uninstall } from '../src/uninstall.mjs';
6
8
  import { doctor } from '../src/doctor.mjs';
7
9
  import { hook } from '../src/hook.mjs';
8
10
  import { migrate } from '../src/migrate.mjs';
9
11
  import { mcpServer } from '../src/mcp-server.mjs';
12
+ import { traceCommand } from '../src/telemetry.mjs';
10
13
 
11
- const VERSION = '1.0.0';
14
+ // Read the version from package.json so it always matches the published
15
+ // package — release-please bumps package.json, and this tracks it for free.
16
+ const VERSION = JSON.parse(
17
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
18
+ ).version;
12
19
 
13
20
  const HELP = `${c.bold('lorekit')} — shared persistent memory for coding agents
14
21
 
@@ -16,8 +23,16 @@ ${c.bold('Usage')}
16
23
  npx @lorekit/cli <command> [options]
17
24
 
18
25
  ${c.bold('Commands')}
19
- install Scaffold the lorekit-memory skill into .claude/skills and
20
- add the LoreKit server to .mcp.json.
26
+ install Scaffold the lorekit-memory skill, wire the LoreKit MCP server,
27
+ and install the deterministic hooks (lessons on SessionStart, a
28
+ nudge on tool failure + at Stop). Prompts to install for this
29
+ project (.claude) or globally for every project (~/.claude);
30
+ --project / --global choose non-interactively, --no-hooks skips
31
+ the hooks (skill stays model-invoked only).
32
+ uninstall Reverse install: remove the lorekit-memory skill, the MCP server
33
+ entry, and the lifecycle hooks for the chosen scope. Surgical —
34
+ other servers, hooks, and settings are left untouched. Prompts
35
+ project vs global; --project / --global choose non-interactively.
21
36
  doctor Verify the skill install, MCP connectivity, token, and scope.
22
37
  migrate Relocate a LoreKit-format local store into the current layout.
23
38
  Dry-run by default; pass --yes to apply. Idempotent.
@@ -31,6 +46,8 @@ ${c.bold('Commands')}
31
46
 
32
47
  ${c.bold('Options')}
33
48
  -d, --dir <path> Target project root (default: current directory)
49
+ --project Install into this project: .claude/skills + .mcp.json (default)
50
+ --global Install for every project: ~/.claude/skills + ~/.claude.json
34
51
  -e, --endpoint <url> LoreKit MCP endpoint
35
52
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
36
53
  --mode <mode> Memory mode: off | local | remote (doctor override)
@@ -40,6 +57,7 @@ ${c.bold('Options')}
40
57
  default routes each entry by scope)
41
58
  --apply Apply the migration (alias of --yes) (migrate)
42
59
  -y, --yes Non-interactive / apply; never prompt
60
+ --no-hooks Skip wiring the lifecycle hooks (install)
43
61
  --force Overwrite existing skill files (install)
44
62
  --deep Do a write→read→delete round-trip (doctor)
45
63
  --adapter <name> Host framework for hook: claude | cursor | codex
@@ -55,9 +73,13 @@ ${c.bold('Environment')}
55
73
  LOREKIT_MCP_URL / LOREKIT_ENDPOINT endpoint fallback
56
74
  LOREKIT_TOKEN token fallback
57
75
  NO_COLOR disable colored output
76
+ LOREKIT_TELEMETRY / DO_NOT_TRACK set to 0/off (or DO_NOT_TRACK=1) to opt
77
+ out of anonymous command-usage telemetry
58
78
 
59
79
  ${c.bold('Examples')}
60
80
  npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
81
+ npx @lorekit/cli install --global # set up memory for every project (~/.claude)
82
+ npx @lorekit/cli uninstall --global # tear that global setup back down
61
83
  npx @lorekit/cli doctor --deep
62
84
  npx @lorekit/cli migrate --from .lore # preview a rename
63
85
  npx @lorekit/cli migrate --from .lore --to project --yes
@@ -67,7 +89,7 @@ async function main() {
67
89
  const argv = process.argv.slice(2);
68
90
  const args = parseArgs(argv, {
69
91
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
70
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version'],
92
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks'],
71
93
  });
72
94
 
73
95
  // `hook` is machine-facing: it must never print help/errors to stdout
@@ -95,13 +117,19 @@ async function main() {
95
117
  return command ? 0 : args.help ? 0 : 1;
96
118
  }
97
119
 
120
+ // Human-facing commands are wrapped so we can see which commands people run
121
+ // (one OTel span + counter per invocation). `hook` and `mcp` are handled
122
+ // above and stay uninstrumented — they are machine-facing, fire on every
123
+ // agent event, and must keep stdout to their host protocol.
98
124
  switch (command) {
99
125
  case 'install':
100
- return install(args);
126
+ return traceCommand('install', args, VERSION, () => install(args));
127
+ case 'uninstall':
128
+ return traceCommand('uninstall', args, VERSION, () => uninstall(args));
101
129
  case 'doctor':
102
- return doctor(args);
130
+ return traceCommand('doctor', args, VERSION, () => doctor(args));
103
131
  case 'migrate':
104
- return migrate(args);
132
+ return traceCommand('migrate', args, VERSION, () => migrate(args));
105
133
  default:
106
134
  err(`${c.red('Unknown command:')} ${command}\n`);
107
135
  log(HELP);
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.0.1",
3
+ "version": "1.3.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/mthines/lorekit"
9
+ },
6
10
  "type": "module",
7
11
  "bin": {
8
12
  "lorekit": "bin/lorekit.mjs"
@@ -33,4 +37,4 @@
33
37
  "doctor": "node bin/lorekit.mjs doctor",
34
38
  "test": "node --test test/*.test.mjs"
35
39
  }
36
- }
40
+ }
@@ -114,8 +114,9 @@ Full resolution rules: [references/scope-resolution.md](./references/scope-resol
114
114
  | `memory.read` | Read one lesson by scope + key | read |
115
115
  | `memory.write` | Store or update a lesson (same scope+key updates in place) | read+write |
116
116
 
117
- Write tools need an `lk_rw_*` token; read tools accept `lk_rw_*` or `lk_ro_*`.
118
- A read-only token cannot write if a write fails with an authorization error,
117
+ Write tools need write permission (`lk_rw_*` or `lk_wo_*`); read tools need read
118
+ permission (`lk_rw_*` or `lk_ro_*`). A read-only token cannot write and a
119
+ write-only token cannot read — if a call fails with an authorization error,
119
120
  report it and move on; do not retry.
120
121
 
121
122
  Every lesson this skill writes carries the tag `skill::lorekit-memory` plus a
package/src/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Project layout + .mcp.json read/merge helpers.
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -12,12 +13,169 @@ export function resolveProjectRoot(dir) {
12
13
  return path.resolve(dir || process.cwd());
13
14
  }
14
15
 
16
+ // Atomic config write: serialize to a sibling temp file, then rename over the
17
+ // target. rename(2) is atomic within a filesystem, so a crash / Ctrl-C / ENOSPC
18
+ // mid-write can never leave a half-written (corrupt) file — the original stays
19
+ // intact until the complete new content is swapped in. This matters most for
20
+ // ~/.claude.json, which can be large and holds all of Claude Code's per-project
21
+ // state and OAuth tokens. The temp file inherits the target's permissions when
22
+ // it exists (so we don't widen a locked-down 0600 config to 0644 on replace).
23
+ export function writeFileAtomic(file, data) {
24
+ const dir = path.dirname(file);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
27
+ try {
28
+ fs.writeFileSync(tmp, data);
29
+ try {
30
+ fs.chmodSync(tmp, fs.statSync(file).mode);
31
+ } catch {
32
+ /* target didn't exist — leave the temp file's default perms */
33
+ }
34
+ fs.renameSync(tmp, file);
35
+ } catch (e) {
36
+ try {
37
+ fs.rmSync(tmp, { force: true });
38
+ } catch {
39
+ /* best-effort cleanup of the temp file */
40
+ }
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ // The user's home directory. Honors $HOME / %USERPROFILE% (so it can be
46
+ // redirected in tests) and falls back to the OS lookup.
47
+ export function homeDir() {
48
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
49
+ }
50
+
15
51
  export function mcpJsonPath(root) {
16
52
  return path.join(root, '.mcp.json');
17
53
  }
18
54
 
19
- export function skillInstallDir(root) {
20
- return path.join(root, '.claude', 'skills', SKILL_NAME);
55
+ // Where the MCP server entry is written for a given scope:
56
+ // project → <root>/.mcp.json (Claude Code project config)
57
+ // global → ~/.claude.json (Claude Code user config, all projects)
58
+ export function mcpConfigPath(root, scope = 'project') {
59
+ return scope === 'global' ? path.join(homeDir(), '.claude.json') : mcpJsonPath(root);
60
+ }
61
+
62
+ // Where the skill is scaffolded for a given scope:
63
+ // project → <root>/.claude/skills/… (this repo only)
64
+ // global → ~/.claude/skills/… (personal skills, all projects)
65
+ export function skillInstallDir(root, scope = 'project') {
66
+ const base = scope === 'global' ? homeDir() : root;
67
+ return path.join(base, '.claude', 'skills', SKILL_NAME);
68
+ }
69
+
70
+ // Claude Code settings file that holds the hooks for a given scope:
71
+ // project → <root>/.claude/settings.json
72
+ // global → ~/.claude/settings.json
73
+ export function settingsPath(root, scope = 'project') {
74
+ const base = scope === 'global' ? homeDir() : root;
75
+ return path.join(base, '.claude', 'settings.json');
76
+ }
77
+
78
+ // The lifecycle events the memory loop wires: read lessons on start, nudge on a
79
+ // tool failure, nudge a retrospective at end of turn. Mirrors the plugin's
80
+ // hooks.json so `install` delivers the same deterministic layer.
81
+ export const CLAUDE_HOOK_EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop'];
82
+
83
+ // Matches a hook command that fires the lorekit engine, whether wired as a
84
+ // global `lorekit hook …` or `npx -y @lorekit/cli hook …`. Shared by the
85
+ // upsert (find-or-update) and remove (uninstall) paths so they agree on what
86
+ // counts as "ours".
87
+ export const LOREKIT_HOOK_RE = /(?:@lorekit\/cli|lorekit) hook\b/;
88
+
89
+ // npx stages the package's own bin into an ephemeral cache dir
90
+ // (…/_npx/<hash>/node_modules/.bin) and prepends it to PATH for the lifetime of
91
+ // the `npx @lorekit/cli …` process. That makes `lorekit` *look* globally
92
+ // installed during `install`, so the hooks get wired as bare `lorekit hook …`
93
+ // — but the symlink vanishes when npx exits, and Claude Code then fails every
94
+ // hook with `lorekit: command not found`. Excluding these transient dirs keeps
95
+ // the bare-`lorekit` runner reserved for a genuine global install.
96
+ const isEphemeralNpxDir = (dir) => /[\\/]_npx[\\/]/.test(dir);
97
+
98
+ // Is `bin` resolvable on a *durable* PATH entry? Used to prefer a fast global
99
+ // `lorekit` over `npx` for hook commands. Zero-dep, cross-platform.
100
+ export function onPath(bin) {
101
+ const dirs = (process.env.PATH || '').split(path.delimiter);
102
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
103
+ for (const dir of dirs) {
104
+ if (!dir || isEphemeralNpxDir(dir)) continue;
105
+ for (const ext of exts) {
106
+ try {
107
+ if (fs.existsSync(path.join(dir, bin + ext))) return true;
108
+ } catch {
109
+ /* ignore unreadable PATH entries */
110
+ }
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // The command prefix for hook entries: a global `lorekit` when it's installed
117
+ // (fast — no npx resolution per event), else `npx -y @lorekit/cli`.
118
+ export function resolveHookRunner() {
119
+ return onPath('lorekit') ? 'lorekit' : 'npx -y @lorekit/cli';
120
+ }
121
+
122
+ // Read the lorekit MCP server entry out of an arbitrary config file.
123
+ function readServerFromFile(file) {
124
+ if (!fs.existsSync(file)) return null;
125
+ let config;
126
+ try {
127
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
128
+ } catch {
129
+ return null;
130
+ }
131
+ const server = config && config.mcpServers && config.mcpServers.lorekit;
132
+ if (!server) return null;
133
+ const args = Array.isArray(server.args) ? server.args : [];
134
+ const url = args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
135
+ return { server, url: url || null };
136
+ }
137
+
138
+ // Wire the lorekit hook engine into Claude Code settings for the scope,
139
+ // preserving all other settings and any non-lorekit hooks. Idempotent: an
140
+ // existing lorekit hook entry per event is updated in place, never duplicated.
141
+ // `runner` is the command prefix (e.g. 'lorekit' or 'npx -y @lorekit/cli').
142
+ export function upsertClaudeHooks(root, scope, runner) {
143
+ const file = settingsPath(root, scope);
144
+ const config = readJsonIfExists(file) || {};
145
+ if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
146
+
147
+ let added = 0;
148
+ let updated = 0;
149
+ let unchanged = 0;
150
+
151
+ for (const event of CLAUDE_HOOK_EVENTS) {
152
+ const command = `${runner} hook --adapter claude --event ${event} --dir "\${CLAUDE_PROJECT_DIR}"`;
153
+ if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
154
+ const groups = config.hooks[event];
155
+
156
+ let existing = null;
157
+ for (const group of groups) {
158
+ const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
159
+ existing = inner.find(
160
+ (h) => h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command),
161
+ );
162
+ if (existing) break;
163
+ }
164
+
165
+ if (existing) {
166
+ if (existing.command === command) unchanged++;
167
+ else {
168
+ existing.command = command;
169
+ updated++;
170
+ }
171
+ } else {
172
+ groups.push({ hooks: [{ type: 'command', command }] });
173
+ added++;
174
+ }
175
+ }
176
+
177
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
178
+ return { file, added, updated, unchanged };
21
179
  }
22
180
 
23
181
  // Throwing read — used by `install` so a corrupt .mcp.json aborts the write
@@ -44,9 +202,10 @@ export function readMcpConfig(root) {
44
202
  }
45
203
  }
46
204
 
47
- // Merge a lorekit server entry into .mcp.json, preserving any other servers.
48
- export function upsertMcpServer(root, remoteUrl) {
49
- const file = mcpJsonPath(root);
205
+ // Merge a lorekit server entry into the scope's MCP config, preserving any
206
+ // other servers (and, for the global ~/.claude.json, all other user settings).
207
+ export function upsertMcpServer(root, remoteUrl, scope = 'project') {
208
+ const file = mcpConfigPath(root, scope);
50
209
  const config = readJsonIfExists(file) || {};
51
210
  if (!config.mcpServers || typeof config.mcpServers !== 'object') {
52
211
  config.mcpServers = {};
@@ -56,20 +215,80 @@ export function upsertMcpServer(root, remoteUrl) {
56
215
  command: 'npx',
57
216
  args: ['-y', 'mcp-remote', remoteUrl],
58
217
  };
59
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
218
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
60
219
  return { file, existed };
61
220
  }
62
221
 
63
- // Pull the configured lorekit remote URL out of .mcp.json, if present.
64
- // Non-throwing: returns null when the file is absent, invalid, or has no
65
- // lorekit server. Callers that need to distinguish those use readMcpConfig.
222
+ // Pull the configured lorekit remote URL out of the project .mcp.json, if
223
+ // present. Non-throwing: returns null when the file is absent, invalid, or has
224
+ // no lorekit server. Callers that need to distinguish those use readMcpConfig.
66
225
  export function readLorekitServer(root) {
67
- const { config } = readMcpConfig(root);
68
- const server = config && config.mcpServers && config.mcpServers.lorekit;
69
- if (!server) return null;
70
- const args = Array.isArray(server.args) ? server.args : [];
71
- const url = args.find((a) => typeof a === 'string' && /^https?:\/\//.test(a));
72
- return { server, url: url || null };
226
+ return readServerFromFile(mcpJsonPath(root));
227
+ }
228
+
229
+ // --- uninstall: surgical removal of the three things `install` writes. -------
230
+ // Each helper touches only lorekit's own entries and leaves every other
231
+ // server / hook / setting intact, so uninstalling never damages a shared
232
+ // ~/.claude.json or settings.json.
233
+
234
+ // Delete the scaffolded skill directory. We own the whole
235
+ // .claude/skills/lorekit-memory tree, so a recursive remove is safe.
236
+ export function removeSkill(root, scope = 'project') {
237
+ const dest = skillInstallDir(root, scope);
238
+ const removed = fs.existsSync(dest);
239
+ if (removed) fs.rmSync(dest, { recursive: true, force: true });
240
+ return { dest, removed };
241
+ }
242
+
243
+ // Drop the `lorekit` MCP server entry, preserving any other servers (and, for
244
+ // the global ~/.claude.json, all other user settings). Prunes an emptied
245
+ // mcpServers object. No-op (no write) when there's nothing to remove.
246
+ export function removeMcpServer(root, scope = 'project') {
247
+ const file = mcpConfigPath(root, scope);
248
+ const config = readJsonIfExists(file);
249
+ const hasEntry =
250
+ config &&
251
+ config.mcpServers &&
252
+ typeof config.mcpServers === 'object' &&
253
+ Object.prototype.hasOwnProperty.call(config.mcpServers, 'lorekit');
254
+ if (!hasEntry) return { file, removed: false };
255
+
256
+ delete config.mcpServers.lorekit;
257
+ if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
258
+ writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
259
+ return { file, removed: true };
260
+ }
261
+
262
+ // Strip lorekit hook entries from every event, preserving non-lorekit hooks in
263
+ // the same groups. Prunes groups left with no hooks and events left with no
264
+ // groups. Returns the count removed; only writes when something changed.
265
+ export function removeClaudeHooks(root, scope = 'project') {
266
+ const file = settingsPath(root, scope);
267
+ const config = readJsonIfExists(file);
268
+ if (!config || !config.hooks || typeof config.hooks !== 'object') {
269
+ return { file, removed: 0 };
270
+ }
271
+
272
+ let removed = 0;
273
+ for (const event of Object.keys(config.hooks)) {
274
+ const groups = config.hooks[event];
275
+ if (!Array.isArray(groups)) continue;
276
+ for (const group of groups) {
277
+ if (!group || !Array.isArray(group.hooks)) continue;
278
+ const before = group.hooks.length;
279
+ group.hooks = group.hooks.filter(
280
+ (h) => !(h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command)),
281
+ );
282
+ removed += before - group.hooks.length;
283
+ }
284
+ // Drop groups we emptied, then the event key if it has no groups left.
285
+ config.hooks[event] = groups.filter((g) => g && Array.isArray(g.hooks) && g.hooks.length > 0);
286
+ if (config.hooks[event].length === 0) delete config.hooks[event];
287
+ }
288
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
289
+
290
+ if (removed > 0) writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
291
+ return { file, removed };
73
292
  }
74
293
 
75
294
  // Recursively copy the skill source into the target, skipping files that
@@ -111,21 +330,25 @@ export function tokenKind(token) {
111
330
  if (!token) return 'none';
112
331
  if (token.startsWith('lk_rw_')) return 'read-write';
113
332
  if (token.startsWith('lk_ro_')) return 'read-only';
333
+ if (token.startsWith('lk_wo_')) return 'write-only';
114
334
  return 'unknown';
115
335
  }
116
336
 
117
- // For hooks: resolve the connection from the project's .mcp.json first
118
- // (that is where `lorekit install` wrote the token), then fall back to env.
119
- // `splitEndpoint` is passed in to avoid a circular import with mcp.mjs.
337
+ // For hooks: resolve the connection closest-scope first — the project's
338
+ // .mcp.json (a project install), then the global ~/.claude.json (a global
339
+ // install), then env. `splitEndpoint` is passed in to avoid a circular import
340
+ // with mcp.mjs.
120
341
  export function resolveProjectConnection(root, splitEndpoint) {
121
- const configured = readLorekitServer(root);
122
- if (configured && configured.url) {
123
- const { endpoint, token } = splitEndpoint(configured.url);
124
- if (endpoint && !endpoint.includes('<project-ref>')) {
125
- return {
126
- endpoint,
127
- token: token || process.env.LOREKIT_TOKEN || null,
128
- };
342
+ const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))];
343
+ for (const configured of sources) {
344
+ if (configured && configured.url) {
345
+ const { endpoint, token } = splitEndpoint(configured.url);
346
+ if (endpoint && !endpoint.includes('<project-ref>')) {
347
+ return {
348
+ endpoint,
349
+ token: token || process.env.LOREKIT_TOKEN || null,
350
+ };
351
+ }
129
352
  }
130
353
  }
131
354
  return resolveConnection({});
package/src/doctor.mjs CHANGED
@@ -41,10 +41,16 @@ export async function doctor(args) {
41
41
  `v${process.versions.node}${major < 18 ? ' — need v18+ for fetch' : ''}`,
42
42
  );
43
43
 
44
- // 2. Skill installed.
45
- const skillMd = path.join(skillInstallDir(root), 'SKILL.md');
46
- if (fs.existsSync(skillMd)) {
47
- record('pass', `skill ${SKILL_NAME}`, path.relative(root, skillMd) || skillMd);
44
+ // 2. Skill installed — check BOTH the project and the global (~/.claude)
45
+ // locations. `lorekit install --global` writes the skill under home, not the
46
+ // repo, so a project-only check reports a healthy global install as "not
47
+ // found" (exactly the false FAIL a --global setup would hit).
48
+ const skillMd = [skillInstallDir(root, 'project'), skillInstallDir(root, 'global')]
49
+ .map((dir) => path.join(dir, 'SKILL.md'))
50
+ .find((p) => fs.existsSync(p));
51
+ if (skillMd) {
52
+ const rel = path.relative(root, skillMd);
53
+ record('pass', `skill ${SKILL_NAME}`, rel && !rel.startsWith('..') ? rel : prettyPath(skillMd));
48
54
  } else {
49
55
  record('fail', `skill ${SKILL_NAME}`, 'not found — run `lorekit install`');
50
56
  }
@@ -163,7 +169,8 @@ async function checkRemote(control, root, args, record) {
163
169
  const kind = tokenKind(token);
164
170
  if (kind === 'none') record('fail', 'token', 'none configured');
165
171
  else if (kind === 'read-only') record('warn', 'token', 'read-only (lk_ro_*) — reads only, no writes');
166
- else if (kind === 'unknown') record('warn', 'token', 'unrecognized prefix (expected lk_rw_* / lk_ro_*)');
172
+ else if (kind === 'write-only') record('warn', 'token', 'write-only (lk_wo_*) writes only, no reads');
173
+ else if (kind === 'unknown') record('warn', 'token', 'unrecognized prefix (expected lk_rw_* / lk_ro_* / lk_wo_*)');
167
174
  else record('pass', 'token', 'read+write (lk_rw_*)');
168
175
 
169
176
  // Connectivity, through the store.