@lorekit/cli 1.20.1 → 1.22.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
@@ -224,13 +224,13 @@ stored by basename only) — so every scope is reconstructed verbatim. Lessons
224
224
  present in both tiers are counted once (project shadows home, the same merge
225
225
  `list` uses); archived lessons are excluded.
226
226
 
227
- **Remote enumeration is not possible, and `scopes` says so honestly.** The
228
- hosted MCP surface exposes no "list all scopes" tool every read tool
229
- (`memory.list` / `memory.search` / `memory.read`) *requires* a scope so a
230
- remote inventory can't be built. The Remote section is therefore always a short
231
- note (never a faked listing), degrading gracefully at exit 0, the same way
232
- `stats` omits a cap-usage figure. `--endpoint` / `--token` / `--store` behave as
233
- in `list`.
227
+ **Remote enumeration is exact too.** `RemoteStore.listScopes()` calls
228
+ `GET /memories/scopes`, which aggregates one `{ scope, count }` row per scope in
229
+ Postgres (never a truncatable `select('scope')` plus a client-side dedupe), so
230
+ the Remote section is a real inventory rendered through the same helpers as the
231
+ Offline one. A denied, unconfigured, unreachable, or erroring remote degrades to
232
+ a short, accurate note (network error / HTTP status never a faked listing) at
233
+ exit 0. `--endpoint` / `--token` / `--store` behave as in `list`.
234
234
 
235
235
  ### `lorekit diff`
236
236
 
package/bin/lorekit.mjs CHANGED
@@ -348,9 +348,10 @@ is a full inventory — it surfaces scopes anywhere in the store, regardless of
348
348
  current directory.
349
349
 
350
350
  Offline counts are exact: each scope is read from the memory files' frontmatter,
