@lorekit/cli 1.0.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.
@@ -0,0 +1,27 @@
1
+ // One-shot throttle so a hook fires an injection at most once per session/tag.
2
+ // Prevents nudge spam and, on Stop hooks, avoids re-injection loops.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+
8
+ function stateDir() {
9
+ const base = process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), 'lorekit-hooks');
10
+ fs.mkdirSync(base, { recursive: true });
11
+ return base;
12
+ }
13
+
14
+ // Returns true the FIRST time called for a given (sessionId, tag), false after.
15
+ // Missing sessionId → always true (cannot throttle without a key).
16
+ export function firstTimeThisSession(sessionId, tag) {
17
+ if (!sessionId) return true;
18
+ const hash = crypto.createHash('sha256').update(`${sessionId}:${tag}`).digest('hex').slice(0, 16);
19
+ const marker = path.join(stateDir(), `${hash}.seen`);
20
+ try {
21
+ // wx fails if the file already exists → not the first time.
22
+ fs.writeFileSync(marker, '', { flag: 'wx' });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // `lorekit doctor` — verify the skill install and the resolved memory backend.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { execFileSync } from 'node:child_process';
7
+ import {
8
+ SKILL_NAME,
9
+ resolveProjectRoot,
10
+ skillInstallDir,
11
+ readLorekitServer,
12
+ readMcpConfig,
13
+ tokenKind,
14
+ } from './config.mjs';
15
+ import { splitEndpoint } from './mcp.mjs';
16
+ import { deriveScope } from './scope.mjs';
17
+ import { loadControl } from './control.mjs';
18
+ import { createStore } from './store/index.mjs';
19
+ import { log, heading, status, c } from './util.mjs';
20
+
21
+ const AUTH_CODES = new Set([401, 403, -32001]);
22
+
23
+ export async function doctor(args) {
24
+ const root = resolveProjectRoot(args.dir);
25
+ let failures = 0;
26
+ let warnings = 0;
27
+ const record = (kind, label, detail) => {
28
+ if (kind === 'fail') failures++;
29
+ if (kind === 'warn') warnings++;
30
+ status(kind, label, detail);
31
+ };
32
+
33
+ heading('LoreKit doctor');
34
+ log(` project: ${c.dim(root)}\n`);
35
+
36
+ // 1. Runtime.
37
+ const major = Number(process.versions.node.split('.')[0]);
38
+ record(
39
+ major >= 18 ? 'pass' : 'fail',
40
+ 'node runtime',
41
+ `v${process.versions.node}${major < 18 ? ' — need v18+ for fetch' : ''}`,
42
+ );
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);
48
+ } else {
49
+ record('fail', `skill ${SKILL_NAME}`, 'not found — run `lorekit install`');
50
+ }
51
+
52
+ // 3. Resolved control model — which mode, and who decided it.
53
+ const control = loadControl(root, { env: withOverrides(args) });
54
+ record('info', 'memory mode', `${control.mode} ${c.dim('— decided by ' + control.decidedBy)}`);
55
+ for (const d of control.denies) {
56
+ record('info', 'deny constraint', `${d.mode} forbidden by ${d.source}`);
57
+ }
58
+
59
+ // 4. Mode-specific checks.
60
+ if (control.mode === 'off') {
61
+ record('info', 'memory', 'disabled — hooks and the skill are silent no-ops');
62
+ } else if (control.mode === 'local') {
63
+ await checkLocal(control, root, args, record);
64
+ } else {
65
+ await checkRemote(control, root, args, record);
66
+ }
67
+
68
+ // 5. Scope.
69
+ const scope = deriveScope(root);
70
+ if (scope.hasRemote) {
71
+ record('info', 'read scope', scope.readOrder.join(' → '));
72
+ record('info', 'write scope', `${scope.repoScope} (default for "went wrong" lessons)`);
73
+ } else {
74
+ record('warn', 'scope', 'no git remote here — lessons fall back to global');
75
+ }
76
+
77
+ // Summary.
78
+ heading('Summary');
79
+ if (failures === 0 && warnings === 0) {
80
+ log(` ${c.green('All checks passed.')} LoreKit memory is ready.`);
81
+ } else {
82
+ log(
83
+ ` ${failures ? c.red(failures + ' failed') : c.green('0 failed')}, ${
84
+ warnings ? c.yellow(warnings + ' warning(s)') : '0 warnings'
85
+ }.`,
86
+ );
87
+ }
88
+ return failures === 0 ? 0 : 1;
89
+ }
90
+
91
+ // Merge doctor's --endpoint / --token flags into the env the resolver reads, so
92
+ // an explicit connection flag is honoured without a separate resolution path.
93
+ function withOverrides(args) {
94
+ const env = { ...process.env };
95
+ if (args.endpoint) env.LOREKIT_MCP_URL = args.endpoint;
96
+ if (args.token) env.LOREKIT_TOKEN = args.token;
97
+ if (args.mode) env.LOREKIT_MODE = args.mode;
98
+ if (args.store) env.LOREKIT_STORE = args.store;
99
+ return env;
100
+ }
101
+
102
+ // Abbreviate the user's home directory to `~` for readable paths.
103
+ function prettyPath(p) {
104
+ const home = os.homedir();
105
+ return p && home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
106
+ }
107
+
108
+ async function checkLocal(control, root, args, record) {
109
+ const store = createStore(control);
110
+ const scope = deriveScope(root);
111
+ const scopes = [...new Set([...scope.readOrder, scope.branchScope, scope.repoScope])].filter(
112
+ Boolean,
113
+ );
114
+
115
+ // Home tier — per-user, cross-repo, always available.
116
+ record('pass', 'home store', prettyPath(store.homeDir));
117
+ record('info', 'home entries', `${await store.home.count(scopes)} lesson(s)`);
118
+
119
+ // Project tier — opt-in; active only when its directory exists.
120
+ const projRel = path.relative(root, store.projectDir) || store.projectDir;
121
+ if (store.projectActive()) {
122
+ const sharing = gitTracked(root, store.projectDir)
123
+ ? 'committed — shared with the team'
124
+ : 'gitignored — private to your checkout';
125
+ record('pass', 'project store', projRel);
126
+ record('info', 'project entries', `${await store.project.count(scopes)} lesson(s) — ${sharing}`);
127
+ } else {
128
+ record(
129
+ 'info',
130
+ 'project store',
131
+ `${projRel} — not opted-in (create it to persist repo/branch lessons here)`,
132
+ );
133
+ }
134
+
135
+ if (args.deep) await deepCheckLocal(store, scope, record);
136
+ }
137
+
138
+ async function checkRemote(control, root, args, record) {
139
+ const override = { endpoint: args.endpoint || null, token: args.token || null };
140
+ const mcp = readMcpConfig(root);
141
+ const configured = mcp.valid ? readLorekitServer(root) : null;
142
+ const fromMcp = configured ? splitEndpoint(configured.url) : { endpoint: null, token: null };
143
+
144
+ const endpoint = override.endpoint || fromMcp.endpoint || control.connection.endpoint;
145
+ const token = override.token || fromMcp.token || control.connection.token;
146
+
147
+ if (mcp.present && !mcp.valid) {
148
+ record('fail', '.mcp.json', 'invalid JSON — fix it or re-run `lorekit install`');
149
+ } else if (configured) {
150
+ record('pass', '.mcp.json', 'lorekit server configured');
151
+ } else {
152
+ record('warn', '.mcp.json', 'no lorekit server entry — using env/flags');
153
+ }
154
+
155
+ if (!endpoint) {
156
+ record('fail', 'endpoint', 'none — set it in .mcp.json or pass --endpoint');
157
+ } else if (endpoint.includes('<project-ref>')) {
158
+ record('fail', 'endpoint', `still a placeholder: ${endpoint}`);
159
+ } else {
160
+ record('pass', 'endpoint', endpoint);
161
+ }
162
+
163
+ const kind = tokenKind(token);
164
+ if (kind === 'none') record('fail', 'token', 'none configured');
165
+ 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_*)');
167
+ else record('pass', 'token', 'read+write (lk_rw_*)');
168
+
169
+ // Connectivity, through the store.
170
+ const store = createStore({ mode: 'remote', connection: { endpoint, token } });
171
+ if (endpoint && !endpoint.includes('<project-ref>') && token) {
172
+ const res = await store.ping();
173
+ if (res.networkError) {
174
+ record('fail', 'connectivity', res.networkError);
175
+ } else if (res.ok) {
176
+ const tools = res.result && Array.isArray(res.result.tools) ? res.result.tools.length : null;
177
+ record('pass', 'connectivity', tools !== null ? `reachable, ${tools} tools` : 'reachable');
178
+ } else if (res.error && AUTH_CODES.has(res.error.code)) {
179
+ record('fail', 'connectivity', `auth rejected (${res.error.code}) — check your token`);
180
+ } else if (res.error) {
181
+ record('warn', 'connectivity', `reachable, server said: ${res.error.message || res.error.code}`);
182
+ } else {
183
+ record('warn', 'connectivity', `unexpected response (HTTP ${res.httpStatus})`);
184
+ }
185
+
186
+ if (args.deep) await deepCheckRemote(store, root, record);
187
+ } else {
188
+ record('warn', 'connectivity', 'skipped — need a valid endpoint and token');
189
+ }
190
+ }
191
+
192
+ async function deepCheckRemote(store, root, record) {
193
+ if (tokenKind(store.token) !== 'read-write') {
194
+ record('warn', 'round-trip', 'skipped — needs a read+write token');
195
+ return;
196
+ }
197
+ const scope = deriveScope(root);
198
+ const writeScope = scope.repoScope || 'global';
199
+ const key = 'lorekit-memory::doctor-check';
200
+
201
+ const w = await store.write({
202
+ scope: writeScope,
203
+ key,
204
+ value: 'LoreKit doctor round-trip check. Safe to delete.',
205
+ tags: ['skill::lorekit-memory', 'source::doctor'],
206
+ trigger: 'manual',
207
+ });
208
+ if (!w.ok) {
209
+ record('fail', 'round-trip', `write failed: ${w.error ? w.error.message || w.error.code : w.networkError}`);
210
+ return;
211
+ }
212
+ const r = await store.read({ scope: writeScope, key });
213
+ const readBack = r.ok && JSON.stringify(r.entry || '').includes('round-trip');
214
+ record(
215
+ readBack ? 'pass' : 'warn',
216
+ 'round-trip',
217
+ readBack ? `wrote + read back in ${writeScope}` : 'wrote, but read-back was inconclusive',
218
+ );
219
+ await store.delete({ scope: writeScope, key, force: true });
220
+ }
221
+
222
+ async function deepCheckLocal(store, scope, record) {
223
+ const writeScope = scope.repoScope || 'global';
224
+ const key = 'lorekit-memory::doctor-check';
225
+ const w = await store.write({
226
+ scope: writeScope,
227
+ key,
228
+ value: 'LoreKit doctor round-trip check. Safe to delete.',
229
+ tags: ['skill::lorekit-memory', 'source::doctor'],
230
+ trigger: 'manual',
231
+ });
232
+ const r = await store.read({ scope: writeScope, key });
233
+ const readBack = w.ok && r.ok && r.entry && String(r.entry.value).includes('round-trip');
234
+ record(
235
+ readBack ? 'pass' : 'warn',
236
+ 'round-trip',
237
+ readBack ? `wrote + read back in ${writeScope}` : 'write/read-back was inconclusive',
238
+ );
239
+ await store.delete({ scope: writeScope, key, force: true });
240
+ }
241
+
242
+ function gitTracked(root, dir) {
243
+ // Heuristic: is the store dir ignored by git? If `git check-ignore` names it,
244
+ // it is private; otherwise it will be committed (team-shared).
245
+ try {
246
+ execFileSync('git', ['check-ignore', '-q', dir], { cwd: root, stdio: 'ignore' });
247
+ return false; // ignored → private
248
+ } catch {
249
+ return true; // not ignored → tracked/committed
250
+ }
251
+ }
package/src/hook.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // `lorekit hook --adapter <claude|cursor|codex> [--event <name>]`
2
+ // The shared hook engine. Reads the framework's JSON on stdin, runs the shared
3
+ // logic, and prints the framework-shaped injection on stdout. Always exits 0 —
4
+ // a memory hook must never block or break the host agent.
5
+ import process from 'node:process';
6
+ import { resolveProjectRoot } from './config.mjs';
7
+ import { deriveScope } from './scope.mjs';
8
+ import { loadControl } from './control.mjs';
9
+ import { createStore } from './store/index.mjs';
10
+ import { fetchLessons, formatLessons, retrospectiveNudge, failureNudge } from './core/lessons.mjs';
11
+ import { isFailure } from './core/failure.mjs';
12
+ import { firstTimeThisSession } from './core/state.mjs';
13
+ import { recordFixture } from './core/record.mjs';
14
+ import { claude } from './adapters/claude.mjs';
15
+ import { cursor } from './adapters/cursor.mjs';
16
+ import { codex } from './adapters/codex.mjs';
17
+
18
+ const ADAPTERS = { claude, cursor, codex };
19
+
20
+ function readStdin() {
21
+ return new Promise((resolve) => {
22
+ let data = '';
23
+ if (process.stdin.isTTY) return resolve('');
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('data', (c) => (data += c));
26
+ process.stdin.on('end', () => resolve(data));
27
+ process.stdin.on('error', () => resolve(data));
28
+ });
29
+ }
30
+
31
+ export async function hook(args) {
32
+ // Guarded so any unexpected error still exits 0 (never break the host agent).
33
+ try {
34
+ return await run(args);
35
+ } catch {
36
+ return 0;
37
+ }
38
+ }
39
+
40
+ async function run(args) {
41
+ const adapter = ADAPTERS[args.adapter];
42
+ if (!adapter) {
43
+ // Unknown adapter: stay silent, don't disrupt the host.
44
+ return 0;
45
+ }
46
+
47
+ const raw = await readStdin();
48
+ let input = {};
49
+ if (raw && raw.trim()) {
50
+ try {
51
+ input = JSON.parse(raw);
52
+ } catch {
53
+ input = {};
54
+ }
55
+ }
56
+
57
+ const parsed = adapter.parse(input);
58
+ const event = args.event || parsed.event;
59
+
60
+ // Harvest the real payload when recording is enabled (opt-in via env).
61
+ recordFixture(args.adapter, event, raw);
62
+
63
+ if (!event) return 0;
64
+
65
+ const intent = adapter.intentFor(event);
66
+ if (intent === 'noop') return 0;
67
+
68
+ const root = resolveProjectRoot(
69
+ args.dir || process.env.CLAUDE_PROJECT_DIR || parsed.cwd || undefined,
70
+ );
71
+ const scope = deriveScope(root);
72
+
73
+ // Resolve the control model once. `off` disables every hook event — no read,
74
+ // no nudges — so memory can be turned off entirely without touching config.
75
+ const control = loadControl(root);
76
+ if (control.mode === 'off') return 0;
77
+
78
+ const emit = (text) => {
79
+ if (text) process.stdout.write(adapter.emit(event, text));
80
+ };
81
+
82
+ if (intent === 'read') {
83
+ if (!firstTimeThisSession(parsed.sessionId, 'read')) return 0;
84
+ const store = createStore(control);
85
+ if (!store) return 0; // unconfigured/unusable — stay silent
86
+ const { scope: readScope, lessons } = await fetchLessons(store, root);
87
+ emit(formatLessons(lessons, readScope));
88
+ return 0;
89
+ }
90
+
91
+ if (intent === 'failure') {
92
+ const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
93
+ if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
94
+ if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
95
+ emit(failureNudge(parsed.toolName, scope));
96
+ return 0;
97
+ }
98
+
99
+ if (intent === 'retrospective') {
100
+ if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
101
+ emit(retrospectiveNudge(scope));
102
+ return 0;
103
+ }
104
+
105
+ return 0;
106
+ }
@@ -0,0 +1,95 @@
1
+ // `lorekit install` — scaffold the skill and wire the MCP server.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import process from 'node:process';
6
+ import {
7
+ SKILL_SOURCE,
8
+ SKILL_NAME,
9
+ resolveProjectRoot,
10
+ skillInstallDir,
11
+ copyDir,
12
+ upsertMcpServer,
13
+ resolveConnection,
14
+ tokenKind,
15
+ } from './config.mjs';
16
+ import { buildRemoteUrl } from './mcp.mjs';
17
+ import { deriveScope } from './scope.mjs';
18
+ import { log, err, heading, status, c } from './util.mjs';
19
+
20
+ function ask(question) {
21
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
22
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
23
+ }
24
+
25
+ const DEFAULT_ENDPOINT_HINT = 'https://<project-ref>.supabase.co/functions/v1/mcp';
26
+
27
+ export async function install(args) {
28
+ const root = resolveProjectRoot(args.dir);
29
+ const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
30
+
31
+ heading('LoreKit install');
32
+ log(` project: ${c.dim(root)}`);
33
+
34
+ // 1. Connection details.
35
+ let { endpoint, token } = resolveConnection(args);
36
+
37
+ if (!endpoint) {
38
+ if (nonInteractive) {
39
+ err(
40
+ `\n${c.red('Missing endpoint.')} Pass --endpoint ${DEFAULT_ENDPOINT_HINT} ` +
41
+ `or set LOREKIT_MCP_URL.`,
42
+ );
43
+ return 1;
44
+ }
45
+ endpoint = await ask(` LoreKit MCP endpoint [${DEFAULT_ENDPOINT_HINT}]: `);
46
+ if (!endpoint) endpoint = DEFAULT_ENDPOINT_HINT;
47
+ }
48
+ if (!token && !nonInteractive) {
49
+ token = await ask(' LoreKit token (lk_rw_… to allow writes, blank to skip): ');
50
+ token = token || null;
51
+ }
52
+
53
+ // 2. Install the skill files.
54
+ const dest = skillInstallDir(root);
55
+ const skillExisted = fs.existsSync(path.join(dest, 'SKILL.md'));
56
+ const written = copyDir(SKILL_SOURCE, dest, { force: Boolean(args.force) });
57
+
58
+ // 3. Wire .mcp.json.
59
+ const remoteUrl = buildRemoteUrl(endpoint, token);
60
+ const { file, existed } = upsertMcpServer(root, remoteUrl);
61
+
62
+ // 4. Report.
63
+ heading('Done');
64
+ const skillState = !skillExisted
65
+ ? 'installed'
66
+ : written > 0
67
+ ? `updated (${written} file(s) written)`
68
+ : 'unchanged — pass --force to overwrite';
69
+ status(skillExisted && written === 0 ? 'info' : 'pass', `skill ${SKILL_NAME}`, `${skillState} → ${path.relative(root, dest) || dest}`);
70
+ status('pass', '.mcp.json', `${existed ? 'updated' : 'created'} lorekit server → ${path.relative(root, file) || file}`);
71
+
72
+ const kind = tokenKind(token);
73
+ if (kind === 'none') {
74
+ status('warn', 'token', 'none configured — reads/writes will fail until a token is set');
75
+ } else if (kind === 'read-only') {
76
+ status('warn', 'token', 'read-only (lk_ro_*) — the skill can read lessons but not write them');
77
+ } else if (kind === 'unknown') {
78
+ status('warn', 'token', 'unrecognized prefix — expected lk_rw_* or lk_ro_*');
79
+ } else {
80
+ status('pass', 'token', 'read+write (lk_rw_*)');
81
+ }
82
+
83
+ const scope = deriveScope(root);
84
+ if (scope.hasRemote) {
85
+ status('info', 'scope', `${scope.repoScope}${scope.branchScope ? ` · ${scope.branchScope}` : ''}`);
86
+ } else {
87
+ status('warn', 'scope', 'no git remote — lessons will fall back to global');
88
+ }
89
+
90
+ log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
91
+ if (token) {
92
+ log(` ${c.dim('Note: your token now lives in .mcp.json — keep it out of version control.')}`);
93
+ }
94
+ return 0;
95
+ }