@lorekit/cli 1.4.1 → 1.6.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/bin/lorekit.mjs +10 -0
- package/package.json +1 -1
- package/src/dotenv.mjs +106 -0
- package/src/mcp-server.mjs +104 -6
- package/src/store/remote.mjs +30 -0
- package/src/telemetry-token.mjs +1 -1
- package/src/telemetry.mjs +1 -1
package/bin/lorekit.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { hook } from '../src/hook.mjs';
|
|
|
10
10
|
import { migrate } from '../src/migrate.mjs';
|
|
11
11
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
12
12
|
import { traceCommand } from '../src/telemetry.mjs';
|
|
13
|
+
import { loadDotEnv } from '../src/dotenv.mjs';
|
|
13
14
|
|
|
14
15
|
// Read the version from package.json so it always matches the published
|
|
15
16
|
// package — release-please bumps package.json, and this tracks it for free.
|
|
@@ -76,6 +77,9 @@ ${c.bold('Environment')}
|
|
|
76
77
|
LOREKIT_TELEMETRY / DO_NOT_TRACK set to 0/off (or DO_NOT_TRACK=1) to opt
|
|
77
78
|
out of anonymous command-usage telemetry
|
|
78
79
|
|
|
80
|
+
A ${c.cyan('.env')} file in the current directory is loaded automatically. Real
|
|
81
|
+
environment variables take precedence, so the file is a fallback.
|
|
82
|
+
|
|
79
83
|
${c.bold('Examples')}
|
|
80
84
|
npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
|
|
81
85
|
npx @lorekit/cli install --global # set up memory for every project (~/.claude)
|
|
@@ -214,6 +218,12 @@ const KNOWN_FLAGS = [
|
|
|
214
218
|
const HUMAN_COMMANDS = new Set(['install', 'uninstall', 'doctor', 'migrate']);
|
|
215
219
|
|
|
216
220
|
async function main() {
|
|
221
|
+
// Load a `.env` from the current directory (if any) before anything reads the
|
|
222
|
+
// environment — so telemetry config, tokens, and endpoints can come from a
|
|
223
|
+
// file. Best-effort and non-overriding: real env vars still win, a missing
|
|
224
|
+
// file is a silent no-op, and it never prints (safe for hook/mcp stdout).
|
|
225
|
+
loadDotEnv();
|
|
226
|
+
|
|
217
227
|
const argv = process.argv.slice(2);
|
|
218
228
|
const args = parseArgs(argv, {
|
|
219
229
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
package/package.json
CHANGED
package/src/dotenv.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// LoreKit CLI — zero-dependency `.env` loader.
|
|
2
|
+
//
|
|
3
|
+
// The CLI is strictly zero-dependency (see package.json), and `engines.node`
|
|
4
|
+
// is `>=18`, so we can rely on neither the `dotenv` package nor Node's built-in
|
|
5
|
+
// `process.loadEnvFile()` (added in 20.6). This hand-rolled loader fills the gap
|
|
6
|
+
// with a small, faithful parser and a best-effort file read.
|
|
7
|
+
//
|
|
8
|
+
// Semantics (chosen to match the dominant `dotenv` mental model, NOT Node's
|
|
9
|
+
// `--env-file`):
|
|
10
|
+
// • A `.env` in the current working directory is loaded automatically if it
|
|
11
|
+
// exists. A missing file is a silent no-op — the default for real users.
|
|
12
|
+
// • Existing real environment variables WIN. A value already present in
|
|
13
|
+
// `process.env` (an explicit `export`, a CI var, an inline `FOO=bar cmd`)
|
|
14
|
+
// is never overwritten by the file. So a committed `.env` is a fallback,
|
|
15
|
+
// and a shell opt-out like `LOREKIT_TELEMETRY=0` can't be silently undone.
|
|
16
|
+
// • Loading never throws — a malformed line is skipped, an unreadable file is
|
|
17
|
+
// ignored. Nothing is printed, so machine-facing `hook` / `mcp` stdout is
|
|
18
|
+
// never touched.
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
|
|
24
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the contents of a `.env` file into a flat key→value object. Pure and
|
|
28
|
+
* unit-tested. Supports: blank lines, `#` comments, an optional `export `
|
|
29
|
+
* prefix, single/double-quoted values (double quotes unescape \n \r \t \\ \"),
|
|
30
|
+
* and trailing inline comments after unquoted values. Invalid keys are skipped.
|
|
31
|
+
*/
|
|
32
|
+
export function parseDotEnv(text) {
|
|
33
|
+
const out = {};
|
|
34
|
+
if (!text) return out;
|
|
35
|
+
for (const rawLine of String(text).split(/\r?\n/)) {
|
|
36
|
+
const line = rawLine.trim();
|
|
37
|
+
if (!line || line.startsWith('#')) continue;
|
|
38
|
+
|
|
39
|
+
// Strip an optional `export ` prefix (a common shell-sourceable convention).
|
|
40
|
+
const withoutExport = line.startsWith('export ') ? line.slice(7).trimStart() : line;
|
|
41
|
+
|
|
42
|
+
const eq = withoutExport.indexOf('=');
|
|
43
|
+
if (eq <= 0) continue; // no key, or `=value` with empty key → skip
|
|
44
|
+
|
|
45
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
46
|
+
if (!KEY_RE.test(key)) continue;
|
|
47
|
+
|
|
48
|
+
out[key] = parseValue(withoutExport.slice(eq + 1).trim());
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve one raw right-hand-side into its final string value: unwrap matching
|
|
55
|
+
* quotes (unescaping only inside double quotes), or, for a bare value, drop a
|
|
56
|
+
* trailing ` # comment`.
|
|
57
|
+
*/
|
|
58
|
+
function parseValue(raw) {
|
|
59
|
+
if (!raw) return '';
|
|
60
|
+
const q = raw[0];
|
|
61
|
+
if ((q === '"' || q === "'") && raw.length >= 2 && raw[raw.length - 1] === q) {
|
|
62
|
+
const inner = raw.slice(1, -1);
|
|
63
|
+
return q === '"'
|
|
64
|
+
? inner.replace(/\\([nrt"\\])/g, (_, ch) =>
|
|
65
|
+
ch === 'n' ? '\n' : ch === 'r' ? '\r' : ch === 't' ? '\t' : ch,
|
|
66
|
+
)
|
|
67
|
+
: inner;
|
|
68
|
+
}
|
|
69
|
+
// Unquoted: a whitespace-preceded `#` starts an inline comment.
|
|
70
|
+
const hash = raw.search(/\s#/);
|
|
71
|
+
return (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load a `.env` from `cwd` into `env`, without overriding keys already set.
|
|
76
|
+
* Best-effort: returns the list of keys it applied (empty if the file is absent
|
|
77
|
+
* or unreadable). Never throws, never prints.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {string} [opts.cwd] directory to look for `.env` in (default cwd)
|
|
81
|
+
* @param {object} [opts.env] target env object to mutate (default process.env)
|
|
82
|
+
* @returns {string[]} keys newly set from the file
|
|
83
|
+
*/
|
|
84
|
+
export function loadDotEnv({ cwd = process.cwd(), env = process.env } = {}) {
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = readFileSync(path.join(cwd, '.env'), 'utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
return []; // no file (ENOENT) or unreadable → nothing to do
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const applied = [];
|
|
93
|
+
try {
|
|
94
|
+
const parsed = parseDotEnv(text);
|
|
95
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
96
|
+
// Real env wins: only fill keys that aren't already set.
|
|
97
|
+
if (env[key] === undefined) {
|
|
98
|
+
env[key] = value;
|
|
99
|
+
applied.push(key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// A malformed file must never break the CLI — leave whatever was applied.
|
|
104
|
+
}
|
|
105
|
+
return applied;
|
|
106
|
+
}
|
package/src/mcp-server.mjs
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// remote → pass tool calls through to the hosted HTTP endpoint
|
|
11
11
|
// off → advertise no tools; a call reports "disabled"
|
|
12
12
|
//
|
|
13
|
+
// org.* tools are always advertised regardless of memory mode. They proxy to
|
|
14
|
+
// the remote endpoint because org management requires a server-side session
|
|
15
|
+
// (JWT auth, SECURITY DEFINER RPCs). In local/off mode a transient RemoteStore
|
|
16
|
+
// is built from the configured endpoint + token. If no remote is configured,
|
|
17
|
+
// a clear error is returned.
|
|
18
|
+
//
|
|
13
19
|
// Machine-facing: ONLY JSON-RPC frames go to stdout — any diagnostics go to
|
|
14
20
|
// stderr. The server never throws on malformed or partial input; a bad frame
|
|
15
21
|
// yields a JSON-RPC parse error and the loop keeps serving.
|
|
@@ -19,6 +25,7 @@ import process from 'node:process';
|
|
|
19
25
|
import { resolveProjectRoot } from './config.mjs';
|
|
20
26
|
import { loadControl } from './control.mjs';
|
|
21
27
|
import { createStore } from './store/index.mjs';
|
|
28
|
+
import { createRemoteStore } from './store/remote.mjs';
|
|
22
29
|
|
|
23
30
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
24
31
|
const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
@@ -26,7 +33,7 @@ const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
|
26
33
|
// Tool advertisements — names + input schemas mirror the production MCP server
|
|
27
34
|
// (supabase/functions/mcp/mcp-handler.ts) so a client sees the same contract
|
|
28
35
|
// whether it points at the hosted endpoint or this local server.
|
|
29
|
-
export const
|
|
36
|
+
export const MEMORY_TOOL_DEFS = [
|
|
30
37
|
{
|
|
31
38
|
name: 'memory.write',
|
|
32
39
|
description: 'Store or update a lesson',
|
|
@@ -86,9 +93,62 @@ export const TOOL_DEFS = [
|
|
|
86
93
|
},
|
|
87
94
|
];
|
|
88
95
|
|
|
96
|
+
// Org tools — always advertised regardless of memory mode. They always route
|
|
97
|
+
// through the remote MCP endpoint because org management requires JWT auth
|
|
98
|
+
// (SECURITY DEFINER RPCs resolve actor via auth.uid(), never a passed user_id).
|
|
99
|
+
export const ORG_TOOL_DEFS = [
|
|
100
|
+
{
|
|
101
|
+
name: 'org.create',
|
|
102
|
+
description:
|
|
103
|
+
'Create a new organization. You become its owner automatically. ' +
|
|
104
|
+
'The slug must be globally unique and lowercase (letters, digits, hyphens).',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
required: ['slug', 'name'],
|
|
108
|
+
properties: {
|
|
109
|
+
slug: { type: 'string', description: 'Unique lowercase identifier, e.g. "my-team"' },
|
|
110
|
+
name: { type: 'string', description: 'Human-readable display name' },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'org.list',
|
|
116
|
+
description: 'List all organizations you are a member of, with your role in each.',
|
|
117
|
+
inputSchema: { type: 'object', properties: {} },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'org.rename',
|
|
121
|
+
description: 'Rename an organization\'s display name. Requires admin or owner role.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
required: ['slug', 'name'],
|
|
125
|
+
properties: {
|
|
126
|
+
slug: { type: 'string', description: 'The org slug to update' },
|
|
127
|
+
name: { type: 'string', description: 'New display name' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'org.delete',
|
|
133
|
+
description:
|
|
134
|
+
'Permanently delete an organization. Requires owner role. ' +
|
|
135
|
+
'All org-owned memories and memberships are cascade-deleted. Unrecoverable.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
required: ['slug'],
|
|
139
|
+
properties: {
|
|
140
|
+
slug: { type: 'string', description: 'The org slug to delete' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
|
|
147
|
+
export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
|
|
148
|
+
|
|
89
149
|
// tool name → (store, args) → store result. The store destructures the args it
|
|
90
150
|
// needs, so the raw `arguments` object is passed straight through.
|
|
91
|
-
const
|
|
151
|
+
const MEMORY_DISPATCH = {
|
|
92
152
|
'memory.write': (store, a) => store.write(a),
|
|
93
153
|
'memory.read': (store, a) => store.read(a),
|
|
94
154
|
'memory.list': (store, a) => store.list(a),
|
|
@@ -97,6 +157,14 @@ const DISPATCH = {
|
|
|
97
157
|
'memory.archive': (store, a) => store.archive(a),
|
|
98
158
|
};
|
|
99
159
|
|
|
160
|
+
// org.* dispatch — always routed to the remote store.
|
|
161
|
+
const ORG_DISPATCH = {
|
|
162
|
+
'org.create': (remote, a) => remote.orgCreate(a),
|
|
163
|
+
'org.list': (remote) => remote.orgList(),
|
|
164
|
+
'org.rename': (remote, a) => remote.orgRename(a),
|
|
165
|
+
'org.delete': (remote, a) => remote.orgDelete(a),
|
|
166
|
+
};
|
|
167
|
+
|
|
100
168
|
function reply(id, result) {
|
|
101
169
|
return { jsonrpc: '2.0', id, result };
|
|
102
170
|
}
|
|
@@ -117,10 +185,22 @@ function toolResult(id, payload) {
|
|
|
117
185
|
}
|
|
118
186
|
|
|
119
187
|
// Build the per-message handler over a resolved control model. `store` is null
|
|
120
|
-
// when mode is `off
|
|
188
|
+
// when mode is `off`. Org tools are always advertised regardless of mode.
|
|
121
189
|
export function createHandler(control) {
|
|
122
190
|
const store = createStore(control);
|
|
123
|
-
const
|
|
191
|
+
const memoryTools = store ? MEMORY_TOOL_DEFS : [];
|
|
192
|
+
|
|
193
|
+
// For org.* calls: prefer the active remote store (already built if mode is
|
|
194
|
+
// remote); fall back to building one from control.connection so org management
|
|
195
|
+
// works even in local/off modes as long as a remote endpoint is configured.
|
|
196
|
+
function getOrgRemote() {
|
|
197
|
+
if (store && store.mode === 'remote') return store;
|
|
198
|
+
const conn = (control && control.connection) || {};
|
|
199
|
+
if (conn.endpoint && conn.token) {
|
|
200
|
+
return createRemoteStore({ endpoint: conn.endpoint, token: conn.token });
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
124
204
|
|
|
125
205
|
// Returns a JSON-RPC response object, or null for a notification (no reply).
|
|
126
206
|
return async function handle(msg) {
|
|
@@ -142,7 +222,7 @@ export function createHandler(control) {
|
|
|
142
222
|
}
|
|
143
223
|
|
|
144
224
|
if (method === 'tools/list') {
|
|
145
|
-
return reply(id, { tools });
|
|
225
|
+
return reply(id, { tools: [...memoryTools, ...ORG_TOOL_DEFS] });
|
|
146
226
|
}
|
|
147
227
|
|
|
148
228
|
if (method === 'tools/call') {
|
|
@@ -150,11 +230,29 @@ export function createHandler(control) {
|
|
|
150
230
|
const name = params.name;
|
|
151
231
|
const args = params.arguments || {};
|
|
152
232
|
|
|
233
|
+
// org.* tools — always proxy to remote.
|
|
234
|
+
if (name && name.startsWith('org.')) {
|
|
235
|
+
const fn = ORG_DISPATCH[name];
|
|
236
|
+
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
237
|
+
const remote = getOrgRemote();
|
|
238
|
+
if (!remote) {
|
|
239
|
+
return toolResult(id, {
|
|
240
|
+
ok: false,
|
|
241
|
+
error:
|
|
242
|
+
'org.* tools require a remote LoreKit endpoint configured with a read-write token. ' +
|
|
243
|
+
'Set LOREKIT_MCP_URL and LOREKIT_TOKEN (lk_rw_*), or run `lorekit install --endpoint <url> --token <lk_rw_*>`.',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const result = await fn(remote, args);
|
|
247
|
+
return toolResult(id, result);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// memory.* tools
|
|
153
251
|
if (!store) {
|
|
154
252
|
return toolResult(id, { ok: false, error: `memory is disabled (mode: ${control.mode})` });
|
|
155
253
|
}
|
|
156
254
|
|
|
157
|
-
const fn =
|
|
255
|
+
const fn = MEMORY_DISPATCH[name];
|
|
158
256
|
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
159
257
|
|
|
160
258
|
const result = await fn(store, args);
|
package/src/store/remote.mjs
CHANGED
|
@@ -82,6 +82,36 @@ class RemoteStore {
|
|
|
82
82
|
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// ── Org management ─────────────────────────────────────────────────────────
|
|
86
|
+
// These proxy to the hosted MCP endpoint's org.* tools. Auth is resolved
|
|
87
|
+
// server-side from the Bearer token; no user-id is passed by the caller.
|
|
88
|
+
|
|
89
|
+
async orgCreate({ slug, name } = {}) {
|
|
90
|
+
const res = await this._call('org.create', clean({ slug, name }));
|
|
91
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
92
|
+
const payload = unwrap(res.result);
|
|
93
|
+
return { ok: true, org: payload };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async orgList() {
|
|
97
|
+
const res = await this._call('org.list', {});
|
|
98
|
+
return this._entries(res);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async orgRename({ slug, name } = {}) {
|
|
102
|
+
const res = await this._call('org.rename', clean({ slug, name }));
|
|
103
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
104
|
+
const payload = unwrap(res.result);
|
|
105
|
+
return { ok: true, ...payload };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async orgDelete({ slug } = {}) {
|
|
109
|
+
const res = await this._call('org.delete', clean({ slug }));
|
|
110
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
111
|
+
const payload = unwrap(res.result);
|
|
112
|
+
return { ok: true, ...payload };
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
// Connectivity probe for doctor — a transport check, not a memory op.
|
|
86
116
|
async ping() {
|
|
87
117
|
if (!this.usable()) return { ok: false, unusable: true };
|
package/src/telemetry-token.mjs
CHANGED
|
@@ -14,4 +14,4 @@
|
|
|
14
14
|
// At runtime this is only the lowest-priority source: an explicit
|
|
15
15
|
// LOREKIT_TELEMETRY_TOKEN env var or OTEL_EXPORTER_OTLP_HEADERS still win (see
|
|
16
16
|
// resolveTelemetryConfig in telemetry.mjs).
|
|
17
|
-
export const TELEMETRY_TOKEN =
|
|
17
|
+
export const TELEMETRY_TOKEN = "auth_XFLXatTBqqgvhZ12Tn9sfttClUkBPodp";
|
package/src/telemetry.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { TELEMETRY_TOKEN } from './telemetry-token.mjs';
|
|
|
28
28
|
// ── Baked-in defaults (public by design) ──────────────────────────────────────
|
|
29
29
|
// The endpoint is a committed default; the token is injected at publish time
|
|
30
30
|
// (empty in the source tree, so default export stays off until built/injected).
|
|
31
|
-
const DEFAULT_ENDPOINT = 'https://ingress.
|
|
31
|
+
const DEFAULT_ENDPOINT = 'https://ingress.europe-west4.gcp.dash0-dev.com';
|
|
32
32
|
const DEFAULT_TOKEN = TELEMETRY_TOKEN; // injected from LOREKIT_TELEMETRY_TOKEN at publish
|
|
33
33
|
const DEFAULT_DATASET = 'lorekit-cli';
|
|
34
34
|
|