351
- not reverse-mapped from the directory layout. The Remote section is always a
352
- short note: the hosted MCP surface has no "list all scopes" tool (every read tool
353
- requires a scope), so a remote inventory isn't possible never an error (exit 0).
351
+ not reverse-mapped from the directory layout. Remote counts are exact too — they
352
+ come from \`GET /memories/scopes\`, which aggregates one row per scope server-side.
353
+ A denied, unconfigured, or unreachable remote degrades to a short, accurate note
354
+ rather than an error (exit 0).
354
355
 
355
356
  ${c.bold('Options')}
356
357
  -d, --dir <path> Target project root (default: current directory)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.20.1",
3
+ "version": "1.22.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": {
package/src/mcp.mjs CHANGED
@@ -27,7 +27,11 @@ export function buildRemoteUrl(endpoint, token) {
27
27
  let idCounter = 0;
28
28
 
29
29
  // Returns { ok, httpStatus, result, error, networkError }.
30
- export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs = 10000 } = {}) {
30
+ //
31
+ // `opts.traceparent` is an optional W3C traceparent header value (see
32
+ // src/telemetry.mjs `getActiveTraceparent`); when present it is forwarded so
33
+ // the server-side span joins the CLI's trace — same idiom as restFetch.
34
+ export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs = 10000, traceparent } = {}) {
31
35
  const controller = new AbortController();
32
36
  const timer = setTimeout(() => controller.abort(), timeoutMs);
33
37
  try {
@@ -40,6 +44,7 @@ export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs
40
44
  'content-type': 'application/json',
41
45
  accept: 'application/json, text/event-stream',
42
46
  ...(token ? { authorization: `Bearer ${token}` } : {}),
47
+ ...(traceparent ? { traceparent } : {}),
43
48
  },
44
49
  body: JSON.stringify({ jsonrpc: '2.0', id: ++idCounter, method, params }),
45
50
  signal: controller.signal,
@@ -87,3 +92,69 @@ function parseBody(text) {
87
92
  return null;
88
93
  }
89
94
  }
95
+
96
+ /**
97
+ * Derive the REST API base URL from an MCP endpoint URL.
98
+ * e.g. 'https://ref.supabase.co/functions/v1/mcp?token=...'
99
+ * → 'https://ref.supabase.co/functions/v1'
100
+ */
101
+ export function mcpToRestBase(mcpEndpointUrl) {
102
+ if (!mcpEndpointUrl) return null;
103
+ try {
104
+ const u = new URL(mcpEndpointUrl);
105
+ u.searchParams.delete('token');
106
+ // Strip /mcp suffix (with or without trailing slash)
107
+ const restPath = u.pathname.replace(/\/mcp\/?$/, '');
108
+ return `${u.origin}${restPath || '/'}`;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Minimal REST fetch for LoreKit REST API endpoints.
116
+ * Returns { ok, httpStatus, data, error, networkError } — same shape as mcpCall.
117
+ *
118
+ * @param {string} baseUrl - REST base URL (from mcpToRestBase)
119
+ * @param {string} token - Bearer token
120
+ * @param {string} path - e.g. '/memories' or '/memories/search'
121
+ * @param {object} [opts]
122
+ * @param {string} [opts.method='GET']
123
+ * @param {object} [opts.body] - JSON body for POST/PATCH/DELETE
124
+ * @param {number} [opts.timeoutMs=10000]
125
+ * @param {string} [opts.traceparent] - W3C traceparent header value
126
+ */
127
+ export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
130
+ try {
131
+ const url = `${baseUrl}${path}`;
132
+ const headers = {
133
+ accept: 'application/json',
134
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
135
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
136
+ ...(traceparent ? { traceparent } : {}),
137
+ };
138
+ const res = await fetch(url, {
139
+ method,
140
+ headers,
141
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
142
+ signal: controller.signal,
143
+ });
144
+ const text = await res.text();
145
+ let data = null;
146
+ try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
147
+ if (!res.ok) {
148
+ return {
149
+ ok: false,
150
+ httpStatus: res.status,
151
+ error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
152
+ };
153
+ }
154
+ return { ok: true, httpStatus: res.status, data };
155
+ } catch (e) {
156
+ return { ok: false, networkError: String(e?.message ?? e) };
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
package/src/scopes.mjs CHANGED
@@ -13,10 +13,12 @@
13
13
  // mapping the on-disk directory layout, which is lossy for `project::{name}`
14
14
  // (stored by basename only) — so every scope is reconstructed exactly.
15
15
  //
16
- // Remote enumeration is NOT possible: the hosted MCP surface exposes no "list
17
- // all scopes" tool every read tool (memory.list / search / read) REQUIRES a
18
- // scope so the Remote section is always an honest note (never faked), the same
19
- // way `stats` omits a cap-usage figure. It still degrades gracefully at exit 0.
16
+ // Remote enumeration is EXACT too: `RemoteStore.listScopes()` calls
17
+ // `GET /memories/scopes`, which aggregates one row per scope in Postgres (never
18
+ // a truncatable `select('scope')` + client-side dedupe), so both sections are
19
+ // real inventories rendered through the same pure helpers. A denied,
20
+ // unconfigured, unreachable, or erroring remote still degrades to a short,
21
+ // accurate note at exit 0 — never a throw.
20
22
  //
21
23
  // Graceful, read-only, human-facing (the bin wraps it in `traceCommand`).
22
24
  // `LOREKIT_DENY` suppresses a section; `--scope <s>` filters the inventory to
@@ -26,15 +28,9 @@ import process from 'node:process';
26
28
  import { resolveProjectRoot } from './config.mjs';
27
29
  import { resolveDenies } from './control.mjs';
28
30
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
29
- import { summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
31
+ import { summarizeScopeInventory, filterScopeInventory, describeError } from './lessons-view.mjs';
30
32
  import { log, heading, status, c } from './util.mjs';
31
33
 
32
- // The honest note the Remote section shows when it isn't denied/unconfigured:
33
- // the hosted MCP surface simply can't enumerate scopes. Exported so tests can
34
- // assert the exact wording rather than a fragile substring.
35
- export const REMOTE_SCOPES_UNSUPPORTED =
36
- 'remote scope enumeration is not supported by the hosted MCP surface (memory.list requires a scope)';
37
-
38
34
  export async function scopes(args) {
39
35
  const root = resolveProjectRoot(args.dir);
40
36
  const env = { ...process.env };
@@ -63,16 +59,21 @@ export async function scopes(args) {
63
59
  offlineSection = { available: true, ...summarizeScopeInventory(inventory) };
64
60
  }
65
61
 
66
- // Remote: never enumerable. A deny note, an unconfigured note, or the honest
67
- // "not supported" note all graceful (exit 0). Order matches the other
68
- // commands' precedence: deny first, then connectivity, then the capability.
62
+ // Remote: the hosted enumeration, via `GET /memories/scopes`. Precedence is
63
+ // unchanged from the other read commands deny first, then connectivity, then
64
+ // the call itself. A failed call degrades to the same bounded, non-PII
65
+ // `describeError` note a failed `list()` gets (network error / HTTP status),
66
+ // never a throw and never a faked inventory.
69
67
  let remoteSection;
70
68
  if (remoteDenied) {
71
69
  remoteSection = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` };
