@lorekit/cli 1.2.0 → 1.4.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 +22 -0
- package/bin/lorekit.mjs +165 -12
- package/package.json +1 -1
- package/src/telemetry-token.mjs +17 -0
- package/src/telemetry.mjs +325 -0
- package/src/util.mjs +8 -1
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
|
|
@@ -80,25 +83,164 @@ ${c.bold('Examples')}
|
|
|
80
83
|
npx @lorekit/cli doctor --deep
|
|
81
84
|
npx @lorekit/cli migrate --from .lore # preview a rename
|
|
82
85
|
npx @lorekit/cli migrate --from .lore --to project --yes
|
|
86
|
+
|
|
87
|
+
Run ${c.cyan('lorekit <command> --help')} for command-specific options.
|
|
83
88
|
`;
|
|
84
89
|
|
|
90
|
+
// Per-command help. Keyed by command; `lorekit <command> --help` prints the
|
|
91
|
+
// focused entry instead of the full top-level HELP, so a user only sees the
|
|
92
|
+
// flags that actually apply to what they're running.
|
|
93
|
+
const COMMAND_HELP = {
|
|
94
|
+
install: `${c.bold('lorekit install')} — scaffold the skill, wire the MCP server, install the hooks
|
|
95
|
+
|
|
96
|
+
${c.bold('Usage')}
|
|
97
|
+
npx @lorekit/cli install [options]
|
|
98
|
+
|
|
99
|
+
Scaffolds the lorekit-memory skill, adds the LoreKit MCP server, and wires the
|
|
100
|
+
deterministic hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
|
|
101
|
+
|
|
102
|
+
${c.bold('Options')}
|
|
103
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
104
|
+
--project Install into this project: .claude/skills + .mcp.json (default)
|
|
105
|
+
--global Install for every project: ~/.claude/skills + ~/.claude.json
|
|
106
|
+
-e, --endpoint <url> LoreKit MCP endpoint (else LOREKIT_MCP_URL)
|
|
107
|
+
-t, --token <token> LoreKit token: lk_rw_* read+write, lk_ro_* read-only, lk_wo_* write-only
|
|
108
|
+
--no-hooks Skip wiring the lifecycle hooks (skill stays model-invoked only)
|
|
109
|
+
--force Overwrite existing skill files
|
|
110
|
+
-y, --yes Non-interactive; never prompt (defaults to --project)
|
|
111
|
+
|
|
112
|
+
${c.bold('Examples')}
|
|
113
|
+
npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
|
|
114
|
+
npx @lorekit/cli install --global
|
|
115
|
+
npx @lorekit/cli install --no-hooks --yes
|
|
116
|
+
`,
|
|
117
|
+
uninstall: `${c.bold('lorekit uninstall')} — reverse install for the chosen scope
|
|
118
|
+
|
|
119
|
+
${c.bold('Usage')}
|
|
120
|
+
npx @lorekit/cli uninstall [options]
|
|
121
|
+
|
|
122
|
+
Removes the lorekit-memory skill, the MCP server entry, and the lifecycle hooks.
|
|
123
|
+
Surgical — other servers, hooks, and settings are left untouched.
|
|
124
|
+
|
|
125
|
+
${c.bold('Options')}
|
|
126
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
127
|
+
--project Uninstall from this project (default)
|
|
128
|
+
--global Uninstall the global (~/.claude) setup
|
|
129
|
+
-y, --yes Non-interactive; never prompt
|
|
130
|
+
|
|
131
|
+
${c.bold('Examples')}
|
|
132
|
+
npx @lorekit/cli uninstall --global
|
|
133
|
+
npx @lorekit/cli uninstall --project --yes
|
|
134
|
+
`,
|
|
135
|
+
doctor: `${c.bold('lorekit doctor')} — verify the skill install and the resolved memory backend
|
|
136
|
+
|
|
137
|
+
${c.bold('Usage')}
|
|
138
|
+
npx @lorekit/cli doctor [options]
|
|
139
|
+
|
|
140
|
+
Checks the node runtime, skill install, resolved memory mode, MCP connectivity,
|
|
141
|
+
token, and scope.
|
|
142
|
+
|
|
143
|
+
${c.bold('Options')}
|
|
144
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
145
|
+
--mode <mode> Override the resolved mode: off | local | remote
|
|
146
|
+
-e, --endpoint <url> Endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
147
|
+
-t, --token <token> Token override (else .mcp.json / LOREKIT_TOKEN)
|
|
148
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
149
|
+
--deep Do a write→read→delete round-trip
|
|
150
|
+
|
|
151
|
+
${c.bold('Examples')}
|
|
152
|
+
npx @lorekit/cli doctor
|
|
153
|
+
npx @lorekit/cli doctor --deep
|
|
154
|
+
npx @lorekit/cli doctor --mode local
|
|
155
|
+
`,
|
|
156
|
+
migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
|
|
157
|
+
|
|
158
|
+
${c.bold('Usage')}
|
|
159
|
+
npx @lorekit/cli migrate --from <path> [options]
|
|
160
|
+
|
|
161
|
+
Dry-run by default; pass --yes (or --apply) to write. Idempotent.
|
|
162
|
+
|
|
163
|
+
${c.bold('Options')}
|
|
164
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
165
|
+
--from <path> Source store to migrate from (required)
|
|
166
|
+
--to <tier> Destination tier: home | project (default routes by scope)
|
|
167
|
+
--apply Apply the migration (alias of --yes)
|
|
168
|
+
-y, --yes Apply the migration; never prompt
|
|
169
|
+
|
|
170
|
+
${c.bold('Examples')}
|
|
171
|
+
npx @lorekit/cli migrate --from .lore # preview a rename
|
|
172
|
+
npx @lorekit/cli migrate --from .lore --to project --yes
|
|
173
|
+
`,
|
|
174
|
+
hook: `${c.bold('lorekit hook')} — hook engine for Claude Code / Cursor / Codex
|
|
175
|
+
|
|
176
|
+
${c.bold('Usage')}
|
|
177
|
+
lorekit hook --adapter <claude|cursor|codex> --event <name> [--dir <path>]
|
|
178
|
+
|
|
179
|
+
Machine-facing: reads the host's JSON on stdin and injects lessons or a
|
|
180
|
+
retrospective nudge on stdout, always exiting 0. Wired into a plugin's hook
|
|
181
|
+
config by \`lorekit install\` — not run by hand.
|
|
182
|
+
|
|
183
|
+
${c.bold('Options')}
|
|
184
|
+
-d, --dir <path> Target project root
|
|
185
|
+
--adapter <name> Host framework: claude | cursor | codex
|
|
186
|
+
--event <name> Host hook event (else read from the stdin payload)
|
|
187
|
+
`,
|
|
188
|
+
mcp: `${c.bold('lorekit mcp')} — local stdio MCP server
|
|
189
|
+
|
|
190
|
+
${c.bold('Usage')}
|
|
191
|
+
lorekit mcp [--dir <path>]
|
|
192
|
+
|
|
193
|
+
Machine-facing: exposes the memory.* tools backed by the resolved store (local
|
|
194
|
+
.lorekit/ offline, or remote passthrough) over JSON-RPC on stdin/stdout, so
|
|
195
|
+
.mcp.json can point at the CLI instead of mcp-remote. Not run by hand.
|
|
196
|
+
|
|
197
|
+
${c.bold('Options')}
|
|
198
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
199
|
+
`,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Every long flag the CLI understands (after alias resolution). Passed to the
|
|
203
|
+
// parser so an unrecognized flag is captured rather than silently ignored — a
|
|
204
|
+
// typo like `--gloabl` should fail loudly, not quietly fall back to --project.
|
|
205
|
+
const KNOWN_FLAGS = [
|
|
206
|
+
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
207
|
+
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
208
|
+
'event', 'help', 'version',
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
212
|
+
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
213
|
+
// never fail on a stray flag, and only ever receive flags we control).
|
|
214
|
+
const HUMAN_COMMANDS = new Set(['install', 'uninstall', 'doctor', 'migrate']);
|
|
215
|
+
|
|
85
216
|
async function main() {
|
|
86
217
|
const argv = process.argv.slice(2);
|
|
87
218
|
const args = parseArgs(argv, {
|
|
88
219
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
89
220
|
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks'],
|
|
221
|
+
known: KNOWN_FLAGS,
|
|
90
222
|
});
|
|
91
223
|
|
|
224
|
+
const command = args._[0];
|
|
225
|
+
|
|
226
|
+
// Help is intercepted first — before the machine-facing hook/mcp dispatch — so
|
|
227
|
+
// `lorekit <command> --help` always documents the command (even hook/mcp)
|
|
228
|
+
// instead of blocking on stdin. Real hook/mcp invocations never pass --help.
|
|
229
|
+
if (args.help) {
|
|
230
|
+
log(command && COMMAND_HELP[command] ? COMMAND_HELP[command] : HELP);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
92
234
|
// `hook` is machine-facing: it must never print help/errors to stdout
|
|
93
235
|
// (that would corrupt the JSON the host parses). Handle it before the
|
|
94
|
-
//
|
|
95
|
-
if (
|
|
236
|
+
// usage branch and always resolve to exit 0.
|
|
237
|
+
if (command === 'hook') {
|
|
96
238
|
return hook(args);
|
|
97
239
|
}
|
|
98
240
|
|
|
99
241
|
// `mcp` is machine-facing too: only JSON-RPC frames may reach stdout, so it
|
|
100
|
-
// must bypass the
|
|
101
|
-
if (
|
|
242
|
+
// must bypass the usage branch. It serves stdio until the client closes.
|
|
243
|
+
if (command === 'mcp') {
|
|
102
244
|
return mcpServer(args);
|
|
103
245
|
}
|
|
104
246
|
|
|
@@ -107,22 +249,33 @@ async function main() {
|
|
|
107
249
|
return 0;
|
|
108
250
|
}
|
|
109
251
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (args.help || !command) {
|
|
252
|
+
if (!command) {
|
|
113
253
|
log(HELP);
|
|
114
|
-
return
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Reject unrecognized flags on human-facing commands with an actionable
|
|
258
|
+
// pointer, rather than silently ignoring a typo that would change behavior.
|
|
259
|
+
if (HUMAN_COMMANDS.has(command) && args._unknown.length > 0) {
|
|
260
|
+
const plural = args._unknown.length > 1 ? 's' : '';
|
|
261
|
+
err(`${c.red(`Unknown option${plural}:`)} ${args._unknown.join(', ')}`);
|
|
262
|
+
err(`Run ${c.cyan(`lorekit ${command} --help`)} to see valid options.`);
|
|
263
|
+
return 1;
|
|
115
264
|
}
|
|
116
265
|
|
|
266
|
+
// Human-facing commands are wrapped so we can see which commands people run
|
|
267
|
+
// (one OTel span + counter per invocation). `hook` and `mcp` are handled
|
|
268
|
+
// above and stay uninstrumented — they are machine-facing, fire on every
|
|
269
|
+
// agent event, and must keep stdout to their host protocol.
|
|
117
270
|
switch (command) {
|
|
118
271
|
case 'install':
|
|
119
|
-
return install(args);
|
|
272
|
+
return traceCommand('install', args, VERSION, () => install(args));
|
|
120
273
|
case 'uninstall':
|
|
121
|
-
return uninstall(args);
|
|
274
|
+
return traceCommand('uninstall', args, VERSION, () => uninstall(args));
|
|
122
275
|
case 'doctor':
|
|
123
|
-
return doctor(args);
|
|
276
|
+
return traceCommand('doctor', args, VERSION, () => doctor(args));
|
|
124
277
|
case 'migrate':
|
|
125
|
-
return migrate(args);
|
|
278
|
+
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
126
279
|
default:
|
|
127
280
|
err(`${c.red('Unknown command:')} ${command}\n`);
|
|
128
281
|
log(HELP);
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -119,8 +119,13 @@ export function select(question, options, { defaultIndex = 0 } = {}) {
|
|
|
119
119
|
|
|
120
120
|
// Minimal flag parser: --key value, --key=value, -k value, and bare --flags.
|
|
121
121
|
// `aliases` maps short → long; `booleans` lists flags that take no value.
|
|
122
|
-
|
|
122
|
+
// When `known` is a non-null list of long flag names, any flag whose resolved
|
|
123
|
+
// name isn't in it is collected (as its original token) into `out._unknown` —
|
|
124
|
+
// letting the caller reject typos like `--gloabl` instead of silently ignoring
|
|
125
|
+
// them. `out._unknown` is only present when `known` was supplied.
|
|
126
|
+
export function parseArgs(argv, { aliases = {}, booleans = [], known = null } = {}) {
|
|
123
127
|
const out = { _: [] };
|
|
128
|
+
const unknown = [];
|
|
124
129
|
for (let i = 0; i < argv.length; i++) {
|
|
125
130
|
let token = argv[i];
|
|
126
131
|
if (!token.startsWith('-')) {
|
|
@@ -135,6 +140,7 @@ export function parseArgs(argv, { aliases = {}, booleans = [] } = {}) {
|
|
|
135
140
|
}
|
|
136
141
|
let key = token.replace(/^-+/, '');
|
|
137
142
|
if (aliases[key]) key = aliases[key];
|
|
143
|
+
if (known && !known.includes(key)) unknown.push(token);
|
|
138
144
|
if (booleans.includes(key)) {
|
|
139
145
|
out[key] = true;
|
|
140
146
|
continue;
|
|
@@ -150,5 +156,6 @@ export function parseArgs(argv, { aliases = {}, booleans = [] } = {}) {
|
|
|
150
156
|
}
|
|
151
157
|
out[key] = value;
|
|
152
158
|
}
|
|
159
|
+
if (known) out._unknown = unknown;
|
|
153
160
|
return out;
|
|
154
161
|
}
|