@lorekit/cli 1.0.0 → 1.2.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 +54 -9
- package/bin/lorekit.mjs +25 -4
- 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 +73 -15
- package/src/mcp-server.mjs +18 -1
- package/src/store/created-at.mjs +28 -0
- package/src/store/local.mjs +19 -3
- package/src/uninstall.mjs +114 -0
- package/src/util.mjs +77 -0
package/README.md
CHANGED
|
@@ -9,30 +9,72 @@ CI included. This CLI wires an agent up to it in two commands.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
+
Install it globally — recommended. You get one pinned version, the `lorekit`
|
|
13
|
+
command stays on your `PATH`, and the plugin hooks (which call `lorekit hook`
|
|
14
|
+
on every lifecycle event) fire instantly instead of paying an `npx` resolution
|
|
15
|
+
cost each time:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @lorekit/cli
|
|
19
|
+
lorekit install
|
|
20
|
+
lorekit doctor
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Upgrade later with `npm install -g @lorekit/cli@latest`.
|
|
24
|
+
|
|
25
|
+
Prefer not to install anything? Run it on demand with `npx` — same commands,
|
|
26
|
+
always the latest version, fetched and cached per use:
|
|
27
|
+
|
|
12
28
|
```bash
|
|
13
29
|
npx @lorekit/cli install
|
|
14
30
|
npx @lorekit/cli doctor
|
|
15
31
|
```
|
|
16
32
|
|
|
17
|
-
Requires Node 18+ (for the built-in `fetch`). No dependencies.
|
|
33
|
+
Requires Node 18+ (for the built-in `fetch`). No dependencies. Works on macOS,
|
|
34
|
+
Linux, and Windows (npm creates the `lorekit` shim on every platform).
|
|
18
35
|
|
|
19
36
|
## Commands
|
|
20
37
|
|
|
21
38
|
### `lorekit install`
|
|
22
39
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
26
58
|
|
|
27
59
|
```bash
|
|
28
60
|
lorekit install \
|
|
29
|
-
--endpoint https
|
|
61
|
+
--endpoint https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp \
|
|
30
62
|
--token lk_rw_your_token
|
|
63
|
+
|
|
64
|
+
lorekit install --global # set it up for every project
|
|
31
65
|
```
|
|
32
66
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
36
78
|
|
|
37
79
|
### `lorekit doctor`
|
|
38
80
|
|
|
@@ -46,7 +88,7 @@ Verifies the setup and prints a status report:
|
|
|
46
88
|
gitignored
|
|
47
89
|
- for `remote`: `.mcp.json` has a `lorekit` server, the endpoint is real (not
|
|
48
90
|
the `<project-ref>` placeholder), the token and its permission tier
|
|
49
|
-
(`lk_rw_*`
|
|
91
|
+
(`lk_rw_*` / `lk_ro_*` / `lk_wo_*`), and that the endpoint is reachable
|
|
50
92
|
- for `off`: a note that memory is disabled
|
|
51
93
|
- the git-derived read/write scopes for the current directory
|
|
52
94
|
|
|
@@ -226,6 +268,8 @@ active deny constraints.
|
|
|
226
268
|
| Flag | Meaning |
|
|
227
269
|
|------|---------|
|
|
228
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`) |
|
|
229
273
|
| `-e, --endpoint <url>` | LoreKit MCP endpoint |
|
|
230
274
|
| `-t, --token <token>` | LoreKit token |
|
|
231
275
|
| `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
|
|
@@ -234,6 +278,7 @@ active deny constraints.
|
|
|
234
278
|
| `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
|
|
235
279
|
| `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
|
|
236
280
|
| `-y, --yes` | Non-interactive / apply; never prompt |
|
|
281
|
+
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
237
282
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
238
283
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
239
284
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
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';
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
// Read the version from package.json so it always matches the published
|
|
14
|
+
// package — release-please bumps package.json, and this tracks it for free.
|
|
15
|
+
const VERSION = JSON.parse(
|
|
16
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
17
|
+
).version;
|
|
12
18
|
|
|
13
19
|
const HELP = `${c.bold('lorekit')} — shared persistent memory for coding agents
|
|
14
20
|
|
|
@@ -16,8 +22,16 @@ ${c.bold('Usage')}
|
|
|
16
22
|
npx @lorekit/cli <command> [options]
|
|
17
23
|
|
|
18
24
|
${c.bold('Commands')}
|
|
19
|
-
install Scaffold the lorekit-memory skill
|
|
20
|
-
|
|
25
|
+
install Scaffold the lorekit-memory skill, wire the LoreKit MCP server,
|
|
26
|
+
and install the deterministic hooks (lessons on SessionStart, a
|
|
27
|
+
nudge on tool failure + at Stop). Prompts to install for this
|
|
28
|
+
project (.claude) or globally for every project (~/.claude);
|
|
29
|
+
--project / --global choose non-interactively, --no-hooks skips
|
|
30
|
+
the hooks (skill stays model-invoked only).
|
|
31
|
+
uninstall Reverse install: remove the lorekit-memory skill, the MCP server
|
|
32
|
+
entry, and the lifecycle hooks for the chosen scope. Surgical —
|
|
33
|
+
other servers, hooks, and settings are left untouched. Prompts
|
|
34
|
+
project vs global; --project / --global choose non-interactively.
|
|
21
35
|
doctor Verify the skill install, MCP connectivity, token, and scope.
|
|
22
36
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
23
37
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
@@ -31,6 +45,8 @@ ${c.bold('Commands')}
|
|
|
31
45
|
|
|
32
46
|
${c.bold('Options')}
|
|
33
47
|
-d, --dir <path> Target project root (default: current directory)
|
|
48
|
+
--project Install into this project: .claude/skills + .mcp.json (default)
|
|
49
|
+
--global Install for every project: ~/.claude/skills + ~/.claude.json
|
|
34
50
|
-e, --endpoint <url> LoreKit MCP endpoint
|
|
35
51
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
36
52
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
@@ -40,6 +56,7 @@ ${c.bold('Options')}
|
|
|
40
56
|
default routes each entry by scope)
|
|
41
57
|
--apply Apply the migration (alias of --yes) (migrate)
|
|
42
58
|
-y, --yes Non-interactive / apply; never prompt
|
|
59
|
+
--no-hooks Skip wiring the lifecycle hooks (install)
|
|
43
60
|
--force Overwrite existing skill files (install)
|
|
44
61
|
--deep Do a write→read→delete round-trip (doctor)
|
|
45
62
|
--adapter <name> Host framework for hook: claude | cursor | codex
|
|
@@ -58,6 +75,8 @@ ${c.bold('Environment')}
|
|
|
58
75
|
|
|
59
76
|
${c.bold('Examples')}
|
|
60
77
|
npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
|
|
78
|
+
npx @lorekit/cli install --global # set up memory for every project (~/.claude)
|
|
79
|
+
npx @lorekit/cli uninstall --global # tear that global setup back down
|
|
61
80
|
npx @lorekit/cli doctor --deep
|
|
62
81
|
npx @lorekit/cli migrate --from .lore # preview a rename
|
|
63
82
|
npx @lorekit/cli migrate --from .lore --to project --yes
|
|
@@ -67,7 +86,7 @@ async function main() {
|
|
|
67
86
|
const argv = process.argv.slice(2);
|
|
68
87
|
const args = parseArgs(argv, {
|
|
69
88
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
70
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version'],
|
|
89
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks'],
|
|
71
90
|
});
|
|
72
91
|
|
|
73
92
|
// `hook` is machine-facing: it must never print help/errors to stdout
|
|
@@ -98,6 +117,8 @@ async function main() {
|
|
|
98
117
|
switch (command) {
|
|
99
118
|
case 'install':
|
|
100
119
|
return install(args);
|
|
120
|
+
case 'uninstall':
|
|
121
|
+
return uninstall(args);
|
|
101
122
|
case 'doctor':
|
|
102
123
|
return doctor(args);
|
|
103
124
|
case 'migrate':
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lorekit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.
|
package/src/install.mjs
CHANGED
|
@@ -10,19 +10,23 @@ import {
|
|
|
10
10
|
skillInstallDir,
|
|
11
11
|
copyDir,
|
|
12
12
|
upsertMcpServer,
|
|
13
|
+
upsertClaudeHooks,
|
|
14
|
+
resolveHookRunner,
|
|
15
|
+
CLAUDE_HOOK_EVENTS,
|
|
13
16
|
resolveConnection,
|
|
14
17
|
tokenKind,
|
|
18
|
+
homeDir,
|
|
15
19
|
} from './config.mjs';
|
|
16
20
|
import { buildRemoteUrl } from './mcp.mjs';
|
|
17
21
|
import { deriveScope } from './scope.mjs';
|
|
18
|
-
import { log, err, heading, status, c } from './util.mjs';
|
|
22
|
+
import { log, err, heading, status, select, c } from './util.mjs';
|
|
19
23
|
|
|
20
24
|
function ask(question) {
|
|
21
25
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
22
26
|
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
const DEFAULT_ENDPOINT_HINT = 'https
|
|
29
|
+
const DEFAULT_ENDPOINT_HINT = 'https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp';
|
|
26
30
|
|
|
27
31
|
export async function install(args) {
|
|
28
32
|
const root = resolveProjectRoot(args.dir);
|
|
@@ -31,7 +35,26 @@ export async function install(args) {
|
|
|
31
35
|
heading('LoreKit install');
|
|
32
36
|
log(` project: ${c.dim(root)}`);
|
|
33
37
|
|
|
34
|
-
// 1.
|
|
38
|
+
// 1. Scope: this project, or user-global (every project). --global / --project
|
|
39
|
+
// force it; otherwise prompt when interactive, else default to project.
|
|
40
|
+
let scope = args.global ? 'global' : args.project ? 'project' : null;
|
|
41
|
+
if (!scope) {
|
|
42
|
+
if (nonInteractive) {
|
|
43
|
+
scope = 'project';
|
|
44
|
+
} else {
|
|
45
|
+
scope = await select('Install LoreKit for…', [
|
|
46
|
+
{ label: 'This project', value: 'project', hint: 'this repo only (.claude, .mcp.json)' },
|
|
47
|
+
{ label: 'All projects (global)', value: 'global', hint: 'every project (~/.claude)' },
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
log(
|
|
52
|
+
` install: ${c.dim(
|
|
53
|
+
scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
|
|
54
|
+
)}`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// 2. Connection details.
|
|
35
58
|
let { endpoint, token } = resolveConnection(args);
|
|
36
59
|
|
|
37
60
|
if (!endpoint) {
|
|
@@ -50,46 +73,81 @@ export async function install(args) {
|
|
|
50
73
|
token = token || null;
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
//
|
|
54
|
-
const dest = skillInstallDir(root);
|
|
76
|
+
// 3. Install the skill files.
|
|
77
|
+
const dest = skillInstallDir(root, scope);
|
|
55
78
|
const skillExisted = fs.existsSync(path.join(dest, 'SKILL.md'));
|
|
56
79
|
const written = copyDir(SKILL_SOURCE, dest, { force: Boolean(args.force) });
|
|
57
80
|
|
|
58
|
-
//
|
|
81
|
+
// 4. Wire the MCP config for the chosen scope.
|
|
59
82
|
const remoteUrl = buildRemoteUrl(endpoint, token);
|
|
60
|
-
const { file, existed } = upsertMcpServer(root, remoteUrl);
|
|
83
|
+
const { file, existed } = upsertMcpServer(root, remoteUrl, scope);
|
|
84
|
+
|
|
85
|
+
// 4b. Wire the deterministic hooks (unless --no-hooks). This is the layer the
|
|
86
|
+
// Claude plugin adds on top of the skill: lessons injected on every
|
|
87
|
+
// SessionStart, a nudge on tool failure, a retrospective nudge on Stop —
|
|
88
|
+
// firing the shared `lorekit hook` engine, which reads the same config.
|
|
89
|
+
const wireHooks = !args['no-hooks'];
|
|
90
|
+
let hooks = null;
|
|
91
|
+
if (wireHooks) {
|
|
92
|
+
hooks = upsertClaudeHooks(root, scope, resolveHookRunner());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Show global paths relative to ~ (a repo-relative path would be a mess of
|
|
96
|
+
// ../../); project paths stay repo-relative.
|
|
97
|
+
const display = (p) =>
|
|
98
|
+
scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
|
|
99
|
+
const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
|
|
61
100
|
|
|
62
|
-
//
|
|
101
|
+
// 5. Report.
|
|
63
102
|
heading('Done');
|
|
64
103
|
const skillState = !skillExisted
|
|
65
104
|
? 'installed'
|
|
66
105
|
: written > 0
|
|
67
106
|
? `updated (${written} file(s) written)`
|
|
68
107
|
: 'unchanged — pass --force to overwrite';
|
|
69
|
-
status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${
|
|
70
|
-
status('pass',
|
|
108
|
+
status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${display(dest)}`);
|
|
109
|
+
status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
|
|
110
|
+
|
|
111
|
+
if (!wireHooks) {
|
|
112
|
+
status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but lessons are model-invoked only');
|
|
113
|
+
} else {
|
|
114
|
+
const n = hooks.added + hooks.updated;
|
|
115
|
+
const hookState =
|
|
116
|
+
n === 0
|
|
117
|
+
? 'unchanged — already wired'
|
|
118
|
+
: `${hooks.added ? `${hooks.added} added` : ''}${hooks.added && hooks.updated ? ', ' : ''}${hooks.updated ? `${hooks.updated} updated` : ''}`;
|
|
119
|
+
status(n === 0 ? 'info' : 'pass', 'hooks', `${hookState} → ${display(hooks.file)} (${CLAUDE_HOOK_EVENTS.join(', ')})`);
|
|
120
|
+
}
|
|
71
121
|
|
|
72
122
|
const kind = tokenKind(token);
|
|
73
123
|
if (kind === 'none') {
|
|
74
124
|
status('warn', 'token', 'none configured — reads/writes will fail until a token is set');
|
|
75
125
|
} else if (kind === 'read-only') {
|
|
76
126
|
status('warn', 'token', 'read-only (lk_ro_*) — the skill can read lessons but not write them');
|
|
127
|
+
} else if (kind === 'write-only') {
|
|
128
|
+
status('warn', 'token', 'write-only (lk_wo_*) — the skill can write lessons but not read them');
|
|
77
129
|
} else if (kind === 'unknown') {
|
|
78
|
-
status('warn', 'token', 'unrecognized prefix — expected lk_rw_
|
|
130
|
+
status('warn', 'token', 'unrecognized prefix — expected lk_rw_*, lk_ro_*, or lk_wo_*');
|
|
79
131
|
} else {
|
|
80
132
|
status('pass', 'token', 'read+write (lk_rw_*)');
|
|
81
133
|
}
|
|
82
134
|
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
status('info', 'scope', `${
|
|
135
|
+
const gitScope = deriveScope(root);
|
|
136
|
+
if (gitScope.hasRemote) {
|
|
137
|
+
status('info', 'scope', `${gitScope.repoScope}${gitScope.branchScope ? ` · ${gitScope.branchScope}` : ''}`);
|
|
86
138
|
} else {
|
|
87
139
|
status('warn', 'scope', 'no git remote — lessons will fall back to global');
|
|
88
140
|
}
|
|
89
141
|
|
|
90
142
|
log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
|
|
91
143
|
if (token) {
|
|
92
|
-
log(
|
|
144
|
+
log(
|
|
145
|
+
` ${c.dim(
|
|
146
|
+
scope === 'global'
|
|
147
|
+
? 'Note: your token now lives in ~/.claude.json (used by every project) — keep that file private.'
|
|
148
|
+
: 'Note: your token now lives in .mcp.json — keep it out of version control.',
|
|
149
|
+
)}`,
|
|
150
|
+
);
|
|
93
151
|
}
|
|
94
152
|
return 0;
|
|
95
153
|
}
|
package/src/mcp-server.mjs
CHANGED
|
@@ -30,7 +30,24 @@ export const TOOL_DEFS = [
|
|
|
30
30
|
{
|
|
31
31
|
name: 'memory.write',
|
|
32
32
|
description: 'Store or update a lesson',
|
|
33
|
-
inputSchema: {
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['scope', 'key', 'value'],
|
|
36
|
+
properties: {
|
|
37
|
+
scope: { type: 'string' },
|
|
38
|
+
key: { type: 'string' },
|
|
39
|
+
value: { type: 'string' },
|
|
40
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
41
|
+
source_agent: { type: 'string' },
|
|
42
|
+
trigger: { type: 'string' },
|
|
43
|
+
created_at: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
format: 'date-time',
|
|
46
|
+
description:
|
|
47
|
+
'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
34
51
|
},
|
|
35
52
|
{
|
|
36
53
|
name: 'memory.read',
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Zero-dependency mirror of the created_at validation used by the hosted MCP
|
|
2
|
+
// server (packages/mcp-core/src/created-at.ts). Keeps the local `lorekit mcp`
|
|
3
|
+
// stdio server's memory.write contract identical to the remote one: an optional
|
|
4
|
+
// ISO 8601 creation-date override, rejected when invalid or future-dated.
|
|
5
|
+
|
|
6
|
+
export const CLOCK_SKEW_MS = 60_000;
|
|
7
|
+
|
|
8
|
+
// Validate and normalise an optional created_at override.
|
|
9
|
+
// Returns the ISO 8601 string, or null when no override was supplied.
|
|
10
|
+
// Throws Error on an invalid or future-dated value.
|
|
11
|
+
export function normalizeCreatedAt(input, now = new Date()) {
|
|
12
|
+
if (input === undefined || input === null) return null;
|
|
13
|
+
if (typeof input !== 'string') {
|
|
14
|
+
throw new Error('created_at must be an ISO 8601 date-time string');
|
|
15
|
+
}
|
|
16
|
+
const trimmed = input.trim();
|
|
17
|
+
if (trimmed === '') {
|
|
18
|
+
throw new Error('created_at must be an ISO 8601 date-time string');
|
|
19
|
+
}
|
|
20
|
+
const ms = Date.parse(trimmed);
|
|
21
|
+
if (Number.isNaN(ms)) {
|
|
22
|
+
throw new Error(`created_at is not a valid date-time: ${input}`);
|
|
23
|
+
}
|
|
24
|
+
if (ms > now.getTime() + CLOCK_SKEW_MS) {
|
|
25
|
+
throw new Error('created_at cannot be in the future');
|
|
26
|
+
}
|
|
27
|
+
return new Date(ms).toISOString();
|
|
28
|
+
}
|
package/src/store/local.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
|
|
11
|
+
import { normalizeCreatedAt } from './created-at.mjs';
|
|
11
12
|
|
|
12
13
|
export function createLocalStore(baseDir) {
|
|
13
14
|
return new LocalStore(baseDir);
|
|
@@ -82,19 +83,34 @@ class LocalStore {
|
|
|
82
83
|
|
|
83
84
|
// write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
|
|
84
85
|
// refreshes `updated`; writing an archived key revives it.
|
|
85
|
-
|
|
86
|
+
//
|
|
87
|
+
// `created_at` is an optional ISO 8601 override for migrating a pre-existing
|
|
88
|
+
// memory (mirrors the hosted memory.write param). It applies only when the
|
|
89
|
+
// entry is first created — on both `created` and `updated`, so a migrated
|
|
90
|
+
// memory is dated by its original time everywhere — and is ignored for an
|
|
91
|
+
// existing key (a creation date never moves). Returns { ok:false, error } on
|
|
92
|
+
// an invalid or future-dated value rather than throwing, matching the store
|
|
93
|
+
// contract's error surfacing.
|
|
94
|
+
async write({ scope, key, value, tags, source_agent, trigger, created_at } = {}) {
|
|
95
|
+
let override;
|
|
96
|
+
try {
|
|
97
|
+
override = normalizeCreatedAt(created_at);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { ok: false, error: e.message };
|
|
100
|
+
}
|
|
86
101
|
const dir = this._dir(scope);
|
|
87
102
|
fs.mkdirSync(dir, { recursive: true });
|
|
88
103
|
const now = new Date().toISOString();
|
|
89
104
|
const existing = this._findByKey(scope, key);
|
|
105
|
+
const created = existing ? existing.entry.created || now : override || now;
|
|
90
106
|
const entry = {
|
|
91
107
|
scope,
|
|
92
108
|
key,
|
|
93
109
|
tags: Array.isArray(tags) ? tags : [],
|
|
94
110
|
source_agent: source_agent || null,
|
|
95
111
|
trigger: trigger || null,
|
|
96
|
-
created
|
|
97
|
-
updated: now,
|
|
112
|
+
created,
|
|
113
|
+
updated: existing ? now : override || now,
|
|
98
114
|
archived_at: null,
|
|
99
115
|
value: value == null ? '' : String(value),
|
|
100
116
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `lorekit uninstall` — reverse `install`: remove the skill, the MCP server
|
|
2
|
+
// entry, and the lifecycle hooks for the chosen scope. Surgical — every other
|
|
3
|
+
// server, hook, and setting is left untouched.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import {
|
|
8
|
+
SKILL_NAME,
|
|
9
|
+
resolveProjectRoot,
|
|
10
|
+
removeSkill,
|
|
11
|
+
removeMcpServer,
|
|
12
|
+
removeClaudeHooks,
|
|
13
|
+
homeDir,
|
|
14
|
+
} from './config.mjs';
|
|
15
|
+
import { log, heading, status, c } from './util.mjs';
|
|
16
|
+
|
|
17
|
+
function ask(question) {
|
|
18
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function uninstall(args) {
|
|
23
|
+
const root = resolveProjectRoot(args.dir);
|
|
24
|
+
const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
|
|
25
|
+
|
|
26
|
+
heading('LoreKit uninstall');
|
|
27
|
+
log(` project: ${c.dim(root)}`);
|
|
28
|
+
|
|
29
|
+
// Mirror install's scope selection: --project / --global force it, else
|
|
30
|
+
// prompt when interactive, else default to project.
|
|
31
|
+
let scope = args.global ? 'global' : args.project ? 'project' : null;
|
|
32
|
+
if (!scope) {
|
|
33
|
+
if (nonInteractive) {
|
|
34
|
+
scope = 'project';
|
|
35
|
+
} else {
|
|
36
|
+
const ans = (
|
|
37
|
+
await ask(' Remove from this project or the global install? [project/global] (project): ')
|
|
38
|
+
).toLowerCase();
|
|
39
|
+
scope = ans.startsWith('g') ? 'global' : 'project';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
log(
|
|
43
|
+
` scope: ${c.dim(
|
|
44
|
+
scope === 'global' ? 'global — ~/.claude, applies to every project' : 'project — this repo only',
|
|
45
|
+
)}`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Each removal is independent and best-effort: a corrupt or unwritable config
|
|
49
|
+
// for one target must not crash the run or block the others. `removeMcpServer`
|
|
50
|
+
// / `removeClaudeHooks` throw on an unparseable file (the same fail-safe as
|
|
51
|
+
// install — never clobber what we can't understand), so we catch and report it
|
|
52
|
+
// cleanly, leaving that file byte-for-byte untouched. Atomic writes
|
|
53
|
+
// (writeFileAtomic) guarantee a config can't be half-written even on a crash.
|
|
54
|
+
const skill = attempt(() => removeSkill(root, scope));
|
|
55
|
+
const mcp = attempt(() => removeMcpServer(root, scope));
|
|
56
|
+
const hooks = attempt(() => removeClaudeHooks(root, scope));
|
|
57
|
+
|
|
58
|
+
// Global paths shown relative to ~; project paths repo-relative.
|
|
59
|
+
const display = (p) =>
|
|
60
|
+
scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
|
|
61
|
+
const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
|
|
62
|
+
|
|
63
|
+
heading('Done');
|
|
64
|
+
report(skill, `skill ${SKILL_NAME}`, {
|
|
65
|
+
done: (r) => `removed → ${display(r.dest)}`,
|
|
66
|
+
noop: 'not installed — nothing to remove',
|
|
67
|
+
});
|
|
68
|
+
report(mcp, mcpLabel, {
|
|
69
|
+
done: (r) => `lorekit server removed → ${display(r.file)}`,
|
|
70
|
+
noop: 'no lorekit server entry — nothing to remove',
|
|
71
|
+
});
|
|
72
|
+
report(hooks, 'hooks', {
|
|
73
|
+
done: (r) => `${r.removed} removed → ${display(r.file)}`,
|
|
74
|
+
noop: 'no lorekit hooks — nothing to remove',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const failed = [skill, mcp, hooks].some((s) => !s.ok);
|
|
78
|
+
const any = (skill.result?.removed || mcp.result?.removed || hooks.result?.removed) && true;
|
|
79
|
+
|
|
80
|
+
if (failed) {
|
|
81
|
+
log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
if (!any) {
|
|
85
|
+
const other = scope === 'global' ? '--project' : '--global';
|
|
86
|
+
log(`\n ${c.dim(`Nothing found for the ${scope} scope — try ${other}?`)}`);
|
|
87
|
+
} else {
|
|
88
|
+
log(`\n ${c.dim('Removed. Your other MCP servers, hooks, and settings were left untouched.')}`);
|
|
89
|
+
log(` ${c.dim('Note: your token may remain in shell history or env — rotate it if it leaked.')}`);
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run a removal step, converting a throw into a reportable outcome so one bad
|
|
95
|
+
// config can't crash the whole uninstall or leave a stack trace on screen.
|
|
96
|
+
function attempt(fn) {
|
|
97
|
+
try {
|
|
98
|
+
return { ok: true, result: fn() };
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { ok: false, error: e };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Render one status line for a step: a clean error (file left untouched), a
|
|
105
|
+
// "removed" line, or a "nothing to remove" line.
|
|
106
|
+
function report(step, label, { done, noop }) {
|
|
107
|
+
if (!step.ok) {
|
|
108
|
+
status('fail', label, `left untouched — ${step.error.message}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const r = step.result;
|
|
112
|
+
const removed = r.removed;
|
|
113
|
+
status(removed ? 'pass' : 'info', label, removed ? done(r) : noop);
|
|
114
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -40,6 +40,83 @@ export function status(kind, label, detail) {
|
|
|
40
40
|
log(` ${mark} ${label}${tail}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Map a raw keypress to a select-list action. Pure so it can be unit-tested
|
|
44
|
+
// without a pseudo-TTY. Supports arrow keys (both the `ESC [` and application
|
|
45
|
+
// `ESC O` cursor modes) and vim-style j/k.
|
|
46
|
+
export function selectAction(key) {
|
|
47
|
+
if (key === '') return 'cancel'; // Ctrl-C
|
|
48
|
+
if (key === '\r' || key === '\n') return 'submit';
|
|
49
|
+
if (key === 'k' || (key.startsWith('') && key.endsWith('A'))) return 'up';
|
|
50
|
+
if (key === 'j' || (key.startsWith('') && key.endsWith('B'))) return 'down';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Interactive single-choice list. `options` is [{ label, value, hint? }].
|
|
55
|
+
// Arrow keys / j / k move, Enter selects, Ctrl-C aborts. Falls back to the
|
|
56
|
+
// default option when stdin isn't a TTY (CI / piped input), matching the
|
|
57
|
+
// non-interactive install path. Zero-dependency; renders with raw ANSI.
|
|
58
|
+
export function select(question, options, { defaultIndex = 0 } = {}) {
|
|
59
|
+
const { stdin, stdout } = process;
|
|
60
|
+
let index = Math.max(0, Math.min(defaultIndex, options.length - 1));
|
|
61
|
+
|
|
62
|
+
if (!stdin.isTTY) return Promise.resolve(options[index].value);
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const render = (first) => {
|
|
66
|
+
if (!first) stdout.write(`[${options.length}A`); // cursor up N lines
|
|
67
|
+
for (let i = 0; i < options.length; i++) {
|
|
68
|
+
const active = i === index;
|
|
69
|
+
const pointer = active ? c.cyan('❯') : ' ';
|
|
70
|
+
const label = active ? c.cyan(options[i].label) : options[i].label;
|
|
71
|
+
const hint = options[i].hint ? ` ${c.dim('— ' + options[i].hint)}` : '';
|
|
72
|
+
stdout.write(`[2K ${pointer} ${label}${hint}\n`); // clear line, write
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
log(` ${question}`);
|
|
77
|
+
stdout.write('[?25l'); // hide cursor
|
|
78
|
+
render(true);
|
|
79
|
+
|
|
80
|
+
const prevRaw = Boolean(stdin.isRaw);
|
|
81
|
+
stdin.setRawMode(true);
|
|
82
|
+
stdin.resume();
|
|
83
|
+
stdin.setEncoding('utf8');
|
|
84
|
+
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
stdin.setRawMode(prevRaw);
|
|
87
|
+
stdin.pause();
|
|
88
|
+
stdin.removeListener('data', onData);
|
|
89
|
+
stdout.write('[?25h'); // show cursor
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onData = (key) => {
|
|
93
|
+
switch (selectAction(key)) {
|
|
94
|
+
case 'cancel':
|
|
95
|
+
cleanup();
|
|
96
|
+
stdout.write('\n');
|
|
97
|
+
process.exit(130);
|
|
98
|
+
break;
|
|
99
|
+
case 'up':
|
|
100
|
+
index = (index - 1 + options.length) % options.length;
|
|
101
|
+
render(false);
|
|
102
|
+
break;
|
|
103
|
+
case 'down':
|
|
104
|
+
index = (index + 1) % options.length;
|
|
105
|
+
render(false);
|
|
106
|
+
break;
|
|
107
|
+
case 'submit':
|
|
108
|
+
cleanup();
|
|
109
|
+
resolve(options[index].value);
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
stdin.on('data', onData);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
43
120
|
// Minimal flag parser: --key value, --key=value, -k value, and bare --flags.
|
|
44
121
|
// `aliases` maps short → long; `booleans` lists flags that take no value.
|
|
45
122
|
export function parseArgs(argv, { aliases = {}, booleans = [] } = {}) {
|