72
70
  } else if (!remote.usable()) {
73
71
  remoteSection = { available: false, reason: remoteUnavailableReason(connection) };
74
72
  } else {
75
- remoteSection = { available: false, reason: REMOTE_SCOPES_UNSUPPORTED };
73
+ const res = await remote.listScopes();
74
+ remoteSection = res.ok
75
+ ? { available: true, ...summarizeScopeInventory(filterScopeInventory(res.scopes, filter)) }
76
+ : { available: false, reason: describeError(res) };
76
77
  }
77
78
 
78
79
  if (args.json) {
@@ -89,15 +90,17 @@ export async function scopes(args) {
89
90
  log('');
90
91
  }
91
92
 
92
- // Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
93
- // string, path, or token. `remote_available` is always false: the surface
94
- // can't enumerate, and saying so is the honest signal.
93
+ // Bounded, non-PII telemetry extras (counts + booleans) — never a scope
94
+ // string, path, or token. `remote_available` now reflects whether the hosted
95
+ // enumeration actually answered; the remote counts mirror the offline pair.
95
96
  return {
96
97
  exitCode: 0,
97
98
  'lorekit.cli.scopes.offline_scope_count': offlineSection.available ? offlineSection.scopes.length : 0,
98
99
  'lorekit.cli.scopes.offline_total': offlineSection.available ? offlineSection.total : 0,
99
100
  'lorekit.cli.scopes.filtered': Boolean(filter),
100
- 'lorekit.cli.scopes.remote_available': false,
101
+ 'lorekit.cli.scopes.remote_scope_count': remoteSection.available ? remoteSection.scopes.length : 0,
102
+ 'lorekit.cli.scopes.remote_total': remoteSection.available ? remoteSection.total : 0,
103
+ 'lorekit.cli.scopes.remote_available': remoteSection.available,
101
104
  };
102
105
  }
103
106
 
