lmnr-cli 0.1.9 → 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
@@ -6,7 +6,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
9
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
10
10
  var __copyProps = (to, from, except, desc) => {
11
11
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
12
  key = keys[i];
@@ -25,11 +25,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
  let commander = require("commander");
26
26
  let fs = require("fs");
27
27
  let path = require("path");
28
+ let path$2 = __toESM(path, 1);
28
29
  path = __toESM(path);
29
30
  let pino = require("pino");
31
+ let pino$3 = __toESM(pino, 1);
30
32
  pino = __toESM(pino);
31
33
  let pino_pretty = require("pino-pretty");
32
- let uuid = require("uuid");
34
+ let node_fs_promises = require("node:fs/promises");
35
+ let node_path = require("node:path");
36
+ let node_os = require("node:os");
33
37
  let csv_parser = require("csv-parser");
34
38
  csv_parser = __toESM(csv_parser);
35
39
  let export_to_csv = require("export-to-csv");
@@ -37,104 +41,34 @@ let fs_promises = require("fs/promises");
37
41
  fs_promises = __toESM(fs_promises);
38
42
  let cli_table3 = require("cli-table3");
39
43
  cli_table3 = __toESM(cli_table3);
40
- let chokidar = require("chokidar");
41
- chokidar = __toESM(chokidar);
42
- let http = require("http");
43
- http = __toESM(http);
44
- let events = require("events");
45
- let eventsource_parser = require("eventsource-parser");
46
- let child_process = require("child_process");
47
- let readline = require("readline");
48
- readline = __toESM(readline);
49
- //#region package.json
50
- var version$1 = "0.1.9";
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");
51
+ //#region ../types/dist/index.mjs
52
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
51
53
  //#endregion
52
- //#region ../../node_modules/.pnpm/dotenv@17.2.3/node_modules/dotenv/package.json
53
- var require_package = /* @__PURE__ */ __commonJSMin(((exports, module) => {
54
- module.exports = {
55
- "name": "dotenv",
56
- "version": "17.2.3",
57
- "description": "Loads environment variables from .env file",
58
- "main": "lib/main.js",
59
- "types": "lib/main.d.ts",
60
- "exports": {
61
- ".": {
62
- "types": "./lib/main.d.ts",
63
- "require": "./lib/main.js",
64
- "default": "./lib/main.js"
65
- },
66
- "./config": "./config.js",
67
- "./config.js": "./config.js",
68
- "./lib/env-options": "./lib/env-options.js",
69
- "./lib/env-options.js": "./lib/env-options.js",
70
- "./lib/cli-options": "./lib/cli-options.js",
71
- "./lib/cli-options.js": "./lib/cli-options.js",
72
- "./package.json": "./package.json"
73
- },
74
- "scripts": {
75
- "dts-check": "tsc --project tests/types/tsconfig.json",
76
- "lint": "standard",
77
- "pretest": "npm run lint && npm run dts-check",
78
- "test": "tap run tests/**/*.js --allow-empty-coverage --disable-coverage --timeout=60000",
79
- "test:coverage": "tap run tests/**/*.js --show-full-coverage --timeout=60000 --coverage-report=text --coverage-report=lcov",
80
- "prerelease": "npm test",
81
- "release": "standard-version"
82
- },
83
- "repository": {
84
- "type": "git",
85
- "url": "git://github.com/motdotla/dotenv.git"
86
- },
87
- "homepage": "https://github.com/motdotla/dotenv#readme",
88
- "funding": "https://dotenvx.com",
89
- "keywords": [
90
- "dotenv",
91
- "env",
92
- ".env",
93
- "environment",
94
- "variables",
95
- "config",
96
- "settings"
97
- ],
98
- "readmeFilename": "README.md",
99
- "license": "BSD-2-Clause",
100
- "devDependencies": {
101
- "@types/node": "^18.11.3",
102
- "decache": "^4.6.2",
103
- "sinon": "^14.0.1",
104
- "standard": "^17.0.0",
105
- "standard-version": "^9.5.0",
106
- "tap": "^19.2.0",
107
- "typescript": "^4.8.4"
108
- },
109
- "engines": { "node": ">=12" },
110
- "browser": { "fs": false }
111
- };
112
- }));
54
+ //#region package.json
55
+ var version$1 = "0.1.11";
113
56
  //#endregion
114
- //#region ../client/dist/index.mjs
115
- var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
57
+ //#region ../../node_modules/.pnpm/dotenv@17.4.2/node_modules/dotenv/lib/main.js
58
+ var require_main = /* @__PURE__ */ __commonJSMin(((exports, module) => {
116
59
  const fs$1 = require("fs");
117
60
  const path$1 = require("path");
118
61
  const os = require("os");
119
62
  const crypto$1 = require("crypto");
120
- const version = require_package().version;
121
63
  const TIPS = [
122
- "🔐 encrypt with Dotenvx: https://dotenvx.com",
123
- "🔐 prevent committing .env to code: https://dotenvx.com/precommit",
124
- "🔐 prevent building .env in docker: https://dotenvx.com/prebuild",
125
- "📡 add observability to secrets: https://dotenvx.com/ops",
126
- "👥 sync secrets across teammates & machines: https://dotenvx.com/ops",
127
- "🗂️ backup and recover secrets: https://dotenvx.com/ops",
128
- " audit secrets and track compliance: https://dotenvx.com/ops",
129
- "🔄 add secrets lifecycle management: https://dotenvx.com/ops",
130
- "🔑 add access controls to secrets: https://dotenvx.com/ops",
131
- "🛠️ run anywhere with `dotenvx run -- yourcommand`",
132
- "⚙️ specify custom .env file path with { path: '/custom/path/.env' }",
133
- "⚙️ enable debug logging with { debug: true }",
134
- "⚙️ override existing env vars with { override: true }",
135
- "⚙️ suppress all logs with { quiet: true }",
136
- "⚙️ write to custom object with { processEnv: myObject }",
137
- "⚙️ load multiple .env files with { path: ['.env.local', '.env'] }"
64
+ " encrypted .env [www.dotenvx.com]",
65
+ " secrets for agents [www.dotenvx.com]",
66
+ " auth for agents [www.vestauth.com]",
67
+ " custom filepath { path: '/custom/path/.env' }",
68
+ " enable debugging { debug: true }",
69
+ " override existing { override: true }",
70
+ " suppress logs { quiet: true }",
71
+ " multiple files { path: ['.env.local', '.env'] }"
138
72
  ];
139
73
  function _getRandomTip() {
140
74
  return TIPS[Math.floor(Math.random() * TIPS.length)];
@@ -198,13 +132,13 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
198
132
  return DotenvModule.parse(decrypted);
199
133
  }
200
134
  function _warn(message) {
201
- console.error(`[dotenv@${version}][WARN] ${message}`);
135
+ console.error(`⚠ ${message}`);
202
136
  }
203
137
  function _debug(message) {
204
- console.log(`[dotenv@${version}][DEBUG] ${message}`);
138
+ console.log(`┆ ${message}`);
205
139
  }
206
140
  function _log(message) {
207
- console.log(`[dotenv@${version}] ${message}`);
141
+ console.log(`◇ ${message}`);
208
142
  }
209
143
  function _dotenvKey(options) {
210
144
  if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) return options.DOTENV_KEY;
@@ -262,7 +196,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
262
196
  function _configVault(options) {
263
197
  const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || options && options.debug);
264
198
  const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || options && options.quiet);
265
- if (debug || !quiet) _log("Loading env from encrypted .env.vault");
199
+ if (debug || !quiet) _log("loading env from encrypted .env.vault");
266
200
  const parsed = DotenvModule._parseVault(options);
267
201
  let processEnv = process.env;
268
202
  if (options && options.processEnv != null) processEnv = options.processEnv;
@@ -277,7 +211,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
277
211
  let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || options && options.debug);
278
212
  let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || options && options.quiet);
279
213
  if (options && options.encoding) encoding = options.encoding;
280
- else if (debug) _debug("No encoding is specified. UTF-8 is used by default");
214
+ else if (debug) _debug("no encoding is specified (UTF-8 is used by default)");
281
215
  let optionPaths = [dotenvPath];
282
216
  if (options && options.path) if (!Array.isArray(options.path)) optionPaths = [_resolveHome(options.path)];
283
217
  else {
@@ -286,11 +220,11 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
286
220
  }
287
221
  let lastError;
288
222
  const parsedAll = {};
289
- for (const path$5 of optionPaths) try {
290
- const parsed = DotenvModule.parse(fs$1.readFileSync(path$5, { encoding }));
223
+ for (const path$3 of optionPaths) try {
224
+ const parsed = DotenvModule.parse(fs$1.readFileSync(path$3, { encoding }));
291
225
  DotenvModule.populate(parsedAll, parsed, options);
292
226
  } catch (e) {
293
- if (debug) _debug(`Failed to load ${path$5} ${e.message}`);
227
+ if (debug) _debug(`failed to load ${path$3} ${e.message}`);
294
228
  lastError = e;
295
229
  }
296
230
  const populated = DotenvModule.populate(processEnv, parsedAll, options);
@@ -303,10 +237,10 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
303
237
  const relative = path$1.relative(process.cwd(), filePath);
304
238
  shortPaths.push(relative);
305
239
  } catch (e) {
306
- if (debug) _debug(`Failed to load ${filePath} ${e.message}`);
240
+ if (debug) _debug(`failed to load ${filePath} ${e.message}`);
307
241
  lastError = e;
308
242
  }
309
- _log(`injecting env (${keysCount}) from ${shortPaths.join(",")} ${dim(`-- tip: ${_getRandomTip()}`)}`);
243
+ _log(`injected env (${keysCount}) from ${shortPaths.join(",")} ${dim(`// tip: ${_getRandomTip()}`)}`);
310
244
  }
311
245
  if (lastError) return {
312
246
  parsed: parsedAll,
@@ -318,7 +252,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
318
252
  if (_dotenvKey(options).length === 0) return DotenvModule.configDotenv(options);
319
253
  const vaultPath = _vaultPath(options);
320
254
  if (!vaultPath) {
321
- _warn(`You set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}. Did you forget to build it?`);
255
+ _warn(`you set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}`);
322
256
  return DotenvModule.configDotenv(options);
323
257
  }
324
258
  return DotenvModule._configVault(options);
@@ -387,23 +321,65 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
387
321
  module.exports.parse = DotenvModule.parse;
388
322
  module.exports.populate = DotenvModule.populate;
389
323
  module.exports = DotenvModule;
390
- })))();
391
- var version = "0.8.18";
324
+ }));
325
+ //#endregion
326
+ //#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/stringify.js
327
+ const byteToHex = [];
328
+ for (let i = 0; i < 256; ++i) byteToHex.push((i + 256).toString(16).slice(1));
329
+ function unsafeStringify(arr, offset = 0) {
330
+ return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
331
+ }
332
+ //#endregion
333
+ //#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/rng.js
334
+ const rnds8 = new Uint8Array(16);
335
+ function rng() {
336
+ return crypto.getRandomValues(rnds8);
337
+ }
338
+ //#endregion
339
+ //#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/v4.js
340
+ function v4(options, buf, offset) {
341
+ if (!buf && !options && crypto.randomUUID) return crypto.randomUUID();
342
+ return _v4(options, buf, offset);
343
+ }
344
+ function _v4(options, buf, offset) {
345
+ options = options || {};
346
+ const rnds = options.random ?? options.rng?.() ?? rng();
347
+ if (rnds.length < 16) throw new Error("Random bytes length must be >= 16");
348
+ rnds[6] = rnds[6] & 15 | 64;
349
+ rnds[8] = rnds[8] & 63 | 128;
350
+ if (buf) {
351
+ offset = offset || 0;
352
+ if (offset < 0 || offset + 16 > buf.length) throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
353
+ for (let i = 0; i < 16; ++i) buf[offset + i] = rnds[i];
354
+ return buf;
355
+ }
356
+ return unsafeStringify(rnds);
357
+ }
358
+ //#endregion
359
+ //#region ../client/dist/index.mjs
360
+ var import_main = require_main();
361
+ var version = "0.8.29";
392
362
  function getLangVersion() {
393
363
  if (typeof process !== "undefined" && process.versions && process.versions.node) return `node-${process.versions.node}`;
394
364
  if (typeof navigator !== "undefined" && navigator.userAgent) return `browser-${navigator.userAgent}`;
395
365
  return null;
396
366
  }
