@lorekit/cli 1.0.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -7
- package/bin/lorekit.mjs +35 -7
- package/package.json +6 -2
- package/skill/lorekit-memory/SKILL.md +3 -2
- package/src/config.mjs +249 -26
- package/src/doctor.mjs +12 -5
- package/src/install.mjs +72 -14
- package/src/mcp-server.mjs +18 -1
- package/src/store/created-at.mjs +28 -0
- package/src/store/local.mjs +19 -3
- package/src/telemetry-token.mjs +17 -0
- package/src/telemetry.mjs +325 -0
- package/src/uninstall.mjs +114 -0
- package/src/util.mjs +77 -0
package/src/install.mjs
CHANGED
|
@@ -10,12 +10,16 @@ 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 });
|
|
@@ -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,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
|
+
}
|