@@ -1,25 +1,17 @@
1
- // Remote store: wraps the LoreKit MCP `memory.*` tools behind the common store
2
- // contract. Behaviour is identical to the previous direct `mcpCall` usage —
3
- // this only relocates it behind the interface. Zero-dependency.
4
- import { mcpCall } from '../mcp.mjs';
5
-
6
- // LoreKit returns tool output as { content: [{ type:'text', text:'<json>' }] }.
7
- function unwrap(result) {
8
- if (!result) return null;
9
- if (Array.isArray(result.content)) {
10
- const text = result.content.map((c) => (c && c.text) || '').join('');
11
- try {
12
- return JSON.parse(text);
13
- } catch {
14
- return null;
15
- }
16
- }
17
- return result;
18
- }
19
-
20
- // Drop undefined/null args so the JSON-RPC payload matches the old direct calls
21
- // (e.g. `memory.list` with only { scope, limit }).
22
- function clean(obj) {
1
+ // Remote store: wraps the LoreKit REST API `memory.*` endpoints behind the
2
+ // common store contract. EVERY memory operation goes over REST list, search,
3
+ // read, write, delete (soft-archive AND `?force=true` hard-delete) and the
4
+ // store-wide `listScopes()` enumeration for lower overhead and W3C
5
+ // traceparent propagation. MCP remains ONLY for the four `org.*` calls and the
6
+ // `ping` fallback: org RPCs require a Supabase JWT session (they resolve the
7
+ // actor via auth.uid() inside SECURITY DEFINER functions), which is
8
+ // incompatible with the lk_* api_key tokens that CLI users have.
9
+ // Zero-dependency.
10
+ import { mcpCall, restFetch, mcpToRestBase } from '../mcp.mjs';
11
+ import { getActiveTraceparent } from '../telemetry.mjs';
12
+
13
+ // Drop undefined/null args so JSON payloads stay tidy.
14
+ function stripUndefined(obj) {
23
15
  const out = {};
24
16
  for (const [k, v] of Object.entries(obj || {})) if (v !== undefined && v !== null) out[k] = v;
25
17
  return out;
@@ -31,8 +23,9 @@ export function createRemoteStore({ endpoint, token } = {}) {
31
23
 
32
24
  class RemoteStore {
33
25
  constructor(endpoint, token) {
34
- this.endpoint = endpoint;
26
+ this.endpoint = endpoint; // MCP URL (kept for org ops)
35
27
  this.token = token;
28
+ this.restBase = mcpToRestBase(endpoint); // REST base URL for memory ops
36
29
  this.mode = 'remote';
37
30
  }
38
31
 
@@ -40,89 +33,166 @@ class RemoteStore {
40
33
  return Boolean(this.endpoint && this.token && !String(this.endpoint).includes('<project-ref>'));
41
34
  }
42
35
 
43
- async _call(name, args) {
36
+ _tp() { return getActiveTraceparent(); }
37
+
38
+ async _rest(path, opts = {}) {
39
+ if (!this.usable()) return { ok: false, unusable: true };
40
+ return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
41
+ }
42
+
43
+ async _mcp(name, args) {
44
44
  if (!this.usable()) return { ok: false, unusable: true };
45
- return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args });
45
+ return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args }, { traceparent: this._tp() });
46
46
  }
47
47
 
48
- _entries(res) {
48
+ _mcpEntries(res) {
49
49
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
50
- const payload = unwrap(res.result);
51
- const entries = payload && Array.isArray(payload.entries) ? payload.entries : [];
52
- return { ok: true, entries };
50
+ // MCP wraps results in { content: [{ type: 'text', text: '<json>' }] }
51
+ let payload = null;
52
+ if (Array.isArray(res.result?.content)) {
53
+ const text = res.result.content.map((c) => c?.text ?? '').join('');
54
+ try { payload = JSON.parse(text); } catch { /* ignore */ }
55
+ } else { payload = res.result; }
56
+ return { ok: true, entries: Array.isArray(payload?.entries) ? payload.entries : [] };
53
57
  }
54
58
 
59
+ // ── Memory operations → REST ──────────────────────────────────────────────
60
+
55
61
  async list({ scope, tags, limit } = {}) {
56
- return this._entries(await this._call('memory.list', clean({ scope, tags, limit })));
62
+ const p = new URLSearchParams();
63
+ if (scope) p.set('scope', scope);
64
+ if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags);
65
+ if (limit) p.set('limit', String(limit));
66
+ const res = await this._rest(`/memories?${p}`);
67
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
68
+ return { ok: true, entries: res.data?.entries ?? [] };
57
69
  }
58
70
 
59
71
  async search({ q, scopes, tags } = {}) {
60
- return this._entries(await this._call('memory.search', clean({ q, scopes, tags })));
72
+ const body = {};
73
+ if (q) body.q = q;
74
+ if (scopes?.length) body.scopes = scopes;
75
+ if (tags?.length) body.tags = tags;
76
+ const res = await this._rest('/memories/search', { method: 'POST', body });
77
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
78
+ return { ok: true, entries: res.data?.entries ?? [] };
61
79
  }
62
80
 
63
81
  async read({ scope, key } = {}) {
64
- const res = await this._call('memory.read', { scope, key });
82
+ const p = new URLSearchParams();
83
+ if (scope) p.set('scope', scope);
84
+ if (key) p.set('key', key);
85
+ // scope+key is unique, so one row is all there can be — don't pull the default page of 50.
86
+ p.set('limit', '1');
87
+ const res = await this._rest(`/memories?${p}`);
65
88
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
66
- const payload = unwrap(res.result);
67
- return { ok: true, entry: payload && payload.entry ? payload.entry : payload };
89
+ const entries = res.data?.entries ?? [];
90
+ return { ok: true, entry: entries[0] ?? null };
68
91
  }
69
92
 
70
93
  async write(args = {}) {
71
- const res = await this._call('memory.write', clean(args));
72
- return { ok: res.ok, error: res.error, networkError: res.networkError, result: res.result };
94
+ const { scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at } = args;
95
+ const body = { scope, key, value };
96
+ if (tags !== undefined) body.tags = tags;
97
+ if (source_agent !== undefined) body.source_agent = source_agent;
98
+ if (trigger !== undefined) body.trigger = trigger;
99
+ if (org !== undefined) body.org = org;
100
+ if (ttl_days !== undefined) body.ttl_days = ttl_days;
101
+ if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
102
+ if (created_at !== undefined) body.created_at = created_at;
103
+ const res = await this._rest('/memories', { method: 'POST', body });
104
+ return { ok: res.ok, error: res.error, networkError: res.networkError };
73
105
  }
74
106
 
75
- async delete({ scope, key, force } = {}) {
76
- const res = await this._call('memory.delete', { scope, key, force: Boolean(force) });
107
+ // Natural-key DELETE. Without `force` the server soft-archives (stamps
108
+ // `archived_at`); `?force=true` hard-deletes the row outright both forms of
109
+ // the same REST route (supabase/functions/memories/handlers/remove.ts).
110
+ async delete({ scope, key, force = false } = {}) {
111
+ const p = new URLSearchParams({ scope, key });
112
+ if (force) p.set('force', 'true');
113
+ const res = await this._rest(`/memories?${p}`, { method: 'DELETE' });
77
114
  return { ok: res.ok, error: res.error, networkError: res.networkError };
78
115
  }
79
116
 
80
117
  async archive({ scope, key } = {}) {
81
- const res = await this._call('memory.archive', { scope, key });
82
- return { ok: res.ok, error: res.error, networkError: res.networkError };
118
+ // Soft-archive = DELETE without force
119
+ return this.delete({ scope, key, force: false });
83
120
  }
84
121
 
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.
122
+ // ── Org operations ─────────────────────────────────────────────────────────
123
+ // Org RPCs (lorekit_org_*) use auth.uid() server-side via SECURITY DEFINER
124
+ // functions, which only works with a Supabase JWT session. CLI uses lk_*
125
+ // api_key tokens which provide no JWT context, so the REST /orgs endpoint
126
+ // returns 403 for api_key callers. Org ops stay on the MCP endpoint which
127
+ // handles this correctly (the Deno edge function has its own auth path).
128
+ // TODO: if org RPCs ever gain api_key support, switch these to REST too.
88
129
 
89
130
  async orgCreate({ slug, name } = {}) {
90
- const res = await this._call('org.create', clean({ slug, name }));
131
+ const res = await this._mcp('org.create', { slug, name });
91
132
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
92
- const payload = unwrap(res.result);
133
+ let payload = null;
134
+ if (Array.isArray(res.result?.content)) {
135
+ try { payload = JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')); } catch {}
136
+ } else { payload = res.result; }
93
137
  return { ok: true, org: payload };
94
138
  }
95
139
 
96
140
  async orgList() {
97
- const res = await this._call('org.list', {});
98
- return this._entries(res);
141
+ const res = await this._mcp('org.list', {});
142
+ return this._mcpEntries(res);
99
143
  }
100
144
 
101
145
  async orgRename({ slug, name } = {}) {
102
- const res = await this._call('org.rename', clean({ slug, name }));
146
+ const res = await this._mcp('org.rename', { slug, name });
103
147
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
104
- const payload = unwrap(res.result);
105
- return { ok: true, ...payload };
148
+ let payload = null;
149
+ try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
150
+ return { ok: true, ...(payload ?? {}) };
106
151
  }
107
152
 
108
153
  async orgDelete({ slug } = {}) {
109
- const res = await this._call('org.delete', clean({ slug }));
154
+ const res = await this._mcp('org.delete', { slug });
110
155
  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
-
115
- // Store-wide scope enumeration is NOT possible against the hosted MCP surface:
116
- // every read tool (memory.list / memory.search / memory.read) REQUIRES a
117
- // scope, and there is no "list all scopes" tool. Signal that honestly so the
118
- // `scopes` command shows a clear note rather than faking an inventory.
156
+ let payload = null;
157
+ try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
158
+ return { ok: true, ...(payload ?? {}) };
159
+ }
160
+
161
+ // Store-wide scope enumeration every distinct scope the caller can see with
162
+ // its count of active (non-archived, non-expired) memories. `GET
163
+ // /memories/scopes` aggregates in Postgres, so the answer is exact at any size
164
+ // (see supabase/functions/memories/handlers/scopes.ts).
165
+ //
166
+ // The `scopes` array is the SAME `[{ scope, count }]` inventory shape
167
+ // `LocalStore.listScopes()` returns, so `scopes.mjs` feeds both through the
168
+ // same pure `filterScopeInventory`/`summarizeScopeInventory` helpers. Ordering
169
+ // is not relied upon (the server sorts by scope asc; the view re-sorts by
170
+ // scope type). Failures use this store's standard `{ ok:false, error,
171
+ // networkError }` envelope so the caller can degrade gracefully.
119
172
  async listScopes() {
120
- return { ok: false, unsupported: true };
173
+ const res = await this._rest('/memories/scopes');
174
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError, unusable: res.unusable };
175
+ const scopes = Array.isArray(res.data?.scopes) ? res.data.scopes : [];
176
+ return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
121
177
  }
122
178
 
123
179
  // Connectivity probe for doctor — a transport check, not a memory op.
124
180
  async ping() {
125
181
  if (!this.usable()) return { ok: false, unusable: true };
126
- return mcpCall(this.endpoint, this.token, 'tools/list', {});
182
+ // Use the /health function as a connectivity probe (public, no auth)
183
+ const healthUrl = this.restBase
184
+ ? `${this.restBase.replace(/\/functions\/v1$/, '')}/functions/v1/health`
185
+ : null;
186
+ if (healthUrl) {
187
+ const tp = this._tp();
188
+ try {
189
+ const res = await fetch(healthUrl, {
190
+ signal: AbortSignal.timeout(5000),
191
+ ...(tp ? { headers: { traceparent: tp } } : {}),
192
+ });
193
+ return { ok: res.ok, httpStatus: res.status };
194
+ } catch (e) { return { ok: false, networkError: String(e?.message ?? e) }; }
195
+ }
196
+ return mcpCall(this.endpoint, this.token, 'tools/list', {}, { traceparent: this._tp() });
127
197
  }
128
198
  }
package/src/telemetry.mjs CHANGED
@@ -40,6 +40,33 @@ const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'js
40
40
 
41
41
  const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
42
42
 
43
+ // ── Active trace context (for traceparent propagation to REST calls) ──────────
44
+ // Set at the start of EVERY traced command run — including runs where export is
45
+ // disabled. Context propagation is deliberately decoupled from export: a user
46
+ // who opted out (or who simply has no OTLP endpoint configured) must still get
47
+ // correlated server-side traces, they just don't contribute a CLI span.
48
+ let _activeTraceId = null;
49
+ let _activeSpanId = null;
50
+ // Whether the current command's span will actually be exported. Drives the
51
+ // W3C `sampled` bit only — never whether the header is sent.
52
+ let _activeSampled = false;
53
+
54
+ /**
55
+ * Get the W3C traceparent for the currently-running CLI command, or null when
56
+ * no command is running. Called by RemoteStore to inject the header into
57
+ * outgoing REST/MCP calls so the server span joins the CLI's trace.
58
+ *
59
+ * Flags are `01` when the CLI span is exported and `00` when it is not — the
60
+ * trace id is still carried either way, so the server can correlate. This
61
+ * mirrors `formatTraceparent` in `packages/mcp-core/src/trace-context.ts`
62
+ * (the CLI is zero-dep `.mjs` and cannot import the TS module); keep the two
63
+ * byte-consistent.
64
+ */
65
+ export function getActiveTraceparent() {
66
+ if (!_activeTraceId || !_activeSpanId) return null;
67
+ return `00-${_activeTraceId}-${_activeSpanId}-${_activeSampled ? '01' : '00'}`;
68
+ }
69
+
43
70
  // ── Config resolution ─────────────────────────────────────────────────────────
44
71
 
45
72
  /**
@@ -155,7 +182,7 @@ export function commandAttributes({ command, args = {}, outcome, exitCode, extra
155
182
  return attrs;
156
183
  }
157
184
 
158
- export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage }) {
185
+ export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }) {
159
186
  return {
160
187
  resourceSpans: [
161
188
  {
@@ -165,8 +192,8 @@ export function buildTracePayload({ version, name, attributes, startMs, endMs, s
165
192
  scope: { name: 'lorekit-cli', version: String(version) },
166
193
  spans: [
167
194
  {
168
- traceId: randHex(16),
169
- spanId: randHex(8),
195
+ traceId: traceId ?? randHex(16),
196
+ spanId: spanId ?? randHex(8),
170
197
  name,
171
198
  kind: 1, // INTERNAL
172
199
  startTimeUnixNano: String(startMs * 1_000_000),
@@ -243,9 +270,9 @@ async function post(url, headers, payload, timeoutMs) {
243
270
  * can never delay or fail the CLI. Awaited before process exit (Node would
244
271
  * otherwise drop the in-flight request), but capped at timeoutMs.
245
272
  */
246
- export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage }, { timeoutMs = 1500 } = {}) {
273
+ export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }, { timeoutMs = 1500 } = {}) {
247
274
  if (!config || !config.enabled) return;
248
- const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage });
275
+ const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId });
249
276
  const metric = buildMetricsPayload({ version, attributes, startMs, endMs });