397
367
  var BaseResource = class {
398
- constructor(baseHttpUrl, projectApiKey) {
368
+ constructor(baseHttpUrl, auth) {
399
369
  this.baseHttpUrl = baseHttpUrl;
400
- 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";
401
376
  }
402
377
  headers() {
403
378
  return {
404
- Authorization: `Bearer ${this.projectApiKey}`,
379
+ Authorization: `Bearer ${this.credential}`,
405
380
  "Content-Type": "application/json",
406
- Accept: "application/json"
381
+ Accept: "application/json",
382
+ ...this.auth.type === "userToken" ? { "x-lmnr-project-id": this.auth.projectId } : {}
407
383
  };
408
384
  }
409
385
  async handleError(response) {
@@ -412,8 +388,8 @@ var BaseResource = class {
412
388
  }
413
389
  };
414
390
  var BrowserEventsResource = class extends BaseResource {
415
- constructor(baseHttpUrl, projectApiKey) {
416
- super(baseHttpUrl, projectApiKey);
391
+ constructor(baseHttpUrl, auth) {
392
+ super(baseHttpUrl, auth);
417
393
  }
418
394
  async send({ sessionId, traceId, events }) {
419
395
  const payload = {
@@ -437,37 +413,64 @@ var BrowserEventsResource = class extends BaseResource {
437
413
  if (!response.ok) await this.handleError(response);
438
414
  }
439
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
+ };
440
443
  function initializeLogger$1(options) {
441
444
  const colorize = options?.colorize ?? true;
442
445
  const level = options?.level ?? process.env.LMNR_LOG_LEVEL?.toLowerCase()?.trim() ?? "info";
443
- return (0, pino.default)({ level }, (0, pino_pretty.PinoPretty)({
446
+ return (0, pino$3.default)({ level }, (0, pino_pretty.PinoPretty)({
444
447
  colorize,
445
448
  minimumLevel: level
446
449
  }));
447
450
  }
448
- const logger$2$1 = initializeLogger$1();
451
+ const logger$4$1 = initializeLogger$1();
449
452
  const isStringUUID = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id);
450
- const newUUID$1 = () => {
453
+ const newUUID = () => {
451
454
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
452
- else return (0, uuid.v4)();
455
+ else return v4();
453
456
  };
454
457
  const otelSpanIdToUUID = (spanId) => {
455
458
  let id = spanId.toLowerCase();
456
459
  if (id.startsWith("0x")) id = id.slice(2);
457
- if (id.length !== 16) logger$2$1.warn(`Span ID ${spanId} is not 16 hex chars long. This is not a valid OpenTelemetry span ID.`);
460
+ if (id.length !== 16) logger$4$1.warn(`Span ID ${spanId} is not 16 hex chars long. This is not a valid OpenTelemetry span ID.`);
458
461
  if (!/^[0-9a-f]+$/.test(id)) {
459
- logger$2$1.error(`Span ID ${spanId} is not a valid hex string. Generating a random UUID instead.`);
460
- return newUUID$1();
462
+ logger$4$1.error(`Span ID ${spanId} is not a valid hex string. Generating a random UUID instead.`);
463
+ return newUUID();
461
464
  }
462
465
  return id.padStart(32, "0").replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
463
466
  };
464
467
  const otelTraceIdToUUID = (traceId) => {
465
468
  let id = traceId.toLowerCase();
466
469
  if (id.startsWith("0x")) id = id.slice(2);
467
- if (id.length !== 32) logger$2$1.warn(`Trace ID ${traceId} is not 32 hex chars long. This is not a valid OpenTelemetry trace ID.`);
470
+ if (id.length !== 32) logger$4$1.warn(`Trace ID ${traceId} is not 32 hex chars long. This is not a valid OpenTelemetry trace ID.`);
468
471
  if (!/^[0-9a-f]+$/.test(id)) {
469
- logger$2$1.error(`Trace ID ${traceId} is not a valid hex string. Generating a random UUID instead.`);
470
- return newUUID$1();
472
+ logger$4$1.error(`Trace ID ${traceId} is not a valid hex string. Generating a random UUID instead.`);
473
+ return newUUID();
471
474
  }
472
475
  return id.replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
473
476
  };
@@ -490,16 +493,16 @@ const loadEnv = (options) => {
490
493
  const verbose = ["debug", "trace"].includes(logLevel.trim().toLowerCase());
491
494
  const quiet = options?.quiet ?? !verbose;
492
495
  (0, import_main.config)({
493
- path: options?.paths ?? envFiles.map((envFile) => path.resolve(envDir, envFile)),
496
+ path: options?.paths ?? envFiles.map((envFile) => path$2.resolve(envDir, envFile)),
494
497
  quiet
495
498
  });
496
499
  };
497
- const logger$1$1 = initializeLogger$1();
500
+ const logger$3$1 = initializeLogger$1();
498
501
  const DEFAULT_DATASET_PULL_LIMIT = 100;
499
502
  const DEFAULT_DATASET_PUSH_BATCH_SIZE$1 = 100;
500
503
  var DatasetsResource = class extends BaseResource {
501
- constructor(baseHttpUrl, projectApiKey) {
502
- super(baseHttpUrl, projectApiKey);
504
+ constructor(baseHttpUrl, auth) {
505
+ super(baseHttpUrl, auth);
503
506
  }
504
507
  /**
505
508
  * List all datasets.
@@ -507,7 +510,7 @@ var DatasetsResource = class extends BaseResource {
507
510
  * @returns {Promise<Dataset[]>} Array of datasets
508
511
  */
509
512
  async listDatasets() {
510
- const response = await fetch(this.baseHttpUrl + "/v1/datasets", {
513
+ const response = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets", {
511
514
  method: "GET",
512
515
  headers: this.headers()
513
516
  });
@@ -522,7 +525,7 @@ var DatasetsResource = class extends BaseResource {
522
525
  */
523
526
  async getDatasetByName(name) {
524
527
  const params = new URLSearchParams({ name });
525
- const response = await fetch(this.baseHttpUrl + `/v1/datasets?${params.toString()}`, {
528
+ const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets?${params.toString()}`, {
526
529
  method: "GET",
527
530
  headers: this.headers()
528
531
  });
@@ -549,9 +552,9 @@ var DatasetsResource = class extends BaseResource {
549
552
  let response;
550
553
  for (let i = 0; i < points.length; i += batchSize) {
551
554
  const batchNum = Math.floor(i / batchSize) + 1;
552
- logger$1$1.debug(`Pushing batch ${batchNum} of ${totalBatches}`);
555
+ logger$3$1.debug(`Pushing batch ${batchNum} of ${totalBatches}`);
553
556
  const batch = points.slice(i, i + batchSize);
554
- const fetchResponse = await fetch(this.baseHttpUrl + "/v1/datasets/datapoints", {
557
+ const fetchResponse = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets/datapoints", {
555
558
  method: "POST",
556
559
  headers: this.headers(),
557
560
  body: JSON.stringify({
@@ -589,7 +592,7 @@ var DatasetsResource = class extends BaseResource {
589
592
  if (name) paramsObj.name = name;
590
593
  else paramsObj.datasetId = id;
591
594
  const params = new URLSearchParams(paramsObj);
592
- const response = await fetch(this.baseHttpUrl + `/v1/datasets/datapoints?${params.toString()}`, {
595
+ const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets/datapoints?${params.toString()}`, {
593
596
  method: "GET",
594
597
  headers: this.headers()
595
598
  });
@@ -597,11 +600,11 @@ var DatasetsResource = class extends BaseResource {
597
600
  return response.json();
598
601
  }
599
602
  };
600
- const logger$6 = initializeLogger$1();
603
+ const logger$2$1 = initializeLogger$1();
601
604
  const INITIAL_EVALUATION_DATAPOINT_MAX_DATA_LENGTH = 16e6;
602
605
  var EvalsResource = class extends BaseResource {
603
- constructor(baseHttpUrl, projectApiKey) {
604
- super(baseHttpUrl, projectApiKey);
606
+ constructor(baseHttpUrl, auth) {
607
+ super(baseHttpUrl, auth);
605
608
  }
606
609
  /**
607
610
  * Initialize an evaluation.
@@ -655,14 +658,14 @@ var EvalsResource = class extends BaseResource {
655
658
  * @returns {Promise<StringUUID>} The datapoint ID
656
659
  */
657
660
  async createDatapoint({ evalId, data, target, metadata, index, traceId }) {
658
- const datapointId = newUUID$1();
661
+ const datapointId = newUUID();
659
662
  const partialDatapoint = {
660
663
  id: datapointId,
661
664
  data,
662
665
  target,
663
666
  index: index ?? 0,
664
- traceId: traceId ?? newUUID$1(),
665
- executorSpanId: newUUID$1(),
667
+ traceId: traceId ?? newUUID(),
668
+ executorSpanId: newUUID(),
666
669
  metadata
667
670
  };
668
671
  await this.saveDatapoints({
@@ -733,7 +736,7 @@ var EvalsResource = class extends BaseResource {
733
736
  * @returns {Promise<GetDatapointsResponse>} Response from the datapoint retrieval
734
737
  */
735
738
  async getDatapoints({ datasetName, offset, limit }) {
736
- logger$6.warn("evals.getDatapoints() is deprecated. Use client.datasets.pull() instead.");
739
+ logger$2$1.warn("evals.getDatapoints() is deprecated. Use client.datasets.pull() instead.");
737
740
  const params = new URLSearchParams({
738
741
  name: datasetName,
739
742
  offset: offset.toString(),
@@ -750,7 +753,7 @@ var EvalsResource = class extends BaseResource {
750
753
  let length = initialLength;
751
754
  let lastResponse = null;
752
755
  for (let i = 0; i < maxRetries; i++) {
753
- logger$6.debug(`Retrying save datapoints... ${i + 1} of ${maxRetries}, length: ${length}`);
756
+ logger$2$1.debug(`Retrying save datapoints... ${i + 1} of ${maxRetries}, length: ${length}`);
754
757
  const response = await fetch(this.baseHttpUrl + `/v1/evals/${evalId}/datapoints`, {
755
758
  method: "POST",
756
759
  headers: this.headers(),
@@ -771,17 +774,12 @@ var EvalsResource = class extends BaseResource {
771
774
  if (lastResponse && !lastResponse.ok) await this.handleError(lastResponse);
772
775
  }
773
776
  };
774
- var EvaluatorScoreSourceType = /* @__PURE__ */ function(EvaluatorScoreSourceType) {
775
- EvaluatorScoreSourceType["Evaluator"] = "Evaluator";
776
- EvaluatorScoreSourceType["Code"] = "Code";
777
- return EvaluatorScoreSourceType;
778
- }(EvaluatorScoreSourceType || {});
779
777
  /**
780
778
  * Resource for creating evaluator scores
781
779
  */
782
780
  var EvaluatorsResource = class extends BaseResource {
783
- constructor(baseHttpUrl, projectApiKey) {
784
- super(baseHttpUrl, projectApiKey);
781
+ constructor(baseHttpUrl, auth) {
782
+ super(baseHttpUrl, auth);
785
783
  }
786
784
  /**
787
785
  * Create a score for a span or trace
@@ -814,25 +812,21 @@ var EvaluatorsResource = class extends BaseResource {
814
812
  async score(options) {
815
813
  const { name, metadata, score } = options;
816
814
  let payload;
817
- if ("traceId" in options && options.traceId) {
818
- const formattedTraceId = isStringUUID(options.traceId) ? options.traceId : otelTraceIdToUUID(options.traceId);
819
- payload = {
820
- name,
821
- metadata,
822
- score,
823
- source: EvaluatorScoreSourceType.Code,
824
- traceId: formattedTraceId
825
- };
826
- } else if ("spanId" in options && options.spanId) {
827
- const formattedSpanId = isStringUUID(options.spanId) ? options.spanId : otelSpanIdToUUID(options.spanId);
828
- payload = {
829
- name,
830
- metadata,
831
- score,
832
- source: EvaluatorScoreSourceType.Code,
833
- spanId: formattedSpanId
834
- };
835
- } else throw new Error("Either 'traceId' or 'spanId' must be provided.");
815
+ if ("traceId" in options && options.traceId) payload = {
816
+ name,
817
+ metadata,
818
+ score,
819
+ source: "Code",
820
+ traceId: isStringUUID(options.traceId) ? options.traceId : otelTraceIdToUUID(options.traceId)
821
+ };
822
+ else if ("spanId" in options && options.spanId) payload = {
823
+ name,
824
+ metadata,
825
+ score,
826
+ source: "Code",
827
+ spanId: isStringUUID(options.spanId) ? options.spanId : otelSpanIdToUUID(options.spanId)
828
+ };
829
+ else throw new Error("Either 'traceId' or 'spanId' must be provided.");
836
830
  const response = await fetch(this.baseHttpUrl + "/v1/evaluators/score", {
837
831
  method: "POST",
838
832
  headers: this.headers(),
@@ -841,70 +835,139 @@ var EvaluatorsResource = class extends BaseResource {
841
835
  if (!response.ok) await this.handleError(response);
842
836
  }
843
837
  };
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
+ };
844
859
  var RolloutSessionsResource = class extends BaseResource {
845
- constructor(baseHttpUrl, projectApiKey) {
846
- super(baseHttpUrl, projectApiKey);
860
+ constructor(baseHttpUrl, auth) {
861
+ super(baseHttpUrl, auth);
847
862
  }
848
863
  /**
849
- * Connects to the SSE stream for rollout debugging sessions
850
- * Returns the Response object for streaming SSE events
864
+ * Idempotently register (upsert) a debug session on the backend, keyed on the
865
+ * SDK-supplied session id. The backend stores the row so the session is
866
+ * visible in the UI; a null/omitted name never clobbers a name set elsewhere.
867
+ *
868
+ * Returns the backend-resolved `projectId` (derived from the API key) so the
869
+ * caller can build the debugger URL; null if the body can't be parsed.
851
870
  */
852
- async connect({ sessionId, name, params, signal }) {
853
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}`, {
871
+ async register({ sessionId, name }) {
872
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
854
873
  method: "POST",
855
- headers: {
856
- ...this.headers(),
857
- "Accept": "text/event-stream"
858
- },
859
- body: JSON.stringify({
860
- name,
861
- params
862
- }),
863
- signal
864
- });
865
- if (!response.ok) throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
866
- if (!response.body) throw new Error("No response body");
867
- return response;
868
- }
869
- async delete({ sessionId }) {
870
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}`, {
871
- method: "DELETE",
872
- headers: this.headers()
874
+ headers: this.headers(),
875
+ body: JSON.stringify({ name })
873
876
  });
874
877
  if (!response.ok) await this.handleError(response);
878
+ try {
879
+ return (await response.json()).projectId ?? null;
880
+ } catch (e) {
881
+ logger$1$1.warn(`Failed to parse rollout register response: ${errorMessage(e)}`);
882
+ return null;
883
+ }
875
884
  }
876
- async setStatus({ sessionId, status }) {
877
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}/status`, {
885
+ /**
886
+ * Rename an existing debug session. Update-only: the backend returns 404 (and
887
+ * this throws) when the session id is unknown for the project, so a mistyped
888
+ * id surfaces as an error rather than silently creating a session. Creation
889
+ * stays the SDK's job via {@link register}.
890
+ */
891
+ async setName({ sessionId, name }) {
892
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/name`, {
878
893
  method: "PATCH",
879
894
  headers: this.headers(),
880
- body: JSON.stringify({ status })
895
+ body: JSON.stringify({ name })
881
896
  });
882
897
  if (!response.ok) await this.handleError(response);
883
898
  }
884
- async sendSpanUpdate({ sessionId, span }) {
885
- const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}/update`, {
886
- method: "PATCH",
887
- headers: this.headers(),
888
- body: JSON.stringify({
889
- type: "spanStart",
890
- spanId: otelSpanIdToUUID(span.spanId),
891
- traceId: otelTraceIdToUUID(span.traceId),
892
- parentSpanId: span.parentSpanId ? otelSpanIdToUUID(span.parentSpanId) : void 0,
893
- attributes: span.attributes,
894
- startTime: span.startTime,
895
- name: span.name,
896
- spanType: span.spanType
897
- })
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
+ }
957
+ async delete({ sessionId }) {
958
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
959
+ method: "DELETE",
960
+ headers: this.headers()
898
961
  });
899
962
  if (!response.ok) await this.handleError(response);
900
963
  }
901
964
  };
902
965
  var SqlResource = class extends BaseResource {
903
- constructor(baseHttpUrl, projectApiKey) {
904
- super(baseHttpUrl, projectApiKey);
966
+ constructor(baseHttpUrl, auth) {
967
+ super(baseHttpUrl, auth);
905
968
  }
906
969
  async query(sql, parameters = {}) {
907
- const response = await fetch(`${this.baseHttpUrl}/v1/sql/query`, {
970
+ const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/sql/query`, {
908
971
  method: "POST",
909
972
  headers: { ...this.headers() },
910
973
  body: JSON.stringify({
@@ -919,8 +982,8 @@ var SqlResource = class extends BaseResource {
919
982
  /** Resource for tagging traces. */
920
983
  var TagsResource = class extends BaseResource {
921
984
  /** Resource for tagging traces. */
922
- constructor(baseHttpUrl, projectApiKey) {
923
- super(baseHttpUrl, projectApiKey);
985
+ constructor(baseHttpUrl, auth) {
986
+ super(baseHttpUrl, auth);
924
987
  }
925
988
  /**
926
989
  * Tag a trace with a list of tags. Note that the trace must be ended before
@@ -971,23 +1034,127 @@ var TagsResource = class extends BaseResource {
971
1034
  return response.json();
972
1035
  }
973
1036
  };
974
- var LaminarClient = class {
975
- constructor({ baseUrl, projectApiKey, port } = {}) {
1037
+ /** Resource for post-factum operations on existing traces. */
1038
+ const logger$5 = initializeLogger$1();
1039
+ var TracesResource = class extends BaseResource {
1040
+ /** Resource for post-factum operations on existing traces. */
1041
+ constructor(baseHttpUrl, auth) {
1042
+ super(baseHttpUrl, auth);
1043
+ }
1044
+ /**
1045
+ * Push a metadata patch to an existing trace.
1046
+ *
1047
+ * The patch is shallow-merged server-side into the trace's existing metadata
1048
+ * (`existing || patch`, last-write-wins per top-level key). Useful for
1049
+ * attaching post-factum signals — quality scores, human edits, triage labels —
1050
+ * to a trace that has already finished. The patch does NOT extend `endTime`
1051
+ * or change tokens / cost / top span / tags / span names. `numSpans` is
1052
+ * incremented by 1 (paid by the virtual span that carried the patch through
1053
+ * the ingestion queue) so the new ClickHouse row beats the prior version on
1054
+ * `ReplacingMergeTree(numSpans)`. No row is added to the `spans` table.
1055
+ *
1056
+ * Compared to `Laminar.setTraceMetadata` (which sets metadata on the
1057
+ * currently in-flight trace via OpenTelemetry attributes), this method
1058
+ * operates on a finished trace by trace id, so it must be called after the
1059
+ * trace has been flushed.
1060
+ *
1061
+ * A 404 response (the trace was not found in the project — typically because
1062
+ * it has not been flushed yet) is logged as a warning and the call returns
1063
+ * without throwing, since the 404 may be expected when pushing too soon
1064
+ * after the trace run. Pass `failOnNotFound: true` to throw instead (e.g.
1065
+ * CLI callers that must report the failure). Any other non-OK status throws.
1066
+ *
1067
+ * @param traceId - The trace id to push metadata to. Accepts a UUID string
1068
+ * or a 32-char OTel hex trace id.
1069
+ * @param metadata - The metadata patch. Top-level keys are merged into the
1070
+ * trace's existing metadata. Must be non-empty (the server rejects empty
1071
+ * patches with 400).
1072
+ * @param options - `failOnNotFound`: throw on 404 instead of warn-and-return.
1073
+ * @example
1074
+ * ```typescript
1075
+ * import { Laminar, observe, LaminarClient } from "@lmnr-ai/lmnr";
1076
+ * Laminar.initialize();
1077
+ * const client = new LaminarClient();
1078
+ *
1079
+ * let traceId: string | null = null;
1080
+ * await observe({ name: "generate" }, async () => {
1081
+ * traceId = await Laminar.getTraceId();
1082
+ * });
1083
+ * await Laminar.flush();
1084
+ *
1085
+ * if (traceId) {
1086
+ * await client.traces.pushMetadata(traceId, {
1087
+ * score: 0.85,
1088
+ * reviewer: "alice",
1089
+ * needsReview: false,
1090
+ * });
1091
+ * }
1092
+ * ```
1093
+ */
1094
+ async pushMetadata(traceId, metadata, options) {
1095
+ if (!metadata || Object.keys(metadata).length === 0) throw new Error("metadata must be a non-empty object");
1096
+ const formattedTraceId = isStringUUID(traceId) ? traceId : otelTraceIdToUUID(traceId);
1097
+ const url = this.baseHttpUrl + this.apiPrefix + "/traces/metadata";
1098
+ const response = await fetch(url, {
1099
+ method: "POST",
1100
+ headers: this.headers(),
1101
+ body: JSON.stringify({
1102
+ traceId: formattedTraceId,
1103
+ metadata
1104
+ })
1105
+ });
1106
+ if (response.status === 404) {
1107
+ const message = `Trace ${formattedTraceId} not found. The trace may not have been flushed yet — call await Laminar.flush() and retry.`;
1108
+ if (options?.failOnNotFound) throw new Error(message);
1109
+ logger$5.warn(message);
1110
+ return;
1111
+ }
1112
+ if (!response.ok) await this.handleError(response);
1113
+ }
1114
+ };
1115
+ var LaminarClient = class LaminarClient {
1116
+ constructor({ baseUrl, port, auth, projectApiKey, cliUserProjectId } = {}) {
976
1117
  loadEnv();
977
- this.projectApiKey = projectApiKey ?? process.env.LMNR_PROJECT_API_KEY;
1118
+ this.auth = LaminarClient.normalizeAuth(auth, projectApiKey, cliUserProjectId);
978
1119
  const httpPort = port ?? (baseUrl?.match(/:\d{1,5}$/g) ? parseInt(baseUrl.match(/:\d{1,5}$/g)[0].slice(1)) : 443);
979
- this.baseUrl = `${(baseUrl ?? process.env.LMNR_BASE_URL)?.replace(/\/$/, "").replace(/:\d{1,5}$/g, "") ?? "https://api.lmnr.ai"}:${httpPort}`;
980
- this._browserEvents = new BrowserEventsResource(this.baseUrl, this.projectApiKey);
981
- this._datasets = new DatasetsResource(this.baseUrl, this.projectApiKey);
982
- this._evals = new EvalsResource(this.baseUrl, this.projectApiKey);
983
- this._evaluators = new EvaluatorsResource(this.baseUrl, this.projectApiKey);
984
- this._rolloutSessions = new RolloutSessionsResource(this.baseUrl, this.projectApiKey);
985
- this._sql = new SqlResource(this.baseUrl, this.projectApiKey);
986
- this._tags = new TagsResource(this.baseUrl, this.projectApiKey);
1120
+ const baseUrlNoPort = (baseUrl ?? process.env.LMNR_BASE_URL)?.replace(/\/$/, "").replace(/:\d{1,5}$/g, "");
1121
+ this.baseUrl = `${baseUrlNoPort ?? "https://api.lmnr.ai"}:${httpPort}`;
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
+ };
987
1151
  }
988
1152
  get browserEvents() {
989
1153
  return this._browserEvents;
990
1154
  }
1155
+ get cli() {
1156
+ return this._cli;
1157
+ }
991
1158
  get datasets() {
992
1159
  return this._datasets;
993
1160
  }
@@ -1006,6 +1173,9 @@ var LaminarClient = class {
1006
1173
  get tags() {
1007
1174
  return this._tags;
1008
1175
  }
1176
+ get traces() {
1177
+ return this._traces;
1178
+ }
1009
1179
  };
1010
1180
  //#endregion
1011
1181
  //#region src/utils/logger.ts
@@ -1019,94 +1189,544 @@ function initializeLogger(options) {
1019
1189
  }));
1020
1190
  }
1021
1191
  //#endregion
1022
- //#region src/utils/file.ts
1023
- const logger$5 = initializeLogger();
1192
+ //#region src/utils/output.ts
1024
1193
  /**
1025
- * Check if a file has a supported extension.
1194
+ * Write structured JSON to stdout. Use this for machine-readable output
1195
+ * when --json is set.
1026
1196
  */
1027
- const isSupportedFile = (file) => {
1028
- const ext = path.extname(file).toLowerCase();
1029
- return [
1030
- ".json",
1031
- ".csv",
1032
- ".jsonl"
1033
- ].includes(ext);
1034
- };
1197
+ function outputJson(data) {
1198
+ console.log(JSON.stringify(data));
1199
+ }
1035
1200
  /**
1036
- * Collect all supported files from the given paths.
1037
- * Handles both files and directories.
1201
+ * Write a JSON error to stdout and exit with code 1.
1202
+ * Use this in --json mode so agents can parse the failure.
1038
1203
  */
1039
- const collectFiles = async (paths, recursive = false) => {
1040
- const collectedFiles = [];
1041
- for (const filepath of paths) try {
1042
- const stats = await fs_promises.stat(filepath);
1043
- if (stats.isFile()) if (isSupportedFile(filepath)) collectedFiles.push(filepath);
1044
- else logger$5.warn(`Skipping unsupported file type: ${filepath}`);
1045
- else if (stats.isDirectory()) {
1046
- const entries = await fs_promises.readdir(filepath);
1047
- for (const entry of entries) {
1048
- const fullPath = path.join(filepath, entry);
1049
- const entryStats = await fs_promises.stat(fullPath);
1050
- if (entryStats.isFile() && isSupportedFile(fullPath)) collectedFiles.push(fullPath);
1051
- else if (recursive && entryStats.isDirectory()) {
1052
- const subFiles = await collectFiles([fullPath], true);
1053
- collectedFiles.push(...subFiles);
1054
- }
1055
- }
1056
- }
1057
- } catch (error) {
1058
- logger$5.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${error instanceof Error ? error.message : String(error)}`);
1059
- }
1060
- return collectedFiles;
1061
- };
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";
1062
1216
  /**
1063
- * Read a JSON file and return its contents.
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.
1064
1220
  */
1065
- const readJsonFile = async (filepath) => {
1066
- const content = await fs_promises.readFile(filepath, "utf-8");
1067
- const parsed = JSON.parse(content);
1068
- return Array.isArray(parsed) ? parsed : [parsed];
1069
- };
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
+ }
1070
1254
  /**
1071
- * Try to parse a string as JSON. If it fails, return the original string.
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).
1072
1257
  */
1073
- const tryParseJson = (content) => {
1074
- if (typeof content !== "string") return content;
1075
- const trimmed = content.trim();
1076
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return content;
1258
+ async function readCredentials() {
1259
+ const path = credentialsPath();
1260
+ let raw;
1077
1261
  try {
1078
- return JSON.parse(content);
1079
- } catch (error) {
1080
- logger$5.debug(`Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`);
1081
- return content;
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;
1082
1321
  }
1083
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
+ }
1084
1342
  /**
1085
- * Parse each field in a CSV row, attempting to convert JSON strings back to objects.
1086
- */
1087
- const parseCsvRow = (row) => {
1088
- const parsed = {};
1089
- for (const [key, value] of Object.entries(row)) parsed[key] = tryParseJson(value);
1090
- return parsed;
1091
- };
1092
- /**
1093
- * Read a CSV file and return its contents as an array of objects.
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).
1094
1345
  */
1095
- const readCsvFile = async (filepath) => new Promise((resolve, reject) => {
1096
- const results = [];
1097
- (0, fs.createReadStream)(filepath).pipe((0, csv_parser.default)()).on("data", (data) => results.push(parseCsvRow(data))).on("end", () => resolve(results)).on("error", reject);
1098
- });
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
+ }
1099
1384
  /**
1100
- * Read a JSONL file and return its contents as an array of objects.
1385
+ * Mint a fresh 15m EdDSA JWT from a session token. Throws DeviceFlowError
1386
+ * "invalid_grant" on 401 (session expired/revoked).
1101
1387
  */
1102
- async function readJsonlFile(filepath) {
1103
- return (await fs_promises.readFile(filepath, "utf-8")).split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
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
+ };
1104
1417
  }
1105
1418
  /**
1106
- * Read a single file and return its contents.
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`.
1107
1422
  */
1108
- async function readFile(filepath) {
1109
- const ext = path.extname(filepath).toLowerCase();
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
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();
1644
+ /**
1645
+ * Check if a file has a supported extension.
1646
+ */
1647
+ const isSupportedFile = (file) => {
1648
+ const ext = path.extname(file).toLowerCase();
1649
+ return [
1650
+ ".json",
1651
+ ".csv",
1652
+ ".jsonl"
1653
+ ].includes(ext);
1654
+ };
1655
+ /**
1656
+ * Collect all supported files from the given paths.
1657
+ * Handles both files and directories.
1658
+ */
1659
+ const collectFiles = async (paths, recursive = false) => {
1660
+ const collectedFiles = [];
1661
+ for (const filepath of paths) try {
1662
+ const stats = await fs_promises.stat(filepath);
1663
+ if (stats.isFile()) if (isSupportedFile(filepath)) collectedFiles.push(filepath);
1664
+ else logger$3.warn(`Skipping unsupported file type: ${filepath}`);
1665
+ else if (stats.isDirectory()) {
1666
+ const entries = await fs_promises.readdir(filepath);
1667
+ for (const entry of entries) {
1668
+ const fullPath = path.join(filepath, entry);
1669
+ const entryStats = await fs_promises.stat(fullPath);
1670
+ if (entryStats.isFile() && isSupportedFile(fullPath)) collectedFiles.push(fullPath);
1671
+ else if (recursive && entryStats.isDirectory()) {
1672
+ const subFiles = await collectFiles([fullPath], true);
1673
+ collectedFiles.push(...subFiles);
1674
+ }
1675
+ }
1676
+ }
1677
+ } catch (error) {
1678
+ logger$3.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${errorMessage(error)}`);
1679
+ }
1680
+ return collectedFiles;
1681
+ };
1682
+ /**
1683
+ * Read a JSON file and return its contents.
1684
+ */
1685
+ const readJsonFile = async (filepath) => {
1686
+ const content = await fs_promises.readFile(filepath, "utf-8");
1687
+ const parsed = JSON.parse(content);
1688
+ return Array.isArray(parsed) ? parsed : [parsed];
1689
+ };
1690
+ /**
1691
+ * Try to parse a string as JSON. If it fails, return the original string.
1692
+ */
1693
+ const tryParseJson = (content) => {
1694
+ if (typeof content !== "string") return content;
1695
+ const trimmed = content.trim();
1696
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return content;
1697
+ try {
1698
+ return JSON.parse(content);
1699
+ } catch (error) {
1700
+ logger$3.debug(`Error parsing JSON: ${errorMessage(error)}`);
1701
+ return content;
1702
+ }
1703
+ };
1704
+ /**
1705
+ * Parse each field in a CSV row, attempting to convert JSON strings back to objects.
1706
+ */
1707
+ const parseCsvRow = (row) => {
1708
+ const parsed = {};
1709
+ for (const [key, value] of Object.entries(row)) parsed[key] = tryParseJson(value);
1710
+ return parsed;
1711
+ };
1712
+ /**
1713
+ * Read a CSV file and return its contents as an array of objects.
1714
+ */
1715
+ const readCsvFile = async (filepath) => new Promise((resolve, reject) => {
1716
+ const results = [];
1717
+ (0, fs.createReadStream)(filepath).pipe((0, csv_parser.default)()).on("data", (data) => results.push(parseCsvRow(data))).on("end", () => resolve(results)).on("error", reject);
1718
+ });
1719
+ /**
1720
+ * Read a JSONL file and return its contents as an array of objects.
1721
+ */
1722
+ async function readJsonlFile(filepath) {
1723
+ return (await fs_promises.readFile(filepath, "utf-8")).split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
1724
+ }
1725
+ /**
1726
+ * Read a single file and return its contents.
1727
+ */
1728
+ async function readFile$1(filepath) {
1729
+ const ext = path.extname(filepath).toLowerCase();
1110
1730
  if (ext === ".json") return readJsonFile(filepath);
1111
1731
  else if (ext === ".csv") return readCsvFile(filepath);
1112
1732
  else if (ext === ".jsonl") return readJsonlFile(filepath);
@@ -1118,17 +1738,17 @@ async function readFile(filepath) {
1118
1738
  const loadFromPaths = async (paths, recursive = false) => {
1119
1739
  const files = await collectFiles(paths, recursive);
1120
1740
  if (files.length === 0) {
1121
- logger$5.warn("No supported files found in the specified paths");
1741
+ logger$3.warn("No supported files found in the specified paths");
1122
1742
  return [];
1123
1743
  }
1124
- logger$5.info(`Found ${files.length} file(s) to read`);
1744
+ logger$3.info(`Found ${files.length} file(s) to read`);
1125
1745
  const result = [];
1126
1746
  for (const file of files) try {
1127
- const data = await readFile(file);
1747
+ const data = await readFile$1(file);
1128
1748
  result.push(...data);
1129
- logger$5.info(`Read ${data.length} record(s) from ${file}`);
1749
+ logger$3.info(`Read ${data.length} record(s) from ${file}`);
1130
1750
  } catch (error) {
1131
- logger$5.error(`Error reading file ${file}: ${error instanceof Error ? error.message : String(error)}`);
1751
+ logger$3.error(`Error reading file ${file}: ${errorMessage(error)}`);
1132
1752
  throw error;
1133
1753
  }
1134
1754
  return result;
@@ -1168,7 +1788,7 @@ const writeToFile = async (filepath, data, format) => {
1168
1788
  const dir = path.dirname(filepath);
1169
1789
  await fs_promises.mkdir(dir, { recursive: true });
1170
1790
  const ext = format ?? path.extname(filepath).slice(1);
1171
- if (format && format !== path.extname(filepath).slice(1)) logger$5.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)}`);
1172
1792
  if (ext === "json") await writeJsonFile(filepath, data);
1173
1793
  else if (ext === "csv") await writeCsvFile(filepath, data);
1174
1794
  else if (ext === "jsonl") await writeJsonlFile(filepath, data);
@@ -1193,7 +1813,7 @@ const printToConsole = (data, format = "json") => {
1193
1813
  if (format === "json") console.log(JSON.stringify(data, null, 2));
1194
1814
  else if (format === "csv") {
1195
1815
  if (data.length === 0) {
1196
- logger$5.error("No data to print");
1816
+ logger$3.error("No data to print");
1197
1817
  return;
1198
1818
  }
1199
1819
  console.log(formatCsv(data));
@@ -1201,23 +1821,6 @@ const printToConsole = (data, format = "json") => {
1201
1821
  else throw new Error(`Unsupported output format: ${String(format)}. (supported formats: json, csv, jsonl)`);
1202
1822
  };
1203
1823
  //#endregion
1204
- //#region src/utils/output.ts
1205
- /**
1206
- * Write structured JSON to stdout. Use this for machine-readable output
1207
- * when --json is set.
1208
- */
1209
- function outputJson(data) {
1210
- console.log(JSON.stringify(data));
1211
- }
1212
- /**
1213
- * Write a JSON error to stdout and exit with code 1.
1214
- * Use this in --json mode so agents can parse the failure.
1215
- */
1216
- function outputJsonError(error, exitCode = 1) {
1217
- console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1218
- process.exit(exitCode);
1219
- }
1220
- //#endregion
1221
1824
  //#region src/utils/table.ts
1222
1825
  const DEFAULT_TERMINAL_WIDTH = 80;
1223
1826
  const PADDING_RIGHT = 2;
@@ -1280,9 +1883,14 @@ function renderTable(head, rows) {
1280
1883
  }
1281
1884
  //#endregion
1282
1885
  //#region src/commands/dataset/index.ts
1283
- const logger$4 = initializeLogger();
1886
+ const logger$2 = initializeLogger();
1284
1887
  const DEFAULT_DATASET_PULL_BATCH_SIZE = 100;
1285
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
+ }
1286
1894
  /**
1287
1895
  * Pull all data from a dataset in batches.
1288
1896
  */
@@ -1307,1242 +1915,985 @@ const pullAllData = async (client, identifier, batchSize = DEFAULT_DATASET_PULL_
1307
1915
  return result;
1308
1916
  };
1309
1917
  /**
1310
- * Handle datasets list command.
1918
+ * Handle datasets list command. Pure handler — `withProjectClient` resolves the
1919
+ * client and owns the error envelope.
1311
1920
  */
1312
- const handleDatasetsList = async (options) => {
1313
- const client = new LaminarClient({
1314
- projectApiKey: options.projectApiKey,
1315
- baseUrl: options.baseUrl,
1316
- 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
+ ];
1317
1938
  });
1318
- try {
1319
- const datasets = await client.datasets.listDatasets();
1320
- if (options.json) {
1321
- outputJson(datasets);
1322
- return;
1323
- }
1324
- if (datasets.length === 0) {
1325
- console.log("No datasets found.");
1326
- return;
1327
- }
1328
- const rows = datasets.map((dataset) => {
1329
- const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
1330
- return [
1331
- dataset.id,
1332
- createdAtStr,
1333
- dataset.name
1334
- ];
1335
- });
1336
- console.log(renderTable([
1337
- "ID",
1338
- "Created At",
1339
- "Name"
1340
- ], rows));
1341
- console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
1342
- } catch (error) {
1343
- if (options.json) outputJsonError(error);
1344
- logger$4.error(`Failed to list datasets: ${error instanceof Error ? error.message : String(error)}`);
1345
- process.exit(1);
1346
- }
1939
+ console.log(renderTable([
1940
+ "ID",
1941
+ "Created At",
1942
+ "Name"
1943
+ ], rows));
1944
+ console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
1347
1945
  };
1348
1946
  /**
1349
1947
  * Handle datasets push command.
1350
1948
  */
1351
- const handleDatasetsPush = async (paths, options) => {
1352
- if (!options.name && !options.id) {
1353
- if (options.json) outputJsonError("Either name or id must be provided");
1354
- logger$4.error("Either name or id must be provided");
1355
- process.exit(1);
1356
- }
1357
- if (options.name && options.id) {
1358
- if (options.json) outputJsonError("Only one of name or id must be provided");
1359
- logger$4.error("Only one of name or id must be provided");
1360
- process.exit(1);
1361
- }
1362
- const client = new LaminarClient({
1363
- projectApiKey: options.projectApiKey,
1364
- baseUrl: options.baseUrl,
1365
- 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
1366
1958
  });
1367
- try {
1368
- const data = await loadFromPaths(paths, options.recursive);
1369
- if (data.length === 0) {
1370
- if (options.json) outputJsonError("No data to push");
1371
- logger$4.error("No data to push. Skipping");
1372
- process.exit(1);
1373
- }
1374
- const identifier = options.name ? { name: options.name } : { id: options.id };
1375
- const result = await client.datasets.push({
1376
- points: data,
1377
- ...identifier,
1378
- batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
1959
+ if (opts.json) {
1960
+ outputJson({
1961
+ datasetId: result?.datasetId,
1962
+ count: data.length
1379
1963
  });
1380
- if (options.json) {
1381
- outputJson({
1382
- datasetId: result?.datasetId,
1383
- count: data.length
1384
- });
1385
- return;
1386
- }
1387
- logger$4.info(`Pushed ${data.length} data points to dataset ${options.name || options.id}`);
1388
- } catch (error) {
1389
- if (options.json) outputJsonError(error);
1390
- logger$4.error(`Failed to push dataset: ${error instanceof Error ? error.message : String(error)}`);
1391
- process.exit(1);
1964
+ return;
1392
1965
  }
1966
+ logger$2.info(`Pushed ${data.length} data points to dataset ${opts.name || opts.id}`);
1393
1967
  };
1394
1968
  /**
1395
1969
  * Handle datasets pull command.
1396
1970
  */
1397
- const handleDatasetsPull = async (outputPath, options) => {
1398
- if (!options.name && !options.id) {
1399
- if (options.json) outputJsonError("Either name or id must be provided");
1400
- logger$4.error("Either name or id must be provided");
1401
- process.exit(1);
1402
- }
1403
- if (options.name && options.id) {
1404
- if (options.json) outputJsonError("Only one of name or id must be provided");
1405
- logger$4.error("Only one of name or id must be provided");
1406
- process.exit(1);
1407
- }
1408
- const client = new LaminarClient({
1409
- projectApiKey: options.projectApiKey,
1410
- baseUrl: options.baseUrl,
1411
- port: options.port
1412
- });
1413
- const identifier = options.name ? { name: options.name } : { id: options.id };
1414
- try {
1415
- const result = await pullAllData(client, identifier, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, options.offset ?? 0, options.limit);
1416
- if (outputPath) {
1417
- await writeToFile(outputPath, result, options.outputFormat);
1418
- if (options.json) outputJson({
1419
- path: outputPath,
1420
- count: result.length
1421
- });
1422
- else logger$4.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
1423
- } else if (options.json) outputJson(result);
1424
- else printToConsole(result, options.outputFormat ?? "json");
1425
- } catch (error) {
1426
- if (options.json) outputJsonError(error);
1427
- logger$4.error(`Failed to pull dataset: ${error instanceof Error ? error.message : String(error)}`);
1428
- process.exit(1);
1429
- }
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");
1430
1983
  };
1431
1984
  /**
1432
1985
  * Handle datasets create command.
1433
1986
  */
1434
- const handleDatasetsCreate = async (name, paths, options) => {
1435
- const client = new LaminarClient({
1436
- projectApiKey: options.projectApiKey,
1437
- baseUrl: options.baseUrl,
1438
- 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
1439
1996
  });
1440
- try {
1441
- const data = await loadFromPaths(paths, options.recursive);
1442
- if (data.length === 0) {
1443
- if (options.json) outputJsonError("No data to push");
1444
- logger$4.error("No data to push. Skipping");
1445
- process.exit(1);
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}`);
2007
+ };
2008
+ //#endregion
2009
+ //#region src/utils/trace-note.ts
2010
+ const NOTE_METADATA_KEY = "rollout.note";
2011
+ /**
2012
+ * Normalize a user-supplied trace id (UUID or 32-char OTel hex, optionally
2013
+ * 0x-prefixed) to the dashed UUID form used by the SQL endpoint. Throws on
2014
+ * anything else so a typo fails loudly instead of querying nothing.
2015
+ */
2016
+ const normalizeTraceId = (traceId) => {
2017
+ const id = traceId.trim().toLowerCase().replace(/^0x/, "");
2018
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) return id;
2019
+ if (/^[0-9a-f]{32}$/.test(id)) return id.replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
2020
+ throw new Error(`Invalid trace id "${traceId}". Expected a UUID or a 32-char OTel hex trace id.`);
2021
+ };
2022
+ /**
2023
+ * Extract the note from a trace's `metadata` column as returned by the SQL
2024
+ * endpoint (a JSON string; tolerate an already-parsed object too). Missing /
2025
+ * malformed metadata reads as "no note".
2026
+ */
2027
+ const readNoteFromMetadata = (metadata) => {
2028
+ let parsed = metadata;
2029
+ if (typeof metadata === "string") {
2030
+ if (metadata === "") return "";
2031
+ try {
2032
+ parsed = JSON.parse(metadata);
2033
+ } catch {
2034
+ return "";
1446
2035
  }
1447
- logger$4.info(`Pushing ${data.length} data points to dataset '${name}'...`);
1448
- await client.datasets.push({
1449
- points: data,
1450
- name,
1451
- batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
1452
- createDataset: true
1453
- });
1454
- logger$4.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
1455
- } catch (error) {
1456
- if (options.json) outputJsonError(error);
1457
- logger$4.error(`Failed to create dataset: ${error instanceof Error ? error.message : String(error)}`);
1458
- process.exit(1);
1459
- }
1460
- logger$4.info(`Pulling data from dataset '${name}'...`);
1461
- try {
1462
- const result = await pullAllData(client, { name }, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
1463
- await writeToFile(options.outputFile, result, options.outputFormat);
1464
- if (options.json) outputJson({
1465
- name,
1466
- path: options.outputFile,
1467
- count: result.length
1468
- });
1469
- else logger$4.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${options.outputFile}`);
1470
- } catch (error) {
1471
- if (options.json) outputJsonError(error);
1472
- logger$4.error(`Failed to pull dataset after creation: ${error instanceof Error ? error.message : String(error)}`);
1473
- process.exit(1);
1474
2036
  }
2037
+ if (typeof parsed !== "object" || parsed === null) return "";
2038
+ const note = parsed[NOTE_METADATA_KEY];
2039
+ return typeof note === "string" ? note : "";
1475
2040
  };
1476
2041
  //#endregion
1477
- //#region src/cache-server.ts
1478
- const DEFAULT_START_PORT = 35667;
2042
+ //#region src/commands/debug/index.ts
2043
+ const logger$1 = initializeLogger();
1479
2044
  /**
1480
- * Finds an available port starting from the given port number
2045
+ * Upsert the display name of a debug session. Update-only on the backend: a
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.
1481
2051
  */
1482
- async function findAvailablePort(startPort) {
1483
- return new Promise((resolve, reject) => {
1484
- const server = http.createServer();
1485
- server.listen(startPort, () => {
1486
- const port = server.address().port;
1487
- server.close(() => resolve(port));
1488
- });
1489
- server.on("error", (err) => {
1490
- if (err.code === "EADDRINUSE") resolve(findAvailablePort(startPort + 1));
1491
- else reject(err);
1492
- });
2052
+ const handleDebugSessionSetName = async (client, sessionId, name, opts) => {
2053
+ await client.rolloutSessions.setName({
2054
+ sessionId,
2055
+ name
1493
2056
  });
1494
- }
2057
+ if (opts.json) {
2058
+ outputJson({
2059
+ sessionId,
2060
+ name
2061
+ });
2062
+ return;
2063
+ }
2064
+ logger$1.info(`Set name of session ${sessionId} to "${name}".`);
2065
+ };
1495
2066
  /**
1496
- * Parses request body as JSON
2067
+ * Print a per-trace summary of a debug session: every trace whose metadata
2068
+ * groups it to the session (`rollout.session_id`), oldest first, with the
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.
1497
2074
  */
1498
- function parseBody(req) {
1499
- return new Promise((resolve, reject) => {
1500
- let body = "";
1501
- req.on("data", (chunk) => {
1502
- body += chunk.toString();
1503
- });
1504
- req.on("end", () => {
1505
- try {
1506
- resolve(body ? JSON.parse(body) : {});
1507
- } catch (err) {
1508
- reject(/* @__PURE__ */ new Error(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`));
1509
- }
1510
- });
1511
- req.on("error", reject);
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;
1512
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);
1513
2101
  }
1514
- /**
1515
- * Starts a local cache server for storing and retrieving cached LLM responses
1516
- * during rollout debugging sessions.
1517
- *
1518
- * @param startPort - Optional starting port number (defaults to 35667)
1519
- * @returns Server information including port, server instance, cache, and metadata setter
1520
- */
1521
- async function startCacheServer(startPort = DEFAULT_START_PORT) {
1522
- const cache = /* @__PURE__ */ new Map();
1523
- let metadata = {
1524
- pathToCount: {},
1525
- overrides: void 0
1526
- };
1527
- const server = http.createServer((req, res) => {
1528
- (async () => {
1529
- res.setHeader("Access-Control-Allow-Origin", "*");
1530
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1531
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1532
- if (req.method === "OPTIONS") {
1533
- res.writeHead(200);
1534
- res.end();
1535
- return;
1536
- }
1537
- if (req.method === "GET" && req.url === "/health") {
1538
- res.writeHead(200, { "Content-Type": "application/json" });
1539
- res.end(JSON.stringify({ status: "ok" }));
1540
- return;
1541
- }
1542
- if (req.method === "POST" && req.url === "/cached") {
1543
- try {
1544
- const { path, index } = await parseBody(req);
1545
- if (typeof path !== "string" || typeof index !== "number") {
1546
- res.writeHead(400, { "Content-Type": "application/json" });
1547
- res.end(JSON.stringify({ error: "Invalid request: path (string) and index (number) required" }));
1548
- return;
1549
- }
1550
- const cacheKey = `${index}:${path}`;
1551
- const response = {
1552
- span: cache.get(cacheKey),
1553
- pathToCount: metadata.pathToCount,
1554
- overrides: metadata.overrides
1555
- };
1556
- res.writeHead(200, { "Content-Type": "application/json" });
1557
- res.end(JSON.stringify(response));
1558
- } catch (err) {
1559
- res.writeHead(400, { "Content-Type": "application/json" });
1560
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1561
- }
1562
- return;
1563
- }
1564
- res.writeHead(404, { "Content-Type": "application/json" });
1565
- res.end(JSON.stringify({ error: "Not found" }));
1566
- })().catch((error) => {
1567
- if (!res.headersSent) {
1568
- res.writeHead(500, { "Content-Type": "application/json" });
1569
- res.end(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }));
1570
- }
1571
- });
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
1572
2121
  });
1573
- const port = await findAvailablePort(startPort);
1574
- return new Promise((resolve, reject) => {
1575
- server.listen(port, () => {
1576
- resolve({
1577
- port,
1578
- server,
1579
- cache,
1580
- setMetadata: (newMetadata) => {
1581
- metadata = newMetadata;
1582
- }
1583
- });
1584
- });
1585
- server.on("error", reject);
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
1586
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 "";
1587
2147
  }
1588
2148
  //#endregion
1589
- //#region src/sse-client.ts
1590
- const HEARTBEAT_INTERVAL = 5e3;
1591
- const MAX_MISSED_HEARTBEATS = 3;
1592
- /**
1593
- * SSE client for rollout debugging sessions
1594
- * Connects to the Laminar backend and listens for run events
1595
- */
1596
- var SSEClient = class extends events.EventEmitter {
1597
- constructor(options) {
1598
- super();
1599
- this.lastHeartbeat = Date.now();
1600
- this.isShutdown = false;
1601
- this.client = options.client;
1602
- this.sessionId = options.sessionId;
1603
- this.params = options.params;
1604
- this.name = options.name;
1605
- }
1606
- /**
1607
- * Connects to the SSE endpoint
1608
- */
1609
- async connectAndListen() {
1610
- if (this.isShutdown) return;
1611
- this.abortController = new AbortController();
1612
- this.lastHeartbeat = Date.now();
1613
- try {
1614
- const response = await this.client.rolloutSessions.connect({
1615
- sessionId: this.sessionId,
1616
- params: this.params,
1617
- signal: this.abortController.signal,
1618
- name: this.name
1619
- });
1620
- this.emit("connected");
1621
- this.startHeartbeatCheck();
1622
- await this.parseSSEStream(response.body);
1623
- } catch (error) {
1624
- if (error.name === "AbortError") return;
1625
- this.emit("error", error);
1626
- if (!this.isShutdown) this.scheduleReconnect();
1627
- }
1628
- }
1629
- /**
1630
- * Parses SSE stream and emits events
1631
- */
1632
- async parseSSEStream(body) {
1633
- const reader = body.getReader();
1634
- const decoder = new TextDecoder();
1635
- const parser = (0, eventsource_parser.createParser)({ onEvent: (event) => {
1636
- this.processSSEEvent(event);
1637
- } });
1638
- try {
1639
- while (true) {
1640
- const { done, value } = await reader.read();
1641
- if (done) break;
1642
- const chunk = decoder.decode(value, { stream: true });
1643
- parser.feed(chunk);
1644
- }
1645
- } finally {
1646
- reader.releaseLock();
1647
- }
1648
- if (!this.isShutdown) this.scheduleReconnect();
1649
- }
1650
- /**
1651
- * Processes a parsed SSE event
1652
- */
1653
- processSSEEvent(event) {
1654
- if (!event.data) return;
1655
- try {
1656
- if (event.event === "heartbeat") {
1657
- this.lastHeartbeat = Date.now();
1658
- this.emit("heartbeat");
1659
- } else if (event.event === "run") {
1660
- const runEvent = {
1661
- event_type: "run",
1662
- data: JSON.parse(event.data)
1663
- };
1664
- this.emit("run", runEvent);
1665
- } else if (event.event === "handshake") {
1666
- const handshakeEvent = {
1667
- event_type: "handshake",
1668
- data: JSON.parse(event.data)
1669
- };
1670
- this.emit("handshake", handshakeEvent);
1671
- } else if (event.event === "stop") this.emit("stop");
1672
- } catch (error) {
1673
- this.emit("error", /* @__PURE__ */ new Error(`Failed to parse SSE event data: ${error}`));
1674
- }
1675
- }
1676
- /**
1677
- * Starts checking for missed heartbeats
1678
- */
1679
- startHeartbeatCheck() {
1680
- this.stopHeartbeatCheck();
1681
- this.heartbeatCheckTimer = setInterval(() => {
1682
- if (Date.now() - this.lastHeartbeat > HEARTBEAT_INTERVAL * MAX_MISSED_HEARTBEATS) {
1683
- this.emit("heartbeat_timeout");
1684
- this.reconnect();
1685
- }
1686
- }, HEARTBEAT_INTERVAL);
1687
- }
1688
- /**
1689
- * Stops heartbeat checking
1690
- */
1691
- stopHeartbeatCheck() {
1692
- if (this.heartbeatCheckTimer) {
1693
- clearInterval(this.heartbeatCheckTimer);
1694
- this.heartbeatCheckTimer = void 0;
1695
- }
1696
- }
1697
- /**
1698
- * Schedules a reconnection attempt
1699
- */
1700
- scheduleReconnect() {
1701
- if (this.reconnectTimer || this.isShutdown) return;
1702
- this.emit("reconnecting");
1703
- this.reconnectTimer = setTimeout(() => {
1704
- this.reconnectTimer = void 0;
1705
- this.reconnect();
1706
- }, 1e3);
1707
- }
1708
- /**
1709
- * Reconnects to the SSE endpoint
1710
- */
1711
- reconnect() {
1712
- this.disconnect(true);
1713
- this.connectAndListen().catch((error) => {
1714
- this.emit("error", error);
1715
- });
1716
- }
1717
- /**
1718
- * Disconnects from the SSE endpoint
1719
- */
1720
- disconnect(stopReconnect = true) {
1721
- if (this.abortController) {
1722
- this.abortController.abort();
1723
- this.abortController = void 0;
1724
- }
1725
- this.stopHeartbeatCheck();
1726
- if (stopReconnect && this.reconnectTimer) {
1727
- clearTimeout(this.reconnectTimer);
1728
- this.reconnectTimer = void 0;
1729
- }
1730
- }
1731
- /**
1732
- * Updates the function metadata (params, name) and reconnects
1733
- */
1734
- updateMetadata(params, name) {
1735
- this.params = params;
1736
- this.name = name;
1737
- this.reconnect();
1738
- }
1739
- /**
1740
- * Shuts down the SSE client gracefully
1741
- */
1742
- shutdown() {
1743
- this.isShutdown = true;
1744
- this.disconnect(true);
1745
- this.emit("shutdown");
1746
- this.removeAllListeners();
1747
- }
1748
- };
2149
+ //#region src/commands/logout/index.ts
1749
2150
  /**
1750
- * Creates an SSE client (does not auto-connect)
1751
- * Call client.connect() after registering event listeners
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.
1752
2155
  */
1753
- function createSSEClient(options) {
1754
- return new SSEClient(options);
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(/\/+$/, "");
1755
2188
  }
1756
2189
  //#endregion
1757
- //#region src/subprocess/executor.ts
1758
- const logger$3 = initializeLogger();
2190
+ //#region src/commands/project/index.ts
1759
2191
  /**
1760
- * Track and kill the currently running subprocess
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.
1761
2195
  */
1762
- var SubprocessManager = class {
1763
- constructor() {
1764
- this.currentProcess = null;
1765
- }
1766
- /**
1767
- * Execute a subprocess and track it
1768
- */
1769
- async execute(options) {
1770
- const { command, args, config } = options;
1771
- const child = (0, child_process.spawn)(command, args, { stdio: [
1772
- "pipe",
1773
- "pipe",
1774
- "pipe"
1775
- ] });
1776
- this.currentProcess = child;
1777
- return new Promise((resolve, reject) => {
1778
- const result = void 0;
1779
- let hasError = false;
1780
- readline.createInterface({
1781
- input: child.stdout,
1782
- crlfDelay: Infinity
1783
- }).on("line", (line) => {
1784
- if (line.startsWith("__LMNR_WORKER__:")) try {
1785
- const messageJson = line.substring(16);
1786
- const message = JSON.parse(messageJson);
1787
- switch (message.type) {
1788
- case "log":
1789
- logger$3[message.level](message.message);
1790
- break;
1791
- case "error":
1792
- hasError = true;
1793
- logger$3.error(`Worker error: ${message.error}`);
1794
- if (message.stack) logger$3.error(message.stack);
1795
- break;
1796
- }
1797
- } catch {
1798
- logger$3.debug("Failed to parse worker protocol message. Printing raw line");
1799
- console.log(line.substring(16));
1800
- }
1801
- else console.log(line);
1802
- });
1803
- child.stderr.on("data", (data) => {
1804
- process.stderr.write(data);
1805
- });
1806
- child.on("exit", (code, signal) => {
1807
- if (this.currentProcess?.pid === child.pid) this.currentProcess = null;
1808
- if (signal) reject(/* @__PURE__ */ new Error(`Worker terminated by signal: ${signal}`));
1809
- else if (code === 0) resolve(result);
1810
- else {
1811
- if (!hasError) logger$3.error(`Worker exited with code ${code}`);
1812
- reject(/* @__PURE__ */ new Error(`Worker exited with code ${code}`));
1813
- }
1814
- });
1815
- child.on("error", (error) => {
1816
- this.currentProcess = null;
1817
- reject(/* @__PURE__ */ new Error(`Failed to spawn worker: ${error.message}`));
1818
- });
1819
- child.stdin?.write(JSON.stringify(config) + "\n");
1820
- child.stdin?.end();
1821
- });
1822
- }
1823
- /**
1824
- * Kill the currently running subprocess
1825
- * @returns true if a process was killed, false if no process was running
1826
- */
1827
- kill() {
1828
- if (this.currentProcess) {
1829
- const processToKill = this.currentProcess;
1830
- this.currentProcess.kill("SIGTERM");
1831
- setTimeout(() => {
1832
- if (processToKill && processToKill.exitCode === null) {
1833
- logger$3.warn("Child process did not terminate, using SIGKILL");
1834
- processToKill.kill("SIGKILL");
1835
- }
1836
- }, 5e3);
1837
- return true;
1838
- }
1839
- return false;
1840
- }
1841
- /**
1842
- * Check if a subprocess is currently running
1843
- */
1844
- isRunning() {
1845
- return this.currentProcess !== null;
1846
- }
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");
1847
2224
  };
1848
2225
  //#endregion
1849
- //#region src/worker-registry.ts
1850
- /**
1851
- * Default workers mapped by file extension
1852
- */
1853
- const DEFAULT_WORKERS = {
1854
- ".ts": {
1855
- command: "node",
1856
- args: []
1857
- },
1858
- ".cts": {
1859
- command: "node",
1860
- args: []
1861
- },
1862
- ".mts": {
1863
- command: "node",
1864
- args: []
1865
- },
1866
- ".tsx": {
1867
- command: "node",
1868
- args: []
1869
- },
1870
- ".jsx": {
1871
- command: "node",
1872
- args: []
1873
- },
1874
- ".js": {
1875
- command: "node",
1876
- args: []
1877
- },
1878
- ".mjs": {
1879
- command: "node",
1880
- args: []
1881
- },
1882
- ".cjs": {
1883
- command: "node",
1884
- args: []
1885
- },
1886
- ".py": {
1887
- command: "python3",
1888
- args: ["-m", "lmnr.cli.worker"]
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
+ };
1889
2281
  }
1890
- };
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
+ }
1891
2311
  /**
1892
- * Get the worker command for a given file path or module.
1893
- * Resolves the TypeScript worker dynamically from @lmnr-ai/lmnr package.
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.
1894
2315
  */
1895
- function getWorkerCommand(filePath, options) {
1896
- if (options?.pythonModule) return {
1897
- command: "python3",
1898
- args: ["-m", "lmnr.cli.worker"]
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" }
1899
2321
  };
1900
- if (!filePath) throw new Error("Either filePath or pythonModule must be provided");
1901
- const ext = path.extname(filePath);
1902
- if (!DEFAULT_WORKERS[ext]) throw new Error(`Unsupported file extension: ${ext}. Supported extensions: ${Object.keys(DEFAULT_WORKERS).join(", ")}`);
1903
- const worker = DEFAULT_WORKERS[ext];
1904
- if ([
1905
- ".ts",
1906
- ".tsx",
1907
- ".js",
1908
- ".mjs",
1909
- ".cjs",
1910
- ".mts",
1911
- ".cts",
1912
- ".jsx"
1913
- ].includes(ext)) try {
1914
- const workerPath = require.resolve("@lmnr-ai/lmnr/dist/cli/worker/index.cjs");
1915
- return {
1916
- command: worker.command,
1917
- args: [workerPath]
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
+ }
1918
2331
  };
1919
- } catch (error) {
1920
- throw new Error(`Failed to resolve TypeScript/JavaScript worker from @lmnr-ai/lmnr package. Make sure @lmnr-ai/lmnr is installed. Error: ${error instanceof Error ? error.message : String(error)}`);
1921
2332
  }
1922
- return worker;
2333
+ return null;
1923
2334
  }
1924
- //#endregion
1925
- //#region src/commands/dev/metadata.ts
1926
- const logger$2 = initializeLogger();
1927
- const TS_JS_EXTENSIONS = [
1928
- ".ts",
1929
- ".tsx",
1930
- ".js",
1931
- ".mjs",
1932
- ".cjs",
1933
- ".jsx",
1934
- ".mts",
1935
- ".cts"
1936
- ];
1937
- const EXTENSIONS_TO_DISCOVER_METADATA = [...TS_JS_EXTENSIONS, ".py"];
1938
2335
  /**
1939
- * Protocol prefix for metadata discovery responses
1940
- * This allows us to safely parse JSON even if there are other log statements in stdout
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.
1941
2345
  */
1942
- const METADATA_PROTOCOL_PREFIX = "LMNR_METADATA:";
1943
- const logLmnrPackageNotFoundAndExit = () => {
1944
- logger$2.error("@lmnr-ai/lmnr package not found or outdated. For JS/TS projects, please install the latest version of @lmnr-ai/lmnr in your project: npm install @lmnr-ai/lmnr\nYou might need to run `lmnr-cli` from the root of your project");
1945
- process.exit(1);
1946
- };
2346
+ const AUTOLOADED_ENV_KEYS = [
2347
+ "LMNR_BASE_URL",
2348
+ "LMNR_HTTP_PORT",
2349
+ "LMNR_FRONTEND_URL"
2350
+ ];
1947
2351
  /**
1948
- * Discovers function metadata for TypeScript and JavaScript files by:
1949
- * 1. Extracting TypeScript metadata (params with types from source - TS only)
1950
- * 2. Building and loading the module with esbuild
1951
- * 3. Selecting the appropriate function
1952
- * 4. Matching metadata by span name
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).
1953
2356
  *
1954
- * For JavaScript files, TypeScript metadata extraction fails gracefully, but runtime
1955
- * parameter extraction via regex still works (param names without types).
1956
- */
1957
- const discoverTypeScriptMetadata = async (filePath, options) => {
1958
- let extractRolloutFunctions;
1959
- let buildFile;
1960
- let loadModule;
1961
- let selectRolloutFunction;
1962
- try {
1963
- const lmnrPackage = "@lmnr-ai/lmnr";
1964
- const tsParserPath = require.resolve(`${lmnrPackage}/dist/cli/worker/ts-parser.cjs`);
1965
- const buildModulePath = require.resolve(`${lmnrPackage}/dist/cli/worker/build.cjs`);
1966
- delete require.cache[tsParserPath];
1967
- delete require.cache[buildModulePath];
1968
- extractRolloutFunctions = require(tsParserPath).extractRolloutFunctions;
1969
- const buildModule = require(buildModulePath);
1970
- buildFile = buildModule.buildFile;
1971
- loadModule = buildModule.loadModule;
1972
- selectRolloutFunction = buildModule.selectRolloutFunction;
1973
- if (!extractRolloutFunctions || !buildFile || !loadModule || !selectRolloutFunction) {
1974
- logger$2.error("Missing exports from @lmnr-ai/lmnr modules. This may indicate an outdated package version.");
1975
- logLmnrPackageNotFoundAndExit();
1976
- }
1977
- } catch (error) {
1978
- if (error.code === "MODULE_NOT_FOUND") logLmnrPackageNotFoundAndExit();
1979
- logger$2.error(`Unexpected error loading @lmnr-ai/lmnr modules: ${error.message}`);
1980
- throw error;
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;
1981
2367
  }
1982
- let paramsMetadata;
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) {
1983
2388
  try {
1984
- paramsMetadata = extractRolloutFunctions(filePath);
1985
- logger$2.debug(`Extracted TypeScript metadata for ${paramsMetadata?.size} functions`);
1986
- } catch (error) {
1987
- logger$2.warn("Failed to extract TypeScript metadata, falling back to runtime parsing: " + (error instanceof Error ? error.message : String(error)));
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;
1988
2397
  }
1989
- const moduleText = await buildFile(filePath, {
1990
- externalPackages: options.externalPackages,
1991
- dynamicImportsToSkip: options.dynamicImportsToSkip
1992
- });
1993
- loadModule({
1994
- filename: filePath,
1995
- moduleText
1996
- });
1997
- const selectedFunction = selectRolloutFunction(options.function);
1998
- if (paramsMetadata) {
1999
- logger$2.debug(`Available TS metadata keys: ${Array.from(paramsMetadata.keys()).join(", ")}`);
2000
- logger$2.debug(`Looking for span name: ${selectedFunction.name} (runtime key: ${selectedFunction.exportName})`);
2001
- let foundMetadata = null;
2002
- for (const [exportName, metadata] of paramsMetadata.entries()) {
2003
- logger$2.debug(`Checking ${exportName}: span name = ${metadata.name}, export name = ${exportName}`);
2004
- if (metadata.name === selectedFunction.name) {
2005
- foundMetadata = metadata;
2006
- logger$2.debug(`Match. Export name: ${exportName}, span name: ${metadata.name}`);
2007
- break;
2008
- }
2009
- }
2010
- if (foundMetadata) {
2011
- selectedFunction.params = foundMetadata.params;
2012
- logger$2.debug(`Using TypeScript metadata for span: ${selectedFunction.name}`);
2013
- } else logger$2.info(`No TypeScript metadata found for span name: ${selectedFunction.name}`);
2398
+ }
2399
+ async function fileExists(path) {
2400
+ try {
2401
+ await (0, node_fs_promises.access)(path);
2402
+ return true;
2403
+ } catch {
2404
+ return false;
2014
2405
  }
2015
- return {
2016
- functionName: selectedFunction.name,
2017
- params: selectedFunction.params || []
2018
- };
2019
- };
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";
2020
2420
  /**
2021
- * Helper to execute subprocess commands
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.
2022
2425
  */
2023
- const execCommand = async (command, args) => new Promise((resolve, reject) => {
2024
- const { spawn } = require("child_process");
2025
- const child = spawn(command, args);
2026
- let stdout = "";
2027
- let stderr = "";
2028
- child.stdout.on("data", (data) => {
2029
- stdout += data.toString();
2030
- });
2031
- child.stderr.on("data", (data) => {
2032
- stderr += data.toString();
2033
- });
2034
- child.on("close", (code) => {
2035
- if (code === 0) resolve({
2036
- stdout,
2037
- stderr
2038
- });
2039
- else reject(/* @__PURE__ */ new Error(`Command failed with code ${code}: ${stderr}`));
2040
- });
2041
- child.on("error", (error) => {
2042
- reject(error);
2043
- });
2044
- });
2426
+ function skillSource() {
2427
+ return `github:${SKILL_REPO}/skills/${SKILL_NAME}#${SKILL_REF}`;
2428
+ }
2429
+ //#endregion
2430
+ //#region src/skill/fetch-skill.ts
2045
2431
  /**
2046
- * Extracts JSON metadata from stdout that may contain other log statements
2047
- * Looks for lines matching the protocol prefix and parses the JSON payload
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.
2048
2437
  *
2049
- * @param stdout - Raw stdout output that may contain logs and metadata
2050
- * @returns Parsed JSON object
2051
- * @throws Error if no valid metadata line is found or JSON parsing fails
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.
2052
2446
  */
2053
- const extractMetadataFromStdout = (stdout) => {
2054
- const prefixPositions = [];
2055
- let searchStart = 0;
2056
- while (true) {
2057
- const pos = stdout.indexOf(METADATA_PROTOCOL_PREFIX, searchStart);
2058
- if (pos === -1) break;
2059
- prefixPositions.push(pos);
2060
- searchStart = pos + 14;
2061
- }
2062
- if (prefixPositions.length === 0) try {
2063
- return JSON.parse(stdout.trim());
2064
- } catch {
2065
- throw new Error("No metadata found in output. Please make sure you are running the latest version of `lmnr` python package.");
2066
- }
2067
- let lastValidJson = null;
2068
- for (const pos of prefixPositions) {
2069
- const startPos = pos + 14;
2070
- const jsonText = stdout.slice(startPos).trim();
2071
- const nextNewline = stdout.indexOf("\n", startPos);
2072
- if (nextNewline !== -1) {
2073
- const lineText = stdout.slice(startPos, nextNewline).trim();
2074
- try {
2075
- lastValidJson = JSON.parse(lineText);
2076
- continue;
2077
- } catch {}
2078
- }
2079
- try {
2080
- let depth = 0;
2081
- let inString = false;
2082
- let escapeNext = false;
2083
- let firstChar = -1;
2084
- for (let i = 0; i < jsonText.length; i++) {
2085
- const char = jsonText[i];
2086
- if (escapeNext) {
2087
- escapeNext = false;
2088
- continue;
2089
- }
2090
- if (char === "\\" && inString) {
2091
- escapeNext = true;
2092
- continue;
2093
- }
2094
- if (char === "\"") {
2095
- inString = !inString;
2096
- continue;
2097
- }
2098
- if (inString) continue;
2099
- if (char === "{" || char === "[") {
2100
- if (firstChar === -1) firstChar = i;
2101
- depth++;
2102
- } else if (char === "}" || char === "]") {
2103
- depth--;
2104
- if (depth === 0 && firstChar !== -1) {
2105
- const candidate = jsonText.slice(0, i + 1);
2106
- lastValidJson = JSON.parse(candidate);
2107
- break;
2108
- }
2109
- }
2110
- }
2111
- if (depth !== 0 || firstChar === -1) lastValidJson = JSON.parse(jsonText);
2112
- } catch {
2113
- continue;
2114
- }
2115
- }
2116
- if (lastValidJson === null) throw new Error("No valid metadata JSON found in output. Please make sure you are running the latest version of `lmnr` python package.");
2117
- return lastValidJson;
2118
- };
2447
+ async function downloadSkill(dir) {
2448
+ await (0, giget.downloadTemplate)(skillSource(), {
2449
+ dir,
2450
+ force: true
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"];
2119
2462
  /**
2120
- * Discovers function metadata for Python files/modules by calling the lmnr Python CLI
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.
2121
2477
  */
2122
- const discoverPythonMetadata = async (filePathOrModule, options) => {
2123
- logger$2.debug(`Discovering Python metadata for ${filePathOrModule}`);
2124
- const args = ["discover"];
2125
- if (options.pythonModule) args.push("--module", options.pythonModule);
2126
- else args.push("--file", filePathOrModule);
2127
- if (options.function) args.push("--function", options.function);
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-"));
2128
2480
  try {
2129
- const response = extractMetadataFromStdout((await execCommand("lmnr", args)).stdout);
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
2504
+ });
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
+ }
2130
2509
  return {
2131
- functionName: response.name,
2132
- params: response.params || []
2510
+ written,
2511
+ defaulted: present.length === 0,
2512
+ skipped: false
2133
2513
  };
2134
- } catch (error) {
2135
- const errorMessage = error instanceof Error ? error.message : String(error);
2136
- logger$2.error(`Error while loading Python file/module: ${errorMessage}`);
2137
- if (errorMessage.toLowerCase().includes("command not found") || errorMessage.includes("spawn lmnr ENOENT")) logger$2.info(`HINT: Make sure latest version of \`lmnr\` python package is installed. \`pip install --upgrade lmnr\`, or if you are running this command from a virtual environment, make sure to activate it. For \`uv\` users, rerun the command with \`uv run\`, e.g. "uv run npx lmnr-cli dev ${filePathOrModule}"`);
2138
- throw error;
2514
+ } finally {
2515
+ await (0, node_fs_promises.rm)(staging, {
2516
+ recursive: true,
2517
+ force: true
2518
+ });
2139
2519
  }
2140
- };
2141
- /**
2142
- * Generic metadata discovery dispatcher that routes to language-specific implementations
2143
- */
2144
- const discoverFunctionMetadata = async (filePathOrModule, options) => {
2145
- if (options.pythonModule) return await discoverPythonMetadata(filePathOrModule, options);
2146
- const ext = path.extname(filePathOrModule);
2147
- if (TS_JS_EXTENSIONS.includes(ext)) return await discoverTypeScriptMetadata(filePathOrModule, options);
2148
- if (ext === ".py") return await discoverPythonMetadata(filePathOrModule, options);
2149
- logger$2.warn(`No metadata discovery available for ${ext} files`);
2150
- return {
2151
- functionName: options.function || path.basename(filePathOrModule, ext),
2152
- params: []
2153
- };
2154
- };
2155
- //#endregion
2156
- //#region src/commands/dev/index.ts
2157
- const logger$1 = initializeLogger();
2158
- function newUUID() {
2159
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
2160
- return (0, uuid.v4)();
2161
2520
  }
2162
- function getFrontendUrl(baseUrl, frontendPort) {
2163
- let url = baseUrl ?? "https://api.lmnr.ai";
2164
- if (url === "https://api.lmnr.ai") url = "https://www.laminar.sh";
2165
- url = url.replace(/\/$/, "");
2166
- if (/localhost|127\.0\.0\.1/.test(url)) {
2167
- const port = frontendPort ?? url.match(/:\d{1,5}$/g)?.[0]?.slice(1) ?? 5667;
2168
- url = url.replace(/:\d{1,5}$/g, "");
2169
- return `${url}:${port}`;
2521
+ async function dirExists(path) {
2522
+ try {
2523
+ await (0, node_fs_promises.access)(path);
2524
+ return true;
2525
+ } catch {
2526
+ return false;
2170
2527
  }
2171
- return url;
2172
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;
2173
2544
  /**
2174
- * Parses request arguments, attempting JSON parse for strings
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).
2175
2558
  */
2176
- const tryParseArg = (arg) => {
2177
- if (typeof arg === "string") try {
2178
- return JSON.parse(arg);
2179
- } catch {
2180
- return arg;
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
2575
+ });
2576
+ } catch (err) {
2577
+ emitError(isJson, "login_failed", describeError(err));
2578
+ process.exit(EXIT_LOGIN_FAILED);
2579
+ }
2580
+ creds = await safeReadCredentials();
2581
+ if (!creds) {
2582
+ emitError(isJson, "login_failed", "credentials missing after login");
2583
+ process.exit(EXIT_LOGIN_FAILED);
2584
+ }
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
+ }
2656
+ }
2181
2657
  }
2182
- return arg;
2183
- };
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
+ }
2184
2693
  /**
2185
- * Handles a run event from the backend
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.
2186
2698
  */
2187
- const handleRunEvent = async (event, sessionId, filePathOrModule, client, cacheServerPort, cache, setMetadata, options, subprocessManager) => {
2188
- logger$1.debug("Received run event");
2189
- const { trace_id, path_to_count, args: rawArgs, overrides } = event.data;
2190
- const parsedArgs = Array.isArray(rawArgs) ? rawArgs.map(tryParseArg) : Object.fromEntries(Object.entries(rawArgs).map(([key, value]) => [key, tryParseArg(value)]));
2191
- cache.clear();
2192
- setMetadata({
2193
- pathToCount: {},
2194
- overrides
2195
- });
2699
+ async function resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options) {
2700
+ let projects;
2196
2701
  try {
2197
- if (!trace_id || trace_id.trim() === "") logger$1.info("No spans in cache, starting fresh");
2198
- else {
2199
- const paths = Object.keys(path_to_count || {});
2200
- if (paths.length === 0) logger$1.info("No spans to cache, starting fresh");
2201
- else {
2202
- const query = `
2203
- SELECT name, input, output, attributes, path
2204
- FROM spans
2205
- WHERE trace_id = {traceId:UUID}
2206
- AND path IN {paths:String[]}
2207
- ORDER BY start_time ASC
2208
- `;
2209
- logger$1.debug(`Querying spans from trace ${trace_id}...`);
2210
- const spans = await client.sql.query(query, {
2211
- traceId: trace_id,
2212
- paths
2213
- });
2214
- logger$1.debug(`Received ${spans.length} spans from backend`);
2215
- const spansByPath = {};
2216
- for (const span of spans) {
2217
- const path$2 = span.path;
2218
- if (!spansByPath[path$2]) spansByPath[path$2] = [];
2219
- spansByPath[path$2].push(span);
2220
- }
2221
- for (const [path$3, pathSpans] of Object.entries(spansByPath)) {
2222
- const maxCount = path_to_count?.[path$3] || 0;
2223
- const spansToCache = pathSpans.slice(0, maxCount);
2224
- spansToCache.forEach((span, index) => {
2225
- let parsedInput;
2226
- let parsedOutput;
2227
- let parsedAttributes;
2228
- try {
2229
- parsedInput = typeof span.input === "string" ? JSON.parse(span.input) : span.input;
2230
- } catch {
2231
- parsedInput = span.input;
2232
- }
2233
- try {
2234
- parsedOutput = typeof span.output === "string" ? span.output : JSON.stringify(span.output);
2235
- } catch {
2236
- parsedOutput = String(span.output);
2237
- }
2238
- try {
2239
- parsedAttributes = typeof span.attributes === "string" ? JSON.parse(span.attributes) : span.attributes;
2240
- } catch {
2241
- parsedAttributes = {};
2242
- }
2243
- const cachedSpan = {
2244
- name: span.name,
2245
- input: parsedInput,
2246
- output: parsedOutput,
2247
- attributes: parsedAttributes
2248
- };
2249
- const cacheKey = `${index}:${path$3}`;
2250
- cache.set(cacheKey, cachedSpan);
2251
- });
2252
- logger$1.info(`Cached ${spansToCache.length} spans for path: ${path$3}`);
2253
- }
2254
- setMetadata({
2255
- pathToCount: path_to_count || {},
2256
- overrides
2257
- });
2258
- }
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);
2259
2711
  }
2260
- const baseUrl = options.baseUrl ?? process.env.LMNR_BASE_URL ?? "https://api.lmnr.ai";
2261
- const httpPort = options.port ?? (baseUrl.match(/:\d{1,5}$/g) ? parseInt(baseUrl.match(/:\d{1,5}$/g)[0].slice(1)) : 443);
2262
- const grpcPort = options.grpcPort ?? 8443;
2263
- const env = {
2264
- LMNR_ROLLOUT_SESSION_ID: sessionId,
2265
- LMNR_ROLLOUT_STATE_SERVER_ADDRESS: `http://localhost:${cacheServerPort}`
2266
- };
2267
- const workerConfig = {
2268
- filePath: options.pythonModule ? void 0 : filePathOrModule,
2269
- modulePath: options.pythonModule,
2270
- functionName: options.function,
2271
- args: parsedArgs,
2272
- env,
2273
- cacheServerPort,
2274
- baseUrl,
2275
- projectApiKey: options.projectApiKey,
2276
- httpPort,
2277
- grpcPort,
2278
- externalPackages: options.externalPackages,
2279
- dynamicImportsToSkip: options.dynamicImportsToSkip
2280
- };
2281
- const workerCommand = options.command ? {
2282
- command: options.command,
2283
- args: options.commandArgs ?? []
2284
- } : getWorkerCommand(options.pythonModule ? void 0 : filePathOrModule, options);
2712
+ process.stderr.write("\nYou have no projects yet. Opening the browser to create your first one...\n");
2713
+ let login;
2285
2714
  try {
2286
- await client.rolloutSessions.setStatus({
2287
- sessionId,
2288
- status: "RUNNING"
2715
+ login = await handleLogin({
2716
+ frontendUrl: issuer,
2717
+ noBrowser: options.noBrowser
2289
2718
  });
2290
- } catch (error) {
2291
- logger$1.error(`Error setting debugger session status: ${error instanceof Error ? error.message : error}`);
2719
+ } catch (err) {
2720
+ emitError(isJson, "login_failed", describeError(err));
2721
+ process.exit(EXIT_LOGIN_FAILED);
2292
2722
  }
2293
- await subprocessManager.execute({
2294
- command: workerCommand.command,
2295
- args: workerCommand.args,
2296
- config: workerConfig
2297
- });
2298
- try {
2299
- await client.rolloutSessions.setStatus({
2300
- sessionId,
2301
- status: "FINISHED"
2302
- });
2303
- } catch (error) {
2304
- logger$1.error(`Error setting debugger session status: ${error instanceof Error ? error.message : error}`);
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);
2305
2726
  }
2306
- } catch (error) {
2307
- logger$1.error(`Error handling run event: ${error instanceof Error ? error.message : error}`);
2308
- if (error instanceof Error && error.stack) logger$1.error(error.stack);
2309
- try {
2310
- await client.rolloutSessions.setStatus({
2311
- sessionId,
2312
- status: "FINISHED"
2313
- });
2314
- } catch (error) {
2315
- logger$1.error(`Error setting debugger session status: ${error instanceof Error ? error.message : error}`);
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);
2316
2742
  }
2743
+ chosen = await promptProjectChoice(projects);
2317
2744
  }
2318
- };
2319
- /**
2320
- * Main dev command handler
2321
- */
2322
- async function runDev(filePath, options = {}) {
2323
- const isPythonModule = !!options.pythonModule;
2324
- const filePathOrModule = filePath || options.pythonModule;
2325
- let didLogHandshake = false;
2326
- const sessionId = newUUID();
2327
- const client = new LaminarClient({
2328
- baseUrl: options.baseUrl,
2329
- projectApiKey: options.projectApiKey,
2330
- port: options.port
2745
+ const linkPath = await writeProjectLink({
2746
+ projectId: chosen.id,
2747
+ projectName: chosen.name,
2748
+ workspaceId: chosen.workspaceId,
2749
+ workspaceName: chosen.workspaceName
2331
2750
  });
2332
- logger$1.debug("Starting cache server...");
2333
- const { port: cacheServerPort, server: cacheServer, cache, setMetadata } = await startCacheServer();
2334
- logger$1.debug(`Cache server started on port ${cacheServerPort}`);
2335
- const subprocessManager = new SubprocessManager();
2336
- let functionName = options.function;
2337
- let params = [];
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 };
2338
2762
  try {
2339
- if (isPythonModule || filePath && EXTENSIONS_TO_DISCOVER_METADATA.includes(path.extname(filePath))) {
2340
- logger$1.debug("Discovering entrypoint functions...");
2341
- const metadata = await discoverFunctionMetadata(filePathOrModule, options);
2342
- functionName = metadata.functionName;
2343
- params = metadata.params;
2344
- logger$1.info(`Serving function: ${functionName}`);
2345
- logger$1.debug(`Function parameters: ${JSON.stringify(params, null, 2)}`);
2346
- } else if (filePath) {
2347
- functionName = options.function || path.basename(filePath, path.extname(filePath));
2348
- logger$1.warn(`Metadata discovery not available for ${path.extname(filePath)} files`);
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
+ };
2349
2772
  }
2350
- } catch (error) {
2351
- logger$1.error("Failed to discover entrypoint functions: " + (error instanceof Error ? error.message : String(error)));
2352
- cacheServer.close();
2353
- throw error;
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`);
2354
2779
  }
2355
- logger$1.debug("Setting up file watcher...");
2356
- const watcher = chokidar.default.watch(".", {
2357
- ignored: (path$4) => {
2358
- const ignoredDirs = [
2359
- "node_modules",
2360
- ".git",
2361
- "dist",
2362
- "build",
2363
- ".next",
2364
- "coverage",
2365
- ".turbo",
2366
- "tmp",
2367
- "temp",
2368
- "venv",
2369
- ".venv",
2370
- "virtualenv",
2371
- ".virtualenv",
2372
- "__pycache__",
2373
- ".pytest_cache",
2374
- ".ruff_cache",
2375
- ".mypy_cache",
2376
- ".cache",
2377
- ".DS_Store"
2378
- ];
2379
- if (path$4.split(/[/\\]/).some((segment) => ignoredDirs.includes(segment))) return true;
2380
- if (path$4.endsWith(".log") || path$4.endsWith(".map")) return true;
2381
- return false;
2382
- },
2383
- persistent: true,
2384
- ignoreInitial: true,
2385
- awaitWriteFinish: {
2386
- stabilityThreshold: 100,
2387
- pollInterval: 100
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: ""
2388
2810
  }
2389
- });
2390
- logger$1.debug("Setting up SSE client...");
2391
- let sseClient = null;
2811
+ }).cli.listProjects();
2812
+ }
2813
+ async function safeReadCredentials() {
2392
2814
  try {
2393
- sseClient = createSSEClient({
2394
- client,
2395
- sessionId,
2396
- params,
2397
- name: functionName ?? ""
2398
- });
2399
- let currentRunPromise = null;
2400
- let stopRequested = false;
2401
- sseClient.on("heartbeat", () => {
2402
- logger$1.debug("Heartbeat received");
2403
- });
2404
- sseClient.on("run", (event) => {
2405
- if (currentRunPromise !== null) {
2406
- logger$1.warn("Already processing a run event, skipping new run");
2407
- return;
2408
- }
2409
- currentRunPromise = (async () => {
2410
- try {
2411
- stopRequested = false;
2412
- if (reloadScheduled) {
2413
- logger$1.info("Reloading function metadata before run...");
2414
- if (isPythonModule || filePath && EXTENSIONS_TO_DISCOVER_METADATA.includes(path.extname(filePath))) try {
2415
- const metadata = await discoverFunctionMetadata(filePathOrModule, options);
2416
- if (stopRequested) {
2417
- logger$1.info("Run cancelled during metadata discovery");
2418
- return;
2419
- }
2420
- logger$1.debug(`Updated function metadata: ${metadata.functionName}`);
2421
- logger$1.debug(`Updated parameters: ${JSON.stringify(metadata.params, null, 2)}`);
2422
- if (sseClient) {
2423
- sseClient.updateMetadata(metadata.params, metadata.functionName);
2424
- logger$1.debug("Notified backend of metadata changes");
2425
- }
2426
- reloadScheduled = false;
2427
- } catch (error) {
2428
- logger$1.error("Failed to update function metadata: " + (error instanceof Error ? error.message : String(error)));
2429
- if (error instanceof Error && error.stack) logger$1.debug(`Stack trace: ${error.stack}`);
2430
- return;
2431
- }
2432
- else reloadScheduled = false;
2433
- }
2434
- if (stopRequested) {
2435
- logger$1.info("Run cancelled before execution");
2436
- return;
2437
- }
2438
- await handleRunEvent(event, sessionId, filePathOrModule, client, cacheServerPort, cache, setMetadata, options, subprocessManager);
2439
- } catch (error) {
2440
- logger$1.error("Unhandled error in run event handler: " + (error instanceof Error ? error.message : String(error)));
2441
- } finally {
2442
- currentRunPromise = null;
2443
- }
2444
- })();
2445
- });
2446
- sseClient.on("handshake", (event) => {
2447
- const projectId = event.data.project_id;
2448
- const sessionId = event.data.session_id;
2449
- const frontendUrl = getFrontendUrl(options.baseUrl, options.frontendPort);
2450
- if (!didLogHandshake) logger$1.info(`View your session at ${frontendUrl}/project/${projectId}/debugger-sessions/${sessionId}`);
2451
- didLogHandshake = true;
2452
- });
2453
- sseClient.on("error", (error) => {
2454
- logger$1.warn(`Error connecting to backend: ${error.message}`);
2455
- });
2456
- sseClient.on("reconnecting", () => {
2457
- logger$1.info("Reconnecting to backend...");
2458
- });
2459
- sseClient.on("heartbeat_timeout", () => {
2460
- logger$1.debug("Heartbeat timeout, reconnecting...");
2461
- });
2462
- sseClient.on("stop", () => {
2463
- logger$1.debug("Stop event received");
2464
- stopRequested = true;
2465
- if (subprocessManager.kill()) logger$1.info("Current run cancelled");
2466
- });
2467
- let reloadTimeout = null;
2468
- let reloadScheduled = false;
2469
- watcher.on("change", (changedPath) => {
2470
- logger$1.info(`File changed: ${changedPath}, scheduling reload...`);
2471
- if (reloadTimeout) clearTimeout(reloadTimeout);
2472
- reloadTimeout = setTimeout(() => {
2473
- logger$1.debug("Marking reload as scheduled for next run...");
2474
- reloadTimeout = null;
2475
- reloadScheduled = true;
2476
- }, 100);
2477
- });
2478
- const shutdown = () => {
2479
- logger$1.debug("Shutting down...");
2480
- if (reloadTimeout) {
2481
- clearTimeout(reloadTimeout);
2482
- reloadTimeout = null;
2483
- }
2484
- reloadScheduled = false;
2485
- logger$1.debug("Closing file watcher...");
2486
- watcher.close().catch((error) => {
2487
- logger$1.error(`Failed to close file watcher: ${error instanceof Error ? error.message : error}`);
2488
- });
2489
- subprocessManager.kill();
2490
- logger$1.debug("Deleting debugger session...");
2491
- client.rolloutSessions.delete({ sessionId }).then(() => {
2492
- if (sseClient) sseClient.shutdown();
2493
- cacheServer.close(() => {
2494
- logger$1.debug("Cache server closed");
2495
- });
2496
- process.exit(0);
2497
- }).catch((error) => {
2498
- logger$1.warn(`Failed to delete debugger session: ${error instanceof Error ? error.message : error}`);
2499
- process.exit(1);
2500
- });
2501
- };
2502
- process.on("SIGINT", shutdown);
2503
- process.on("SIGTERM", shutdown);
2504
- process.stdin.resume();
2505
- logger$1.debug("Connecting to backend...");
2506
- await sseClient.connectAndListen();
2507
- } catch (error) {
2508
- logger$1.error("Failed to start dev command: " + (error instanceof Error ? error.message : String(error)));
2509
- try {
2510
- await client.rolloutSessions.delete({ sessionId });
2511
- } catch {}
2512
- await watcher.close();
2513
- cacheServer.close(() => {
2514
- process.exit(1);
2515
- });
2815
+ return await readCredentials();
2816
+ } catch {
2817
+ return null;
2516
2818
  }
2517
2819
  }
2518
- //#endregion
2519
- //#region src/commands/sql/index.ts
2520
- const logger = initializeLogger();
2521
- const handleSqlQuery = async (query, options) => {
2522
- const client = new LaminarClient({
2523
- projectApiKey: options.projectApiKey,
2524
- baseUrl: options.baseUrl,
2525
- port: options.port
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
2526
2846
  });
2527
2847
  try {
2528
- const rows = await client.sql.query(query);
2529
- if (options.json) {
2530
- outputJson(rows);
2531
- return;
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`);
2532
2853
  }
2533
- if (rows.length === 0) {
2534
- console.log("No rows returned.");
2535
- return;
2536
- }
2537
- const columns = Object.keys(rows[0]);
2538
- const tableRows = rows.map((row) => columns.map((col) => String(row[col] ?? "")));
2539
- console.log(renderTable(columns, tableRows));
2540
- console.log(`\n${rows.length} row(s)\n`);
2541
- } catch (error) {
2542
- if (options.json) outputJsonError(error);
2543
- logger.error(`Query failed: ${error instanceof Error ? error.message : String(error)}`);
2544
- process.exit(1);
2854
+ } finally {
2855
+ rl.close();
2545
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`);
2546
2897
  };
2547
2898
  //#endregion
2548
2899
  //#region src/commands/sql/schema.ts
@@ -2591,44 +2942,60 @@ Available tables:
2591
2942
  data (String), target (String), metadata (String)
2592
2943
  `;
2593
2944
  //#endregion
2945
+ //#region src/commands/trace/index.ts
2946
+ const logger = initializeLogger();
2947
+ const NOTE_SEPARATOR = "\n\n";
2948
+ /**
2949
+ * Append a free-text note to an existing trace. Stored under the
2950
+ * `rollout.note` trace-metadata key via the post-factum metadata patch
2951
+ * endpoint. The patch endpoint is last-write-wins per key, so the current
2952
+ * note is read back first (via the SQL endpoint) and the new text is pushed
2953
+ * as `existing + "\n\n" + note`. The note may contain markdown /
2954
+ * span-reference links.
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
+ *
2960
+ * The read-modify-write is not transactional: the patch lands via the async
2961
+ * ingestion queue, so a second append issued within ~a second of the first
2962
+ * can read the pre-patch note and drop the first append. Fine for the
2963
+ * intended cadence (one note per investigation step), not for concurrent
2964
+ * writers.
2965
+ *
2966
+ * TODO: revisit — make the append atomic server-side (e.g. an append mode on
2967
+ * the metadata patch endpoint that concatenates within the Postgres UPDATE,
2968
+ * which already serializes on the trace row lock).
2969
+ */
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;
2983
+ }
2984
+ logger.info(`Appended note to trace ${id}.`);
2985
+ };
2986
+ //#endregion
2594
2987
  //#region src/index.ts
2595
2988
  async function main() {
2989
+ await loadLocalEnv(process.cwd());
2596
2990
  const program = new commander.Command();
2597
2991
  program.name("lmnr-cli").description("CLI for the Laminar agent observability platform").version(version$1, "-v, --version", "display version number");
2598
- program.command("dev").description("Start a debugging session").argument("[file]", "Path to file containing the entrypoint function(s). Either `file` or `-m` must be provided.").option("-m, --python-module <module>", "Python module path (e.g., src.myfile). Either `file` or `-m` must be provided.").option("--function <name>", "Specific function to serve (if multiple entrypoint functions found)").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("--grpc-port <port>", "Port for the Laminar gRPC backend. Defaults to 8443", (val) => parseInt(val, 10)).option("--frontend-port <port>", "Port for the Laminar frontend. Defaults to 5667", (val) => parseInt(val, 10)).option("--external-packages <packages...>", "[ADVANCED] List of packages to pass as external to esbuild. This will not link the packages directly into the dev file, but will instead require them at runtime. Read more: https://esbuild.github.io/api/#external").option("--dynamic-imports-to-skip <modules...>", "[ADVANCED] List of module names to skip when encountered as dynamic imports. These dynamic imports will resolve to an empty module to prevent build failures. This is meant to skip the imports that are not used in the entrypoint function itself.").option("--command <command>", "[ADVANCED] Custom command to run the worker (e.g., python3, node)").option("--command-args <args...>", "[ADVANCED] Arguments for the custom command").action(async (file, options) => {
2599
- if (!file && !options.pythonModule) {
2600
- console.error("Error: Must provide either a file path or --python-module (-m) flag");
2601
- process.exit(1);
2602
- }
2603
- if (file && options.pythonModule) {
2604
- console.error("Error: Cannot specify both file path and --python-module (-m) flag");
2605
- process.exit(1);
2606
- }
2607
- await runDev(file, options);
2608
- }).addHelpText("after", `
2609
- Examples:
2610
- $ lmnr-cli dev agent.ts # TypeScript file
2611
- $ lmnr-cli dev agent.py # Python file (script mode)
2612
- $ lmnr-cli dev -m src.agent # Python module (module mode)
2613
- $ lmnr-cli dev agent.ts --function myAgent # Specific function
2614
- `);
2615
- 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");
2616
- datasetsCmd.command("list").description("List all datasets").action(async (_options, cmd) => {
2617
- await handleDatasetsList(cmd.optsWithGlobals());
2618
- });
2619
- 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) => {
2620
- await handleDatasetsPush(paths, cmd.optsWithGlobals());
2621
- });
2622
- 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) => {
2623
- await handleDatasetsPull(outputPath, cmd.optsWithGlobals());
2624
- });
2625
- 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) => {
2626
- await handleDatasetsCreate(name, paths, cmd.optsWithGlobals());
2627
- });
2628
- 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");
2629
- sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(async (query, _options, cmd) => {
2630
- await handleSqlQuery(query, cmd.optsWithGlobals());
2631
- }).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 + `
2632
2999
  Examples:
2633
3000
  $ lmnr-cli sql query "SELECT * FROM spans LIMIT 10"
2634
3001
  $ lmnr-cli sql query "SELECT id, total_cost, status FROM traces LIMIT 20"
@@ -2637,32 +3004,77 @@ Examples:
2637
3004
  sqlCmd.command("schema").description("Show available tables and their columns").action(() => {
2638
3005
  process.stdout.write(SQL_SCHEMA_HELP);
2639
3006
  });
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", `
3021
+ Notes accumulate: each call appends a new paragraph to the trace's existing
3022
+ note rather than overwriting it.
3023
+
3024
+ Examples:
3025
+ $ lmnr-cli trace append-note <trace-id> "Reproduced the timeout on the search tool."
3026
+ `);
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", `
3028
+ Learn more about debugging features at https://laminar.sh/docs/platform/debugger
3029
+ `).command("session").description("Manage debug sessions").addHelpText("after", `
3030
+ Learn more about debugging features at https://laminar.sh/docs/platform/debugger
3031
+ `);
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", `
3033
+ Examples:
3034
+ $ lmnr-cli debug session set-name <session-id> "Fix report length + search tool"
3035
+ `);
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", `
3037
+ Output is one block per trace (oldest first), the trace's note followed by a
3038
+ self-closing tag carrying the trace id and end time:
3039
+
3040
+ {note}
3041
+ <trace id="{trace-id}" end-time="{end-time}"/>
3042
+
3043
+ With --json, prints an array of {"note", "traceId", "endTime"} objects.
3044
+
3045
+ Examples:
3046
+ $ lmnr-cli debug session summary <session-id>
3047
+ $ lmnr-cli debug session summary <session-id> --json
3048
+ `);
2640
3049
  program.addHelpText("after", `
2641
3050
  Authentication:
2642
- Most commands require a project API key. Provide it in one of two ways:
2643
- 1. Environment variable: export LMNR_PROJECT_API_KEY=<your-key>
2644
- 2. CLI flag: --project-api-key <your-key>
2645
- 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.
2646
3056
 
2647
3057
  Examples:
2648
- lmnr-cli dev agent.ts # Debugger TypeScript entrypoint
2649
- lmnr-cli dev agent.py # Debugger Python script mode
2650
- lmnr-cli dev -m src.agent # Debugger Python module mode
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
2651
3062
  lmnr-cli dataset list --json # List all datasets
2652
3063
  lmnr-cli dataset push data.jsonl -n my-dataset --json # Push data to a dataset
2653
3064
  lmnr-cli dataset pull output.jsonl -n my-dataset --json # Pull data from a dataset
2654
3065
  lmnr-cli sql query "SELECT * FROM spans LIMIT 10" --json # Query spans
2655
- lmnr-cli sql query "SELECT t.id, s.name FROM traces t JOIN spans s ON t.id = s.trace_id" --json
2656
3066
  lmnr-cli sql schema # Show available tables
3067
+ lmnr-cli trace append-note <trace-id> "note text" # Append a note to a trace
3068
+ lmnr-cli debug session set-name <session-id> "title" # Rename a debug session
3069
+ lmnr-cli debug session summary <session-id> # Notes for each trace in a session
2657
3070
 
2658
3071
  For more information about the Laminar platfrom:
2659
3072
  Documentation: https://laminar.sh/docs
2660
- Dashboard: https://www.laminar.sh
2661
3073
  `);
2662
3074
  await program.parseAsync();
2663
3075
  }
2664
3076
  main().catch((err) => {
2665
- console.error(err instanceof Error ? err.message : err);
3077
+ console.error(errorMessage(err));
2666
3078
  process.exit(1);
2667
3079
  });
2668
3080
  //#endregion