lmnr-cli 0.1.10 → 0.1.11

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/dist/index.cjs CHANGED
@@ -31,6 +31,9 @@ let pino = require("pino");
31
31
  let pino$3 = __toESM(pino, 1);
32
32
  pino = __toESM(pino);
33
33
  let pino_pretty = require("pino-pretty");
34
+ let node_fs_promises = require("node:fs/promises");
35
+ let node_path = require("node:path");
36
+ let node_os = require("node:os");
34
37
  let csv_parser = require("csv-parser");
35
38
  csv_parser = __toESM(csv_parser);
36
39
  let export_to_csv = require("export-to-csv");
@@ -38,11 +41,18 @@ let fs_promises = require("fs/promises");
38
41
  fs_promises = __toESM(fs_promises);
39
42
  let cli_table3 = require("cli-table3");
40
43
  cli_table3 = __toESM(cli_table3);
44
+ let open = require("open");
45
+ open = __toESM(open);
46
+ let picocolors = require("picocolors");
47
+ let node_readline_promises = require("node:readline/promises");
48
+ let node_child_process = require("node:child_process");
49
+ let node_util = require("node:util");
50
+ let giget = require("giget");
41
51
  //#region ../types/dist/index.mjs
42
52
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
43
53
  //#endregion
44
54
  //#region package.json
45
- var version$1 = "0.1.10";
55
+ var version$1 = "0.1.11";
46
56
  //#endregion
47
57
  //#region ../../node_modules/.pnpm/dotenv@17.4.2/node_modules/dotenv/lib/main.js
