@plune-ai/cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -41,6 +41,35 @@ var init_cjs_shims = __esm({
41
41
  }
42
42
  });
43
43
 
44
+ // src/cli/api-url.ts
45
+ var api_url_exports = {};
46
+ __export(api_url_exports, {
47
+ FEEDBACK_URL: () => FEEDBACK_URL,
48
+ dashboardUrl: () => dashboardUrl,
49
+ resolveApiUrl: () => resolveApiUrl,
50
+ whereToGetAToken: () => whereToGetAToken
51
+ });
52
+ function resolveApiUrl(explicit) {
53
+ const raw = explicit ?? process.env["PLUNE_API_URL"] ?? DEFAULT_API_URL;
54
+ return raw.replace(/\/+$/, "");
55
+ }
56
+ function dashboardUrl(apiUrl = resolveApiUrl()) {
57
+ return apiUrl === DEFAULT_API_URL ? "https://beta.plune.ai" : void 0;
58
+ }
59
+ function whereToGetAToken(apiUrl = resolveApiUrl()) {
60
+ const web = dashboardUrl(apiUrl);
61
+ return web === void 0 ? `Get one from your Plune dashboard (the web app in front of ${apiUrl}), under Settings \u2192 API tokens.` : `Get one at ${web} \u2192 Settings \u2192 API tokens.`;
62
+ }
63
+ var FEEDBACK_URL, DEFAULT_API_URL;
64
+ var init_api_url = __esm({
65
+ "src/cli/api-url.ts"() {
66
+ "use strict";
67
+ init_cjs_shims();
68
+ FEEDBACK_URL = "https://github.com/plune-ai/feedback/issues/new?template=feedback.yml";
69
+ DEFAULT_API_URL = "https://beta-api.plune.ai";
70
+ }
71
+ });
72
+
44
73
  // src/cli/env.ts
45
74
  var env_exports = {};
