@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 +57 -7
- package/bin/lorekit.mjs +35 -7
- package/package.json +6 -2
- package/skill/lorekit-memory/SKILL.md +3 -2
- package/src/config.mjs +249 -26
- package/src/doctor.mjs +12 -5
- package/src/install.mjs +72 -14
- package/src/mcp-server.mjs +18 -1
- package/src/store/created-at.mjs +28 -0
- package/src/store/local.mjs +19 -3
- package/src/telemetry-token.mjs +17 -0
- package/src/telemetry.mjs +325 -0
- package/src/uninstall.mjs +114 -0
- package/src/util.mjs +77 -0
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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_*`
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
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
|
|
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
|
|
118
|
-
A read-only token cannot write
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
// Non-throwing: returns null when the file is absent, invalid, or has
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
118
|
-
// (
|
|
119
|
-
// `splitEndpoint` is passed in to avoid a circular import
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 === '
|
|
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.
|