@lorekit/cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -297,6 +297,10 @@ active deny constraints.
297
297
  | `LOREKIT_MCP_URL` / `LOREKIT_ENDPOINT` | endpoint fallback |
298
298
  | `LOREKIT_TOKEN` | token fallback |
299
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 |
300
304
 
301
305
  ## What the skill does
302
306
 
@@ -359,6 +363,24 @@ forever. This reduces manual validation to a single capture pass per tool.
359
363
  > the real Claude CLI accepts the wiring; for a true live check, install the
360
364
  > plugin and start one session per tool.
361
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
+
362
384
  ## Security note
363
385
 
364
386
  `install` writes your token into `.mcp.json`. Keep that file out of version
package/bin/lorekit.mjs CHANGED
@@ -9,6 +9,7 @@ import { doctor } from '../src/doctor.mjs';
9
9
  import { hook } from '../src/hook.mjs';
10
10
  import { migrate } from '../src/migrate.mjs';
11
11
  import { mcpServer } from '../src/mcp-server.mjs';
12
+ import { traceCommand } from '../src/telemetry.mjs';
12
13
 
13
14
  // Read the version from package.json so it always matches the published
14
15
  // package — release-please bumps package.json, and this tracks it for free.
@@ -72,6 +73,8 @@ ${c.bold('Environment')}
72
73
  LOREKIT_MCP_URL / LOREKIT_ENDPOINT endpoint fallback
73
74
  LOREKIT_TOKEN token fallback
74
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
75
78
 
76
79
  ${c.bold('Examples')}
77
80
  npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
@@ -114,15 +117,19 @@ async function main() {
114
117
  return command ? 0 : args.help ? 0 : 1;
115
118
  }