250
277
  await Promise.all([
251
278
  post(`${config.endpoint}/v1/traces`, config.headers, trace, timeoutMs),
@@ -297,13 +324,32 @@ export async function traceCommand(command, args, version, run) {
297
324
  config = { enabled: false };
298
325
  }
299
326
 
327
+ // Generate trace context BEFORE the export gate below, so outgoing REST/MCP
328
+ // calls can forward it as `traceparent` even when telemetry is disabled.
329
+ // Propagation is decoupled from export: a user with no OTLP endpoint (the
330
+ // common case — TELEMETRY_TOKEN is empty in git) still gets a single
331
+ // correlated server-side trace. The `sampled` bit is what reflects export.
332
+ // The same IDs are reused in exportInvocation() to link the CLI span to
333
+ // REST spans.
334
+ _activeTraceId = randHex(16);
335
+ _activeSpanId = randHex(8);
336
+ _activeSampled = Boolean(config.enabled);
337
+
300
338
  // Fast path: no export configured → run with zero telemetry overhead. Still
301
339
  // normalize the result to a numeric exit code: commands may resolve to an
302
340
  // { exitCode, ...extra } object (e.g. `doctor`), and only the instrumented
303
341
  // path below unwraps it. Returning `run()` raw would leak that object all the
304
342
  // way to `process.exit(obj)` in the bin entry → ERR_INVALID_ARG_TYPE crash
305
343
  // (exit 1) for every user without an OTLP endpoint configured.
306
- if (!config.enabled) return normalizeExitCode(await run());
344
+ if (!config.enabled) {
345
+ try {
346
+ return normalizeExitCode(await run());
347
+ } finally {
348
+ _activeTraceId = null;
349
+ _activeSpanId = null;
350
+ _activeSampled = false;
351
+ }
352
+ }
307
353
 
308
354
  const startMs = Date.now();
309
355
  let exitCode = 0;
@@ -360,9 +406,15 @@ export async function traceCommand(command, args, version, run) {
360
406
  endMs: Date.now(),
361
407
  status,
362
408
  statusMessage,
409
+ traceId: _activeTraceId,
410
+ spanId: _activeSpanId,
363
411
  });
364
412
  } catch {
365
413
  // never let telemetry break the CLI
414
+ } finally {
415
+ _activeTraceId = null;
416
+ _activeSpanId = null;
417
+ _activeSampled = false;
366
418
  }
367
419
  }
368
420
  }