46
75
  __export(env_exports, {
@@ -189,9 +218,9 @@ var init_schema = __esm({
189
218
  input_per_1k_usd: import_zod.z.number().nonnegative(),
190
219
  output_per_1k_usd: import_zod.z.number().nonnegative()
191
220
  });
192
- pricingSchema = import_zod.z.record(modelPriceSchema);
221
+ pricingSchema = import_zod.z.record(import_zod.z.string(), modelPriceSchema);
193
222
  datasetRowSchema = import_zod.z.object({
194
- vars: import_zod.z.record(import_zod.z.union([import_zod.z.string(), import_zod.z.number(), import_zod.z.boolean()])),
223
+ vars: import_zod.z.record(import_zod.z.string(), import_zod.z.union([import_zod.z.string(), import_zod.z.number(), import_zod.z.boolean()])),
195
224
  expected: import_zod.z.string().optional()
196
225
  });
197
226
  datasetRefSchema = import_zod.z.union([
@@ -221,7 +250,7 @@ var init_schema = __esm({
221
250
  });
222
251
  jsonSchemaAssertionSchema = import_zod.z.object({
223
252
  type: import_zod.z.literal("json-schema"),
224
- schema: import_zod.z.record(import_zod.z.unknown()),
253
+ schema: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()),
225
254
  extract: import_zod.z.enum(["auto", "strict"]).optional()
226
255
  });
227
256
  llmJudgeAssertionSchema = import_zod.z.object({
@@ -456,7 +485,7 @@ function readHeader(headers, name) {
456
485
  return typeof v === "string" || typeof v === "number" ? v : void 0;
457
486
  }
458
487
  async function withRetry(fn, options) {
459
- const { base_delay_ms = 500, sleep = defaultSleep, random = Math.random } = options;
488
+ const { base_delay_ms = 500, sleep: sleep2 = defaultSleep, random = Math.random } = options;
460
489
  const maxRetries = Math.max(0, Math.floor(options.max_retries));
461
490
  let attempt = 0;
462
491
  for (; ; ) {
@@ -472,7 +501,7 @@ async function withRetry(fn, options) {
472
501
  const backoff = base_delay_ms * 2 ** attempt;
473
502
  const jitter = backoff * 0.1 * random();
474
503
  const delay = Math.max(backoff + jitter, retryAfterMs(err) ?? 0);
475
- await sleep(delay);
504
+ await sleep2(delay);
476
505
  attempt += 1;
477
506
  }
478
507
  }
@@ -886,7 +915,7 @@ var init_cache = __esm({
886
915
  return void 0;
887
916
  }
888
917
  }
889
- set(key, value) {
918
+ set(key, value2) {
890
919
  try {
891
920
  this.db.prepare(
892
921
  `INSERT INTO completions (cache_key, output, input_tokens, output_tokens, created_at)
@@ -896,7 +925,7 @@ var init_cache = __esm({
896
925
  input_tokens = excluded.input_tokens,
897
926
  output_tokens = excluded.output_tokens,
898
927
  created_at = excluded.created_at`
899
- ).run(key, value.output, value.usage.input_tokens, value.usage.output_tokens, this.now());
928
+ ).run(key, value2.output, value2.usage.input_tokens, value2.usage.output_tokens, this.now());
900
929
  } catch {
901
930
  }
902
931
  }
@@ -934,32 +963,32 @@ var init_cache2 = __esm({
934
963
  });
935
964
 
936
965
  // src/util/canonical-json.ts
937
- function normalizeValue(value) {
938
- if (value === null) return null;
939
- if (typeof value === "boolean") return value;
940
- if (typeof value === "number") {
941
- if (!isFinite(value)) {
966
+ function normalizeValue(value2) {
967
+ if (value2 === null) return null;
968
+ if (typeof value2 === "boolean") return value2;
969
+ if (typeof value2 === "number") {
970
+ if (!isFinite(value2)) {
942
971
  throw new Error(
943
- `canonicalJson: non-finite number rejected: ${String(value)}`
972
+ `canonicalJson: non-finite number rejected: ${String(value2)}`
944
973
  );
945
974
  }
946
- return value === 0 ? 0 : value;
975
+ return value2 === 0 ? 0 : value2;
947
976
  }
948
- if (typeof value === "string") {
949
- return value.normalize("NFC");
977
+ if (typeof value2 === "string") {
978
+ return value2.normalize("NFC");
950
979
  }
951
- if (Array.isArray(value)) {
952
- return value.map(normalizeValue);
980
+ if (Array.isArray(value2)) {
981
+ return value2.map(normalizeValue);
953
982
  }
954
- if (typeof value === "object") {
955
- const proto = Object.getPrototypeOf(value);
983
+ if (typeof value2 === "object") {
984
+ const proto = Object.getPrototypeOf(value2);
956
985
  if (proto !== null && proto !== Object.prototype) {
957
- const name = value.constructor?.name ?? "unknown";
986
+ const name = value2.constructor?.name ?? "unknown";
958
987
  throw new Error(
959
988
  `canonicalJson: unsupported non-plain object (${name}) \u2014 only plain objects, arrays, and primitives are serializable`
960
989
  );
961
990
  }
962
- const obj = value;
991
+ const obj = value2;
963
992
  const sorted = {};
964
993
  for (const key of Object.keys(obj).sort()) {
965
994
  sorted[key] = normalizeValue(obj[key]);
@@ -967,11 +996,11 @@ function normalizeValue(value) {
967
996
  return sorted;
968
997
  }
969
998
  throw new Error(
970
- `canonicalJson: unsupported value type: ${typeof value}`
999
+ `canonicalJson: unsupported value type: ${typeof value2}`
971
1000
  );
972
1001
  }
973
- function canonicalJson(value) {
974
- return JSON.stringify(normalizeValue(value));
1002
+ function canonicalJson(value2) {
1003
+ return JSON.stringify(normalizeValue(value2));
975
1004
  }
976
1005
  var init_canonical_json = __esm({
977
1006
  "src/util/canonical-json.ts"() {
@@ -1009,8 +1038,8 @@ function interpolate(template, ctx) {
1009
1038
  }
1010
1039
  if (key.startsWith("vars.")) {
1011
1040
  const name = key.slice("vars.".length);
1012
- const value = ctx.vars[name];
1013
- return value === void 0 ? match : String(value);
1041
+ const value2 = ctx.vars[name];
1042
+ return value2 === void 0 ? match : String(value2);
1014
1043
  }
1015
1044
  return match;
1016
1045
  });
@@ -1063,12 +1092,12 @@ var init_contains = __esm({
1063
1092
  init_interpolate();
1064
1093
  contains = {
1065
1094
  run(ctx) {
1066
- const value = interpolate(ctx.params.value, ctx);
1095
+ const value2 = interpolate(ctx.params.value, ctx);
1067
1096
  const found = norm(ctx.output, ctx.params.ignore_case).includes(
1068
- norm(value, ctx.params.ignore_case)
1097
+ norm(value2, ctx.params.ignore_case)
1069
1098
  );
1070
1099
  return Promise.resolve(
1071
- found ? { passed: true } : { passed: false, reason: `expected output to contain: "${value}"` }
1100
+ found ? { passed: true } : { passed: false, reason: `expected output to contain: "${value2}"` }
1072
1101
  );
1073
1102
  }
1074
1103
  };
@@ -2117,6 +2146,609 @@ var init_reporters = __esm({
2117
2146
  }
2118
2147
  });
2119
2148
 
2149
+ // src/cli/credentials.ts
2150
+ function credentialsFile() {
2151
+ const base = process.env["XDG_CONFIG_HOME"]?.trim() || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config");
2152
+ return (0, import_node_path.join)(base, "plune", "credentials.json");
2153
+ }
2154
+ function saveToken(token) {
2155
+ const file = credentialsFile();
2156
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(file), { recursive: true, mode: 448 });
2157
+ (0, import_node_fs.writeFileSync)(file, JSON.stringify({ token }, null, 2) + "\n", { mode: 384 });
2158
+ (0, import_node_fs.chmodSync)(file, 384);
2159
+ return file;
2160
+ }
2161
+ function loadToken() {
2162
+ try {
2163
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
2164
+ return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
2165
+ } catch {
2166
+ return null;
2167
+ }
2168
+ }
2169
+ function clearToken() {
2170
+ const file = credentialsFile();
2171
+ if (!(0, import_node_fs.existsSync)(file)) return { removed: false, path: file };
2172
+ (0, import_node_fs.rmSync)(file);
2173
+ return { removed: true, path: file };
2174
+ }
2175
+ var import_node_fs, import_node_os, import_node_path;
2176
+ var init_credentials = __esm({
2177
+ "src/cli/credentials.ts"() {
2178
+ "use strict";
2179
+ init_cjs_shims();
2180
+ import_node_fs = require("fs");
2181
+ import_node_os = require("os");
2182
+ import_node_path = require("path");
2183
+ }
2184
+ });
2185
+
2186
+ // src/reporter-core/client.ts
2187
+ async function detailOf(res) {
2188
+ try {
2189
+ const body = await res.json();
2190
+ return typeof body.error === "string" ? body.error : "";
2191
+ } catch {
2192
+ return "";
2193
+ }
2194
+ }
2195
+ function retryAfterMs2(res) {
2196
+ const raw = res.headers.get("retry-after");
2197
+ if (raw === null) return null;
2198
+ const seconds = Number(raw.trim());
2199
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
2200
+ }
2201
+ function classify3(status) {
2202
+ if (status === 401 || status === 403) return "auth";
2203
+ if (status === 409) return "conflict";
2204
+ if (status === 400) return "invalid";
2205
+ if (status === 429 || status >= 500) return "unavailable";
2206
+ return "other";
2207
+ }
2208
+ function createClient(opts) {
2209
+ const doFetch = opts.fetchImpl ?? fetch;
2210
+ const wait = opts.wait ?? sleep;
2211
+ const base = opts.apiUrl.replace(/\/+$/, "");
2212
+ const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2213
+ async function post(path12, body) {
2214
+ let last = {
2215
+ kind: "unavailable",
2216
+ status: null,
2217
+ detail: "no attempt was made"
2218
+ };
2219
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2220
+ let res;
2221
+ try {
2222
+ res = await doFetch(`${base}${path12}`, {
2223
+ method: "POST",
2224
+ headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2225
+ body: JSON.stringify(body)
2226
+ });
2227
+ } catch (err) {
2228
+ last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
2229
+ if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
2230
+ continue;
2231
+ }
2232
+ if (res.ok) return { ok: true, status: res.status, body: await res.json() };
2233
+ const detail = scrub(await detailOf(res));
2234
+ const kind = classify3(res.status);
2235
+ last = { kind, status: res.status, detail };
2236
+ if (kind !== "unavailable") break;
2237
+ if (attempt < RETRY_DELAYS_MS.length) {
2238
+ await wait(retryAfterMs2(res) ?? RETRY_DELAYS_MS[attempt]);
2239
+ }
2240
+ }
2241
+ return { ok: false, ...last };
2242
+ }
2243
+ return { post };
2244
+ }
2245
+ var RETRY_DELAYS_MS, sleep;
2246
+ var init_client = __esm({
2247
+ "src/reporter-core/client.ts"() {
2248
+ "use strict";
2249
+ init_cjs_shims();
2250
+ RETRY_DELAYS_MS = [300, 900];
2251
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2252
+ }
2253
+ });
2254
+
2255
+ // src/reporter-core/env.ts
2256
+ function value(env, name) {
2257
+ const raw = env[name]?.trim();
2258
+ return raw === void 0 || raw === "" ? void 0 : raw;
2259
+ }
2260
+ function flag(env, name) {
2261
+ const raw = value(env, name)?.toLowerCase();
2262
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
2263
+ }
2264
+ function count(env, name) {
2265
+ const raw = value(env, name);
2266
+ if (raw === void 0) return void 0;
2267
+ const n = Number(raw);
2268
+ return Number.isInteger(n) && n > 0 ? n : void 0;
2269
+ }
2270
+ function list(env, name) {
2271
+ const raw = value(env, name);
2272
+ if (raw === void 0) return void 0;
2273
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2274
+ return items.length === 0 ? void 0 : items;
2275
+ }
2276
+ function readEnv(env = process.env) {
2277
+ const externalKey = value(env, "PLUNE_RUN");
2278
+ const token = value(env, "PLUNE_TOKEN");
2279
+ const apiUrl = value(env, "PLUNE_API_URL");
2280
+ const fallbackPath = value(env, "PLUNE_FALLBACK");
2281
+ const batchSize = count(env, "PLUNE_BATCH_SIZE");
2282
+ const title = value(env, "PLUNE_RUN_TITLE");
2283
+ const environment = value(env, "PLUNE_ENV");
2284
+ const labels = list(env, "PLUNE_LABELS");
2285
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
2286
+ return {
2287
+ config: {
2288
+ ...externalKey !== void 0 ? { externalKey } : {},
2289
+ ...token !== void 0 ? { token } : {},
2290
+ ...apiUrl !== void 0 ? { apiUrl } : {},
2291
+ ...fallbackPath !== void 0 ? { fallbackPath } : {},
2292
+ ...batchSize !== void 0 ? { batchSize } : {},
2293
+ ...title !== void 0 ? { title } : {},
2294
+ ...environment !== void 0 ? { environment } : {},
2295
+ ...labels !== void 0 ? { labels } : {},
2296
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
2297
+ // variable is not an instruction.
2298
+ ...offerDiscovered ? { offerDiscovered } : {}
2299
+ },
2300
+ // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2301
+ // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
2302
+ // way this process must not be the one to call it finished.
2303
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
2304
+ ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
2305
+ };
2306
+ }
2307
+ var UNSUPPORTED_VARS;
2308
+ var init_env2 = __esm({
2309
+ "src/reporter-core/env.ts"() {
2310
+ "use strict";
2311
+ init_cjs_shims();
2312
+ UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2313
+ }
2314
+ });
2315
+
2316
+ // src/reporter-core/fallback.ts
2317
+ function appendBatch(file, batch) {
2318
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
2319
+ const line = {
2320
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2321
+ runId: batch.runId,
2322
+ externalKey: batch.externalKey,
2323
+ results: batch.results
2324
+ };
2325
+ fs6.appendFileSync(file, `${JSON.stringify(line)}
2326
+ `, "utf8");
2327
+ }
2328
+ var fs6, path6, DEFAULT_FALLBACK_PATH;
2329
+ var init_fallback = __esm({
2330
+ "src/reporter-core/fallback.ts"() {
2331
+ "use strict";
2332
+ init_cjs_shims();
2333
+ fs6 = __toESM(require("fs"), 1);
2334
+ path6 = __toESM(require("path"), 1);
2335
+ DEFAULT_FALLBACK_PATH = path6.join(".plune", "pending-results.jsonl");
2336
+ }
2337
+ });
2338
+
2339
+ // src/reporter-core/session.ts
2340
+ function chunk(items, size) {
2341
+ const out = [];
2342
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2343
+ return out;
2344
+ }
2345
+ function warnIfDropped(sent, run, log) {
2346
+ const names = Object.keys(sent);
2347
+ if (names.length === 0) return;
2348
+ if (names.some((name) => run[name] != null)) return;
2349
+ log(
2350
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2351
+ );
2352
+ }
2353
+ async function startRun(passed, expected) {
2354
+ const env = readEnv();
2355
+ const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2356
+ const cfg = { ...env.config, ...stated };
2357
+ const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
2358
+ `));
2359
+ const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2360
+ const externalKey = cfg.externalKey ?? null;
2361
+ const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2362
+ const offerDiscovered = cfg.offerDiscovered ?? false;
2363
+ const token = cfg.token ?? loadToken() ?? "";
2364
+ const client = createClient({
2365
+ apiUrl: resolveApiUrl(cfg.apiUrl),
2366
+ token,
2367
+ ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
2368
+ });
2369
+ const stats = {
2370
+ accepted: 0,
2371
+ duplicate: 0,
2372
+ conflict: 0,
2373
+ rejected: 0,
2374
+ unresolved: 0,
2375
+ offered: 0,
2376
+ deferred: 0
2377
+ };
2378
+ const resolved = /* @__PURE__ */ new Map();
2379
+ let buffer = [];
2380
+ const discoveries = /* @__PURE__ */ new Map();
2381
+ let runId = null;
2382
+ let joined = false;
2383
+ let offline = token === "";
2384
+ let done = false;
2385
+ if (offline) {
2386
+ log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2387
+ }
2388
+ if (env.ignored.length > 0) {
2389
+ log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
2390
+ }
2391
+ function defer(results) {
2392
+ appendBatch(fallbackPath, { runId, externalKey, results });
2393
+ stats.deferred += results.length;
2394
+ }
2395
+ async function resolveKeys(values) {
2396
+ const unknown = values.filter((k) => !resolved.has(k.value));
2397
+ if (unknown.length === 0 || offline) return;
2398
+ for (const part of chunk(unknown, RESOLVE_CHUNK)) {
2399
+ const out = await client.post(
2400
+ "/v1/test-cases/resolve",
2401
+ { keys: part }
2402
+ );
2403
+ if (!out.ok) {
2404
+ log(`plune: could not look up test cases (${out.detail || out.kind}).`);
2405
+ return;
2406
+ }
2407
+ for (const row of out.body.results) resolved.set(row.key.value, row.testCaseId);
2408
+ }
2409
+ }
2410
+ if (!offline) {
2411
+ if (expected !== void 0) await resolveKeys(expected.flat());
2412
+ const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
2413
+ const configuration = declared.length === 0 ? void 0 : {
2414
+ expected: declared.flatMap((keys) => {
2415
+ const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2416
+ if (typeof hit === "string") return [{ testCaseId: hit }];
2417
+ return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
2418
+ })
2419
+ };
2420
+ if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2421
+ log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
2422
+ }
2423
+ const described = {
2424
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
2425
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
2426
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
2427
+ };
2428
+ const start = await client.post("/v1/runs", {
2429
+ schemaVersion: 2,
2430
+ kind: cfg.kind ?? "automated",
2431
+ ...externalKey !== null ? { externalKey } : {},
2432
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2433
+ ...configuration !== void 0 ? { configuration } : {},
2434
+ ...described
2435
+ });
2436
+ if (start.ok) {
2437
+ runId = start.body.run.id;
2438
+ joined = start.status === 200;
2439
+ warnIfDropped(described, start.body.run, log);
2440
+ } else {
2441
+ log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
2442
+ }
2443
+ }
2444
+ function matchFor(result) {
2445
+ if (result.testCaseId !== void 0) return { kind: "found", testCaseId: result.testCaseId };
2446
+ let unanswered = false;
2447
+ for (const key of result.keys) {
2448
+ const hit = resolved.get(key.value);
2449
+ if (typeof hit === "string") return { kind: "found", testCaseId: hit };
2450
+ if (hit === void 0) unanswered = true;
2451
+ }
2452
+ return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
2453
+ }
2454
+ function remember(result) {
2455
+ if (!offerDiscovered) return;
2456
+ if (result.title === void 0 || result.specRef === void 0) return;
2457
+ if (result.keys.length === 0) return;
2458
+ discoveries.set(result.keys[0].value, {
2459
+ keys: result.keys,
2460
+ title: result.title,
2461
+ source: result.source,
2462
+ specRef: result.specRef,
2463
+ rawStatus: result.rawStatus
2464
+ });
2465
+ }
2466
+ async function offer() {
2467
+ if (discoveries.size === 0 || offline || runId === null) return;
2468
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
2469
+ const out = await client.post("/v1/review-items/discovered", {
2470
+ discovered: batch,
2471
+ runId
2472
+ });
2473
+ if (!out.ok) {
2474
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
2475
+ return;
2476
+ }
2477
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2478
+ }
2479
+ }
2480
+ async function flush() {
2481
+ if (buffer.length === 0) return;
2482
+ const pending = buffer;
2483
+ buffer = [];
2484
+ if (offline || runId === null) {
2485
+ defer(pending);
2486
+ return;
2487
+ }
2488
+ await resolveKeys(pending.flatMap((p) => p.keys));
2489
+ const submissions = [];
2490
+ const unanswered = [];
2491
+ for (const result of pending) {
2492
+ const match = matchFor(result);
2493
+ if (match.kind === "found") {
2494
+ const { keys: _keys, ...rest } = result;
2495
+ submissions.push({ ...rest, testCaseId: match.testCaseId });
2496
+ } else if (match.kind === "unmatched") {
2497
+ stats.unresolved += 1;
2498
+ remember(result);
2499
+ } else {
2500
+ unanswered.push(result);
2501
+ }
2502
+ }
2503
+ if (unanswered.length > 0) defer(unanswered);
2504
+ if (submissions.length === 0) return;
2505
+ const out = await client.post(
2506
+ `/v1/runs/${runId}/results`,
2507
+ { results: submissions }
2508
+ );
2509
+ if (out.ok) {
2510
+ stats.accepted += out.body.counts.accepted ?? 0;
2511
+ stats.duplicate += out.body.counts.duplicate ?? 0;
2512
+ stats.conflict += out.body.counts.conflict ?? 0;
2513
+ stats.rejected += out.body.counts.rejected ?? 0;
2514
+ return;
2515
+ }
2516
+ if (out.kind === "auth") {
2517
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
2518
+ offline = true;
2519
+ } else if (out.kind === "conflict") {
2520
+ log(`plune: this run is closed \u2014 ${out.detail}`);
2521
+ } else {
2522
+ log(`plune: could not send results (${out.detail || out.kind}).`);
2523
+ }
2524
+ defer(submissions);
2525
+ }
2526
+ function summarise() {
2527
+ const parts = [`${stats.accepted} accepted`];
2528
+ if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
2529
+ if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
2530
+ if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2531
+ if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2532
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2533
+ if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2534
+ log(`plune: ${parts.join(" \xB7 ")}`);
2535
+ }
2536
+ return {
2537
+ get runId() {
2538
+ return runId;
2539
+ },
2540
+ get joined() {
2541
+ return joined;
2542
+ },
2543
+ get stats() {
2544
+ return stats;
2545
+ },
2546
+ async add(result) {
2547
+ buffer.push(result);
2548
+ if (buffer.length >= batchSize) await flush();
2549
+ },
2550
+ flush,
2551
+ async finish(reason) {
2552
+ if (done) return;
2553
+ done = true;
2554
+ await flush();
2555
+ await offer();
2556
+ if (runId !== null && !offline) {
2557
+ const out = await client.post(`/v1/runs/${runId}/events`, {
2558
+ event: "finish",
2559
+ ...reason !== void 0 ? { reason } : {}
2560
+ });
2561
+ if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
2562
+ }
2563
+ summarise();
2564
+ },
2565
+ async leaveOpen() {
2566
+ if (done) return;
2567
+ done = true;
2568
+ await flush();
2569
+ await offer();
2570
+ if (runId !== null) {
2571
+ log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
2572
+ }
2573
+ summarise();
2574
+ }
2575
+ };
2576
+ }
2577
+ var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2578
+ var init_session = __esm({
2579
+ "src/reporter-core/session.ts"() {
2580
+ "use strict";
2581
+ init_cjs_shims();
2582
+ init_api_url();
2583
+ init_credentials();
2584
+ init_client();
2585
+ init_env2();
2586
+ init_fallback();
2587
+ RESOLVE_CHUNK = 500;
2588
+ BATCH_MAX = 500;
2589
+ DISCOVER_MAX = 500;
2590
+ BATCH_DEFAULT = 100;
2591
+ EXPECTED_MAX = 1e4;
2592
+ }
2593
+ });
2594
+
2595
+ // src/cli/commands/run-lifecycle.ts
2596
+ var run_lifecycle_exports = {};
2597
+ __export(run_lifecycle_exports, {
2598
+ NoTokenError: () => NoTokenError,
2599
+ RunCommandError: () => RunCommandError,
2600
+ handleRunExec: () => handleRunExec,
2601
+ handleRunFinish: () => handleRunFinish,
2602
+ handleRunReport: () => handleRunReport,
2603
+ handleRunStart: () => handleRunStart
2604
+ });
2605
+ function connect(deps) {
2606
+ const env = readEnv();
2607
+ const apiUrl = resolveApiUrl(deps.apiUrl ?? env.config.apiUrl);
2608
+ const token = deps.token ?? env.config.token ?? loadToken() ?? "";
2609
+ if (token === "") throw new NoTokenError();
2610
+ return {
2611
+ client: createClient({
2612
+ apiUrl,
2613
+ token,
2614
+ ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {}
2615
+ }),
2616
+ apiUrl,
2617
+ write: deps.write ?? ((line) => void process.stdout.write(`${line}
2618
+ `))
2619
+ };
2620
+ }
2621
+ function runUrl(apiUrl, id) {
2622
+ const web = dashboardUrl(apiUrl);
2623
+ return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
2624
+ }
2625
+ async function handleRunStart(options = {}) {
2626
+ const { client, apiUrl, write } = connect(options);
2627
+ const env = readEnv();
2628
+ const externalKey = options.key ?? env.config.externalKey ?? `plune-${(options.now ?? Date.now)().toString(36)}`;
2629
+ const out = await client.post("/v1/runs", {
2630
+ schemaVersion: 2,
2631
+ kind: options.kind ?? "automated",
2632
+ externalKey
2633
+ });
2634
+ if (!out.ok) throw new RunCommandError(`Could not start the run \u2014 ${out.detail || out.kind}.`);
2635
+ const result = {
2636
+ id: out.body.run.id,
2637
+ joined: out.status === 200,
2638
+ externalKey,
2639
+ url: runUrl(apiUrl, out.body.run.id)
2640
+ };
2641
+ if (options.json === true) {
2642
+ write(JSON.stringify(result));
2643
+ } else {
2644
+ write(`${result.joined ? "Joined" : "Started"} run ${result.id}`);
2645
+ write(result.url);
2646
+ }
2647
+ return result;
2648
+ }
2649
+ async function handleRunFinish(id, options = {}) {
2650
+ const { client, write } = connect(options);
2651
+ const event = options.terminate === true ? "terminate" : "finish";
2652
+ const out = await client.post(`/v1/runs/${id}/events`, {
2653
+ event,
2654
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
2655
+ });
2656
+ if (!out.ok) {
2657
+ throw new RunCommandError(
2658
+ out.status === 404 ? `No run '${id}' \u2014 check the id, or whether the token belongs to the same project.` : `Could not ${event} run ${id} \u2014 ${out.detail || out.kind}.`
2659
+ );
2660
+ }
2661
+ write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
2662
+ }
2663
+ async function handleRunExec(options) {
2664
+ const [command, ...args] = options.argv;
2665
+ if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
2666
+ const run = await handleRunStart(options);
2667
+ const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
2668
+ const code = await new Promise((resolve6) => {
2669
+ const child = spawnImpl(command, args, {
2670
+ stdio: "inherit",
2671
+ shell: process.platform === "win32",
2672
+ env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
2673
+ });
2674
+ child.on("close", (exitCode) => resolve6(exitCode ?? 1));
2675
+ child.on("error", () => resolve6(1));
2676
+ });
2677
+ await handleRunFinish(run.id, options);
2678
+ return code;
2679
+ }
2680
+ async function handleRunReport(options = {}) {
2681
+ const file = options.file ?? DEFAULT_FALLBACK_PATH;
2682
+ const write = options.write ?? ((line) => void process.stdout.write(`${line}
2683
+ `));
2684
+ let raw;
2685
+ try {
2686
+ raw = fs7.readFileSync(file, "utf8");
2687
+ } catch {
2688
+ throw new RunCommandError(`Nothing to send \u2014 no file at ${file}.`);
2689
+ }
2690
+ const lines = raw.split("\n").filter((l) => l.trim() !== "").map((l) => JSON.parse(l));
2691
+ if (lines.length === 0) throw new RunCommandError(`Nothing to send \u2014 ${file} is empty.`);
2692
+ const { client } = connect(options);
2693
+ const result = { batches: lines.length, sent: 0, failed: 0 };
2694
+ for (const line of lines) {
2695
+ const pending = line.results.filter(isPending);
2696
+ const ready = line.results.filter((r) => !isPending(r));
2697
+ if (line.runId !== null && ready.length > 0) {
2698
+ const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
2699
+ if (out.ok) result.sent += ready.length;
2700
+ else {
2701
+ result.failed += ready.length;
2702
+ write(`Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`);
2703
+ }
2704
+ }
2705
+ if (pending.length > 0) {
2706
+ const session = await startRun({
2707
+ ...options.apiUrl !== void 0 ? { apiUrl: options.apiUrl } : {},
2708
+ ...options.token !== void 0 ? { token: options.token } : {},
2709
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2710
+ ...line.externalKey !== null ? { externalKey: line.externalKey } : {},
2711
+ // The replay does not know whether the run is complete, so it must not say that it is.
2712
+ log: write
2713
+ });
2714
+ for (const r of pending) await session.add(r);
2715
+ await session.leaveOpen();
2716
+ result.sent += session.stats.accepted + session.stats.duplicate;
2717
+ result.failed += session.stats.deferred + session.stats.unresolved;
2718
+ }
2719
+ }
2720
+ write(`Replayed ${result.batches} batch(es) from ${file}: ${result.sent} sent, ${result.failed} not.`);
2721
+ return result;
2722
+ }
2723
+ var import_node_child_process, fs7, NoTokenError, RunCommandError, isPending;
2724
+ var init_run_lifecycle = __esm({
2725
+ "src/cli/commands/run-lifecycle.ts"() {
2726
+ "use strict";
2727
+ init_cjs_shims();
2728
+ import_node_child_process = require("child_process");
2729
+ fs7 = __toESM(require("fs"), 1);
2730
+ init_api_url();
2731
+ init_credentials();
2732
+ init_client();
2733
+ init_env2();
2734
+ init_fallback();
2735
+ init_session();
2736
+ NoTokenError = class extends Error {
2737
+ constructor() {
2738
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
2739
+ this.name = "NoTokenError";
2740
+ }
2741
+ };
2742
+ RunCommandError = class extends Error {
2743
+ constructor(message) {
2744
+ super(message);
2745
+ this.name = "RunCommandError";
2746
+ }
2747
+ };
2748
+ isPending = (r) => "keys" in r;
2749
+ }
2750
+ });
2751
+
2120
2752
  // src/cli/commands/report.ts
2121
2753
  var report_exports = {};
2122
2754
  __export(report_exports, {
@@ -2130,10 +2762,10 @@ function isRunResult(v) {
2130
2762
  }
2131
2763
  function handleReport(opts = {}) {
2132
2764
  const cwd = opts.cwd ?? process.cwd();
2133
- const file = path6.join(cwd, ".plune", "last-run.json");
2765
+ const file = path7.join(cwd, ".plune", "last-run.json");
2134
2766
  let raw;
2135
2767
  try {
2136
- raw = fs6.readFileSync(file, "utf8");
2768
+ raw = fs8.readFileSync(file, "utf8");
2137
2769
  } catch {
2138
2770
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
2139
2771
  }
@@ -2148,13 +2780,13 @@ function handleReport(opts = {}) {
2148
2780
  }
2149
2781
  return parsed;
2150
2782
  }
2151
- var fs6, path6, ReportNotFoundError;
2783
+ var fs8, path7, ReportNotFoundError;
2152
2784
  var init_report = __esm({
2153
2785
  "src/cli/commands/report.ts"() {
2154
2786
  "use strict";
2155
2787
  init_cjs_shims();
2156
- fs6 = __toESM(require("fs"), 1);
2157
- path6 = __toESM(require("path"), 1);
2788
+ fs8 = __toESM(require("fs"), 1);
2789
+ path7 = __toESM(require("path"), 1);
2158
2790
  ReportNotFoundError = class extends Error {
2159
2791
  code = "NO_SAVED_RUN";
2160
2792
  constructor(message) {
@@ -2177,7 +2809,7 @@ function evalStatus(e) {
2177
2809
  if (statuses.includes("errored")) return "errored";
2178
2810
  return "passed";
2179
2811
  }
2180
- function classify3(baseline, current) {
2812
+ function classify4(baseline, current) {
2181
2813
  if (current === "errored") return "errored";
2182
2814
  if (baseline === "absent") return current === "passed" ? "new-pass" : "new-fail";
2183
2815
  if (current === "absent") return "removed";
@@ -2191,19 +2823,19 @@ function diffRuns(baseline, current) {
2191
2823
  const evals = ids.map((id) => {
2192
2824
  const baselineP = baseMap.get(id) ?? "absent";
2193
2825
  const currentP = curMap.get(id) ?? "absent";
2194
- return { id, status: classify3(baselineP, currentP), baseline: baselineP, current: currentP };
2826
+ return { id, status: classify4(baselineP, currentP), baseline: baselineP, current: currentP };
2195
2827
  });
2196
- const count = (s) => evals.filter((e) => e.status === s).length;
2828
+ const count2 = (s) => evals.filter((e) => e.status === s).length;
2197
2829
  const summary = {
2198
- regressions: count("regression"),
2199
- improvements: count("improvement"),
2200
- preExistingFails: count("pre-existing-fail"),
2201
- newFails: count("new-fail"),
2202
- newPasses: count("new-pass"),
2203
- stablePasses: count("stable-pass"),
2204
- errored: count("errored"),
2205
- removed: count("removed"),
2206
- hasRegression: count("regression") > 0
2830
+ regressions: count2("regression"),
2831
+ improvements: count2("improvement"),
2832
+ preExistingFails: count2("pre-existing-fail"),
2833
+ newFails: count2("new-fail"),
2834
+ newPasses: count2("new-pass"),
2835
+ stablePasses: count2("stable-pass"),
2836
+ errored: count2("errored"),
2837
+ removed: count2("removed"),
2838
+ hasRegression: count2("regression") > 0
2207
2839
  };
2208
2840
  return { evals, summary };
2209
2841
  }
@@ -2228,7 +2860,7 @@ function isRunResult2(v) {
2228
2860
  function loadRunResult(file, label) {
2229
2861
  let raw;
2230
2862
  try {
2231
- raw = fs7.readFileSync(file, "utf8");
2863
+ raw = fs9.readFileSync(file, "utf8");
2232
2864
  } catch {
2233
2865
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
2234
2866
  }
@@ -2248,12 +2880,12 @@ function handleDiff(opts) {
2248
2880
  const current = loadRunResult(opts.currentPath, "current");
2249
2881
  return diffRuns(baseline, current);
2250
2882
  }
2251
- var fs7, DiffInputError;
2883
+ var fs9, DiffInputError;
2252
2884
  var init_diff2 = __esm({
2253
2885
  "src/cli/commands/diff.ts"() {
2254
2886
  "use strict";
2255
2887
  init_cjs_shims();
2256
- fs7 = __toESM(require("fs"), 1);
2888
+ fs9 = __toESM(require("fs"), 1);
2257
2889
  init_diff();
2258
2890
  DiffInputError = class extends Error {
2259
2891
  code = "DIFF_INPUT";
@@ -2378,8 +3010,8 @@ async function runInitWizard(cwd) {
2378
3010
  process.exit(1);
2379
3011
  return;
2380
3012
  }
2381
- const outputPath = path7.join(cwd, "plune.yaml");
2382
- if (fs8.existsSync(outputPath)) {
3013
+ const outputPath = path8.join(cwd, "plune.yaml");
3014
+ if (fs10.existsSync(outputPath)) {
2383
3015
  const overwrite = await clack.confirm({
2384
3016
  message: "plune.yaml already exists. Overwrite?"
2385
3017
  });
@@ -2418,18 +3050,18 @@ async function runInitWizard(cwd) {
2418
3050
  `;
2419
3051
  const spin = clack.spinner();
2420
3052
  spin.start("Writing plune.yaml\u2026");
2421
- fs8.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
3053
+ fs10.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
2422
3054
  spin.stop("plune.yaml created!");
2423
- clack.outro("Done! Run `plune run` to execute your evaluations.");
3055
+ clack.outro("plune.yaml is ready.");
2424
3056
  }
2425
- var clack, fs8, path7, import_yaml2;
3057
+ var clack, fs10, path8, import_yaml2;
2426
3058
  var init_wizard = __esm({
2427
3059
  "src/config/init/wizard.ts"() {
2428
3060
  "use strict";
2429
3061
  init_cjs_shims();
2430
3062
  clack = __toESM(require("@clack/prompts"), 1);
2431
- fs8 = __toESM(require("fs"), 1);
2432
- path7 = __toESM(require("path"), 1);
3063
+ fs10 = __toESM(require("fs"), 1);
3064
+ path8 = __toESM(require("path"), 1);
2433
3065
  import_yaml2 = require("yaml");
2434
3066
  init_errors();
2435
3067
  }
@@ -2437,21 +3069,21 @@ var init_wizard = __esm({
2437
3069
 
2438
3070
  // src/cli/templates/index.ts
2439
3071
  function writeTemplateFile(cwd, rel, content, force) {
2440
- const target = path8.join(cwd, rel);
2441
- if (!force && fs9.existsSync(target)) {
3072
+ const target = path9.join(cwd, rel);
3073
+ if (!force && fs11.existsSync(target)) {
2442
3074
  return "skipped";
2443
3075
  }
2444
- fs9.mkdirSync(path8.dirname(target), { recursive: true });
2445
- fs9.writeFileSync(target, content, "utf8");
3076
+ fs11.mkdirSync(path9.dirname(target), { recursive: true });
3077
+ fs11.writeFileSync(target, content, "utf8");
2446
3078
  return "written";
2447
3079
  }
2448
- var fs9, path8, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3080
+ var fs11, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
2449
3081
  var init_templates = __esm({
2450
3082
  "src/cli/templates/index.ts"() {
2451
3083
  "use strict";
2452
3084
  init_cjs_shims();
2453
- fs9 = __toESM(require("fs"), 1);
2454
- path8 = __toESM(require("path"), 1);
3085
+ fs11 = __toESM(require("fs"), 1);
3086
+ path9 = __toESM(require("path"), 1);
2455
3087
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
2456
3088
  # .env is gitignored; .env.example is safe to commit.
2457
3089
  # Plune reads whichever key matches your configured provider.
@@ -2487,6 +3119,7 @@ evals:
2487
3119
  // src/cli/commands/init.ts
2488
3120
  var init_exports = {};
2489
3121
  __export(init_exports, {
3122
+ NEXT_STEPS: () => NEXT_STEPS,
2490
3123
  initCommand: () => initCommand
2491
3124
  });
2492
3125
  function reportWrite(cwd, rel, content, force) {
@@ -2506,8 +3139,9 @@ async function initCommand({ cwd, force, yes = false }) {
2506
3139
  for (const [rel, content] of TEMPLATES) {
2507
3140
  reportWrite(cwd, rel, content, force);
2508
3141
  }
3142
+ process.stdout.write(NEXT_STEPS);
2509
3143
  }
2510
- var TEMPLATES;
3144
+ var TEMPLATES, NEXT_STEPS;
2511
3145
  var init_init = __esm({
2512
3146
  "src/cli/commands/init.ts"() {
2513
3147
  "use strict";
@@ -2518,43 +3152,14 @@ var init_init = __esm({
2518
3152
  ["datasets/example.jsonl", EXAMPLE_JSONL_TPL],
2519
3153
  [".env.example", ENV_EXAMPLE_TPL]
2520
3154
  ];
2521
- }
2522
- });
2523
-
2524
- // src/cli/credentials.ts
2525
- function credentialsFile() {
2526
- const base = process.env["XDG_CONFIG_HOME"]?.trim() || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config");
2527
- return (0, import_node_path.join)(base, "plune", "credentials.json");
2528
- }
2529
- function saveToken(token) {
2530
- const file = credentialsFile();
2531
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(file), { recursive: true, mode: 448 });
2532
- (0, import_node_fs.writeFileSync)(file, JSON.stringify({ token }, null, 2) + "\n", { mode: 384 });
2533
- (0, import_node_fs.chmodSync)(file, 384);
2534
- return file;
2535
- }
2536
- function loadToken() {
2537
- try {
2538
- const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
2539
- return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
2540
- } catch {
2541
- return null;
2542
- }
2543
- }
2544
- function clearToken() {
2545
- const file = credentialsFile();
2546
- if (!(0, import_node_fs.existsSync)(file)) return { removed: false, path: file };
2547
- (0, import_node_fs.rmSync)(file);
2548
- return { removed: true, path: file };
2549
- }
2550
- var import_node_fs, import_node_os, import_node_path;
2551
- var init_credentials = __esm({
2552
- "src/cli/credentials.ts"() {
2553
- "use strict";
2554
- init_cjs_shims();
2555
- import_node_fs = require("fs");
2556
- import_node_os = require("os");
2557
- import_node_path = require("path");
3155
+ NEXT_STEPS = [
3156
+ "",
3157
+ "Next:",
3158
+ " 1. plune run \u2014 runs locally. No account, no token needed.",
3159
+ " 2. plune login \u2014 only if you want history and trends: paste a token from the dashboard.",
3160
+ " 3. plune sync \u2014 sends the last run to the platform.",
3161
+ ""
3162
+ ].join("\n");
2558
3163
  }
2559
3164
  });
2560
3165
 
@@ -2562,25 +3167,81 @@ var init_credentials = __esm({
2562
3167
  var login_exports = {};
2563
3168
  __export(login_exports, {
2564
3169
  EmptyTokenError: () => EmptyTokenError,
2565
- handleLogin: () => handleLogin
3170
+ LoginHttpError: () => LoginHttpError,
3171
+ LoginNetworkError: () => LoginNetworkError,
3172
+ TokenRejectedError: () => TokenRejectedError,
3173
+ handleLogin: () => handleLogin,
3174
+ reportLoginFailure: () => reportLoginFailure
2566
3175
  });
2567
- function handleLogin(opts) {
2568
- const token = opts.token.trim();
2569
- if (token.length === 0) throw new EmptyTokenError();
2570
- return { path: saveToken(token) };
3176
+ async function handleLogin(deps) {
3177
+ const token = deps.token.trim();
3178
+ const apiUrl = resolveApiUrl(deps.apiUrl);
3179
+ if (token.length === 0) throw new EmptyTokenError(apiUrl);
3180
+ if (deps.skipVerify === true) return { path: saveToken(token), verified: false };
3181
+ const doFetch = deps.fetchImpl ?? fetch;
3182
+ let res;
3183
+ try {
3184
+ res = await doFetch(`${apiUrl}/v1/runs?limit=1`, {
3185
+ headers: { authorization: `Bearer ${token}` }
3186
+ });
3187
+ } catch {
3188
+ throw new LoginNetworkError(apiUrl);
3189
+ }
3190
+ await res.text().catch(() => "");
3191
+ if (res.status === 401) throw new TokenRejectedError(apiUrl);
3192
+ if (!res.ok) throw new LoginHttpError(res.status);
3193
+ return { path: saveToken(token), verified: true };
2571
3194
  }
2572
- var EmptyTokenError;
3195
+ function reportLoginFailure(err, write) {
3196
+ if (err instanceof EmptyTokenError || err instanceof TokenRejectedError) {
3197
+ write(`${err.message}
3198
+ `);
3199
+ return 2;
3200
+ }
3201
+ if (err instanceof LoginNetworkError || err instanceof LoginHttpError) {
3202
+ write(`${err.message}
3203
+ `);
3204
+ return 1;
3205
+ }
3206
+ return null;
3207
+ }
3208
+ var EmptyTokenError, TokenRejectedError, LoginNetworkError, LoginHttpError;
2573
3209
  var init_login = __esm({
2574
3210
  "src/cli/commands/login.ts"() {
2575
3211
  "use strict";
2576
3212
  init_cjs_shims();
3213
+ init_api_url();
2577
3214
  init_credentials();
2578
3215
  EmptyTokenError = class extends Error {
2579
- constructor() {
2580
- super("No API token provided. Pass --token <token> or pipe it via stdin.");
3216
+ constructor(apiUrl = resolveApiUrl()) {
3217
+ super(
3218
+ `No API token provided. ${whereToGetAToken(apiUrl)} Then pass --token <token> or pipe it via stdin.`
3219
+ );
2581
3220
  this.name = "EmptyTokenError";
2582
3221
  }
2583
3222
  };
3223
+ TokenRejectedError = class extends Error {
3224
+ constructor(apiUrl = resolveApiUrl()) {
3225
+ super(`That API token was rejected (401). Nothing was saved. ${whereToGetAToken(apiUrl)}`);
3226
+ this.name = "TokenRejectedError";
3227
+ }
3228
+ };
3229
+ LoginNetworkError = class extends Error {
3230
+ constructor(apiUrl) {
3231
+ super(
3232
+ `Could not reach the Plune API at ${apiUrl}, so the token was not checked and NOT saved. Try again, or use --skip-verify to save it without checking.`
3233
+ );
3234
+ this.name = "LoginNetworkError";
3235
+ }
3236
+ };
3237
+ LoginHttpError = class extends Error {
3238
+ constructor(status) {
3239
+ super(
3240
+ `The Plune API returned ${status} while checking the token, so it was NOT saved. Try again.`
3241
+ );
3242
+ this.name = "LoginHttpError";
3243
+ }
3244
+ };
2584
3245
  }
2585
3246
  });
2586
3247
 
@@ -2601,8 +3262,8 @@ var init_logout = __esm({
2601
3262
  });
2602
3263
 
2603
3264
  // src/types/run-result-schema.ts
2604
- function parseRunResult(value) {
2605
- return runResultSchema.parse(value);
3265
+ function parseRunResult(value2) {
3266
+ return runResultSchema.parse(value2);
2606
3267
  }
2607
3268
  var import_zod2, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
2608
3269
  var init_run_result_schema = __esm({
@@ -2632,7 +3293,7 @@ var init_run_result_schema = __esm({
2632
3293
  message: import_zod2.z.string()
2633
3294
  });
2634
3295
  rowResultSchema = import_zod2.z.object({
2635
- vars: import_zod2.z.record(import_zod2.z.unknown()),
3296
+ vars: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()),
2636
3297
  output: import_zod2.z.string().nullable(),
2637
3298
  cached: import_zod2.z.boolean(),
2638
3299
  usage: usageSchema.optional(),
@@ -2673,19 +3334,15 @@ __export(sync_exports, {
2673
3334
  SyncFileError: () => SyncFileError,
2674
3335
  SyncHttpError: () => SyncHttpError,
2675
3336
  SyncNetworkError: () => SyncNetworkError,
2676
- TokenRejectedError: () => TokenRejectedError,
3337
+ TokenRejectedError: () => TokenRejectedError2,
2677
3338
  handleSync: () => handleSync,
2678
3339
  reportSyncFailure: () => reportSyncFailure
2679
3340
  });
2680
- function resolveApiUrl(explicit) {
2681
- const raw = explicit ?? process.env["PLUNE_API_URL"] ?? DEFAULT_API_URL;
2682
- return raw.replace(/\/+$/, "");
2683
- }
2684
3341
  function readRun(cwd, file) {
2685
- const target = file !== void 0 ? path9.resolve(cwd, file) : path9.join(cwd, ".plune", "last-run.json");
3342
+ const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
2686
3343
  let raw;
2687
3344
  try {
2688
- raw = fs10.readFileSync(target, "utf8");
3345
+ raw = fs12.readFileSync(target, "utf8");
2689
3346
  } catch {
2690
3347
  throw new SyncFileError(
2691
3348
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -2731,7 +3388,7 @@ async function handleSync(deps = {}) {
2731
3388
  }
2732
3389
  if (!res.ok) {
2733
3390
  const detail = await safeErrorDetail(res);
2734
- if (res.status === 401) throw new TokenRejectedError();
3391
+ if (res.status === 401) throw new TokenRejectedError2();
2735
3392
  throw new SyncHttpError(res.status, detail);
2736
3393
  }
2737
3394
  const body = await res.json().catch(() => ({}));
@@ -2739,7 +3396,7 @@ async function handleSync(deps = {}) {
2739
3396
  return { id, url: `${endpoint}/${id}` };
2740
3397
  }
2741
3398
  function reportSyncFailure(err, write) {
2742
- if (err instanceof NotLoggedInError || err instanceof TokenRejectedError || err instanceof SyncFileError) {
3399
+ if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
2743
3400
  write(err.message + "\n");
2744
3401
  return 2;
2745
3402
  }
@@ -2749,23 +3406,23 @@ function reportSyncFailure(err, write) {
2749
3406
  }
2750
3407
  return null;
2751
3408
  }
2752
- var fs10, path9, DEFAULT_API_URL, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError;
3409
+ var fs12, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
2753
3410
  var init_sync = __esm({
2754
3411
  "src/cli/commands/sync.ts"() {
2755
3412
  "use strict";
2756
3413
  init_cjs_shims();
2757
- fs10 = __toESM(require("fs"), 1);
2758
- path9 = __toESM(require("path"), 1);
3414
+ fs12 = __toESM(require("fs"), 1);
3415
+ path10 = __toESM(require("path"), 1);
2759
3416
  init_run_result_schema();
3417
+ init_api_url();
2760
3418
  init_credentials();
2761
- DEFAULT_API_URL = "https://beta-api.plune.ai";
2762
3419
  NotLoggedInError = class extends Error {
2763
3420
  constructor() {
2764
3421
  super('Not logged in. Run "plune login" first to save your API token.');
2765
3422
  this.name = "NotLoggedInError";
2766
3423
  }
2767
3424
  };
2768
- TokenRejectedError = class extends Error {
3425
+ TokenRejectedError2 = class extends Error {
2769
3426
  constructor() {
2770
3427
  super(
2771
3428
  'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
@@ -2799,15 +3456,34 @@ var init_sync = __esm({
2799
3456
  });
2800
3457
 
2801
3458
  // src/cli/commands/cairn-artifact.ts
3459
+ async function findLatestCairnRun(runsDir = "runs") {
3460
+ const entries = await fs13.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3461
+ if (entries === null) throw new NoCairnRunsError(runsDir);
3462
+ let newest;
3463
+ for (const entry of entries) {
3464
+ if (!entry.isDirectory()) continue;
3465
+ const dir = path11.join(runsDir, entry.name);
3466
+ try {
3467
+ const report = await fs13.lstat(path11.join(dir, "report.json"));
3468
+ if (!report.isFile()) continue;
3469
+ if (newest === void 0 || report.mtimeMs > newest.at) {
3470
+ newest = { dir, at: report.mtimeMs };
3471
+ }
3472
+ } catch {
3473
+ }
3474
+ }
3475
+ if (newest === void 0) throw new NoCairnRunsError(runsDir);
3476
+ return newest.dir;
3477
+ }
2802
3478
  async function readJson(file) {
2803
3479
  try {
2804
- return JSON.parse(await fs11.readFile(file, "utf8"));
3480
+ return JSON.parse(await fs13.readFile(file, "utf8"));
2805
3481
  } catch {
2806
3482
  return void 0;
2807
3483
  }
2808
3484
  }
2809
3485
  async function defaultReadEvidence(dir) {
2810
- const raw = await readJson(path10.join(dir, "api-evidence.json"));
3486
+ const raw = await readJson(path11.join(dir, "api-evidence.json"));
2811
3487
  if (!Array.isArray(raw)) return void 0;
2812
3488
  return raw.map((r) => {
2813
3489
  const rec = asRecord(r);
@@ -2815,8 +3491,8 @@ async function defaultReadEvidence(dir) {
2815
3491
  });
2816
3492
  }
2817
3493
  function casesFromDesign(report) {
2818
- const list = Array.isArray(report["testCases"]) ? report["testCases"] : [];
2819
- return list.map((raw) => {
3494
+ const list2 = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3495
+ return list2.map((raw) => {
2820
3496
  const c = asRecord(raw);
2821
3497
  const steps = (Array.isArray(c["steps"]) ? c["steps"] : []).map((s) => str(s)).filter((s) => s !== "");
2822
3498
  return {
@@ -2831,8 +3507,8 @@ function casesFromDesign(report) {
2831
3507
  });
2832
3508
  }
2833
3509
  function casesFromApi(report, verdicts) {
2834
- const list = Array.isArray(report["cases"]) ? report["cases"] : [];
2835
- return list.map((raw) => {
3510
+ const list2 = Array.isArray(report["cases"]) ? report["cases"] : [];
3511
+ return list2.map((raw) => {
2836
3512
  const c = asRecord(raw);
2837
3513
  const name = str(c["name"], "unnamed operation");
2838
3514
  const request = `${str(c["method"], "GET")} ${str(c["path"], "/")}`;
@@ -2849,7 +3525,7 @@ function casesFromApi(report, verdicts) {
2849
3525
  });
2850
3526
  }
2851
3527
  async function readCairnRun(dir, deps = {}) {
2852
- const raw = await readJson(path10.join(dir, "report.json"));
3528
+ const raw = await readJson(path11.join(dir, "report.json"));
2853
3529
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
2854
3530
  const report = asRecord(raw);
2855
3531
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -2860,7 +3536,9 @@ async function readCairnRun(dir, deps = {}) {
2860
3536
  }
2861
3537
  const mode = report["mode"];
2862
3538
  if (mode !== "design" && mode !== "explore" && mode !== "api") {
2863
- throw new CairnRunVersionError(`${SUPPORTED_ARTIFACT_VERSION} (unknown run mode ${String(mode)})`);
3539
+ throw new CairnRunVersionError(
3540
+ `${SUPPORTED_ARTIFACT_VERSION} (unknown run mode ${String(mode)})`
3541
+ );
2864
3542
  }
2865
3543
  let cases;
2866
3544
  if (mode === "api") {
@@ -2877,25 +3555,33 @@ async function readCairnRun(dir, deps = {}) {
2877
3555
  return {
2878
3556
  schemaVersion: INGEST_SCHEMA_VERSION,
2879
3557
  run: {
2880
- runId: str(report["runId"], path10.basename(dir)),
3558
+ runId: str(report["runId"], path11.basename(dir)),
2881
3559
  mode,
2882
3560
  target: str(report["url"], "unknown")
2883
3561
  },
2884
3562
  cases
2885
3563
  };
2886
3564
  }
2887
- var fs11, path10, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
3565
+ var fs13, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
2888
3566
  var init_cairn_artifact = __esm({
2889
3567
  "src/cli/commands/cairn-artifact.ts"() {
2890
3568
  "use strict";
2891
3569
  init_cjs_shims();
2892
- fs11 = __toESM(require("fs/promises"), 1);
2893
- path10 = __toESM(require("path"), 1);
3570
+ fs13 = __toESM(require("fs/promises"), 1);
3571
+ path11 = __toESM(require("path"), 1);
2894
3572
  SUPPORTED_ARTIFACT_VERSION = 1;
2895
3573
  INGEST_SCHEMA_VERSION = 1;
3574
+ NoCairnRunsError = class extends Error {
3575
+ constructor(runsDir) {
3576
+ super(`No Cairn run found under ${runsDir} \u2014 name the directory instead: plune ingest <dir>.`);
3577
+ this.name = "NoCairnRunsError";
3578
+ }
3579
+ };
2896
3580
  CairnRunNotFoundError = class extends Error {
2897
3581
  constructor(dir) {
2898
- super(`No Cairn run found in ${dir} \u2014 expected a report.json written by "cairn design|explore|api".`);
3582
+ super(
3583
+ `No Cairn run found in ${dir} \u2014 expected a report.json written by "cairn design|explore|api".`
3584
+ );
2899
3585
  this.name = "CairnRunNotFoundError";
2900
3586
  }
2901
3587
  };
@@ -2937,10 +3623,6 @@ __export(ingest_exports, {
2937
3623
  handleIngest: () => handleIngest,
2938
3624
  reportIngestFailure: () => reportIngestFailure
2939
3625
  });
2940
- function resolveApiUrl2(explicit) {
2941
- const raw = explicit ?? process.env["PLUNE_API_URL"] ?? DEFAULT_API_URL2;
2942
- return raw.replace(/\/+$/, "");
2943
- }
2944
3626
  async function safeErrorDetail2(res) {
2945
3627
  try {
2946
3628
  const body = await res.json();
@@ -2950,10 +3632,11 @@ async function safeErrorDetail2(res) {
2950
3632
  }
2951
3633
  }
2952
3634
  async function handleIngest(deps) {
2953
- const apiUrl = resolveApiUrl2(deps.apiUrl);
3635
+ const apiUrl = resolveApiUrl(deps.apiUrl);
2954
3636
  const token = (deps.loadToken ?? loadToken)();
2955
3637
  if (token === null) throw new IngestNotLoggedInError();
2956
- const payload = await readCairnRun(deps.dir);
3638
+ const dir = deps.dir ?? await findLatestCairnRun(deps.runsDir);
3639
+ const payload = await readCairnRun(dir);
2957
3640
  const doFetch = deps.fetchImpl ?? fetch;
2958
3641
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
2959
3642
  let res;
@@ -2980,7 +3663,7 @@ async function handleIngest(deps) {
2980
3663
  };
2981
3664
  }
2982
3665
  function reportIngestFailure(err, write) {
2983
- if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
3666
+ if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof NoCairnRunsError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
2984
3667
  write(`${err.message}
2985
3668
  `);
2986
3669
  return 2;
@@ -3002,14 +3685,14 @@ function formatIngestResult(r) {
3002
3685
  return `${lines.join("\n")}
3003
3686
  `;
3004
3687
  }
3005
- var DEFAULT_API_URL2, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
3688
+ var IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
3006
3689
  var init_ingest = __esm({
3007
3690
  "src/cli/commands/ingest.ts"() {
3008
3691
  "use strict";
3009
3692
  init_cjs_shims();
3693
+ init_api_url();
3010
3694
  init_credentials();
3011
3695
  init_cairn_artifact();
3012
- DEFAULT_API_URL2 = "https://beta-api.plune.ai";
3013
3696
  IngestNotLoggedInError = class extends Error {
3014
3697
  constructor() {
3015
3698
  super('Not logged in. Run "plune login" first to save your API token.');
@@ -3046,6 +3729,7 @@ __export(cli_exports, {
3046
3729
  });
3047
3730
  module.exports = __toCommonJS(cli_exports);
3048
3731
  init_cjs_shims();
3732
+ init_api_url();
3049
3733
  var import_commander = require("commander");
3050
3734
  var import_node_fs2 = require("fs");
3051
3735
  var import_node_path2 = require("path");
@@ -3076,10 +3760,17 @@ function failUnexpected(err, verbose) {
3076
3760
  } else {
3077
3761
  process.stderr.write(String(err) + "\n");
3078
3762
  }
3763
+ process.stderr.write(
3764
+ `This one is on us. Run again with --verbose and send the output: ${FEEDBACK_URL}
3765
+ `
3766
+ );
3079
3767
  process.exit(1);
3080
3768
  }
3081
3769
  async function readTokenFromStdin() {
3082
3770
  if (process.stdin.isTTY === true) {
3771
+ const { resolveApiUrl: resolveApiUrl2, whereToGetAToken: whereToGetAToken2 } = await Promise.resolve().then(() => (init_api_url(), api_url_exports));
3772
+ process.stderr.write(`${whereToGetAToken2(resolveApiUrl2())}
3773
+ `);
3083
3774
  process.stderr.write("Paste your API token, then press Enter:\n");
3084
3775
  }
3085
3776
  const { createInterface } = await import("readline");
@@ -3095,7 +3786,7 @@ function createProgram() {
3095
3786
  const program = new import_commander.Command();
3096
3787
  program.name("plune").description("Plune \u2014 AI-powered assertion testing CLI").version(pkg.version);
3097
3788
  program.option("-c, --config <path>", "Path to plune.yaml config file (applies to all commands)").option("-v, --verbose", "Verbose output, including stack traces on unexpected errors", false).option("--no-color", "Disable colored output regardless of TTY");
3098
- program.command("run").description("Run assertions against a dataset").option("--dry-run", "Estimate cost/tokens; do not call the model", false).option("--config <path>", "Path to plune.yaml config file").option(
3789
+ const runCommand = program.command("run").description("Run assertions against a dataset").option("--dry-run", "Estimate cost/tokens; do not call the model", false).option("--config <path>", "Path to plune.yaml config file").option(
3099
3790
  "--only <selector>",
3100
3791
  "Run a subset by eval id or tag (repeatable)",
3101
3792
  (val, prev) => [...prev, val],
@@ -3171,6 +3862,58 @@ function createProgram() {
3171
3862
  }
3172
3863
  }
3173
3864
  );
3865
+ const failRunCommand = async (err, verbose) => {
3866
+ const { NoTokenError: NoTokenError2, RunCommandError: RunCommandError2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3867
+ if (err instanceof NoTokenError2) {
3868
+ process.stderr.write(err.message + "\n");
3869
+ process.exit(2);
3870
+ }
3871
+ if (err instanceof RunCommandError2) {
3872
+ process.stderr.write(err.message + "\n");
3873
+ process.exit(1);
3874
+ }
3875
+ failUnexpected(err, verbose);
3876
+ process.exit(1);
3877
+ };
3878
+ const verboseOf = (command) => command.optsWithGlobals().verbose === true;
3879
+ runCommand.command("start").description("Open a platform run (or join one already carrying the key) and print its id").option("--key <externalKey>", "The key several processes share; generated when absent").option("--json", "Print one machine-readable line instead of prose", false).action(async (options, command) => {
3880
+ try {
3881
+ const { handleRunStart: handleRunStart2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3882
+ await handleRunStart2({ ...options.key !== void 0 ? { key: options.key } : {}, json: options.json });
3883
+ } catch (err) {
3884
+ await failRunCommand(err, verboseOf(command));
3885
+ }
3886
+ });
3887
+ runCommand.command("finish").argument("<id>", "The run to close").description("Close a platform run \u2014 what the reporter tells you to do for a run left open").option("--terminate", "Record the run as cut short rather than finished", false).option("--reason <text>", "Why, for a terminated run").action(async (id, options, command) => {
3888
+ try {
3889
+ const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3890
+ await handleRunFinish2(id, {
3891
+ terminate: options.terminate,
3892
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
3893
+ });
3894
+ } catch (err) {
3895
+ await failRunCommand(err, verboseOf(command));
3896
+ }
3897
+ });
3898
+ runCommand.command("exec").argument("<command...>", "The command to run inside the run, after `--`").description("Open a run, run a command inside it, close the run \u2014 whatever the command returns").option("--key <externalKey>", "The key several processes share; generated when absent").action(async (argv, options, command) => {
3899
+ try {
3900
+ const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3901
+ process.exitCode = await handleRunExec2({
3902
+ argv,
3903
+ ...options.key !== void 0 ? { key: options.key } : {}
3904
+ });
3905
+ } catch (err) {
3906
+ await failRunCommand(err, verboseOf(command));
3907
+ }
3908
+ });
3909
+ runCommand.command("report").description("Send what the reporter could not \u2014 replays .plune/pending-results.jsonl").option("--file <path>", "The fallback file to replay").action(async (options, command) => {
3910
+ try {
3911
+ const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3912
+ await handleRunReport2({ ...options.file !== void 0 ? { file: options.file } : {} });
3913
+ } catch (err) {
3914
+ await failRunCommand(err, verboseOf(command));
3915
+ }
3916
+ });
3174
3917
  program.command("report").description("Render the most recent run in a chosen format").option("--format <fmt>", "console | json | markdown", "console").option("-o, --output <path>", "Write the report to a file instead of stdout").action(async (options, command) => {
3175
3918
  const globals = command.optsWithGlobals();
3176
3919
  const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
@@ -3234,18 +3977,26 @@ function createProgram() {
3234
3977
  failUnexpected(err, verbose);
3235
3978
  }
3236
3979
  });
3237
- program.command("login").description("Save your Plune API token so the CLI can reach the platform").option("--token <token>", "API token (omit to paste it / pipe it via stdin)").action(async (options, command) => {
3980
+ program.command("login").description("Save your Plune API token (get one in the dashboard: Settings \u2192 API tokens)").option("--token <token>", "API token (omit to paste it / pipe it via stdin)").option(
3981
+ "--skip-verify",
3982
+ "Save without checking the token against the API (offline setup)",
3983
+ false
3984
+ ).action(async (options, command) => {
3238
3985
  const verbose = command.optsWithGlobals().verbose === true;
3239
- const { handleLogin: handleLogin2, EmptyTokenError: EmptyTokenError2 } = await Promise.resolve().then(() => (init_login(), login_exports));
3986
+ const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
3240
3987
  try {
3241
3988
  const token = options.token ?? await readTokenFromStdin();
3242
- const { path: path11 } = handleLogin2({ token });
3243
- process.stdout.write(`Logged in. Token saved to ${path11}
3244
- `);
3989
+ const { path: path12, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
3990
+ process.stdout.write(
3991
+ verified ? `Logged in. Token checked against the API and saved to ${path12}
3992
+ ` : `Token saved to ${path12} WITHOUT being checked (--skip-verify).
3993
+ `
3994
+ );
3245
3995
  } catch (err) {
3246
- if (err instanceof EmptyTokenError2) {
3247
- process.stderr.write(err.message + "\n");
3248
- process.exit(2);
3996
+ const code = reportLoginFailure2(err, (s) => process.stderr.write(s));
3997
+ if (code !== null) {
3998
+ maybeStack(err, verbose);
3999
+ process.exitCode = code;
3249
4000
  return;
3250
4001
  }
3251
4002
  failUnexpected(err, verbose);
@@ -3255,9 +4006,9 @@ function createProgram() {
3255
4006
  const verbose = command.optsWithGlobals().verbose === true;
3256
4007
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
3257
4008
  try {
3258
- const { removed, path: path11 } = handleLogout2();
4009
+ const { removed, path: path12 } = handleLogout2();
3259
4010
  process.stdout.write(
3260
- removed ? `Logged out. Removed ${path11}
4011
+ removed ? `Logged out. Removed ${path12}
3261
4012
  ` : "Not logged in \u2014 nothing to remove.\n"
3262
4013
  );
3263
4014
  } catch (err) {
@@ -3286,12 +4037,14 @@ function createProgram() {
3286
4037
  failUnexpected(err, verbose);
3287
4038
  }
3288
4039
  });
3289
- program.command("ingest").argument("<dir>", "A Cairn run directory (the one holding report.json)").description("Record a Cairn run in Plune \u2014 generated cases arrive as review proposals").action(async (dir, _options, command) => {
4040
+ program.command("ingest").argument("[dir]", "A Cairn run directory; omit it for the newest run under ./runs").description("Record a Cairn run in Plune \u2014 generated cases arrive as review proposals").action(async (dir, _options, command) => {
3290
4041
  const globals = command.optsWithGlobals();
3291
4042
  const verbose = globals.verbose === true;
3292
4043
  const { handleIngest: handleIngest2, reportIngestFailure: reportIngestFailure2, formatIngestResult: formatIngestResult2 } = await Promise.resolve().then(() => (init_ingest(), ingest_exports));
3293
4044
  try {
3294
- process.stdout.write(formatIngestResult2(await handleIngest2({ dir })));
4045
+ process.stdout.write(
4046
+ formatIngestResult2(await handleIngest2({ ...dir !== void 0 ? { dir } : {} }))
4047
+ );
3295
4048
  } catch (err) {
3296
4049
  const code = reportIngestFailure2(err, (s) => process.stderr.write(s));
3297
4050
  if (code !== null) {