116
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.
117
124
  switch (command) {
118
125
  case 'install':
119
- return install(args);
126
+ return traceCommand('install', args, VERSION, () => install(args));
120
127
  case 'uninstall':
121
- return uninstall(args);
128
+ return traceCommand('uninstall', args, VERSION, () => uninstall(args));
122
129
  case 'doctor':
123
- return doctor(args);
130
+ return traceCommand('doctor', args, VERSION, () => doctor(args));
124
131
  case 'migrate':
125
- return migrate(args);
132
+ return traceCommand('migrate', args, VERSION, () => migrate(args));
126
133
  default:
127
134
  err(`${c.red('Unknown command:')} ${command}\n`);
128
135
  log(HELP);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.2.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
6
  "repository": {
@@ -0,0 +1,17 @@
1
+ // Build-time injection point for the Dash0 ingesting-only telemetry token.
2
+ //
3
+ // The committed value is EMPTY on purpose — default telemetry stays off in the
4
+ // source tree and nothing secret is ever committed to git. The release workflow
5
+ // (.github/workflows/release.yml → publish-cli) overwrites this file at publish
6
+ // time from the LOREKIT_TELEMETRY_TOKEN secret via
7
+ // scripts/inject-telemetry-token.mjs, so only the *published npm tarball*
8
+ // carries the token.
9
+ //
10
+ // The token is public by design once published (anyone can unpack the tarball),
11
+ // so it MUST be a Dash0 *ingesting-only* token — it can POST spans but cannot
12
+ // read, query, or manage anything.
13
+ //
14
+ // At runtime this is only the lowest-priority source: an explicit
15
+ // LOREKIT_TELEMETRY_TOKEN env var or OTEL_EXPORTER_OTLP_HEADERS still win (see
16
+ // resolveTelemetryConfig in telemetry.mjs).
17
+ export const TELEMETRY_TOKEN = '';
@@ -0,0 +1,325 @@
1
+ // LoreKit CLI — self-contained OpenTelemetry export for command usage.
2
+ //
3
+ // The CLI is strictly zero-dependency (see packages/cli/package.json), so this
4
+ // mirrors the Edge Function's SDK-free approach (supabase/functions/_shared/
5
+ // otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
6
+ // packages. One span + one counter data point per human-facing command
7
+ // (install / doctor / migrate), fired to Dash0 so the maintainers can see
8
+ // which commands people actually run.
9
+ //
10
+ // Privacy — this runs on end-users' machines, so it is deliberately narrow:
11
+ // • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
12
+ // cross-vendor DO_NOT_TRACK=1, disables all export.
13
+ // • No PII is ever attached: only the command name, a bounded allow-list of
14
+ // boolean flags, the CLI/runtime/OS identity, and the outcome. Never a
15
+ // path, cwd, token, endpoint, repo, or scope string.
16
+ // • Disabled outright when no OTLP endpoint resolves.
17
+ //
18
+ // The default endpoint + token below are baked into the published package and
19
+ // are therefore public by design. The token MUST be Dash0 ingestion-only
20
+ // (write/POST spans, no read/query/manage) — anyone can unpack the npm tarball
21
+ // and read it. It is NOT committed to git: the release workflow injects it into
22
+ // telemetry-token.mjs at publish time from a secret (see that file). Standard
23
+ // OTEL_EXPORTER_OTLP_* env vars — or LOREKIT_TELEMETRY_TOKEN — override it.
24
+
25
+ import process from 'node:process';
26
+ import { TELEMETRY_TOKEN } from './telemetry-token.mjs';
27
+
28
+ // ── Baked-in defaults (public by design) ──────────────────────────────────────
29
+ // The endpoint is a committed default; the token is injected at publish time
30
+ // (empty in the source tree, so default export stays off until built/injected).
31
+ const DEFAULT_ENDPOINT = 'https://ingress.us-east-1.aws.dash0.com';
32
+ const DEFAULT_TOKEN = TELEMETRY_TOKEN; // injected from LOREKIT_TELEMETRY_TOKEN at publish
33
+ const DEFAULT_DATASET = 'lorekit-cli';
34
+
35
+ // Flags worth counting (e.g. how many installs are --global). Bounded on
36
+ // purpose: only these booleans are ever attached, never free-form values.
37
+ const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks'];
38
+
39
+ const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
40
+
41
+ // ── Config resolution ─────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Resolve telemetry config from env + baked-in defaults.
45
+ * Returns { enabled: false } when disabled or unconfigured, else the endpoint
46
+ * and headers to export with.
47
+ */
48
+ export function resolveTelemetryConfig(env = process.env) {
49
+ const optOut = env.LOREKIT_TELEMETRY;
50
+ if (optOut !== undefined && OFF_VALUES.has(String(optOut).trim().toLowerCase())) {
51
+ return { enabled: false };
52
+ }
53
+ // DNT spec designates exactly `1` as the opt-out signal (consoledonottrack.com).
54
+ // Match it precisely — a stray `DO_NOT_TRACK=false` should NOT disable export
55
+ // (use LOREKIT_TELEMETRY for the loose app-specific opt-out values).
56
+ if (env.DO_NOT_TRACK && String(env.DO_NOT_TRACK).trim() === '1') {
57
+ return { enabled: false };
58
+ }
59
+
60
+ const endpoint = (env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_ENDPOINT || '')
61
+ .trim()
62
+ .replace(/\/+$/, '');
63
+ if (!endpoint) return { enabled: false };
64
+
65
+ const headers = {};
66
+ // Auth header priority (highest first):
67
+ // 1. OTEL_EXPORTER_OTLP_HEADERS — explicit comma list of key=value.
68
+ // 2. LOREKIT_TELEMETRY_TOKEN — a bare bearer token via env (e.g. set as a
69
+ // GitHub Actions secret to inject the token, or for local testing).
70
+ // 3. DEFAULT_TOKEN — baked into the tarball at publish time.
71
+ const rawHeaders = env.OTEL_EXPORTER_OTLP_HEADERS;
72
+ const envToken = env.LOREKIT_TELEMETRY_TOKEN ? String(env.LOREKIT_TELEMETRY_TOKEN).trim() : '';
73
+ if (rawHeaders) {
74
+ for (const pair of String(rawHeaders).split(',')) {
75
+ const idx = pair.indexOf('=');
76
+ if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
77
+ }
78
+ } else if (envToken) {
79
+ headers['Authorization'] = `Bearer ${envToken}`;
80
+ } else if (DEFAULT_TOKEN) {
81
+ headers['Authorization'] = `Bearer ${DEFAULT_TOKEN}`;
82
+ }
83
+
84
+ // With the baked-in default endpoint we need the baked-in token (or explicit
85
+ // headers) to authenticate — no point exporting an unauthenticated request.
86
+ const usingDefaultEndpoint = !env.OTEL_EXPORTER_OTLP_ENDPOINT;
87
+ if (usingDefaultEndpoint && Object.keys(headers).length === 0) {
88
+ return { enabled: false };
89
+ }
90
+
91
+ const dataset = env.DASH0_DATASET || DEFAULT_DATASET;
92
+ if (dataset) headers['Dash0-Dataset'] = dataset;
93
+
94
+ return { enabled: true, endpoint, headers };
95
+ }
96
+
97
+ // ── ID + value helpers (mirror _shared/otel.ts) ───────────────────────────────
98
+
99
+ export function randHex(bytes) {
100
+ const b = new Uint8Array(bytes);
101
+ // `crypto` here is the WebCrypto global (globalThis.crypto), a stable global
102
+ // since Node 19 and also present on Node 18 — hence used unqualified rather
103
+ // than imported. (Do NOT `import crypto from 'node:crypto'`: that module's
104
+ // default export does not expose getRandomValues; only `webcrypto` does.)
105
+ crypto.getRandomValues(b);
106
+ return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
107
+ }
108
+
109
+ function toOtlpValue(v) {
110
+ if (typeof v === 'number') return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
111
+ if (typeof v === 'boolean') return { boolValue: v };
112
+ return { stringValue: String(v) };
113
+ }
114
+
115
+ // Map a flat attribute bag to the OTLP key/value list shape.
116
+ function toOtlpAttributes(attributes) {
117
+ return Object.entries(attributes).map(([key, value]) => ({ key, value: toOtlpValue(value) }));
118
+ }
119
+
120
+ function resourceAttributes(version) {
121
+ return [
122
+ { key: 'service.name', value: { stringValue: 'lorekit-cli' } },
123
+ { key: 'service.namespace', value: { stringValue: 'lorekit' } },
124
+ { key: 'service.version', value: { stringValue: String(version) } },
125
+ { key: 'process.runtime.name', value: { stringValue: 'nodejs' } },
126
+ { key: 'process.runtime.version', value: { stringValue: process.versions.node } },
127
+ { key: 'os.type', value: { stringValue: process.platform } },
128
+ { key: 'host.arch', value: { stringValue: process.arch } },
129
+ ];
130
+ }
131
+
132
+ // ── Payload builders (pure — unit-tested) ─────────────────────────────────────
133
+
134
+ /**
135
+ * Collect the bounded, non-PII attributes for a command invocation. Only the
136
+ * command name, allow-listed boolean flags, the outcome and the exit code.
137
+ */
138
+ export function commandAttributes({ command, args = {}, outcome, exitCode }) {
139
+ const attrs = { 'lorekit.cli.command': command, 'lorekit.cli.outcome': outcome };
140
+ if (typeof exitCode === 'number') attrs['lorekit.cli.exit_code'] = exitCode;
141
+ for (const flag of FLAG_ATTRS) {
142
+ if (args[flag]) attrs[`lorekit.cli.flag.${flag}`] = true;
143
+ }
144
+ return attrs;
145
+ }
146
+
147
+ export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage }) {
148
+ return {
149
+ resourceSpans: [
150
+ {
151
+ resource: { attributes: resourceAttributes(version) },
152
+ scopeSpans: [
153
+ {
154
+ scope: { name: 'lorekit-cli', version: String(version) },
155
+ spans: [
156
+ {
157
+ traceId: randHex(16),
158
+ spanId: randHex(8),
159
+ name,
160
+ kind: 1, // INTERNAL
161
+ startTimeUnixNano: String(startMs * 1_000_000),
162
+ endTimeUnixNano: String(endMs * 1_000_000),
163
+ attributes: toOtlpAttributes(attributes),
164
+ status: {
165
+ code: status === 'error' ? 2 : 1,
166
+ ...(statusMessage ? { message: statusMessage } : {}),
167
+ },
168
+ },
169
+ ],
170
+ },
171
+ ],
172
+ },
173
+ ],
174
+ };
175
+ }
176
+
177
+ export function buildMetricsPayload({ version, attributes, startMs, endMs }) {
178
+ return {
179
+ resourceMetrics: [
180
+ {
181
+ resource: { attributes: resourceAttributes(version) },
182
+ scopeMetrics: [
183
+ {
184
+ scope: { name: 'lorekit-cli', version: String(version) },
185
+ metrics: [
186
+ {
187
+ name: 'lorekit.cli.invocations',
188
+ description: 'Count of LoreKit CLI command invocations',
189
+ unit: '1',
190
+ sum: {
191
+ aggregationTemporality: 1, // DELTA — a single-shot CLI reports +1
192
+ isMonotonic: true,
193
+ dataPoints: [
194
+ {
195
+ asInt: '1',
196
+ startTimeUnixNano: String(startMs * 1_000_000),
197
+ timeUnixNano: String(endMs * 1_000_000),
198
+ attributes: toOtlpAttributes(attributes),
199
+ },
200
+ ],
201
+ },
202
+ },
203
+ ],
204
+ },
205
+ ],
206
+ },
207
+ ],
208
+ };
209
+ }
210
+
211
+ // ── Export ────────────────────────────────────────────────────────────────────
212
+
213
+ async function post(url, headers, payload, timeoutMs) {
214
+ const controller = new AbortController();
215
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
216
+ try {
217
+ await fetch(url, {
218
+ method: 'POST',
219
+ headers: { 'content-type': 'application/json', ...headers },
220
+ body: JSON.stringify(payload),
221
+ signal: controller.signal,
222
+ });
223
+ } catch {
224
+ // Telemetry is best-effort — never surface a network/abort error.
225
+ } finally {
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Export the trace + metric for one command. Best-effort and time-bounded so it
232
+ * can never delay or fail the CLI. Awaited before process exit (Node would
233
+ * otherwise drop the in-flight request), but capped at timeoutMs.
234
+ */
235
+ export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage }, { timeoutMs = 1500 } = {}) {
236
+ if (!config || !config.enabled) return;
237
+ const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage });
238
+ const metric = buildMetricsPayload({ version, attributes, startMs, endMs });
239
+ await Promise.all([
240
+ post(`${config.endpoint}/v1/traces`, config.headers, trace, timeoutMs),
241
+ post(`${config.endpoint}/v1/metrics`, config.headers, metric, timeoutMs),
242
+ ]);
243
+ }
244
+
245
+ // ── Command wrapper ───────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * A bounded, non-PII label for a thrown error: its `code` (e.g. `ENOENT`) or,
249
+ * failing that, its constructor name (e.g. `TypeError`). Never the free-form
250
+ * message, which can carry paths and other user data.
251
+ */
252
+ function errorLabel(e) {
253
+ if (e && typeof e.code === 'string' && e.code) return e.code;
254
+ if (e && e.constructor && e.constructor.name) return e.constructor.name;
255
+ return 'Error';
256
+ }
257
+
258
+ /**
259
+ * Time a human-facing command, record its outcome, and export one span + one
260
+ * counter point. Returns the command's exit code unchanged. Telemetry failures
261
+ * are swallowed — the command result is never affected.
262
+ *
263
+ * @param {string} command bounded: install | doctor | migrate
264
+ * @param {object} args parsed CLI args (read for allow-listed flags only)
265
+ * @param {string} version CLI version (from package.json)
266
+ * @param {() => Promise<number>} run the command handler
267
+ */
268
+ export async function traceCommand(command, args, version, run) {
269
+ let config;
270
+ try {
271
+ config = resolveTelemetryConfig();
272
+ } catch {
273
+ config = { enabled: false };
274
+ }
275
+
276
+ // Fast path: no export configured → run with zero overhead.
277
+ if (!config.enabled) return run();
278
+
279
+ const startMs = Date.now();
280
+ let exitCode = 0;
281
+ let status = 'ok';
282
+ let statusMessage;
283
+ try {
284
+ exitCode = await run();
285
+ if (typeof exitCode === 'number' && exitCode !== 0) {
286
+ status = 'error';
287
+ statusMessage = `exit ${exitCode}`;
288
+ }
289
+ return exitCode;
290
+ } catch (e) {
291
+ status = 'error';
292
+ // Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
293
+ // network error messages embed absolute paths (e.g. "ENOENT: ... open
294
+ // '/home/me/proj/.mcp.json'"), which must never reach an exported span. The
295
+ // error status code already conveys failure.
296
+ statusMessage = errorLabel(e);
297
+ exitCode = 1;
298
+ throw e;
299
+ } finally {
300
+ // NOTE: on a thrown command error the `throw e` above is deferred until this
301
+ // finally settles, so a crash still awaits `exportInvocation` (up to
302
+ // timeoutMs, default 1500 ms) before propagating. This is intentional — the
303
+ // in-flight span would otherwise be dropped on exit. Do not shorten the
304
+ // timeout without weighing this "crash appears to hang ~1.5 s" trade-off.
305
+ try {
306
+ const attributes = commandAttributes({
307
+ command,
308
+ args,
309
+ outcome: status === 'error' ? 'error' : 'ok',
310
+ exitCode: typeof exitCode === 'number' ? exitCode : undefined,
311
+ });
312
+ await exportInvocation(config, {
313
+ version,
314
+ name: `lorekit.cli.${command}`,
315
+ attributes,
316
+ startMs,
317
+ endMs: Date.now(),
318
+ status,
319
+ statusMessage,
320
+ });
321
+ } catch {
322
+ // never let telemetry break the CLI
323
+ }
324
+ }
325
+ }