48
58
  var require_main = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -348,22 +358,28 @@ function _v4(options, buf, offset) {
348
358
  //#endregion
349
359
  //#region ../client/dist/index.mjs
350
360
  var import_main = require_main();
351
- var version = "0.8.27";
361
+ var version = "0.8.29";
352
362
  function getLangVersion() {
353
363
  if (typeof process !== "undefined" && process.versions && process.versions.node) return `node-${process.versions.node}`;
354
364
  if (typeof navigator !== "undefined" && navigator.userAgent) return `browser-${navigator.userAgent}`;
355
365
  return null;
356
366
  }
357
367
  var BaseResource = class {
358
- constructor(baseHttpUrl, projectApiKey) {
368
+ constructor(baseHttpUrl, auth) {
359
369
  this.baseHttpUrl = baseHttpUrl;
360
- this.projectApiKey = projectApiKey;
370
+ this.auth = auth;
371
+ this.credential = auth.type === "apiKey" ? auth.key : auth.token;
372
+ }
373
+ /** API path prefix: `/v1/cli` for CLI user-token auth, `/v1` otherwise. */
374
+ get apiPrefix() {
375
+ return this.auth.type === "userToken" ? "/v1/cli" : "/v1";
361
376
  }
362
377
  headers() {
363
378
  return {
364
- Authorization: `Bearer ${this.projectApiKey}`,
379
+ Authorization: `Bearer ${this.credential}`,
365
380
  "Content-Type": "application/json",
366
- Accept: "application/json"
381
+ Accept: "application/json",
382
+ ...this.auth.type === "userToken" ? { "x-lmnr-project-id": this.auth.projectId } : {}
367
383
  };
368
384
  }
369
385
  async handleError(response) {
@@ -372,8 +388,8 @@ var BaseResource = class {
372
388
  }
373
389
  };
374
390
  var BrowserEventsResource = class extends BaseResource {
375
- constructor(baseHttpUrl, projectApiKey) {
376
- super(baseHttpUrl, projectApiKey);
391
+ constructor(baseHttpUrl, auth) {
392
+ super(baseHttpUrl, auth);
377
393
  }
378
394
  async send({ sessionId, traceId, events }) {
379
395
  const payload = {
@@ -397,6 +413,33 @@ var BrowserEventsResource = class extends BaseResource {
397
413
  if (!response.ok) await this.handleError(response);
398
414
  }
399
415
  };
416
+ /**
417
+ * User-scoped CLI endpoints that don't target a specific project. Authed by the
418
+ * BetterAuth user JWT (the `credential`); deliberately does NOT send an
419
+ * `x-lmnr-project-id` header (these routes are project discovery, pre-selection).
420
+ *
421
+ * Discovery exception: this resource always hits `/v1/cli/projects` with the
422
+ * bare bearer and overrides `BaseResource.headers()`/`apiPrefix`, so it works
423
+ * even when constructed with a `userToken` auth that has no real project id yet.
424
+ */
425
+ var CliResource = class extends BaseResource {
426
+ constructor(baseHttpUrl, auth) {
427
+ super(baseHttpUrl, auth);
428
+ }
429
+ /** Workspaces + projects the authenticated user can access. */
430
+ async listProjects() {
431
+ const response = await fetch(`${this.baseHttpUrl}/v1/cli/projects`, {
432
+ method: "GET",
433
+ headers: {
434
+ Authorization: `Bearer ${this.credential}`,
435
+ Accept: "application/json"
436
+ }
437
+ });
438
+ if (!response.ok) await this.handleError(response);
439
+ const body = await response.json();
440
+ return Array.isArray(body?.projects) ? body.projects : [];
441
+ }
442
+ };
400
443
  function initializeLogger$1(options) {
401
444
  const colorize = options?.colorize ?? true;
402
445
  const level = options?.level ?? process.env.LMNR_LOG_LEVEL?.toLowerCase()?.trim() ?? "info";
@@ -458,8 +501,8 @@ const logger$3$1 = initializeLogger$1();
458
501
  const DEFAULT_DATASET_PULL_LIMIT = 100;
459
502
  const DEFAULT_DATASET_PUSH_BATCH_SIZE$1 = 100;
460
503
  var DatasetsResource = class extends BaseResource {
461
- constructor(baseHttpUrl, projectApiKey) {
462
- super(baseHttpUrl, projectApiKey);
504
+ constructor(baseHttpUrl, auth) {
505
+ super(baseHttpUrl, auth);
463
506
  }
464
507
  /**
465
508
  * List all datasets.
@@ -467,7 +510,7 @@ var DatasetsResource = class extends BaseResource {
467
510
  * @returns {Promise<Dataset[]>} Array of datasets
468
511
  */
469
512
  async listDatasets() {
470
- const response = await fetch(this.baseHttpUrl + "/v1/datasets", {
513
+ const response = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets", {
471
514
  method: "GET",
472
515
  headers: this.headers()
473
516
  });
@@ -482,7 +525,7 @@ var DatasetsResource = class extends BaseResource {
482
525
  */
483
526
  async getDatasetByName(name) {
484
527
  const params = new URLSearchParams({ name });
485
- const response = await fetch(this.baseHttpUrl + `/v1/datasets?${params.toString()}`, {
528
+ const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets?${params.toString()}`, {
486
529
  method: "GET",
487
530
  headers: this.headers()
488
531
  });
@@ -511,7 +554,7 @@ var DatasetsResource = class extends BaseResource {
511
554
  const batchNum = Math.floor(i / batchSize) + 1;
512
555
  logger$3$1.debug(`Pushing batch ${batchNum} of ${totalBatches}`);
513
556
  const batch = points.slice(i, i + batchSize);
514
- const fetchResponse = await fetch(this.baseHttpUrl + "/v1/datasets/datapoints", {
557
+ const fetchResponse = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets/datapoints", {
515
558
  method: "POST",
516
559
  headers: this.headers(),
517
560
  body: JSON.stringify({
@@ -549,7 +592,7 @@ var DatasetsResource = class extends BaseResource {
549
592
  if (name) paramsObj.name = name;
550
593
  else paramsObj.datasetId = id;
551
594
  const params = new URLSearchParams(paramsObj);
552
- const response = await fetch(this.baseHttpUrl + `/v1/datasets/datapoints?${params.toString()}`, {
595
+ const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets/datapoints?${params.toString()}`, {
553
596
  method: "GET",
554
597
  headers: this.headers()
555
598
  });
@@ -560,8 +603,8 @@ var DatasetsResource = class extends BaseResource {
560
603
  const logger$2$1 = initializeLogger$1();
561
604
  const INITIAL_EVALUATION_DATAPOINT_MAX_DATA_LENGTH = 16e6;
562
605
  var EvalsResource = class extends BaseResource {
563
- constructor(baseHttpUrl, projectApiKey) {
564
- super(baseHttpUrl, projectApiKey);
606
+ constructor(baseHttpUrl, auth) {
607
+ super(baseHttpUrl, auth);
565
608
  }
566
609
  /**
567
610
  * Initialize an evaluation.
@@ -735,8 +778,8 @@ var EvalsResource = class extends BaseResource {
735
778
  * Resource for creating evaluator scores
736
779
  */
737
780
  var EvaluatorsResource = class extends BaseResource {
738
- constructor(baseHttpUrl, projectApiKey) {
739
- super(baseHttpUrl, projectApiKey);
781
+ constructor(baseHttpUrl, auth) {
782
+ super(baseHttpUrl, auth);
740
783
  }
741
784
  /**
742
785
  * Create a score for a span or trace
@@ -793,9 +836,29 @@ var EvaluatorsResource = class extends BaseResource {
793
836
  }
794
837
  };
795
838
  const logger$1$1 = initializeLogger$1();
839
+ /**
840
+ * Map the opaque HIT `response` payload onto a {@link CachedSpan} the provider
841
+ * wrappers can replay. The server-side shape of `response` is not yet frozen
842
+ * (app-server plan 01 leaves it as a `serde_json::Value`), so this stays
843
+ * deliberately tolerant: the whole payload is serialized into `output` (the only
844
+ * field the AI SDK wrapper's `parseCachedSpan` actually reads, via
845
+ * `JSON.parse`), and a `finishReason` is surfaced into `attributes` when the
846
+ * payload carries one. `name`/`input` are irrelevant to replay and left empty.
847
+ */
848
+ const toCachedSpan = (response) => {
849
+ const output = typeof response === "string" ? response : JSON.stringify(response ?? null);
850
+ const attributes = {};
851
+ if (response !== null && typeof response === "object" && typeof response.finishReason === "string") attributes["ai.response.finishReason"] = response.finishReason;
852
+ return {
853
+ name: "",
854
+ input: "",
855
+ output,
856
+ attributes
857
+ };
858
+ };
796
859
  var RolloutSessionsResource = class extends BaseResource {
797
- constructor(baseHttpUrl, projectApiKey) {
798
- super(baseHttpUrl, projectApiKey);
860
+ constructor(baseHttpUrl, auth) {
861
+ super(baseHttpUrl, auth);
799
862
  }
800
863
  /**
801
864
  * Idempotently register (upsert) a debug session on the backend, keyed on the
@@ -806,7 +869,7 @@ var RolloutSessionsResource = class extends BaseResource {
806
869
  * caller can build the debugger URL; null if the body can't be parsed.
807
870
  */
808
871
  async register({ sessionId, name }) {
809
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}`, {
872
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
810
873
  method: "POST",
811
874
  headers: this.headers(),
812
875
  body: JSON.stringify({ name })
@@ -826,15 +889,73 @@ var RolloutSessionsResource = class extends BaseResource {
826
889
  * stays the SDK's job via {@link register}.
827
890
  */
828
891
  async setName({ sessionId, name }) {
829
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}/name`, {
892
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/name`, {
830
893
  method: "PATCH",
831
894
  headers: this.headers(),
832
895
  body: JSON.stringify({ name })
833
896
  });
834
897
  if (!response.ok) await this.handleError(response);
835
898
  }
899
+ /**
900
+ * Look up the debug-replay cache for a single LLM call (debug-replay v2).
901
+ *
902
+ * The server is keyed by `inputHash` (hex blake3 of the canonicalized,
903
+ * system-stripped input messages). It returns one of three outcomes:
904
+ * - `{ outcome: "hit", response }` — a cached response to replay.
905
+ * - `{ outcome: "miss" }` — no entry; caller latches live mode.
906
+ * - `{ outcome: "live" }` — run this call live (COLD degrade).
907
+ *
908
+ * Error posture: a non-OK response or a transport error degrades to
909
+ * `{ kind: "live" }` for THIS call only — it never throws and never latches
910
+ * the process-wide live flag (only a real MISS does that). This keeps a flaky
911
+ * cache backend from turning a replay into a crash.
912
+ */
913
+ async cache({ sessionId, replayTraceId, cacheUntil, inputHash }) {
914
+ let response;
915
+ try {
916
+ response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/cache`, {
917
+ method: "POST",
918
+ headers: this.headers(),
919
+ body: JSON.stringify({
920
+ replayTraceId,
921
+ cacheUntil,
922
+ inputHash
923
+ })
924
+ });
925
+ } catch (e) {
926
+ logger$1$1.warn(`Debug cache lookup failed, running live: ${errorMessage(e)}`);
927
+ return { kind: "live" };
928
+ }
929
+ if (!response.ok) {
930
+ logger$1$1.warn(`Debug cache lookup returned ${response.status}, running live`);
931
+ return { kind: "live" };
932
+ }
933
+ let body;
934
+ try {
935
+ body = await response.json();
936
+ } catch (e) {
937
+ logger$1$1.warn(`Failed to parse debug cache response, running live: ${errorMessage(e)}`);
938
+ return { kind: "live" };
939
+ }
940
+ switch (body.outcome) {
941
+ case "hit":
942
+ if (body.response === null || body.response === void 0) {
943
+ logger$1$1.warn("Debug cache HIT had no response payload, running live");
944
+ return { kind: "live" };
945
+ }
946
+ return {
947
+ kind: "hit",
948
+ cached: toCachedSpan(body.response)
949
+ };
950
+ case "miss": return { kind: "miss" };
951
+ case "live": return { kind: "live" };
952
+ default:
953
+ logger$1$1.warn(`Unknown debug cache outcome "${body.outcome}", running live`);
954
+ return { kind: "live" };
955
+ }
956
+ }
836
957
  async delete({ sessionId }) {
837
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}`, {
958
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
838
959
  method: "DELETE",
839
960
  headers: this.headers()
840
961
  });
@@ -842,11 +963,11 @@ var RolloutSessionsResource = class extends BaseResource {
842
963
  }
843
964
  };
844
965
  var SqlResource = class extends BaseResource {
845
- constructor(baseHttpUrl, projectApiKey) {
846
- super(baseHttpUrl, projectApiKey);
966
+ constructor(baseHttpUrl, auth) {
967
+ super(baseHttpUrl, auth);
847
968
  }
848
969
  async query(sql, parameters = {}) {
849
- const response = await fetch(`${this.baseHttpUrl}/v1/sql/query`, {
970
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/sql/query`, {
850
971
  method: "POST",
851
972
  headers: { ...this.headers() },
852
973
  body: JSON.stringify({
@@ -861,8 +982,8 @@ var SqlResource = class extends BaseResource {
861
982
  /** Resource for tagging traces. */
862
983
  var TagsResource = class extends BaseResource {
863
984
  /** Resource for tagging traces. */
864
- constructor(baseHttpUrl, projectApiKey) {
865
- super(baseHttpUrl, projectApiKey);
985
+ constructor(baseHttpUrl, auth) {
986
+ super(baseHttpUrl, auth);
866
987
  }
867
988
  /**
868
989
  * Tag a trace with a list of tags. Note that the trace must be ended before
@@ -917,8 +1038,8 @@ var TagsResource = class extends BaseResource {
917
1038
  const logger$5 = initializeLogger$1();
918
1039
  var TracesResource = class extends BaseResource {
919
1040
  /** Resource for post-factum operations on existing traces. */
920
- constructor(baseHttpUrl, projectApiKey) {
921
- super(baseHttpUrl, projectApiKey);
1041
+ constructor(baseHttpUrl, auth) {
1042
+ super(baseHttpUrl, auth);
922
1043
  }
923
1044
  /**
924
1045
  * Push a metadata patch to an existing trace.
@@ -973,7 +1094,7 @@ var TracesResource = class extends BaseResource {
973
1094
  async pushMetadata(traceId, metadata, options) {
974
1095
  if (!metadata || Object.keys(metadata).length === 0) throw new Error("metadata must be a non-empty object");
975
1096
  const formattedTraceId = isStringUUID(traceId) ? traceId : otelTraceIdToUUID(traceId);
976
- const url = this.baseHttpUrl + "/v1/traces/metadata";
1097
+ const url = this.baseHttpUrl + this.apiPrefix + "/traces/metadata";
977
1098
  const response = await fetch(url, {
978
1099
  method: "POST",
979
1100
  headers: this.headers(),
@@ -991,25 +1112,49 @@ var TracesResource = class extends BaseResource {
991
1112
  if (!response.ok) await this.handleError(response);
992
1113
  }
993
1114
  };
994
- var LaminarClient = class {
995
- constructor({ baseUrl, projectApiKey, port } = {}) {
1115
+ var LaminarClient = class LaminarClient {
1116
+ constructor({ baseUrl, port, auth, projectApiKey, cliUserProjectId } = {}) {
996
1117
  loadEnv();
997
- this.projectApiKey = projectApiKey ?? process.env.LMNR_PROJECT_API_KEY;
1118
+ this.auth = LaminarClient.normalizeAuth(auth, projectApiKey, cliUserProjectId);
998
1119
  const httpPort = port ?? (baseUrl?.match(/:\d{1,5}$/g) ? parseInt(baseUrl.match(/:\d{1,5}$/g)[0].slice(1)) : 443);
999
1120
  const baseUrlNoPort = (baseUrl ?? process.env.LMNR_BASE_URL)?.replace(/\/$/, "").replace(/:\d{1,5}$/g, "");
1000
1121
  this.baseUrl = `${baseUrlNoPort ?? "https://api.lmnr.ai"}:${httpPort}`;
1001
- this._browserEvents = new BrowserEventsResource(this.baseUrl, this.projectApiKey);
1002
- this._datasets = new DatasetsResource(this.baseUrl, this.projectApiKey);
1003
- this._evals = new EvalsResource(this.baseUrl, this.projectApiKey);
1004
- this._evaluators = new EvaluatorsResource(this.baseUrl, this.projectApiKey);
1005
- this._rolloutSessions = new RolloutSessionsResource(this.baseUrl, this.projectApiKey);
1006
- this._sql = new SqlResource(this.baseUrl, this.projectApiKey);
1007
- this._tags = new TagsResource(this.baseUrl, this.projectApiKey);
1008
- this._traces = new TracesResource(this.baseUrl, this.projectApiKey);
1122
+ this._browserEvents = new BrowserEventsResource(this.baseUrl, this.auth);
1123
+ this._cli = new CliResource(this.baseUrl, this.auth);
1124
+ this._datasets = new DatasetsResource(this.baseUrl, this.auth);
1125
+ this._evals = new EvalsResource(this.baseUrl, this.auth);
1126
+ this._evaluators = new EvaluatorsResource(this.baseUrl, this.auth);
1127
+ this._rolloutSessions = new RolloutSessionsResource(this.baseUrl, this.auth);
1128
+ this._sql = new SqlResource(this.baseUrl, this.auth);
1129
+ this._tags = new TagsResource(this.baseUrl, this.auth);
1130
+ this._traces = new TracesResource(this.baseUrl, this.auth);
1131
+ }
1132
+ /**
1133
+ * Normalize the constructor's auth inputs into a {@link LaminarAuth} union.
1134
+ * Precedence: an explicit `auth` wins; otherwise the legacy
1135
+ * `projectApiKey` (+ optional `cliUserProjectId`) is mapped — a present
1136
+ * `cliUserProjectId` selects the user-token surface, otherwise the project
1137
+ * key surface. Falls back to `LMNR_PROJECT_API_KEY` as a project key.
1138
+ */
1139
+ static normalizeAuth(auth, projectApiKey, cliUserProjectId) {
1140
+ if (auth) return auth;
1141
+ const key = projectApiKey ?? process.env.LMNR_PROJECT_API_KEY;
1142
+ if (cliUserProjectId) return {
1143
+ type: "userToken",
1144
+ token: key,
1145
+ projectId: cliUserProjectId
1146
+ };
1147
+ return {
1148
+ type: "apiKey",
1149
+ key
1150
+ };
1009
1151
  }
1010
1152
  get browserEvents() {
1011
1153
  return this._browserEvents;
1012
1154
  }
1155
+ get cli() {
1156
+ return this._cli;
1157
+ }
1013
1158
  get datasets() {
1014
1159
  return this._datasets;
1015
1160
  }
@@ -1044,8 +1189,458 @@ function initializeLogger(options) {
1044
1189
  }));
1045
1190
  }
1046
1191
  //#endregion
1047
- //#region src/utils/file.ts
1192
+ //#region src/utils/output.ts
1193
+ /**
1194
+ * Write structured JSON to stdout. Use this for machine-readable output
1195
+ * when --json is set.
1196
+ */
1197
+ function outputJson(data) {
1198
+ console.log(JSON.stringify(data));
1199
+ }
1200
+ /**
1201
+ * Write a JSON error to stdout and exit with code 1.
1202
+ * Use this in --json mode so agents can parse the failure.
1203
+ */
1204
+ function outputJsonError(error, exitCode = 1) {
1205
+ console.log(JSON.stringify({ error: errorMessage(error) }));
1206
+ process.exit(exitCode);
1207
+ }
1208
+ //#endregion
1209
+ //#region src/constants.ts
1210
+ const DEFAULT_FRONTEND_URL$1 = "https://www.laminar.sh";
1211
+ const DEFAULT_BASE_URL$1 = "https://api.lmnr.ai";
1212
+ //#endregion
1213
+ //#region src/utils/project-link.ts
1214
+ const LINK_DIR = ".lmnr";
1215
+ const LINK_FILE = "project.json";
1216
+ /**
1217
+ * Find the nearest `.lmnr/project.json`, walking up from `startDir` to the
1218
+ * filesystem root (so commands work from subdirectories of a linked project).
1219
+ * Returns null if none is found.
1220
+ */
1221
+ async function readProjectLink(startDir = process.cwd()) {
1222
+ let dir = startDir;
1223
+ const root = (0, node_path.parse)(dir).root;
1224
+ while (true) {
1225
+ const candidate = (0, node_path.join)(dir, LINK_DIR, LINK_FILE);
1226
+ try {
1227
+ const parsed = JSON.parse(await (0, node_fs_promises.readFile)(candidate, "utf8"));
1228
+ if (parsed && typeof parsed.projectId === "string" && parsed.projectId.length > 0) return parsed;
1229
+ } catch {}
1230
+ const parent = (0, node_path.dirname)(dir);
1231
+ if (parent === dir || dir === root) break;
1232
+ dir = parent;
1233
+ }
1234
+ return null;
1235
+ }
1236
+ /** Write `.lmnr/project.json` under `dir` (default cwd). Returns the file path. */
1237
+ async function writeProjectLink(link, dir = process.cwd()) {
1238
+ const linkDir = (0, node_path.join)(dir, LINK_DIR);
1239
+ await (0, node_fs_promises.mkdir)(linkDir, { recursive: true });
1240
+ const path = (0, node_path.join)(linkDir, LINK_FILE);
1241
+ await (0, node_fs_promises.writeFile)(path, JSON.stringify(link, null, 2) + "\n", "utf8");
1242
+ return path;
1243
+ }
1244
+ function globalLmnrDirectory() {
1245
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
1246
+ if (xdg && xdg.length > 0) return (0, node_path.join)(xdg, "lmnr");
1247
+ const appData = process.env.APPDATA?.trim();
1248
+ if (process.platform === "win32" && appData && appData.length > 0) return (0, node_path.join)(appData, "lmnr");
1249
+ return (0, node_path.join)((0, node_os.homedir)(), ".config", "lmnr");
1250
+ }
1251
+ function credentialsPath() {
1252
+ return (0, node_path.join)(globalLmnrDirectory(), "credentials.json");
1253
+ }
1254
+ /**
1255
+ * Read the credentials file. Returns null when missing or not the current flat
1256
+ * v1 shape (treated as "not logged in" — `lmnr-cli login` overwrites).
1257
+ */
1258
+ async function readCredentials() {
1259
+ const path = credentialsPath();
1260
+ let raw;
1261
+ try {
1262
+ raw = await (0, node_fs_promises.readFile)(path, "utf-8");
1263
+ } catch (e) {
1264
+ if (isNotFound(e)) return null;
1265
+ throw e;
1266
+ }
1267
+ let parsed;
1268
+ try {
1269
+ parsed = JSON.parse(raw);
1270
+ } catch {
1271
+ return null;
1272
+ }
1273
+ if (parsed.version === 1 && typeof parsed.sessionToken === "string") return parsed;
1274
+ return null;
1275
+ }
1276
+ async function writeCredentials(creds) {
1277
+ const path = credentialsPath();
1278
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), {
1279
+ recursive: true,
1280
+ mode: 448
1281
+ });
1282
+ let mode = 384;
1283
+ try {
1284
+ mode = (await (0, node_fs_promises.stat)(path)).mode & 511;
1285
+ } catch (e) {
1286
+ if (!isNotFound(e)) throw e;
1287
+ }
1288
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
1289
+ await (0, node_fs_promises.writeFile)(tmp, JSON.stringify(creds, null, 2), {
1290
+ mode,
1291
+ flag: "wx"
1292
+ });
1293
+ await (0, node_fs_promises.rename)(tmp, path);
1294
+ }
1295
+ async function deleteCredentials() {
1296
+ const path = credentialsPath();
1297
+ try {
1298
+ await (0, node_fs_promises.stat)(path);
1299
+ } catch (e) {
1300
+ if (isNotFound(e)) return false;
1301
+ throw e;
1302
+ }
1303
+ await (0, node_fs_promises.rm)(path, { force: true });
1304
+ return true;
1305
+ }
1306
+ function isNotFound(e) {
1307
+ return typeof e === "object" && e !== null && "code" in e && e.code === "ENOENT";
1308
+ }
1309
+ //#endregion
1310
+ //#region src/auth/device.ts
1311
+ const CLI_CLIENT_ID = "lmnr-cli";
1312
+ const CLI_SCOPE = "projects:rw";
1313
+ const DEVICE_CODE_ENDPOINT = "/api/auth/device/code";
1314
+ const DEVICE_TOKEN_ENDPOINT = "/api/auth/device/token";
1315
+ const TOKEN_ENDPOINT = "/api/auth/token";
1316
+ const SESSION_ENDPOINT = "/api/auth/get-session";
1317
+ var DeviceFlowError = class extends Error {
1318
+ constructor(code, message) {
1319
+ super(message);
1320
+ this.code = code;
1321
+ }
1322
+ };
1323
+ function trimSlash$3(url) {
1324
+ return url.replace(/\/+$/, "");
1325
+ }
1326
+ async function initiateDevice(issuer, scope = CLI_SCOPE) {
1327
+ const url = `${trimSlash$3(issuer)}${DEVICE_CODE_ENDPOINT}`;
1328
+ const res = await fetch(url, {
1329
+ method: "POST",
1330
+ headers: { "content-type": "application/json" },
1331
+ body: JSON.stringify({
1332
+ client_id: CLI_CLIENT_ID,
1333
+ scope
1334
+ })
1335
+ });
1336
+ if (!res.ok) {
1337
+ const body = await safeJson(res) ?? {};
1338
+ throw new DeviceFlowError(typeof body.error === "string" ? body.error : `http_${res.status}`, typeof body.error_description === "string" ? body.error_description : `Device authorization request failed (${res.status})`);
1339
+ }
1340
+ return await res.json();
1341
+ }
1342
+ /**
1343
+ * Poll BetterAuth's native token endpoint until the user approves. Returns the
1344
+ * full token response (whose `access_token` is the durable session token).
1345
+ */
1346
+ async function pollDevice(issuer, deviceCode, opts = {}) {
1347
+ let intervalSeconds = Math.max(1, opts.intervalSeconds ?? 5);
1348
+ const timeoutMs = (opts.timeoutSeconds ?? 900) * 1e3;
1349
+ const deadline = Date.now() + timeoutMs;
1350
+ while (true) {
1351
+ if (Date.now() > deadline) throw new DeviceFlowError("expired_token", "Timed out waiting for authorization");
1352
+ const url = `${trimSlash$3(issuer)}${DEVICE_TOKEN_ENDPOINT}`;
1353
+ const res = await fetch(url, {
1354
+ method: "POST",
1355
+ headers: { "content-type": "application/json" },
1356
+ body: JSON.stringify({
1357
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1358
+ device_code: deviceCode,
1359
+ client_id: CLI_CLIENT_ID
1360
+ })
1361
+ });
1362
+ if (res.ok) {
1363
+ const body = await res.json();
1364
+ if (!body.access_token) throw new DeviceFlowError("server_error", "Device token response missing access_token");
1365
+ body.metadata = res.headers.get("x-lmnr-metadata");
1366
+ return body;
1367
+ }
1368
+ const body = await safeJson(res) ?? {};
1369
+ const code = typeof body.error === "string" ? body.error : `http_${res.status}`;
1370
+ const description = typeof body.error_description === "string" ? body.error_description : code;
1371
+ if (code === "authorization_pending") {
1372
+ opts.onTick?.();
1373
+ await sleep(intervalSeconds * 1e3);
1374
+ continue;
1375
+ }
1376
+ if (code === "slow_down") {
1377
+ intervalSeconds += 5;
1378
+ await sleep(intervalSeconds * 1e3);
1379
+ continue;
1380
+ }
1381
+ throw new DeviceFlowError(code, description);
1382
+ }
1383
+ }
1384
+ /**
1385
+ * Mint a fresh 15m EdDSA JWT from a session token. Throws DeviceFlowError
1386
+ * "invalid_grant" on 401 (session expired/revoked).
1387
+ */
1388
+ async function mintAccessJwt(issuer, sessionToken) {
1389
+ const url = `${trimSlash$3(issuer)}${TOKEN_ENDPOINT}`;
1390
+ const res = await fetch(url, {
1391
+ method: "GET",
1392
+ headers: { authorization: `Bearer ${sessionToken}` }
1393
+ });
1394
+ if (res.status === 401) throw new DeviceFlowError("invalid_grant", "Session expired or revoked");
1395
+ if (!res.ok) {
1396
+ const body = await safeJson(res) ?? {};
1397
+ throw new DeviceFlowError(typeof body.error === "string" ? body.error : `http_${res.status}`, `Failed to mint access token (${res.status})`);
1398
+ }
1399
+ const body = await res.json();
1400
+ if (!body.token) throw new DeviceFlowError("server_error", "Token endpoint response missing token");
1401
+ return body.token;
1402
+ }
1403
+ /** Fetch the BetterAuth session for profile metadata (userId, email). */
1404
+ async function fetchSession(issuer, sessionToken) {
1405
+ const url = `${trimSlash$3(issuer)}${SESSION_ENDPOINT}`;
1406
+ const res = await fetch(url, {
1407
+ method: "GET",
1408
+ headers: { authorization: `Bearer ${sessionToken}` }
1409
+ });
1410
+ if (!res.ok) throw new DeviceFlowError(`http_${res.status}`, `Failed to fetch session (${res.status})`);
1411
+ const user = (await res.json())?.user;
1412
+ if (!user?.id) throw new DeviceFlowError("server_error", "Session response missing user");
1413
+ return {
1414
+ id: user.id,
1415
+ email: user.email ?? ""
1416
+ };
1417
+ }
1418
+ /**
1419
+ * Decode a JWT's `exp` (seconds since epoch) → ISO string. No signature
1420
+ * verification — the CLI trusts a token it just received over TLS. Returns null
1421
+ * when the token is malformed or carries no numeric `exp`.
1422
+ */
1423
+ function decodeJwtExp(jwt) {
1424
+ const parts = jwt.split(".");
1425
+ if (parts.length < 2) return null;
1426
+ try {
1427
+ const json = Buffer.from(parts[1], "base64url").toString("utf-8");
1428
+ const payload = JSON.parse(json);
1429
+ if (typeof payload.exp !== "number") return null;
1430
+ return (/* @__PURE__ */ new Date(payload.exp * 1e3)).toISOString();
1431
+ } catch {
1432
+ return null;
1433
+ }
1434
+ }
1435
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1436
+ /**
1437
+ * Extract the browser-selected projectId from the device-token `x-lmnr-metadata`
1438
+ * response header — a JSON string, e.g. `{"projectId":"<uuid>"}`, forwarded by
1439
+ * the server's /device/token route wrapper. Returns null when absent or malformed.
1440
+ */
1441
+ function parseProjectFromMetadata(metadata) {
1442
+ if (!metadata) return null;
1443
+ try {
1444
+ const pid = JSON.parse(metadata)?.projectId;
1445
+ return typeof pid === "string" && UUID_RE.test(pid) ? pid : null;
1446
+ } catch {
1447
+ return null;
1448
+ }
1449
+ }
1450
+ async function safeJson(res) {
1451
+ try {
1452
+ return await res.json();
1453
+ } catch {
1454
+ return null;
1455
+ }
1456
+ }
1457
+ function sleep(ms) {
1458
+ return new Promise((r) => setTimeout(r, ms));
1459
+ }
1460
+ //#endregion
1461
+ //#region src/auth/resolve.ts
1462
+ const REFRESH_SKEW_MS = 3e4;
1463
+ /**
1464
+ * HTTP port from `LMNR_HTTP_PORT`. The Laminar convention is that `baseUrl`
1465
+ * carries NO port and the port is supplied separately (mirrors the SDK's
1466
+ * `LMNR_HTTP_PORT` / `LMNR_GRPC_PORT`). Returns undefined when unset/invalid so
1467
+ * the client keeps its 443 default for Cloud. An explicit `--port` flag still
1468
+ * wins (callers do `opts.port ?? envHttpPort()`).
1469
+ */
1470
+ function envHttpPort() {
1471
+ const raw = process.env.LMNR_HTTP_PORT?.trim();
1472
+ if (!raw) return void 0;
1473
+ const n = Number.parseInt(raw, 10);
1474
+ return Number.isFinite(n) ? n : void 0;
1475
+ }
1476
+ /**
1477
+ * Resolve the data-API base URL: `--base-url` flag → `LMNR_BASE_URL` env →
1478
+ * default. Intentionally NOT read from credentials.json — the endpoint is not
1479
+ * persisted at login, so a self-host `.env` change applies to every command
1480
+ * without re-logging-in (and base URL behaves symmetrically with the port).
1481
+ */
1482
+ function resolveBaseUrl(optBaseUrl) {
1483
+ return optBaseUrl?.trim() || process.env.LMNR_BASE_URL?.trim() || "https://api.lmnr.ai";
1484
+ }
1485
+ /**
1486
+ * CLI auth is **user-token only** — the CLI authenticates as the single
1487
+ * signed-in user via the stored BetterAuth session (refreshed access JWT),
1488
+ * never via a project API key.
1489
+ *
1490
+ * Project precedence (directory-scoped): `--project-id` flag > the nearest
1491
+ * `.lmnr/project.json` (written by `setup`). The project is NOT stored in
1492
+ * credentials.json — that holds only user auth.
1493
+ */
1494
+ async function resolveAuth(opts) {
1495
+ const creds = await readCredentials();
1496
+ if (!creds) throw new Error("Not authenticated. Run `lmnr-cli login`.");
1497
+ let projectId = opts.projectId;
1498
+ if (!projectId || projectId.length === 0) projectId = (await readProjectLink())?.projectId;
1499
+ if (!projectId || projectId.length === 0) throw new Error("No project for this directory. Run `lmnr-cli setup` here, or pass --project-id <id>.");
1500
+ return {
1501
+ bearer: (await refreshIfNeeded(creds)).accessToken,
1502
+ baseUrl: resolveBaseUrl(opts.baseUrl),
1503
+ port: opts.port ?? envHttpPort(),
1504
+ projectId
1505
+ };
1506
+ }
1507
+ /**
1508
+ * Resolve only the user-scoped access token (no project) — for discovery
1509
+ * endpoints like listing projects, which run BEFORE a project is selected.
1510
+ */
1511
+ async function resolveUserToken(opts) {
1512
+ const creds = await readCredentials();
1513
+ if (!creds) throw new Error("Not authenticated. Run `lmnr-cli login`.");
1514
+ return {
1515
+ bearer: (await refreshIfNeeded(creds)).accessToken,
1516
+ baseUrl: resolveBaseUrl(opts.baseUrl),
1517
+ port: opts.port ?? envHttpPort()
1518
+ };
1519
+ }
1520
+ /**
1521
+ * Re-mint the access JWT when it's near expiry and persist it. A 401 from the
1522
+ * token endpoint means the session is gone — surface a clear "run login" error.
1523
+ *
1524
+ * Logout race guard: we write credentials ONLY when we actually re-minted. The
1525
+ * old code unconditionally rewrote the file (just to bump lastUsedAt) on every
1526
+ * command, so a concurrent `logout` that deleted the file mid-flight could have
1527
+ * its delete clobbered by this in-flight atomic rename — logout would appear to
1528
+ * succeed while tokens remained on disk. For a fresh (not-near-expiry) token we
1529
+ * now do no write at all, eliminating that window for the common case.
1530
+ */
1531
+ async function refreshIfNeeded(creds) {
1532
+ const expMs = new Date(creds.accessTokenExpiresAt).getTime();
1533
+ if (!(!Number.isFinite(expMs) || expMs - Date.now() <= REFRESH_SKEW_MS)) return creds;
1534
+ const next = { ...creds };
1535
+ try {
1536
+ const jwt = await mintAccessJwt(creds.issuer, creds.sessionToken);
1537
+ next.accessToken = jwt;
1538
+ next.accessTokenExpiresAt = decodeJwtExp(jwt) ?? (/* @__PURE__ */ new Date()).toISOString();
1539
+ } catch (e) {
1540
+ if (e instanceof DeviceFlowError && e.code === "invalid_grant") throw new Error("Session expired — run `lmnr-cli login`.");
1541
+ throw e;
1542
+ }
1543
+ next.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
1544
+ await writeCredentials(next);
1545
+ return next;
1546
+ }
1547
+ //#endregion
1548
+ //#region src/auth/client.ts
1549
+ /**
1550
+ * Build a LaminarClient for CLI commands. Auth is user-token only: the bearer
1551
+ * is the stored BetterAuth access JWT (auto-refreshed near expiry) and requests
1552
+ * route to `/v1/cli/*` with the resolved project in `x-lmnr-project-id`.
1553
+ */
1554
+ async function buildLaminarClient(opts) {
1555
+ const auth = await resolveAuth(opts);
1556
+ return new LaminarClient({
1557
+ baseUrl: auth.baseUrl,
1558
+ port: auth.port,
1559
+ auth: {
1560
+ type: "userToken",
1561
+ token: auth.bearer,
1562
+ projectId: auth.projectId
1563
+ }
1564
+ });
1565
+ }
1566
+ //#endregion
1567
+ //#region src/auth/with-client.ts
1048
1568
  const logger$4 = initializeLogger();
1569
+ const defaultExitCode = () => 1;
1570
+ /**
1571
+ * Pull the commander positionals out of an `.action(...)` argument list.
1572
+ * Commander invokes the handler as `(arg1, ..., argN, options, command)`, so
1573
+ * the positionals are everything except the trailing `(options, command)`.
1574
+ */
1575
+ function splitCommanderArgs(cmdArgs) {
1576
+ const command = cmdArgs.at(-1);
1577
+ return {
1578
+ positionals: cmdArgs.slice(0, -2),
1579
+ command,
1580
+ opts: command.optsWithGlobals()
1581
+ };
1582
+ }
1583
+ /**
1584
+ * The error envelope shared by both wrappers: in `--json` mode emit a structured
1585
+ * error line and exit with the mapped code; otherwise log and exit. Owning this
1586
+ * here lets handlers stay pure `(client, ...args) => work` with no try/catch.
1587
+ */
1588
+ function runWithEnvelope(work, opts, exitCodeFor) {
1589
+ return work().catch((error) => {
1590
+ const code = exitCodeFor(error);
1591
+ if (opts.json) outputJsonError(error, code);
1592
+ logger$4.error(errorMessage(error));
1593
+ process.exit(code);
1594
+ });
1595
+ }
1596
+ /**
1597
+ * Wrap a project-scoped command handler. Resolves a user-token
1598
+ * {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project),
1599
+ * threads the commander positionals + options through, and owns the error
1600
+ * envelope.
1601
+ *
1602
+ * @example
1603
+ * sqlCmd.command("query")
1604
+ * .argument("<query>")
1605
+ * .action(withProjectClient(handleSqlQuery)); // (client, query, opts) => work
1606
+ */
1607
+ const withProjectClient = (action, exitCodeFor = defaultExitCode) => async (...cmdArgs) => {
1608
+ const { positionals, opts } = splitCommanderArgs(cmdArgs);
1609
+ await runWithEnvelope(async () => {
1610
+ await action(await buildLaminarClient({
1611
+ projectId: opts.projectId,
1612
+ baseUrl: opts.baseUrl,
1613
+ port: opts.port
1614
+ }), ...positionals, opts);
1615
+ }, opts, exitCodeFor);
1616
+ };
1617
+ /**
1618
+ * Wrap a discovery command handler. Resolves a user-token
1619
+ * {@link LaminarClient} with NO project (the discovery surface — e.g. listing
1620
+ * projects — runs before a project is selected), threads positionals +
1621
+ * options, and owns the error envelope.
1622
+ */
1623
+ const withUserToken = (action, exitCodeFor = defaultExitCode) => async (...cmdArgs) => {
1624
+ const { positionals, opts } = splitCommanderArgs(cmdArgs);
1625
+ await runWithEnvelope(async () => {
1626
+ const token = await resolveUserToken({
1627
+ baseUrl: opts.baseUrl,
1628
+ port: opts.port
1629
+ });
1630
+ await action(new LaminarClient({
1631
+ baseUrl: token.baseUrl,
1632
+ port: token.port,
1633
+ auth: {
1634
+ type: "userToken",
1635
+ token: token.bearer,
1636
+ projectId: ""
1637
+ }
1638
+ }), ...positionals, opts);
1639
+ }, opts, exitCodeFor);
1640
+ };
1641
+ //#endregion
1642
+ //#region src/utils/file.ts
1643
+ const logger$3 = initializeLogger();
1049
1644
  /**
1050
1645
  * Check if a file has a supported extension.
1051
1646
  */
@@ -1066,7 +1661,7 @@ const collectFiles = async (paths, recursive = false) => {
1066
1661
  for (const filepath of paths) try {
1067
1662
  const stats = await fs_promises.stat(filepath);
1068
1663
  if (stats.isFile()) if (isSupportedFile(filepath)) collectedFiles.push(filepath);
1069
- else logger$4.warn(`Skipping unsupported file type: ${filepath}`);
1664
+ else logger$3.warn(`Skipping unsupported file type: ${filepath}`);
1070
1665
  else if (stats.isDirectory()) {
1071
1666
  const entries = await fs_promises.readdir(filepath);
1072
1667
  for (const entry of entries) {
@@ -1080,7 +1675,7 @@ const collectFiles = async (paths, recursive = false) => {
1080
1675
  }
1081
1676
  }
1082
1677
  } catch (error) {
1083
- logger$4.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${errorMessage(error)}`);
1678
+ logger$3.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${errorMessage(error)}`);
1084
1679
  }
1085
1680
  return collectedFiles;
1086
1681
  };
@@ -1102,7 +1697,7 @@ const tryParseJson = (content) => {
1102
1697
  try {
1103
1698
  return JSON.parse(content);
1104
1699
  } catch (error) {
1105
- logger$4.debug(`Error parsing JSON: ${errorMessage(error)}`);
1700
+ logger$3.debug(`Error parsing JSON: ${errorMessage(error)}`);
1106
1701
  return content;
1107
1702
  }
1108
1703
  };
@@ -1130,7 +1725,7 @@ async function readJsonlFile(filepath) {
1130
1725
  /**
1131
1726
  * Read a single file and return its contents.
1132
1727
  */
1133
- async function readFile(filepath) {
1728
+ async function readFile$1(filepath) {
1134
1729
  const ext = path.extname(filepath).toLowerCase();
1135
1730
  if (ext === ".json") return readJsonFile(filepath);
1136
1731
  else if (ext === ".csv") return readCsvFile(filepath);
@@ -1143,17 +1738,17 @@ async function readFile(filepath) {
1143
1738
  const loadFromPaths = async (paths, recursive = false) => {
1144
1739
  const files = await collectFiles(paths, recursive);
1145
1740
  if (files.length === 0) {
1146
- logger$4.warn("No supported files found in the specified paths");
1741
+ logger$3.warn("No supported files found in the specified paths");
1147
1742
  return [];
1148
1743
  }
1149
- logger$4.info(`Found ${files.length} file(s) to read`);
1744
+ logger$3.info(`Found ${files.length} file(s) to read`);
1150
1745
  const result = [];
1151
1746
  for (const file of files) try {
1152
- const data = await readFile(file);
1747
+ const data = await readFile$1(file);
1153
1748
  result.push(...data);
1154
- logger$4.info(`Read ${data.length} record(s) from ${file}`);
1749
+ logger$3.info(`Read ${data.length} record(s) from ${file}`);
1155
1750
  } catch (error) {
1156
- logger$4.error(`Error reading file ${file}: ${errorMessage(error)}`);
1751
+ logger$3.error(`Error reading file ${file}: ${errorMessage(error)}`);
1157
1752
  throw error;
1158
1753
  }
1159
1754
  return result;
@@ -1193,7 +1788,7 @@ const writeToFile = async (filepath, data, format) => {
1193
1788
  const dir = path.dirname(filepath);
1194
1789
  await fs_promises.mkdir(dir, { recursive: true });
1195
1790
  const ext = format ?? path.extname(filepath).slice(1);
1196
- if (format && format !== path.extname(filepath).slice(1)) logger$4.warn(`Output format ${format} does not match file extension ${path.extname(filepath).slice(1)}`);
1791
+ if (format && format !== path.extname(filepath).slice(1)) logger$3.warn(`Output format ${format} does not match file extension ${path.extname(filepath).slice(1)}`);
1197
1792
  if (ext === "json") await writeJsonFile(filepath, data);
1198
1793
  else if (ext === "csv") await writeCsvFile(filepath, data);
1199
1794
  else if (ext === "jsonl") await writeJsonlFile(filepath, data);
@@ -1218,7 +1813,7 @@ const printToConsole = (data, format = "json") => {
1218
1813
  if (format === "json") console.log(JSON.stringify(data, null, 2));
1219
1814
  else if (format === "csv") {
1220
1815
  if (data.length === 0) {
1221
- logger$4.error("No data to print");
1816
+ logger$3.error("No data to print");
1222
1817
  return;
1223
1818
  }
1224
1819
  console.log(formatCsv(data));
@@ -1226,23 +1821,6 @@ const printToConsole = (data, format = "json") => {
1226
1821
  else throw new Error(`Unsupported output format: ${String(format)}. (supported formats: json, csv, jsonl)`);
1227
1822
  };
1228
1823
  //#endregion
1229
- //#region src/utils/output.ts
1230
- /**
1231
- * Write structured JSON to stdout. Use this for machine-readable output
1232
- * when --json is set.
1233
- */
1234
- function outputJson(data) {
1235
- console.log(JSON.stringify(data));
1236
- }
1237
- /**
1238
- * Write a JSON error to stdout and exit with code 1.
1239
- * Use this in --json mode so agents can parse the failure.
1240
- */
1241
- function outputJsonError(error, exitCode = 1) {
1242
- console.log(JSON.stringify({ error: errorMessage(error) }));
1243
- process.exit(exitCode);
1244
- }
1245
- //#endregion
1246
1824
  //#region src/utils/table.ts
1247
1825
  const DEFAULT_TERMINAL_WIDTH = 80;
1248
1826
  const PADDING_RIGHT = 2;
@@ -1305,9 +1883,14 @@ function renderTable(head, rows) {
1305
1883
  }
1306
1884
  //#endregion
1307
1885
  //#region src/commands/dataset/index.ts
1308
- const logger$3 = initializeLogger();
1886
+ const logger$2 = initializeLogger();
1309
1887
  const DEFAULT_DATASET_PULL_BATCH_SIZE = 100;
1310
1888
  const DEFAULT_DATASET_PUSH_BATCH_SIZE = 100;
1889
+ /** Throw on the name/id mutual-exclusion rule. The wrapper renders the error. */
1890
+ function requireSingleIdentifier(opts) {
1891
+ if (!opts.name && !opts.id) throw new Error("Either name or id must be provided");
1892
+ if (opts.name && opts.id) throw new Error("Only one of name or id must be provided");
1893
+ }
1311
1894
  /**
1312
1895
  * Pull all data from a dataset in batches.
1313
1896
  */
@@ -1332,171 +1915,95 @@ const pullAllData = async (client, identifier, batchSize = DEFAULT_DATASET_PULL_
1332
1915
  return result;
1333
1916
  };
1334
1917
  /**
1335
- * Handle datasets list command.
1918
+ * Handle datasets list command. Pure handler — `withProjectClient` resolves the
1919
+ * client and owns the error envelope.
1336
1920
  */
1337
- const handleDatasetsList = async (options) => {
1338
- const client = new LaminarClient({
1339
- projectApiKey: options.projectApiKey,
1340
- baseUrl: options.baseUrl,
1341
- port: options.port
1921
+ const handleDatasetsList = async (client, opts) => {
1922
+ const datasets = await client.datasets.listDatasets();
1923
+ if (opts.json) {
1924
+ outputJson(datasets);
1925
+ return;
1926
+ }
1927
+ if (datasets.length === 0) {
1928
+ console.log("No datasets found.");
1929
+ return;
1930
+ }
1931
+ const rows = datasets.map((dataset) => {
1932
+ const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
1933
+ return [
1934
+ dataset.id,
1935
+ createdAtStr,
1936
+ dataset.name
1937
+ ];
1342
1938
  });
1343
- try {
1344
- const datasets = await client.datasets.listDatasets();
1345
- if (options.json) {
1346
- outputJson(datasets);
1347
- return;
1348
- }
1349
- if (datasets.length === 0) {
1350
- console.log("No datasets found.");
1351
- return;
1352
- }
1353
- const rows = datasets.map((dataset) => {
1354
- const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
1355
- return [
1356
- dataset.id,
1357
- createdAtStr,
1358
- dataset.name
1359
- ];
1360
- });
1361
- console.log(renderTable([
1362
- "ID",
1363
- "Created At",
1364
- "Name"
1365
- ], rows));
1366
- console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
1367
- } catch (error) {
1368
- if (options.json) outputJsonError(error);
1369
- logger$3.error(`Failed to list datasets: ${errorMessage(error)}`);
1370
- process.exit(1);
1371
- }
1939
+ console.log(renderTable([
1940
+ "ID",
1941
+ "Created At",
1942
+ "Name"
1943
+ ], rows));
1944
+ console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
1372
1945
  };
1373
1946
  /**
1374
1947
  * Handle datasets push command.
1375
1948
  */
1376
- const handleDatasetsPush = async (paths, options) => {
1377
- if (!options.name && !options.id) {
1378
- if (options.json) outputJsonError("Either name or id must be provided");
1379
- logger$3.error("Either name or id must be provided");
1380
- process.exit(1);
1381
- }
1382
- if (options.name && options.id) {
1383
- if (options.json) outputJsonError("Only one of name or id must be provided");
1384
- logger$3.error("Only one of name or id must be provided");
1385
- process.exit(1);
1386
- }
1387
- const client = new LaminarClient({
1388
- projectApiKey: options.projectApiKey,
1389
- baseUrl: options.baseUrl,
1390
- port: options.port
1949
+ const handleDatasetsPush = async (client, paths, opts) => {
1950
+ requireSingleIdentifier(opts);
1951
+ const data = await loadFromPaths(paths, opts.recursive);
1952
+ if (data.length === 0) throw new Error("No data to push");
1953
+ const identifier = opts.name ? { name: opts.name } : { id: opts.id };
1954
+ const result = await client.datasets.push({
1955
+ points: data,
1956
+ ...identifier,
1957
+ batchSize: opts.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
1391
1958
  });
1392
- try {
1393
- const data = await loadFromPaths(paths, options.recursive);
1394
- if (data.length === 0) {
1395
- if (options.json) outputJsonError("No data to push");
1396
- logger$3.error("No data to push. Skipping");
1397
- process.exit(1);
1398
- }
1399
- const identifier = options.name ? { name: options.name } : { id: options.id };
1400
- const result = await client.datasets.push({
1401
- points: data,
1402
- ...identifier,
1403
- batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
1959
+ if (opts.json) {
1960
+ outputJson({
1961
+ datasetId: result?.datasetId,
1962
+ count: data.length
1404
1963
  });
1405
- if (options.json) {
1406
- outputJson({
1407
- datasetId: result?.datasetId,
1408
- count: data.length
1409
- });
1410
- return;
1411
- }
1412
- logger$3.info(`Pushed ${data.length} data points to dataset ${options.name || options.id}`);
1413
- } catch (error) {
1414
- if (options.json) outputJsonError(error);
1415
- logger$3.error(`Failed to push dataset: ${errorMessage(error)}`);
1416
- process.exit(1);
1964
+ return;
1417
1965
  }
1966
+ logger$2.info(`Pushed ${data.length} data points to dataset ${opts.name || opts.id}`);
1418
1967
  };
1419
1968
  /**
1420
1969
  * Handle datasets pull command.
1421
1970
  */
1422
- const handleDatasetsPull = async (outputPath, options) => {
1423
- if (!options.name && !options.id) {
1424
- if (options.json) outputJsonError("Either name or id must be provided");
1425
- logger$3.error("Either name or id must be provided");
1426
- process.exit(1);
1427
- }
1428
- if (options.name && options.id) {
1429
- if (options.json) outputJsonError("Only one of name or id must be provided");
1430
- logger$3.error("Only one of name or id must be provided");
1431
- process.exit(1);
1432
- }
1433
- const client = new LaminarClient({
1434
- projectApiKey: options.projectApiKey,
1435
- baseUrl: options.baseUrl,
1436
- port: options.port
1437
- });
1438
- const identifier = options.name ? { name: options.name } : { id: options.id };
1439
- try {
1440
- const result = await pullAllData(client, identifier, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, options.offset ?? 0, options.limit);
1441
- if (outputPath) {
1442
- await writeToFile(outputPath, result, options.outputFormat);
1443
- if (options.json) outputJson({
1444
- path: outputPath,
1445
- count: result.length
1446
- });
1447
- else logger$3.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
1448
- } else if (options.json) outputJson(result);
1449
- else printToConsole(result, options.outputFormat ?? "json");
1450
- } catch (error) {
1451
- if (options.json) outputJsonError(error);
1452
- logger$3.error(`Failed to pull dataset: ${errorMessage(error)}`);
1453
- process.exit(1);
1454
- }
1971
+ const handleDatasetsPull = async (client, outputPath, opts) => {
1972
+ requireSingleIdentifier(opts);
1973
+ const result = await pullAllData(client, opts.name ? { name: opts.name } : { id: opts.id }, opts.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, opts.offset ?? 0, opts.limit);
1974
+ if (outputPath) {
1975
+ await writeToFile(outputPath, result, opts.outputFormat);
1976
+ if (opts.json) outputJson({
1977
+ path: outputPath,
1978
+ count: result.length
1979
+ });
1980
+ else logger$2.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
1981
+ } else if (opts.json) outputJson(result);
1982
+ else printToConsole(result, opts.outputFormat ?? "json");
1455
1983
  };
1456
1984
  /**
1457
1985
  * Handle datasets create command.
1458
1986
  */
1459
- const handleDatasetsCreate = async (name, paths, options) => {
1460
- const client = new LaminarClient({
1461
- projectApiKey: options.projectApiKey,
1462
- baseUrl: options.baseUrl,
1463
- port: options.port
1987
+ const handleDatasetsCreate = async (client, name, paths, opts) => {
1988
+ const data = await loadFromPaths(paths, opts.recursive);
1989
+ if (data.length === 0) throw new Error("No data to push");
1990
+ logger$2.info(`Pushing ${data.length} data points to dataset '${name}'...`);
1991
+ await client.datasets.push({
1992
+ points: data,
1993
+ name,
1994
+ batchSize: opts.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
1995
+ createDataset: true
1464
1996
  });
1465
- try {
1466
- const data = await loadFromPaths(paths, options.recursive);
1467
- if (data.length === 0) {
1468
- if (options.json) outputJsonError("No data to push");
1469
- logger$3.error("No data to push. Skipping");
1470
- process.exit(1);
1471
- }
1472
- logger$3.info(`Pushing ${data.length} data points to dataset '${name}'...`);
1473
- await client.datasets.push({
1474
- points: data,
1475
- name,
1476
- batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
1477
- createDataset: true
1478
- });
1479
- logger$3.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
1480
- } catch (error) {
1481
- if (options.json) outputJsonError(error);
1482
- logger$3.error(`Failed to create dataset: ${errorMessage(error)}`);
1483
- process.exit(1);
1484
- }
1485
- logger$3.info(`Pulling data from dataset '${name}'...`);
1486
- try {
1487
- const result = await pullAllData(client, { name }, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
1488
- await writeToFile(options.outputFile, result, options.outputFormat);
1489
- if (options.json) outputJson({
1490
- name,
1491
- path: options.outputFile,
1492
- count: result.length
1493
- });
1494
- else logger$3.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${options.outputFile}`);
1495
- } catch (error) {
1496
- if (options.json) outputJsonError(error);
1497
- logger$3.error("Failed to pull dataset after creation: " + errorMessage(error));
1498
- process.exit(1);
1499
- }
1997
+ logger$2.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
1998
+ logger$2.info(`Pulling data from dataset '${name}'...`);
1999
+ const result = await pullAllData(client, { name }, opts.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
2000
+ await writeToFile(opts.outputFile, result, opts.outputFormat);
2001
+ if (opts.json) outputJson({
2002
+ name,
2003
+ path: opts.outputFile,
2004
+ count: result.length
2005
+ });
2006
+ else logger$2.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${opts.outputFile}`);
1500
2007
  };
1501
2008
  //#endregion
1502
2009
  //#region src/utils/trace-note.ts
@@ -1533,112 +2040,860 @@ const readNoteFromMetadata = (metadata) => {
1533
2040
  };
1534
2041
  //#endregion
1535
2042
  //#region src/commands/debug/index.ts
1536
- const logger$2 = initializeLogger();
2043
+ const logger$1 = initializeLogger();
1537
2044
  /**
1538
2045
  * Upsert the display name of a debug session. Update-only on the backend: a
1539
2046
  * session id unknown to the project 404s rather than creating a ghost session.
2047
+ *
2048
+ * Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
2049
+ * {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
2050
+ * owns the error envelope.
1540
2051
  */
1541
- const handleDebugSessionSetName = async (sessionId, name, options) => {
1542
- const client = new LaminarClient({
1543
- projectApiKey: options.projectApiKey,
1544
- baseUrl: options.baseUrl,
1545
- port: options.port
2052
+ const handleDebugSessionSetName = async (client, sessionId, name, opts) => {
2053
+ await client.rolloutSessions.setName({
2054
+ sessionId,
2055
+ name
1546
2056
  });
1547
- try {
1548
- await client.rolloutSessions.setName({
2057
+ if (opts.json) {
2058
+ outputJson({
1549
2059
  sessionId,
1550
2060
  name
1551
2061
  });
1552
- if (options.json) {
1553
- outputJson({
1554
- sessionId,
1555
- name
1556
- });
1557
- return;
1558
- }
1559
- logger$2.info(`Set name of session ${sessionId} to "${name}".`);
1560
- } catch (error) {
1561
- if (options.json) outputJsonError(error);
1562
- logger$2.error(`Failed to set session name: ${errorMessage(error)}`);
1563
- process.exit(1);
2062
+ return;
1564
2063
  }
2064
+ logger$1.info(`Set name of session ${sessionId} to "${name}".`);
1565
2065
  };
1566
- const SUMMARY_PAGE_SIZE = 1e3;
1567
2066
  /**
1568
2067
  * Print a per-trace summary of a debug session: every trace whose metadata
1569
2068
  * groups it to the session (`rollout.session_id`), oldest first, with the
1570
2069
  * agent-authored note (`rollout.note`) attached to each.
2070
+ *
2071
+ * Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
2072
+ * {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
2073
+ * owns the error envelope.
2074
+ */
2075
+ const handleDebugSessionSummary = async (client, sessionId, opts) => {
2076
+ const traces = (await client.sql.query("SELECT id, formatDateTime(end_time, '%Y-%m-%dT%H:%i:%S.%fZ') AS end_time, metadata FROM traces WHERE simpleJSONExtractString(metadata, 'rollout.session_id') = {session_id:String} ORDER BY start_time", { session_id: sessionId })).map((row) => ({
2077
+ note: readNoteFromMetadata(row.metadata),
2078
+ traceId: String(row.id ?? ""),
2079
+ endTime: String(row.end_time ?? "")
2080
+ }));
2081
+ if (opts.json) {
2082
+ outputJson(traces);
2083
+ return;
2084
+ }
2085
+ if (traces.length === 0) {
2086
+ console.log(`No traces found for session ${sessionId}.`);
2087
+ return;
2088
+ }
2089
+ const blocks = traces.map((trace) => {
2090
+ const tag = `<trace id="${trace.traceId}" end-time="${trace.endTime}"/>`;
2091
+ return trace.note ? `${trace.note}\n${tag}` : tag;
2092
+ });
2093
+ console.log(blocks.join("\n\n"));
2094
+ };
2095
+ //#endregion
2096
+ //#region src/utils/colors.ts
2097
+ function enabledFor(stream) {
2098
+ if ("NO_COLOR" in process.env) return false;
2099
+ if ("FORCE_COLOR" in process.env && process.env.FORCE_COLOR !== "0") return true;
2100
+ return Boolean(stream.isTTY);
2101
+ }
2102
+ const pc = (0, picocolors.createColors)(enabledFor(process.stderr));
2103
+ const pcOut = (0, picocolors.createColors)(enabledFor(process.stdout));
2104
+ const stderrColorEnabled = enabledFor(process.stderr);
2105
+ const orange = (text) => stderrColorEnabled ? `\x1b[38;2;208;117;78m${text}\x1b[39m` : text;
2106
+ //#endregion
2107
+ //#region src/commands/login/index.ts
2108
+ async function handleLogin(options) {
2109
+ const issuer = pick$1(options.frontendUrl, process.env.LMNR_FRONTEND_URL, DEFAULT_FRONTEND_URL$1);
2110
+ const da = await initiateDevice(issuer, CLI_SCOPE);
2111
+ const completeUri = da.verification_uri_complete ?? da.verification_uri;
2112
+ process.stderr.write(`\nOpen this URL in your browser to authorize:\n ${pc.cyan(completeUri)}\n`);
2113
+ if (da.user_code) process.stderr.write(`Code: ${pc.bold(pc.cyan(da.user_code))}\n\n`);
2114
+ if (!options.noBrowser) try {
2115
+ await (0, open.default)(completeUri);
2116
+ } catch {}
2117
+ process.stderr.write(pc.dim("Waiting for authorization...\n"));
2118
+ const token = await pollDevice(issuer, da.device_code, {
2119
+ intervalSeconds: da.interval,
2120
+ timeoutSeconds: da.expires_in
2121
+ });
2122
+ const sessionToken = token.access_token;
2123
+ const jwt = await mintAccessJwt(issuer, sessionToken);
2124
+ const session = await fetchSession(issuer, sessionToken);
2125
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2126
+ await writeCredentials({
2127
+ version: 1,
2128
+ issuer,
2129
+ sessionToken,
2130
+ accessToken: jwt,
2131
+ accessTokenExpiresAt: decodeJwtExp(jwt) ?? now,
2132
+ sessionExpiresAt: typeof token.expires_in === "number" ? new Date(Date.now() + token.expires_in * 1e3).toISOString() : void 0,
2133
+ userEmail: session.email || void 0,
2134
+ userId: session.id,
2135
+ createdAt: now,
2136
+ lastUsedAt: now
2137
+ });
2138
+ return {
2139
+ userId: session.id,
2140
+ userEmail: session.email || null,
2141
+ projectId: parseProjectFromMetadata(token.metadata)
2142
+ };
2143
+ }
2144
+ function pick$1(...candidates) {
2145
+ for (const c of candidates) if (c && c.length > 0) return c;
2146
+ return "";
2147
+ }
2148
+ //#endregion
2149
+ //#region src/commands/logout/index.ts
2150
+ /**
2151
+ * Best-effort server-side session revoke via POST /api/auth/sign-out with the
2152
+ * session token as Bearer. Logout MUST complete locally even if the server is
2153
+ * unreachable — the user expects "log me out" to remove the file. On any
2154
+ * failure we log to stderr and continue.
2155
+ */
2156
+ async function revokeSession(creds) {
2157
+ if (!creds.sessionToken || creds.sessionToken.length === 0) return;
2158
+ const url = `${trimSlash$2(creds.issuer)}/api/auth/sign-out`;
2159
+ try {
2160
+ const res = await fetch(url, {
2161
+ method: "POST",
2162
+ headers: {
2163
+ authorization: `Bearer ${creds.sessionToken}`,
2164
+ "content-type": "application/json"
2165
+ },
2166
+ body: "{}"
2167
+ });
2168
+ if (!res.ok) process.stderr.write(`warning: session revoke at ${url} returned ${res.status}; local credentials still removed.
2169
+ `);
2170
+ } catch (e) {
2171
+ const msg = e instanceof Error ? e.message : String(e);
2172
+ process.stderr.write(`warning: session revoke at ${url} failed (${msg}); local credentials still removed.\n`);
2173
+ }
2174
+ }
2175
+ async function handleLogout() {
2176
+ const creds = await readCredentials();
2177
+ if (!creds) {
2178
+ process.stderr.write("Already logged out.\n");
2179
+ return;
2180
+ }
2181
+ const label = creds.userEmail ?? creds.userId;
2182
+ await deleteCredentials();
2183
+ await revokeSession(creds);
2184
+ process.stderr.write(`Logged out of ${label}. Removed ${credentialsPath()}.\n`);
2185
+ }
2186
+ function trimSlash$2(url) {
2187
+ return url.replace(/\/+$/, "");
2188
+ }
2189
+ //#endregion
2190
+ //#region src/commands/project/index.ts
2191
+ /**
2192
+ * List the projects the signed-in user can access (discovery — no project
2193
+ * scope). Pure handler: the `withUserToken` wrapper resolves the user-token
2194
+ * client and owns the error envelope.
2195
+ */
2196
+ const handleProjectsList = async (client, opts) => {
2197
+ const projects = await client.cli.listProjects();
2198
+ const linked = (await readProjectLink())?.projectId;
2199
+ if (opts.json) {
2200
+ outputJson(projects.map((p) => ({
2201
+ ...p,
2202
+ linked: p.id === linked
2203
+ })));
2204
+ return;
2205
+ }
2206
+ if (projects.length === 0) {
2207
+ console.log("No projects found. Create one in the dashboard, then run `lmnr-cli setup`.");
2208
+ return;
2209
+ }
2210
+ const columns = [
2211
+ "",
2212
+ "Workspace",
2213
+ "Project",
2214
+ "Project ID"
2215
+ ];
2216
+ const rows = projects.map((p) => [
2217
+ p.id === linked ? "●" : "",
2218
+ p.workspaceName,
2219
+ p.name,
2220
+ p.id
2221
+ ]);
2222
+ console.log(renderTable(columns, rows));
2223
+ console.log(linked ? "\n● = linked to this directory (lmnr-cli setup). Override per-command with --project-id.\n" : "\nNot linked here. Run `lmnr-cli setup` in your project directory, or pass --project-id.\n");
2224
+ };
2225
+ //#endregion
2226
+ //#region src/auth/project-id.ts
2227
+ function trimSlash$1(url) {
2228
+ return url.replace(/\/+$/, "");
2229
+ }
2230
+ async function probeProjectKey(projectApiKey, baseUrl = DEFAULT_BASE_URL$1, port) {
2231
+ const url = new URL(trimSlash$1(baseUrl));
2232
+ if (port) url.port = String(port);
2233
+ url.pathname = "/v1/project";
2234
+ let res;
2235
+ try {
2236
+ res = await fetch(url.toString(), {
2237
+ method: "GET",
2238
+ headers: {
2239
+ Authorization: `Bearer ${projectApiKey}`,
2240
+ Accept: "application/json"
2241
+ }
2242
+ });
2243
+ } catch {
2244
+ return { status: "unverifiable" };
2245
+ }
2246
+ if (res.status === 401) return { status: "invalid" };
2247
+ if (!res.ok) return { status: "unverifiable" };
2248
+ const body = await res.json().catch(() => null);
2249
+ return body?.projectId ? {
2250
+ status: "ok",
2251
+ projectId: body.projectId
2252
+ } : { status: "unverifiable" };
2253
+ }
2254
+ //#endregion
2255
+ //#region src/utils/env-file.ts
2256
+ const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
2257
+ const DEFAULT_VAR_NAME = "LMNR_PROJECT_API_KEY";
2258
+ const CANDIDATE_FILES = [".env.local", ".env"];
2259
+ /**
2260
+ * Write/replace `<varName>=<value>` in the .env file at `envPath`.
2261
+ *
2262
+ * Semantics:
2263
+ * - If the file does not exist: create it with mode 0o600 and a single line.
2264
+ * - If the file exists:
2265
+ * - Replace an existing `^<varName>\s*=.*` line in place (preserves comments,
2266
+ * ordering, and other keys).
2267
+ * - Otherwise append the line (with a leading newline if the file does not
2268
+ * end in one).
2269
+ * - DO NOT change the file mode on existing files — the user may have set
2270
+ * a deliberate mode and changing it surprises them.
2271
+ * - Writes are atomic: write to `<envPath>.tmp`, then rename.
2272
+ */
2273
+ async function writeEnvFile(envPath, value, varName = DEFAULT_VAR_NAME) {
2274
+ if (!await fileExists(envPath)) {
2275
+ await atomicWrite(envPath, `${varName}=${value}\n`, 384);
2276
+ return {
2277
+ path: envPath,
2278
+ created: true,
2279
+ replaced: false
2280
+ };
2281
+ }
2282
+ const original = await (0, node_fs_promises.readFile)(envPath, "utf-8");
2283
+ const regex = new RegExp(`^${escapeRegex(varName)}\\s*=.*$`, "m");
2284
+ let next;
2285
+ let replaced = false;
2286
+ if (regex.test(original)) {
2287
+ next = original.replace(regex, `${varName}=${value}`);
2288
+ replaced = true;
2289
+ } else next = `${original.endsWith("\n") || original.length === 0 ? original : `${original}\n`}${varName}=${value}\n`;
2290
+ const existingMode = (await (0, node_fs_promises.stat)(envPath)).mode & 511;
2291
+ await atomicWrite(envPath, next, existingMode);
2292
+ return {
2293
+ path: envPath,
2294
+ created: false,
2295
+ replaced
2296
+ };
2297
+ }
2298
+ /**
2299
+ * Read a single env var's value from a .env file. Returns null when the file
2300
+ * is missing, the var is absent, or its value is empty/whitespace.
2301
+ */
2302
+ async function readEnvVar(envPath, varName = DEFAULT_VAR_NAME) {
2303
+ if (!await fileExists(envPath)) return null;
2304
+ const original = await (0, node_fs_promises.readFile)(envPath, "utf-8");
2305
+ const regex = new RegExp(`^${escapeRegex(varName)}\\s*=(.*)$`, "m");
2306
+ const match = original.match(regex);
2307
+ if (!match) return null;
2308
+ const value = match[1].trim().replace(/^["']|["']$/g, "");
2309
+ return value.length > 0 ? value : null;
2310
+ }
2311
+ /**
2312
+ * Find an already-configured key, checking `process.env` → `.env.local` → `.env`
2313
+ * (first match wins, mirroring Next.js precedence). `process.env` comes first
2314
+ * because an exported var is what actually runs, regardless of language.
2315
+ */
2316
+ async function findEnvKey(cwd, varName = DEFAULT_VAR_NAME) {
2317
+ const fromProcess = process.env[varName]?.trim();
2318
+ if (fromProcess) return {
2319
+ value: fromProcess,
2320
+ source: { type: "process-env" }
2321
+ };
2322
+ for (const name of CANDIDATE_FILES) {
2323
+ const path = (0, node_path.resolve)(cwd, name);
2324
+ const value = await readEnvVar(path, varName);
2325
+ if (value) return {
2326
+ value,
2327
+ source: {
2328
+ type: "file",
2329
+ path
2330
+ }
2331
+ };
2332
+ }
2333
+ return null;
2334
+ }
2335
+ /**
2336
+ * LMNR_* config keys the CLI hydrates from a project `.env.local` / `.env`.
2337
+ * Curated on purpose — we do NOT slurp the whole file, so unrelated app secrets
2338
+ * (model API keys, etc.) never enter the CLI process. Notable exclusions:
2339
+ * - `LMNR_GRPC_PORT`: the CLI is REST-only (no gRPC), so it has no use for it.
2340
+ * - `LMNR_LOG_LEVEL`: loggers are built at module-import time (before
2341
+ * `loadLocalEnv` runs), so hydrating it here would silently have no effect.
2342
+ * - `LMNR_PROJECT_ID`: the project comes from `--project-id` or
2343
+ * `.lmnr/project.json` only; `resolveAuth` ignores the env var, so loading
2344
+ * it here would just contradict that.
2345
+ */
2346
+ const AUTOLOADED_ENV_KEYS = [
2347
+ "LMNR_BASE_URL",
2348
+ "LMNR_HTTP_PORT",
2349
+ "LMNR_FRONTEND_URL"
2350
+ ];
2351
+ /**
2352
+ * Hydrate `process.env` from `.env.local` / `.env` in `cwd` for the curated
2353
+ * {@link AUTOLOADED_ENV_KEYS}, WITHOUT overriding values already present in the
2354
+ * environment (a real exported var / Claude Code `settings.json` env always
2355
+ * wins — `findEnvKey` checks `process.env` first and we skip those).
2356
+ *
2357
+ * Why this exists: Claude Code and many other runners do NOT inject a project
2358
+ * `.env` into a spawned subprocess's environment, so self-hosters who put
2359
+ * `LMNR_BASE_URL` / `LMNR_HTTP_PORT` in `.env` previously had to export them or
2360
+ * pass flags on every call. cwd-only, no upward directory walk (mirrors
2361
+ * dotenv's default) — the CLI must be invoked from the dir holding the `.env`.
2362
+ */
2363
+ async function loadLocalEnv(cwd, keys = AUTOLOADED_ENV_KEYS) {
2364
+ for (const key of keys) {
2365
+ const found = await findEnvKey(cwd, key);
2366
+ if (found?.source.type === "file") process.env[key] = found.value;
2367
+ }
2368
+ }
2369
+ /**
2370
+ * Pick the file to write a freshly-minted key into:
2371
+ * - rewrite in place if the key already lives in a file,
2372
+ * - else prefer an existing `.env.local` (its presence proves the project opted
2373
+ * into the gitignored-secret convention) — but never CREATE one, since Python
2374
+ * loaders ignore it,
2375
+ * - else `.env` (the default every ecosystem loads).
2376
+ */
2377
+ async function resolveEnvWriteTarget(cwd, existing) {
2378
+ if (existing?.source.type === "file") return existing.source.path;
2379
+ const local = (0, node_path.resolve)(cwd, ".env.local");
2380
+ if (await fileExists(local)) return local;
2381
+ return (0, node_path.resolve)(cwd, ".env");
2382
+ }
2383
+ /**
2384
+ * Whether `path` is gitignored. Returns null when it can't be determined (not a
2385
+ * git repo / git absent) so callers can stay silent rather than warn wrongly.
2386
+ */
2387
+ async function isPathGitIgnored(path) {
2388
+ try {
2389
+ await execFileAsync("git", [
2390
+ "check-ignore",
2391
+ "-q",
2392
+ path
2393
+ ]);
2394
+ return true;
2395
+ } catch (err) {
2396
+ return err.code === 1 ? false : null;
2397
+ }
2398
+ }
2399
+ async function fileExists(path) {
2400
+ try {
2401
+ await (0, node_fs_promises.access)(path);
2402
+ return true;
2403
+ } catch {
2404
+ return false;
2405
+ }
2406
+ }
2407
+ async function atomicWrite(path, contents, mode) {
2408
+ const tmp = `${path}.tmp`;
2409
+ await (0, node_fs_promises.writeFile)(tmp, contents, mode === void 0 ? void 0 : { mode });
2410
+ await (0, node_fs_promises.rename)(tmp, path);
2411
+ }
2412
+ function escapeRegex(raw) {
2413
+ return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2414
+ }
2415
+ //#endregion
2416
+ //#region src/skill/laminar-skill.ts
2417
+ const SKILL_REPO = "lmnr-ai/lmnr-skills";
2418
+ const SKILL_REF = "main";
2419
+ const SKILL_NAME = "laminar";
2420
+ /**
2421
+ * giget source for the pinned skill subdir: `github:<owner>/<repo>/<subdir>#<ref>`.
2422
+ * giget resolves this against codeload.github.com, gunzips, untars, strips the
2423
+ * `repo-<ref>/` prefix, and extracts only the subdir — all the mechanics we'd
2424
+ * otherwise hand-roll.
2425
+ */
2426
+ function skillSource() {
2427
+ return `github:${SKILL_REPO}/skills/${SKILL_NAME}#${SKILL_REF}`;
2428
+ }
2429
+ //#endregion
2430
+ //#region src/skill/fetch-skill.ts
2431
+ /**
2432
+ * Download the pinned Laminar skill subtree into `dir` using giget.
2433
+ *
2434
+ * giget owns every quirk we'd otherwise hand-roll: the codeload tarball URL,
2435
+ * gunzip, untar, stripping the `repo-<ref>/` leading segment, subdir filtering,
2436
+ * and ref pinning. `force` overwrites `dir` so reruns are idempotent.
2437
+ *
2438
+ * We deliberately do NOT pass `preferOffline`: giget caches the tarball keyed by
2439
+ * REF name, and SKILL_REF is a moving branch (`main` / a feature branch), so
2440
+ * `preferOffline` would reuse a stale cached tarball forever — reinstalling
2441
+ * files that were since deleted upstream. Without it, giget revalidates via the
2442
+ * stored etag and re-downloads when the branch moved.
2443
+ *
2444
+ * Throws on network / resolve / extract errors — callers (skill install is
2445
+ * best-effort) catch and skip.
1571
2446
  */
1572
- const handleDebugSessionSummary = async (sessionId, options) => {
1573
- const client = new LaminarClient({
1574
- projectApiKey: options.projectApiKey,
1575
- baseUrl: options.baseUrl,
1576
- port: options.port
2447
+ async function downloadSkill(dir) {
2448
+ await (0, giget.downloadTemplate)(skillSource(), {
2449
+ dir,
2450
+ force: true
1577
2451
  });
2452
+ }
2453
+ //#endregion
2454
+ //#region src/utils/install-skill.ts
2455
+ const AGENT_DIRS = [
2456
+ ".claude",
2457
+ ".cursor",
2458
+ ".codex",
2459
+ ".agents"
2460
+ ];
2461
+ const DEFAULT_AGENT_DIRS = [".claude", ".agents"];
2462
+ /**
2463
+ * Fetch the pinned Laminar skill (`SKILL_NAME`) from the lmnr-skills repo and
2464
+ * write its full tree into every present agent dir under `cwd`
2465
+ * (`<dir>/skills/<SKILL_NAME>/SKILL.md`, `.../references/*.md`, ...). If none
2466
+ * are present, default to BOTH `.claude/` and `.agents/`. Idempotent —
2467
+ * overwrites on rerun.
2468
+ *
2469
+ * We download ONCE into a temp staging dir (one network hit) and copy it into
2470
+ * each agent dir. Skill install is the last, best-effort step of `setup`: a
2471
+ * download failure MUST NOT break setup — on error we log a warning and return
2472
+ * `{ written: [], defaulted: false, skipped: true }`.
2473
+ *
2474
+ * For .cursor/.codex the skills layout is not guaranteed to match CC; we write
2475
+ * the CC-guaranteed `skills/<SKILL_NAME>/` shape there too rather than
2476
+ * inventing a path that silently loads nothing.
2477
+ */
2478
+ async function installSkill(cwd = process.cwd()) {
2479
+ const staging = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "lmnr-skill-"));
1578
2480
  try {
1579
- const traces = [];
1580
- let offset = 0;
1581
- for (;;) {
1582
- const rows = await client.sql.query("SELECT id, formatDateTime(end_time, '%Y-%m-%dT%H:%i:%S.%fZ') AS end_time, metadata FROM traces WHERE simpleJSONExtractString(metadata, 'rollout.session_id') = {session_id:String} ORDER BY start_time LIMIT {limit:UInt32} OFFSET {offset:UInt32}", {
1583
- session_id: sessionId,
1584
- limit: SUMMARY_PAGE_SIZE,
1585
- offset
2481
+ try {
2482
+ await downloadSkill(staging);
2483
+ } catch (err) {
2484
+ process.stderr.write(`Warning: could not fetch the Laminar skill (${skillSource()}): ${describeError$1(err)}; skipping skill install.\n`);
2485
+ return {
2486
+ written: [],
2487
+ defaulted: false,
2488
+ skipped: true
2489
+ };
2490
+ }
2491
+ const relFiles = (await (0, node_fs_promises.readdir)(staging, {
2492
+ recursive: true,
2493
+ withFileTypes: true
2494
+ })).filter((d) => d.isFile()).map((d) => (0, node_path.relative)(staging, (0, node_path.join)(d.parentPath, d.name)));
2495
+ const present = [];
2496
+ for (const dir of AGENT_DIRS) if (await dirExists((0, node_path.join)(cwd, dir))) present.push(dir);
2497
+ const targets = present.length > 0 ? present : DEFAULT_AGENT_DIRS;
2498
+ const written = [];
2499
+ for (const dir of targets) {
2500
+ const skillRoot = (0, node_path.join)(cwd, dir, "skills", SKILL_NAME);
2501
+ await (0, node_fs_promises.rm)(skillRoot, {
2502
+ recursive: true,
2503
+ force: true
1586
2504
  });
1587
- for (const row of rows) traces.push({
1588
- note: readNoteFromMetadata(row.metadata),
1589
- traceId: String(row.id ?? ""),
1590
- endTime: String(row.end_time ?? "")
2505
+ await (0, node_fs_promises.mkdir)(skillRoot, { recursive: true });
2506
+ await (0, node_fs_promises.cp)(staging, skillRoot, { recursive: true });
2507
+ for (const rel of relFiles) written.push((0, node_path.join)(skillRoot, rel));
2508
+ }
2509
+ return {
2510
+ written,
2511
+ defaulted: present.length === 0,
2512
+ skipped: false
2513
+ };
2514
+ } finally {
2515
+ await (0, node_fs_promises.rm)(staging, {
2516
+ recursive: true,
2517
+ force: true
2518
+ });
2519
+ }
2520
+ }
2521
+ async function dirExists(path) {
2522
+ try {
2523
+ await (0, node_fs_promises.access)(path);
2524
+ return true;
2525
+ } catch {
2526
+ return false;
2527
+ }
2528
+ }
2529
+ function describeError$1(err) {
2530
+ return err instanceof Error ? err.message : String(err);
2531
+ }
2532
+ //#endregion
2533
+ //#region src/commands/setup/index.ts
2534
+ const DEFAULT_FRONTEND_URL = "https://www.laminar.sh";
2535
+ const DEFAULT_BASE_URL = "https://api.lmnr.ai";
2536
+ const EXIT_NO_ACCESS = 4;
2537
+ const EXIT_LOGIN_FAILED = 6;
2538
+ const EXIT_NO_PROJECT = 7;
2539
+ const EXIT_ENV_WRITE_FAILED = 8;
2540
+ const EXIT_SETUP_KEY_FAILED = 9;
2541
+ const EXIT_LIST_PROJECTS_FAILED = 10;
2542
+ const EXIT_KEY_PROBE_FAILED = 11;
2543
+ const EXIT_KEY_MISMATCH = 12;
2544
+ /**
2545
+ * Directory-scoped onboarding (SPEC decision tree):
2546
+ * - log in if needed (browser picks/creates the project; its id rides back on
2547
+ * the device-token metadata, see parseProjectFromMetadata),
2548
+ * - resolve a project for this directory (`.lmnr/project.json`), enforcing
2549
+ * access,
2550
+ * - mint a project API key only when one isn't already configured for this
2551
+ * project (checked across process.env → .env.local → .env), then write it to
2552
+ * an existing .env.local or else .env,
2553
+ * - install the Laminar skill into present agent dirs,
2554
+ * - print a summary.
2555
+ *
2556
+ * The minted key goes ONLY into the project's env file, never into
2557
+ * credentials.json (which stores user-scoped BetterAuth tokens).
2558
+ */
2559
+ async function handleSetup(options) {
2560
+ const writeEnv = options.writeEnv !== false;
2561
+ const frontendUrl = pick(options.frontendUrl, process.env.LMNR_FRONTEND_URL, DEFAULT_FRONTEND_URL);
2562
+ const baseUrl = pick(options.baseUrl, process.env.LMNR_BASE_URL, DEFAULT_BASE_URL);
2563
+ const isJson = options.json === true;
2564
+ if (!isJson) process.stderr.write(`\n${orange("Laminar CLI")} ${pc.dim(`v${version$1}`)}\n\n`);
2565
+ const cwd = process.cwd();
2566
+ const existingKey = await findEnvKey(cwd);
2567
+ let creds = await safeReadCredentials();
2568
+ let link = await readProjectLink();
2569
+ if (!creds) {
2570
+ let login;
2571
+ try {
2572
+ login = await handleLogin({
2573
+ frontendUrl,
2574
+ noBrowser: options.noBrowser
1591
2575
  });
1592
- if (rows.length < SUMMARY_PAGE_SIZE) break;
1593
- offset += SUMMARY_PAGE_SIZE;
2576
+ } catch (err) {
2577
+ emitError(isJson, "login_failed", describeError(err));
2578
+ process.exit(EXIT_LOGIN_FAILED);
1594
2579
  }
1595
- if (options.json) {
1596
- outputJson(traces);
1597
- return;
2580
+ creds = await safeReadCredentials();
2581
+ if (!creds) {
2582
+ emitError(isJson, "login_failed", "credentials missing after login");
2583
+ process.exit(EXIT_LOGIN_FAILED);
1598
2584
  }
1599
- if (traces.length === 0) {
1600
- console.log(`No traces found for session ${sessionId}.`);
1601
- return;
2585
+ const issuer = creds.issuer || frontendUrl;
2586
+ const userBaseUrl = baseUrl;
2587
+ if (link) await assertAccess(creds, userBaseUrl, link.projectId, isJson);
2588
+ else if (login.projectId) link = await writeLink(issuer, userBaseUrl, login.projectId, isJson);
2589
+ else link = await resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options);
2590
+ } else {
2591
+ const userBaseUrl = baseUrl;
2592
+ const issuer = creds.issuer || frontendUrl;
2593
+ if (link) await assertAccess(creds, userBaseUrl, link.projectId, isJson);
2594
+ else link = await resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options);
2595
+ }
2596
+ if (!isJson) {
2597
+ process.stderr.write(`${pc.green("✓")} Logged in as ${creds.userEmail ?? "<unknown>"}\n`);
2598
+ process.stderr.write(`${pc.green("✓")} Project: ${link.projectName ?? link.projectId}` + (link.workspaceName ? pc.dim(` (${link.workspaceName})`) : "") + "\n");
2599
+ }
2600
+ if (!creds || !link.projectId) {
2601
+ emitError(isJson, "setup_invariant", "missing credentials or project after resolution");
2602
+ process.exit(EXIT_NO_PROJECT);
2603
+ }
2604
+ const issuer = creds.issuer || frontendUrl;
2605
+ const userBaseUrl = baseUrl;
2606
+ let apiKey = null;
2607
+ let envPath = null;
2608
+ let keyMeta = null;
2609
+ let needMint = true;
2610
+ if (existingKey) {
2611
+ const probe = await probeProjectKey(existingKey.value, userBaseUrl, envHttpPort());
2612
+ const where = existingKey.source.type === "process-env" ? "your environment" : (0, node_path.relative)(cwd, existingKey.source.path);
2613
+ if (probe.status === "unverifiable") {
2614
+ emitError(isJson, "key_probe_failed", `Couldn't verify the existing Project API Key in ${where} (network or server error). Check your connection and re-run.`);
2615
+ process.exit(EXIT_KEY_PROBE_FAILED);
2616
+ } else if (probe.status === "ok" && probe.projectId === link.projectId) {
2617
+ needMint = false;
2618
+ if (!isJson) process.stderr.write(`${pc.green("✓")} Project API Key already set in ${where}\n`);
2619
+ } else if (probe.status === "ok") {
2620
+ emitError(isJson, "key_mismatch", `The Project API Key in ${where} belongs to a different project (${probe.projectId}), not the one linked here (${link.projectId}). Remove or update it, then re-run.`);
2621
+ process.exit(EXIT_KEY_MISMATCH);
2622
+ } else if (!isJson) process.stderr.write(`${pc.yellow("⚠")} Existing Project API Key in ${where} is invalid or revoked, minting a new one\n`);
2623
+ }
2624
+ if (needMint) {
2625
+ try {
2626
+ keyMeta = await mintSetupKey(issuer, creds.sessionToken, link.projectId);
2627
+ } catch (err) {
2628
+ emitError(isJson, "setup_key_failed", describeError(err));
2629
+ process.exit(EXIT_SETUP_KEY_FAILED);
2630
+ }
2631
+ apiKey = keyMeta.apiKey;
2632
+ if (!link.projectName && keyMeta.projectName) link.projectName = keyMeta.projectName;
2633
+ if (!link.workspaceName && keyMeta.workspaceName) link.workspaceName = keyMeta.workspaceName;
2634
+ if (!link.workspaceId && keyMeta.workspaceId) link.workspaceId = keyMeta.workspaceId;
2635
+ if (writeEnv) {
2636
+ const target = await resolveEnvWriteTarget(cwd, existingKey);
2637
+ try {
2638
+ const result = await writeEnvFile(target, apiKey);
2639
+ envPath = result.path;
2640
+ if (!isJson) {
2641
+ const rel = (0, node_path.relative)(cwd, result.path);
2642
+ const verb = result.created ? "Created" : result.replaced ? "Updated LMNR_PROJECT_API_KEY in" : "Added LMNR_PROJECT_API_KEY to";
2643
+ process.stderr.write(`${pc.green("✓")} ${verb} ${rel}\n`);
2644
+ if (await isPathGitIgnored(result.path) === false) process.stderr.write(`${pc.yellow("⚠")} ${rel} isn't gitignored; add it so the key isn't committed\n`);
2645
+ }
2646
+ } catch (err) {
2647
+ process.stderr.write(`\n${pc.red("ERROR")}: failed to write ${target}: ${describeError(err)}\n` + pc.dim("Your API key (set it manually):") + `\n LMNR_PROJECT_API_KEY=${apiKey}\n\n`);
2648
+ if (isJson) process.stdout.write(JSON.stringify({
2649
+ error: "env_write_failed",
2650
+ apiKey,
2651
+ projectId: link.projectId,
2652
+ message: describeError(err)
2653
+ }) + "\n");
2654
+ process.exit(EXIT_ENV_WRITE_FAILED);
2655
+ }
1602
2656
  }
1603
- const blocks = traces.map((trace) => {
1604
- const tag = `<trace id="${trace.traceId}" end-time="${trace.endTime}"/>`;
1605
- return trace.note ? `${trace.note}\n${tag}` : tag;
1606
- });
1607
- console.log(blocks.join("\n\n"));
1608
- } catch (error) {
1609
- if (options.json) outputJsonError(error);
1610
- logger$2.error(`Failed to summarize session: ${errorMessage(error)}`);
1611
- process.exit(1);
1612
2657
  }
1613
- };
1614
- //#endregion
1615
- //#region src/commands/sql/index.ts
1616
- const logger$1 = initializeLogger();
1617
- const handleSqlQuery = async (query, options) => {
1618
- const client = new LaminarClient({
1619
- projectApiKey: options.projectApiKey,
1620
- baseUrl: options.baseUrl,
1621
- port: options.port
2658
+ let skillsInstalled = [];
2659
+ try {
2660
+ const skillResult = await installSkill(process.cwd());
2661
+ skillsInstalled = skillResult.written;
2662
+ if (!isJson) {
2663
+ if (skillResult.skipped) process.stderr.write(pc.dim(" Laminar skill install skipped\n"));
2664
+ else if (skillResult.written.length > 0) {
2665
+ const note = skillResult.defaulted ? pc.dim(" (no agent dir found; defaulted to .claude and .agents)") : "";
2666
+ process.stderr.write(`${pc.green("✓")} Installed Laminar skill${note}\n`);
2667
+ }
2668
+ }
2669
+ } catch (err) {
2670
+ if (!isJson) process.stderr.write(`${pc.yellow("Warning")}: could not install Laminar skill (${describeError(err)}).\n`);
2671
+ }
2672
+ const frontendLink = `${trimSlash(issuer)}/project/${link.projectId}/traces`;
2673
+ const result = {
2674
+ projectId: link.projectId,
2675
+ projectName: link.projectName ?? null,
2676
+ workspaceId: link.workspaceId ?? null,
2677
+ workspaceName: link.workspaceName ?? null,
2678
+ apiKey,
2679
+ envFileUpdated: envPath,
2680
+ skillsInstalled,
2681
+ frontendUrl: frontendLink,
2682
+ userEmail: creds.userEmail ?? null
2683
+ };
2684
+ if (isJson) process.stdout.write(JSON.stringify(result) + "\n");
2685
+ else process.stdout.write(`
2686
+ Next steps:
2687
+ 1. Instrument your project with Laminar using the installed skill or the docs:
2688
+ ${pcOut.cyan("https://laminar.sh/docs/tracing/integrations/overview")}\n 2. Run your project.
2689
+ 3. Verify instrumentation:
2690
+ ${pcOut.green("lmnr-cli sql query \"SELECT * FROM traces ORDER BY start_time DESC LIMIT 1\" --json")}\n 4. View your traces in the browser:
2691
+ ${pcOut.cyan(frontendLink)}\n`);
2692
+ }
2693
+ /**
2694
+ * Resolve a project via the CLI (logged-in, no link). 0 projects routes to the
2695
+ * browser create flow (gap A): we re-run the device flow, which lands on the
2696
+ * /device picker → first-project create UI, and the new project's id rides back
2697
+ * on the device-token metadata. >1 prompts a CLI choice; ==1 auto-selects.
2698
+ */
2699
+ async function resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options) {
2700
+ let projects;
2701
+ try {
2702
+ projects = await listProjects(creds, userBaseUrl);
2703
+ } catch (err) {
2704
+ emitError(isJson, "list_projects_failed", describeError(err));
2705
+ process.exit(EXIT_LIST_PROJECTS_FAILED);
2706
+ }
2707
+ if (projects.length === 0) {
2708
+ if (isJson) {
2709
+ emitError(isJson, "no_projects", `No projects found. Run \`lmnr-cli setup\` interactively (it opens the browser to create your first project) or create one at ${trimSlash(issuer)}/onboarding.`);
2710
+ process.exit(EXIT_NO_PROJECT);
2711
+ }
2712
+ process.stderr.write("\nYou have no projects yet. Opening the browser to create your first one...\n");
2713
+ let login;
2714
+ try {
2715
+ login = await handleLogin({
2716
+ frontendUrl: issuer,
2717
+ noBrowser: options.noBrowser
2718
+ });
2719
+ } catch (err) {
2720
+ emitError(isJson, "login_failed", describeError(err));
2721
+ process.exit(EXIT_LOGIN_FAILED);
2722
+ }
2723
+ if (!login.projectId) {
2724
+ emitError(isJson, "no_projects", `No project was created. Create one at ${trimSlash(issuer)}/onboarding then re-run setup.`);
2725
+ process.exit(EXIT_NO_PROJECT);
2726
+ }
2727
+ return writeLink(issuer, userBaseUrl, login.projectId, isJson);
2728
+ }
2729
+ let chosen;
2730
+ if (options.projectId) {
2731
+ const match = projects.find((p) => p.id === options.projectId);
2732
+ if (!match) {
2733
+ emitError(isJson, "no_access", `You don't have access to project ${options.projectId}. Accessible: ` + projects.map((p) => `${p.id} (${p.workspaceName}/${p.name})`).join(", "));
2734
+ process.exit(EXIT_NO_ACCESS);
2735
+ }
2736
+ chosen = match;
2737
+ } else if (projects.length === 1) chosen = projects[0];
2738
+ else {
2739
+ if (isJson) {
2740
+ emitError(isJson, "project_ambiguous", `Multiple projects: pass --project-id <id>, or run setup interactively. ` + projects.map((p) => `${p.id} (${p.workspaceName}/${p.name})`).join(", "));
2741
+ process.exit(EXIT_NO_PROJECT);
2742
+ }
2743
+ chosen = await promptProjectChoice(projects);
2744
+ }
2745
+ const linkPath = await writeProjectLink({
2746
+ projectId: chosen.id,
2747
+ projectName: chosen.name,
2748
+ workspaceId: chosen.workspaceId,
2749
+ workspaceName: chosen.workspaceName
1622
2750
  });
2751
+ if (!isJson) process.stderr.write(`${pc.green("✓")} Linked ${linkPath}\n`);
2752
+ return {
2753
+ projectId: chosen.id,
2754
+ projectName: chosen.name,
2755
+ workspaceId: chosen.workspaceId,
2756
+ workspaceName: chosen.workspaceName
2757
+ };
2758
+ }
2759
+ /** Write `.lmnr/project.json`, enriching display details from listProjects when possible. */
2760
+ async function writeLink(issuer, userBaseUrl, projectId, isJson) {
2761
+ let link = { projectId };
1623
2762
  try {
1624
- const rows = await client.sql.query(query);
1625
- if (options.json) {
1626
- outputJson(rows);
1627
- return;
2763
+ const creds = await safeReadCredentials();
2764
+ if (creds) {
2765
+ const match = (await listProjects(creds, userBaseUrl)).find((p) => p.id === projectId);
2766
+ if (match) link = {
2767
+ projectId,
2768
+ projectName: match.name,
2769
+ workspaceId: match.workspaceId,
2770
+ workspaceName: match.workspaceName
2771
+ };
1628
2772
  }
1629
- if (rows.length === 0) {
1630
- console.log("No rows returned.");
1631
- return;
2773
+ } catch {}
2774
+ try {
2775
+ const linkPath = await writeProjectLink(link);
2776
+ if (!isJson) process.stderr.write(`${pc.green("✓")} Linked ${linkPath}\n`);
2777
+ } catch (err) {
2778
+ if (!isJson) process.stderr.write(`${pc.yellow("Warning")}: could not write .lmnr/project.json (${describeError(err)}). CLI commands will need --project-id ${projectId}.\n`);
2779
+ }
2780
+ return link;
2781
+ }
2782
+ /**
2783
+ * Access check: the user must be a member of `projectId`. Calls
2784
+ * GET /v1/cli/projects (user JWT) and asserts the id is present; aborts
2785
+ * otherwise (SPEC: "You don't have access to the project in this directory").
2786
+ */
2787
+ async function assertAccess(creds, userBaseUrl, projectId, isJson) {
2788
+ let projects;
2789
+ try {
2790
+ projects = await listProjects(creds, userBaseUrl);
2791
+ } catch (err) {
2792
+ emitError(isJson, "list_projects_failed", describeError(err));
2793
+ process.exit(EXIT_LIST_PROJECTS_FAILED);
2794
+ }
2795
+ if (!projects.some((p) => p.id === projectId)) {
2796
+ emitError(isJson, "no_access", "You don't have access to the project in this directory");
2797
+ process.exit(EXIT_NO_ACCESS);
2798
+ }
2799
+ }
2800
+ /** List the projects the user can access (user-JWT-authed discovery). */
2801
+ async function listProjects(creds, baseUrl) {
2802
+ const updated = await refreshIfNeeded(creds);
2803
+ return new LaminarClient({
2804
+ baseUrl,
2805
+ port: envHttpPort(),
2806
+ auth: {
2807
+ type: "userToken",
2808
+ token: updated.accessToken,
2809
+ projectId: ""
1632
2810
  }
1633
- const columns = Object.keys(rows[0]);
1634
- const tableRows = rows.map((row) => columns.map((col) => String(row[col] ?? "")));
1635
- console.log(renderTable(columns, tableRows));
1636
- console.log(`\n${rows.length} row(s)\n`);
1637
- } catch (error) {
1638
- if (options.json) outputJsonError(error);
1639
- logger$1.error(`Query failed: ${errorMessage(error)}`);
1640
- process.exit(1);
2811
+ }).cli.listProjects();
2812
+ }
2813
+ async function safeReadCredentials() {
2814
+ try {
2815
+ return await readCredentials();
2816
+ } catch {
2817
+ return null;
2818
+ }
2819
+ }
2820
+ /** POST /api/cli/api-key with the session bearer for an explicit project. */
2821
+ async function mintSetupKey(issuer, sessionToken, projectId) {
2822
+ const url = `${trimSlash(issuer)}/api/cli/api-key`;
2823
+ const res = await fetch(url, {
2824
+ method: "POST",
2825
+ headers: {
2826
+ authorization: `Bearer ${sessionToken}`,
2827
+ "content-type": "application/json"
2828
+ },
2829
+ body: JSON.stringify({
2830
+ deviceName: (0, node_os.hostname)(),
2831
+ projectId
2832
+ })
2833
+ });
2834
+ if (res.ok) return await res.json();
2835
+ const body = await res.json().catch(() => ({}));
2836
+ throw new Error(body.error ?? `api-key request failed (${res.status})`);
2837
+ }
2838
+ async function promptProjectChoice(projects) {
2839
+ process.stderr.write("\nMultiple projects available. Choose one:\n");
2840
+ projects.forEach((p, i) => {
2841
+ process.stderr.write(` ${i + 1}) ${p.workspaceName} / ${p.name}\n`);
2842
+ });
2843
+ const rl = (0, node_readline_promises.createInterface)({
2844
+ input: process.stdin,
2845
+ output: process.stderr
2846
+ });
2847
+ try {
2848
+ while (true) {
2849
+ const answer = (await rl.question(`Select [1-${projects.length}]: `)).trim();
2850
+ const idx = Number.parseInt(answer, 10);
2851
+ if (Number.isInteger(idx) && idx >= 1 && idx <= projects.length) return projects[idx - 1];
2852
+ process.stderr.write(`${pc.red("Invalid selection.")}\n`);
2853
+ }
2854
+ } finally {
2855
+ rl.close();
1641
2856
  }
2857
+ }
2858
+ function pick(...candidates) {
2859
+ for (const c of candidates) if (c && c.length > 0) return c;
2860
+ return "";
2861
+ }
2862
+ function trimSlash(url) {
2863
+ return url.replace(/\/+$/, "");
2864
+ }
2865
+ function describeError(err) {
2866
+ if (err instanceof Error) return err.message;
2867
+ return String(err);
2868
+ }
2869
+ function emitError(json, code, detail) {
2870
+ if (json) process.stdout.write(JSON.stringify({
2871
+ error: code,
2872
+ detail
2873
+ }) + "\n");
2874
+ else process.stderr.write(`\n${pc.red(`ERROR (${code})`)}: ${detail}\n`);
2875
+ }
2876
+ //#endregion
2877
+ //#region src/commands/sql/index.ts
2878
+ /**
2879
+ * Run a SQL query against the project's data and print the rows. Pure handler:
2880
+ * the command wrapper (`withProjectClient`) resolves the client and owns the
2881
+ * error envelope (`--json` → structured error + exit, otherwise log + exit).
2882
+ */
2883
+ const handleSqlQuery = async (client, query, opts) => {
2884
+ const rows = await client.sql.query(query);
2885
+ if (opts.json) {
2886
+ outputJson(rows);
2887
+ return;
2888
+ }
2889
+ if (rows.length === 0) {
2890
+ console.log("No rows returned.");
2891
+ return;
2892
+ }
2893
+ const columns = Object.keys(rows[0]);
2894
+ const tableRows = rows.map((row) => columns.map((col) => String(row[col] ?? "")));
2895
+ console.log(renderTable(columns, tableRows));
2896
+ console.log(`\n${rows.length} row(s)\n`);
1642
2897
  };
1643
2898
  //#endregion
1644
2899
  //#region src/commands/sql/schema.ts
@@ -1698,6 +2953,10 @@ const NOTE_SEPARATOR = "\n\n";
1698
2953
  * as `existing + "\n\n" + note`. The note may contain markdown /
1699
2954
  * span-reference links.
1700
2955
  *
2956
+ * Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
2957
+ * {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
2958
+ * owns the error envelope (`--json` → structured error + exit, else log + exit).
2959
+ *
1701
2960
  * The read-modify-write is not transactional: the patch lands via the async
1702
2961
  * ingestion queue, so a second append issued within ~a second of the first
1703
2962
  * can read the pre-patch note and drop the first append. Fine for the
@@ -1708,55 +2967,35 @@ const NOTE_SEPARATOR = "\n\n";
1708
2967
  * the metadata patch endpoint that concatenates within the Postgres UPDATE,
1709
2968
  * which already serializes on the trace row lock).
1710
2969
  */
1711
- const handleTraceAppendNote = async (traceId, note, options) => {
1712
- const client = new LaminarClient({
1713
- projectApiKey: options.projectApiKey,
1714
- baseUrl: options.baseUrl,
1715
- port: options.port
1716
- });
1717
- try {
1718
- const id = normalizeTraceId(traceId);
1719
- const rows = await client.sql.query("SELECT metadata FROM traces WHERE id = {trace_id:UUID} LIMIT 1", { trace_id: id });
1720
- if (rows.length === 0) throw new Error(`Trace ${id} not found. If the run just finished, the trace may not be flushed yet. Retry in a few seconds.`);
1721
- const existing = readNoteFromMetadata(rows[0].metadata);
1722
- const updated = existing ? `${existing}${NOTE_SEPARATOR}${note}` : note;
1723
- await client.traces.pushMetadata(id, { [NOTE_METADATA_KEY]: updated }, { failOnNotFound: true });
1724
- if (options.json) {
1725
- outputJson({
1726
- traceId: id,
1727
- note: updated
1728
- });
1729
- return;
1730
- }
1731
- logger.info(`Appended note to trace ${id}.`);
1732
- } catch (error) {
1733
- if (options.json) outputJsonError(error);
1734
- logger.error(`Failed to append trace note: ${errorMessage(error)}`);
1735
- process.exit(1);
2970
+ const handleTraceAppendNote = async (client, traceId, note, opts) => {
2971
+ const id = normalizeTraceId(traceId);
2972
+ const rows = await client.sql.query("SELECT metadata FROM traces WHERE id = {trace_id:UUID} LIMIT 1", { trace_id: id });
2973
+ if (rows.length === 0) throw new Error(`Trace ${id} not found. If the run just finished, the trace may not be flushed yet. Retry in a few seconds.`);
2974
+ const existing = readNoteFromMetadata(rows[0].metadata);
2975
+ const updated = existing ? `${existing}${NOTE_SEPARATOR}${note}` : note;
2976
+ await client.traces.pushMetadata(id, { [NOTE_METADATA_KEY]: updated }, { failOnNotFound: true });
2977
+ if (opts.json) {
2978
+ outputJson({
2979
+ traceId: id,
2980
+ note: updated
2981
+ });
2982
+ return;
1736
2983
  }
2984
+ logger.info(`Appended note to trace ${id}.`);
1737
2985
  };
1738
2986
  //#endregion
1739
2987
  //#region src/index.ts
1740
2988
  async function main() {
2989
+ await loadLocalEnv(process.cwd());
1741
2990
  const program = new commander.Command();
1742
2991
  program.name("lmnr-cli").description("CLI for the Laminar agent observability platform").version(version$1, "-v, --version", "display version number");
1743
- const datasetsCmd = program.command("dataset").description("Manage datasets").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
1744
- datasetsCmd.command("list").description("List all datasets").action(async (_options, cmd) => {
1745
- await handleDatasetsList(cmd.optsWithGlobals());
1746
- });
1747
- datasetsCmd.command("push").description("Push datapoints to an existing dataset").argument("<paths...>", "Paths to files or directories containing data to push").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing data", (val) => parseInt(val, 10), 100).action(async (paths, _options, cmd) => {
1748
- await handleDatasetsPush(paths, cmd.optsWithGlobals());
1749
- });
1750
- datasetsCmd.command("pull").description("Pull data from a dataset").argument("[output-path]", "Path to save the data. If not provided, prints to console").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("--batch-size <size>", "Batch size for pulling data", (val) => parseInt(val, 10), 100).option("--limit <limit>", "Limit number of datapoints to pull", (val) => parseInt(val, 10)).option("--offset <offset>", "Offset for pagination", (val) => parseInt(val, 10), 0).action(async (outputPath, _options, cmd) => {
1751
- await handleDatasetsPull(outputPath, cmd.optsWithGlobals());
1752
- });
1753
- datasetsCmd.command("create").description("Create a dataset from input files").argument("<name>", "Name of the dataset to create").argument("<paths...>", "Paths to files or directories containing data to push").requiredOption("-o, --output-file <file>", "Path to save the pulled data").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing/pulling data", (val) => parseInt(val, 10), 100).action(async (name, paths, _options, cmd) => {
1754
- await handleDatasetsCreate(name, paths, cmd.optsWithGlobals());
1755
- });
1756
- const sqlCmd = program.command("sql").description("Run SQL queries against your Laminar project data").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
1757
- sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(async (query, _options, cmd) => {
1758
- await handleSqlQuery(query, cmd.optsWithGlobals());
1759
- }).addHelpText("after", SQL_SCHEMA_HELP + `
2992
+ const datasetsCmd = program.command("dataset").description("Manage datasets").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
2993
+ datasetsCmd.command("list").description("List all datasets").action(withProjectClient(handleDatasetsList));
2994
+ datasetsCmd.command("push").description("Push datapoints to an existing dataset").argument("<paths...>", "Paths to files or directories containing data to push").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing data", (val) => parseInt(val, 10), 100).action(withProjectClient(handleDatasetsPush));
2995
+ datasetsCmd.command("pull").description("Pull data from a dataset").argument("[output-path]", "Path to save the data. If not provided, prints to console").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("--batch-size <size>", "Batch size for pulling data", (val) => parseInt(val, 10), 100).option("--limit <limit>", "Limit number of datapoints to pull", (val) => parseInt(val, 10)).option("--offset <offset>", "Offset for pagination", (val) => parseInt(val, 10), 0).action(withProjectClient(handleDatasetsPull));
2996
+ datasetsCmd.command("create").description("Create a dataset from input files").argument("<name>", "Name of the dataset to create").argument("<paths...>", "Paths to files or directories containing data to push").requiredOption("-o, --output-file <file>", "Path to save the pulled data").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing/pulling data", (val) => parseInt(val, 10), 100).action(withProjectClient(handleDatasetsCreate));
2997
+ const sqlCmd = program.command("sql").description("Run SQL queries against your Laminar project data").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
2998
+ sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(withProjectClient(handleSqlQuery)).addHelpText("after", SQL_SCHEMA_HELP + `
1760
2999
  Examples:
1761
3000
  $ lmnr-cli sql query "SELECT * FROM spans LIMIT 10"
1762
3001
  $ lmnr-cli sql query "SELECT id, total_cost, status FROM traces LIMIT 20"
@@ -1765,29 +3004,36 @@ Examples:
1765
3004
  sqlCmd.command("schema").description("Show available tables and their columns").action(() => {
1766
3005
  process.stdout.write(SQL_SCHEMA_HELP);
1767
3006
  });
1768
- program.command("trace").description("Operate on existing traces").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").command("append-note").description("Append a free-text note to a trace (stored in trace metadata)").argument("<trace-id>", "Trace ID (UUID or 32-char OTel hex trace id)").argument("<note>", "Note text (may contain markdown)").action(async (traceId, note, _options, cmd) => {
1769
- await handleTraceAppendNote(traceId, note, cmd.optsWithGlobals());
1770
- }).addHelpText("after", `
3007
+ program.command("project").description("Work with Laminar projects").command("list").description("List the projects you can access (● = linked to this directory)").option("--base-url <url>", "Base URL for the Laminar API. Defaults to the logged-in session or LMNR_BASE_URL").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").action(withUserToken(handleProjectsList));
3008
+ program.command("login").description("Authenticate the CLI via OAuth Device Flow").option("--frontend-url <url>", "Frontend URL (issuer). Defaults to https://www.laminar.sh or LMNR_FRONTEND_URL env variable").option("--no-browser", "Do not open the verification URL in a browser").action(async (options) => {
3009
+ const result = await handleLogin(options);
3010
+ process.stderr.write(`${pc.green("✓")} Logged in as ${result.userEmail ?? "<unknown>"}.\n`);
3011
+ process.stderr.write(pc.dim("Client: lmnr-cli. Tokens stored at ~/.config/lmnr/credentials.json (mode 0600).\n"));
3012
+ process.stderr.write(pc.dim("Run `lmnr-cli setup` in a project directory to link it and write its API key.\n"));
3013
+ });
3014
+ program.command("logout").description("Log out and remove the stored credentials").action(async () => {
3015
+ await handleLogout();
3016
+ });
3017
+ program.command("setup").description("One-shot onboarding: login, select a project, write its key to .env, link .lmnr, and install the Laminar agent skill").option("--write-env", "Write LMNR_PROJECT_API_KEY to ./.env (default)", true).option("--no-write-env", "Do not write to ./.env").option("--project-id <id>", "Project to link when you can access more than one (disambiguates the project_ambiguous case in --json mode)").option("--json", "Emit a machine-readable JSON line on stdout").option("--no-browser", "Do not auto-open the device-flow URL").option("--frontend-url <url>", "Frontend URL (issuer). Defaults to LMNR_FRONTEND_URL or https://www.laminar.sh").option("--base-url <url>", "Base URL for the Laminar API. Defaults to LMNR_BASE_URL or https://api.lmnr.ai").action(async (options) => {
3018
+ await handleSetup(options);
3019
+ });
3020
+ program.command("trace").description("Inspect and operate on traces").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").command("append-note").description("Append a free-text note to a trace (stored in trace metadata)").argument("<trace-id>", "Trace ID (UUID or 32-char OTel hex trace id)").argument("<note>", "Note text (may contain markdown)").action(withProjectClient(handleTraceAppendNote)).addHelpText("after", `
1771
3021
  Notes accumulate: each call appends a new paragraph to the trace's existing
1772
3022
  note rather than overwriting it.
1773
3023
 
1774
3024
  Examples:
1775
3025
  $ lmnr-cli trace append-note <trace-id> "Reproduced the timeout on the search tool."
1776
3026
  `);
1777
- const debugSessionCmd = program.command("debug").description("Operate on debug sessions").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").addHelpText("after", `
3027
+ const debugSessionCmd = program.command("debug").description("Operate on debug sessions").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").addHelpText("after", `
1778
3028
  Learn more about debugging features at https://laminar.sh/docs/platform/debugger
1779
3029
  `).command("session").description("Manage debug sessions").addHelpText("after", `
1780
3030
  Learn more about debugging features at https://laminar.sh/docs/platform/debugger
1781
3031
  `);
1782
- debugSessionCmd.command("set-name").description("Set the display name of a debug session").argument("<session-id>", "Debug session ID").argument("<name>", "Session display name").action(async (sessionId, name, _options, cmd) => {
1783
- await handleDebugSessionSetName(sessionId, name, cmd.optsWithGlobals());
1784
- }).addHelpText("after", `
3032
+ debugSessionCmd.command("set-name").description("Set the display name of a debug session").argument("<session-id>", "Debug session ID").argument("<name>", "Session display name").action(withProjectClient(handleDebugSessionSetName)).addHelpText("after", `
1785
3033
  Examples:
1786
3034
  $ lmnr-cli debug session set-name <session-id> "Fix report length + search tool"
1787
3035
  `);
1788
- debugSessionCmd.command("summary").description("Print every trace in a debug session with its note, oldest first").argument("<session-id>", "Debug session ID").action(async (sessionId, _options, cmd) => {
1789
- await handleDebugSessionSummary(sessionId, cmd.optsWithGlobals());
1790
- }).addHelpText("after", `
3036
+ debugSessionCmd.command("summary").description("Print every trace in a debug session with its note, oldest first").argument("<session-id>", "Debug session ID").action(withProjectClient(handleDebugSessionSummary)).addHelpText("after", `
1791
3037
  Output is one block per trace (oldest first), the trace's note followed by a
1792
3038
  self-closing tag carrying the trace id and end time:
1793
3039
 
@@ -1802,17 +3048,21 @@ Examples:
1802
3048
  `);
1803
3049
  program.addHelpText("after", `
1804
3050
  Authentication:
1805
- Most commands require a project API key. Provide it in one of two ways:
1806
- 1. Environment variable: export LMNR_PROJECT_API_KEY=<your-key>
1807
- 2. CLI flag: --project-api-key <your-key>
1808
- Get your key at https://www.laminar.sh (Settings > Project API Keys).
3051
+ Run \`lmnr-cli setup\` to login, link this directory, write a project API key to
3052
+ ./.env, and install the Laminar skill
3053
+ \`lmnr-cli login\` authenticates as a user. Every project command
3054
+ (sql / dataset / project / trace / debug) runs on that user session and
3055
+ targets a project via --project-id or the linked .lmnr/project.json.
1809
3056
 
1810
3057
  Examples:
3058
+ lmnr-cli setup # Logs in and prepares directory
3059
+ lmnr-cli login # Authenticate (user)
3060
+ lmnr-cli project list # Projects you can access
3061
+ lmnr-cli logout # Log out
1811
3062
  lmnr-cli dataset list --json # List all datasets
1812
3063
  lmnr-cli dataset push data.jsonl -n my-dataset --json # Push data to a dataset
1813
3064
  lmnr-cli dataset pull output.jsonl -n my-dataset --json # Pull data from a dataset
1814
3065
  lmnr-cli sql query "SELECT * FROM spans LIMIT 10" --json # Query spans
1815
- lmnr-cli sql query "SELECT t.id, s.name FROM traces t JOIN spans s ON t.id = s.trace_id" --json
1816
3066
  lmnr-cli sql schema # Show available tables
1817
3067
  lmnr-cli trace append-note <trace-id> "note text" # Append a note to a trace
1818
3068
  lmnr-cli debug session set-name <session-id> "title" # Rename a debug session
@@ -1820,7 +3070,6 @@ Examples:
1820
3070
 
1821
3071
  For more information about the Laminar platfrom:
1822
3072
  Documentation: https://laminar.sh/docs
1823
- Dashboard: https://www.laminar.sh
1824
3073
  `);
1825
3074
  await program.parseAsync();
1826
3075
  }