@plune-ai/cli 0.5.0 → 0.7.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
  }
@@ -808,9 +837,11 @@ var init_embedder = __esm({
808
837
  return rows.map((row) => Float32Array.from(row));
809
838
  }
810
839
  load() {
811
- this.extractor ??= import("@huggingface/transformers").then(
812
- ({ pipeline }) => pipeline("feature-extraction", MODEL_ID)
813
- );
840
+ this.extractor ??= import("@huggingface/transformers").catch(() => {
841
+ throw new Error(
842
+ "The `semantic-similarity` and RAG assertions need local embeddings, which ship separately: install @huggingface/transformers to enable them. Every other assertion works without it."
843
+ );
844
+ }).then(({ pipeline }) => pipeline("feature-extraction", MODEL_ID));
814
845
  return this.extractor;
815
846
  }
816
847
  };
@@ -884,7 +915,7 @@ var init_cache = __esm({
884
915
  return void 0;
885
916
  }
886
917
  }
887
- set(key, value) {
918
+ set(key, value2) {
888
919
  try {
889
920
  this.db.prepare(
890
921
  `INSERT INTO completions (cache_key, output, input_tokens, output_tokens, created_at)
@@ -894,7 +925,7 @@ var init_cache = __esm({
894
925
  input_tokens = excluded.input_tokens,
895
926
  output_tokens = excluded.output_tokens,
896
927
  created_at = excluded.created_at`
897
- ).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());
898
929
  } catch {
899
930
  }
900
931
  }
@@ -932,32 +963,32 @@ var init_cache2 = __esm({
932
963
  });
933
964
 
934
965
  // src/util/canonical-json.ts
935
- function normalizeValue(value) {
936
- if (value === null) return null;
937
- if (typeof value === "boolean") return value;
938
- if (typeof value === "number") {
939
- 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)) {
940
971
  throw new Error(
941
- `canonicalJson: non-finite number rejected: ${String(value)}`
972
+ `canonicalJson: non-finite number rejected: ${String(value2)}`
942
973
  );
943
974
  }
944
- return value === 0 ? 0 : value;
975
+ return value2 === 0 ? 0 : value2;
945
976
  }
946
- if (typeof value === "string") {
947
- return value.normalize("NFC");
977
+ if (typeof value2 === "string") {
978
+ return value2.normalize("NFC");
948
979
  }
949
- if (Array.isArray(value)) {
950
- return value.map(normalizeValue);
980
+ if (Array.isArray(value2)) {
981
+ return value2.map(normalizeValue);
951
982
  }
952
- if (typeof value === "object") {
953
- const proto = Object.getPrototypeOf(value);
983
+ if (typeof value2 === "object") {
984
+ const proto = Object.getPrototypeOf(value2);
954
985
  if (proto !== null && proto !== Object.prototype) {
955
- const name = value.constructor?.name ?? "unknown";
986
+ const name = value2.constructor?.name ?? "unknown";
956
987
  throw new Error(
957
988
  `canonicalJson: unsupported non-plain object (${name}) \u2014 only plain objects, arrays, and primitives are serializable`
958
989
  );
959
990
  }
960
- const obj = value;
991
+ const obj = value2;
961
992
  const sorted = {};
962
993
  for (const key of Object.keys(obj).sort()) {
963
994
  sorted[key] = normalizeValue(obj[key]);
@@ -965,11 +996,11 @@ function normalizeValue(value) {
965
996
  return sorted;
966
997
  }
967
998
  throw new Error(
968
- `canonicalJson: unsupported value type: ${typeof value}`
999
+ `canonicalJson: unsupported value type: ${typeof value2}`
969
1000
  );
970
1001
  }
971
- function canonicalJson(value) {
972
- return JSON.stringify(normalizeValue(value));
1002
+ function canonicalJson(value2) {
1003
+ return JSON.stringify(normalizeValue(value2));
973
1004
  }
974
1005
  var init_canonical_json = __esm({
975
1006
  "src/util/canonical-json.ts"() {
@@ -1007,8 +1038,8 @@ function interpolate(template, ctx) {
1007
1038
  }
1008
1039
  if (key.startsWith("vars.")) {
1009
1040
  const name = key.slice("vars.".length);
1010
- const value = ctx.vars[name];
1011
- return value === void 0 ? match : String(value);
1041
+ const value2 = ctx.vars[name];
1042
+ return value2 === void 0 ? match : String(value2);
1012
1043
  }
1013
1044
  return match;
1014
1045
  });
@@ -1061,12 +1092,12 @@ var init_contains = __esm({
1061
1092
  init_interpolate();
1062
1093
  contains = {
1063
1094
  run(ctx) {
1064
- const value = interpolate(ctx.params.value, ctx);
1095
+ const value2 = interpolate(ctx.params.value, ctx);
1065
1096
  const found = norm(ctx.output, ctx.params.ignore_case).includes(
1066
- norm(value, ctx.params.ignore_case)
1097
+ norm(value2, ctx.params.ignore_case)
1067
1098
  );
1068
1099
  return Promise.resolve(
1069
- found ? { passed: true } : { passed: false, reason: `expected output to contain: "${value}"` }
1100
+ found ? { passed: true } : { passed: false, reason: `expected output to contain: "${value2}"` }
1070
1101
  );
1071
1102
  }
1072
1103
  };
@@ -2115,6 +2146,549 @@ var init_reporters = __esm({
2115
2146
  }
2116
2147
  });
2117
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 readEnv(env = process.env) {
2271
+ const externalKey = value(env, "PLUNE_RUN");
2272
+ const token = value(env, "PLUNE_TOKEN");
2273
+ const apiUrl = value(env, "PLUNE_API_URL");
2274
+ const fallbackPath = value(env, "PLUNE_FALLBACK");
2275
+ const batchSize = count(env, "PLUNE_BATCH_SIZE");
2276
+ return {
2277
+ config: {
2278
+ ...externalKey !== void 0 ? { externalKey } : {},
2279
+ ...token !== void 0 ? { token } : {},
2280
+ ...apiUrl !== void 0 ? { apiUrl } : {},
2281
+ ...fallbackPath !== void 0 ? { fallbackPath } : {},
2282
+ ...batchSize !== void 0 ? { batchSize } : {}
2283
+ },
2284
+ // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2285
+ // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
2286
+ // way this process must not be the one to call it finished.
2287
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
2288
+ ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
2289
+ };
2290
+ }
2291
+ var UNSUPPORTED_VARS;
2292
+ var init_env2 = __esm({
2293
+ "src/reporter-core/env.ts"() {
2294
+ "use strict";
2295
+ init_cjs_shims();
2296
+ UNSUPPORTED_VARS = [
2297
+ "PLUNE_RUN_TITLE",
2298
+ "PLUNE_ENV",
2299
+ "PLUNE_LABELS",
2300
+ "PLUNE_GROUP"
2301
+ ];
2302
+ }
2303
+ });
2304
+
2305
+ // src/reporter-core/fallback.ts
2306
+ function appendBatch(file, batch) {
2307
+ fs6.mkdirSync(path6.dirname(file), { recursive: true });
2308
+ const line = {
2309
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2310
+ runId: batch.runId,
2311
+ externalKey: batch.externalKey,
2312
+ results: batch.results
2313
+ };
2314
+ fs6.appendFileSync(file, `${JSON.stringify(line)}
2315
+ `, "utf8");
2316
+ }
2317
+ var fs6, path6, DEFAULT_FALLBACK_PATH;
2318
+ var init_fallback = __esm({
2319
+ "src/reporter-core/fallback.ts"() {
2320
+ "use strict";
2321
+ init_cjs_shims();
2322
+ fs6 = __toESM(require("fs"), 1);
2323
+ path6 = __toESM(require("path"), 1);
2324
+ DEFAULT_FALLBACK_PATH = path6.join(".plune", "pending-results.jsonl");
2325
+ }
2326
+ });
2327
+
2328
+ // src/reporter-core/session.ts
2329
+ function chunk(items, size) {
2330
+ const out = [];
2331
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2332
+ return out;
2333
+ }
2334
+ async function startRun(passed, expected) {
2335
+ const env = readEnv();
2336
+ const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2337
+ const cfg = { ...env.config, ...stated };
2338
+ const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
2339
+ `));
2340
+ const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2341
+ const externalKey = cfg.externalKey ?? null;
2342
+ const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2343
+ const token = cfg.token ?? loadToken() ?? "";
2344
+ const client = createClient({
2345
+ apiUrl: resolveApiUrl(cfg.apiUrl),
2346
+ token,
2347
+ ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
2348
+ });
2349
+ const stats = {
2350
+ accepted: 0,
2351
+ duplicate: 0,
2352
+ conflict: 0,
2353
+ rejected: 0,
2354
+ unresolved: 0,
2355
+ deferred: 0
2356
+ };
2357
+ const resolved = /* @__PURE__ */ new Map();
2358
+ let buffer = [];
2359
+ let runId = null;
2360
+ let joined = false;
2361
+ let offline = token === "";
2362
+ let done = false;
2363
+ if (offline) {
2364
+ log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2365
+ }
2366
+ if (env.ignored.length > 0) {
2367
+ log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
2368
+ }
2369
+ function defer(results) {
2370
+ appendBatch(fallbackPath, { runId, externalKey, results });
2371
+ stats.deferred += results.length;
2372
+ }
2373
+ async function resolveKeys(values) {
2374
+ const unknown = values.filter((k) => !resolved.has(k.value));
2375
+ if (unknown.length === 0 || offline) return;
2376
+ for (const part of chunk(unknown, RESOLVE_CHUNK)) {
2377
+ const out = await client.post(
2378
+ "/v1/test-cases/resolve",
2379
+ { keys: part }
2380
+ );
2381
+ if (!out.ok) {
2382
+ log(`plune: could not look up test cases (${out.detail || out.kind}).`);
2383
+ return;
2384
+ }
2385
+ for (const row of out.body.results) resolved.set(row.key.value, row.testCaseId);
2386
+ }
2387
+ }
2388
+ if (!offline) {
2389
+ if (expected !== void 0) await resolveKeys(expected.flat());
2390
+ const declared = expected?.slice(0, EXPECTED_MAX) ?? [];
2391
+ const configuration = declared.length === 0 ? void 0 : {
2392
+ expected: declared.flatMap((keys) => {
2393
+ const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2394
+ if (typeof hit === "string") return [{ testCaseId: hit }];
2395
+ return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
2396
+ })
2397
+ };
2398
+ if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2399
+ log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
2400
+ }
2401
+ const start = await client.post("/v1/runs", {
2402
+ schemaVersion: 2,
2403
+ kind: cfg.kind ?? "automated",
2404
+ ...externalKey !== null ? { externalKey } : {},
2405
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2406
+ ...configuration !== void 0 ? { configuration } : {}
2407
+ });
2408
+ if (start.ok) {
2409
+ runId = start.body.run.id;
2410
+ joined = start.status === 200;
2411
+ } else {
2412
+ log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
2413
+ }
2414
+ }
2415
+ function matchFor(result) {
2416
+ if (result.testCaseId !== void 0) return { kind: "found", testCaseId: result.testCaseId };
2417
+ let unanswered = false;
2418
+ for (const key of result.keys) {
2419
+ const hit = resolved.get(key.value);
2420
+ if (typeof hit === "string") return { kind: "found", testCaseId: hit };
2421
+ if (hit === void 0) unanswered = true;
2422
+ }
2423
+ return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
2424
+ }
2425
+ async function flush() {
2426
+ if (buffer.length === 0) return;
2427
+ const pending = buffer;
2428
+ buffer = [];
2429
+ if (offline || runId === null) {
2430
+ defer(pending);
2431
+ return;
2432
+ }
2433
+ await resolveKeys(pending.flatMap((p) => p.keys));
2434
+ const submissions = [];
2435
+ const unanswered = [];
2436
+ for (const result of pending) {
2437
+ const match = matchFor(result);
2438
+ if (match.kind === "found") {
2439
+ const { keys: _keys, ...rest } = result;
2440
+ submissions.push({ ...rest, testCaseId: match.testCaseId });
2441
+ } else if (match.kind === "unmatched") {
2442
+ stats.unresolved += 1;
2443
+ } else {
2444
+ unanswered.push(result);
2445
+ }
2446
+ }
2447
+ if (unanswered.length > 0) defer(unanswered);
2448
+ if (submissions.length === 0) return;
2449
+ const out = await client.post(
2450
+ `/v1/runs/${runId}/results`,
2451
+ { results: submissions }
2452
+ );
2453
+ if (out.ok) {
2454
+ stats.accepted += out.body.counts.accepted ?? 0;
2455
+ stats.duplicate += out.body.counts.duplicate ?? 0;
2456
+ stats.conflict += out.body.counts.conflict ?? 0;
2457
+ stats.rejected += out.body.counts.rejected ?? 0;
2458
+ return;
2459
+ }
2460
+ if (out.kind === "auth") {
2461
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
2462
+ offline = true;
2463
+ } else if (out.kind === "conflict") {
2464
+ log(`plune: this run is closed \u2014 ${out.detail}`);
2465
+ } else {
2466
+ log(`plune: could not send results (${out.detail || out.kind}).`);
2467
+ }
2468
+ defer(submissions);
2469
+ }
2470
+ function summarise() {
2471
+ const parts = [`${stats.accepted} accepted`];
2472
+ if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
2473
+ if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
2474
+ if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2475
+ if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2476
+ if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2477
+ log(`plune: ${parts.join(" \xB7 ")}`);
2478
+ }
2479
+ return {
2480
+ get runId() {
2481
+ return runId;
2482
+ },
2483
+ get joined() {
2484
+ return joined;
2485
+ },
2486
+ get stats() {
2487
+ return stats;
2488
+ },
2489
+ async add(result) {
2490
+ buffer.push(result);
2491
+ if (buffer.length >= batchSize) await flush();
2492
+ },
2493
+ flush,
2494
+ async finish(reason) {
2495
+ if (done) return;
2496
+ done = true;
2497
+ await flush();
2498
+ if (runId !== null && !offline) {
2499
+ const out = await client.post(`/v1/runs/${runId}/events`, {
2500
+ event: "finish",
2501
+ ...reason !== void 0 ? { reason } : {}
2502
+ });
2503
+ if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
2504
+ }
2505
+ summarise();
2506
+ },
2507
+ async leaveOpen() {
2508
+ if (done) return;
2509
+ done = true;
2510
+ await flush();
2511
+ if (runId !== null) {
2512
+ log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
2513
+ }
2514
+ summarise();
2515
+ }
2516
+ };
2517
+ }
2518
+ var RESOLVE_CHUNK, BATCH_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2519
+ var init_session = __esm({
2520
+ "src/reporter-core/session.ts"() {
2521
+ "use strict";
2522
+ init_cjs_shims();
2523
+ init_api_url();
2524
+ init_credentials();
2525
+ init_client();
2526
+ init_env2();
2527
+ init_fallback();
2528
+ RESOLVE_CHUNK = 500;
2529
+ BATCH_MAX = 500;
2530
+ BATCH_DEFAULT = 100;
2531
+ EXPECTED_MAX = 1e4;
2532
+ }
2533
+ });
2534
+
2535
+ // src/cli/commands/run-lifecycle.ts
2536
+ var run_lifecycle_exports = {};
2537
+ __export(run_lifecycle_exports, {
2538
+ NoTokenError: () => NoTokenError,
2539
+ RunCommandError: () => RunCommandError,
2540
+ handleRunExec: () => handleRunExec,
2541
+ handleRunFinish: () => handleRunFinish,
2542
+ handleRunReport: () => handleRunReport,
2543
+ handleRunStart: () => handleRunStart
2544
+ });
2545
+ function connect(deps) {
2546
+ const env = readEnv();
2547
+ const apiUrl = resolveApiUrl(deps.apiUrl ?? env.config.apiUrl);
2548
+ const token = deps.token ?? env.config.token ?? loadToken() ?? "";
2549
+ if (token === "") throw new NoTokenError();
2550
+ return {
2551
+ client: createClient({
2552
+ apiUrl,
2553
+ token,
2554
+ ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {}
2555
+ }),
2556
+ apiUrl,
2557
+ write: deps.write ?? ((line) => void process.stdout.write(`${line}
2558
+ `))
2559
+ };
2560
+ }
2561
+ function runUrl(apiUrl, id) {
2562
+ const web = dashboardUrl(apiUrl);
2563
+ return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
2564
+ }
2565
+ async function handleRunStart(options = {}) {
2566
+ const { client, apiUrl, write } = connect(options);
2567
+ const env = readEnv();
2568
+ const externalKey = options.key ?? env.config.externalKey ?? `plune-${(options.now ?? Date.now)().toString(36)}`;
2569
+ const out = await client.post("/v1/runs", {
2570
+ schemaVersion: 2,
2571
+ kind: options.kind ?? "automated",
2572
+ externalKey
2573
+ });
2574
+ if (!out.ok) throw new RunCommandError(`Could not start the run \u2014 ${out.detail || out.kind}.`);
2575
+ const result = {
2576
+ id: out.body.run.id,
2577
+ joined: out.status === 200,
2578
+ externalKey,
2579
+ url: runUrl(apiUrl, out.body.run.id)
2580
+ };
2581
+ if (options.json === true) {
2582
+ write(JSON.stringify(result));
2583
+ } else {
2584
+ write(`${result.joined ? "Joined" : "Started"} run ${result.id}`);
2585
+ write(result.url);
2586
+ }
2587
+ return result;
2588
+ }
2589
+ async function handleRunFinish(id, options = {}) {
2590
+ const { client, write } = connect(options);
2591
+ const event = options.terminate === true ? "terminate" : "finish";
2592
+ const out = await client.post(`/v1/runs/${id}/events`, {
2593
+ event,
2594
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
2595
+ });
2596
+ if (!out.ok) {
2597
+ throw new RunCommandError(
2598
+ 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}.`
2599
+ );
2600
+ }
2601
+ write(`Run ${id} ${event === "finish" ? "finished" : "terminated"}.`);
2602
+ }
2603
+ async function handleRunExec(options) {
2604
+ const [command, ...args] = options.argv;
2605
+ if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
2606
+ const run = await handleRunStart(options);
2607
+ const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
2608
+ const code = await new Promise((resolve6) => {
2609
+ const child = spawnImpl(command, args, {
2610
+ stdio: "inherit",
2611
+ shell: process.platform === "win32",
2612
+ env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
2613
+ });
2614
+ child.on("close", (exitCode) => resolve6(exitCode ?? 1));
2615
+ child.on("error", () => resolve6(1));
2616
+ });
2617
+ await handleRunFinish(run.id, options);
2618
+ return code;
2619
+ }
2620
+ async function handleRunReport(options = {}) {
2621
+ const file = options.file ?? DEFAULT_FALLBACK_PATH;
2622
+ const write = options.write ?? ((line) => void process.stdout.write(`${line}
2623
+ `));
2624
+ let raw;
2625
+ try {
2626
+ raw = fs7.readFileSync(file, "utf8");
2627
+ } catch {
2628
+ throw new RunCommandError(`Nothing to send \u2014 no file at ${file}.`);
2629
+ }
2630
+ const lines = raw.split("\n").filter((l) => l.trim() !== "").map((l) => JSON.parse(l));
2631
+ if (lines.length === 0) throw new RunCommandError(`Nothing to send \u2014 ${file} is empty.`);
2632
+ const { client } = connect(options);
2633
+ const result = { batches: lines.length, sent: 0, failed: 0 };
2634
+ for (const line of lines) {
2635
+ const pending = line.results.filter(isPending);
2636
+ const ready = line.results.filter((r) => !isPending(r));
2637
+ if (line.runId !== null && ready.length > 0) {
2638
+ const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
2639
+ if (out.ok) result.sent += ready.length;
2640
+ else {
2641
+ result.failed += ready.length;
2642
+ write(`Could not send ${ready.length} result(s) to run ${line.runId} \u2014 ${out.detail || out.kind}.`);
2643
+ }
2644
+ }
2645
+ if (pending.length > 0) {
2646
+ const session = await startRun({
2647
+ ...options.apiUrl !== void 0 ? { apiUrl: options.apiUrl } : {},
2648
+ ...options.token !== void 0 ? { token: options.token } : {},
2649
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
2650
+ ...line.externalKey !== null ? { externalKey: line.externalKey } : {},
2651
+ // The replay does not know whether the run is complete, so it must not say that it is.
2652
+ log: write
2653
+ });
2654
+ for (const r of pending) await session.add(r);
2655
+ await session.leaveOpen();
2656
+ result.sent += session.stats.accepted + session.stats.duplicate;
2657
+ result.failed += session.stats.deferred + session.stats.unresolved;
2658
+ }
2659
+ }
2660
+ write(`Replayed ${result.batches} batch(es) from ${file}: ${result.sent} sent, ${result.failed} not.`);
2661
+ return result;
2662
+ }
2663
+ var import_node_child_process, fs7, NoTokenError, RunCommandError, isPending;
2664
+ var init_run_lifecycle = __esm({
2665
+ "src/cli/commands/run-lifecycle.ts"() {
2666
+ "use strict";
2667
+ init_cjs_shims();
2668
+ import_node_child_process = require("child_process");
2669
+ fs7 = __toESM(require("fs"), 1);
2670
+ init_api_url();
2671
+ init_credentials();
2672
+ init_client();
2673
+ init_env2();
2674
+ init_fallback();
2675
+ init_session();
2676
+ NoTokenError = class extends Error {
2677
+ constructor() {
2678
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
2679
+ this.name = "NoTokenError";
2680
+ }
2681
+ };
2682
+ RunCommandError = class extends Error {
2683
+ constructor(message) {
2684
+ super(message);
2685
+ this.name = "RunCommandError";
2686
+ }
2687
+ };
2688
+ isPending = (r) => "keys" in r;
2689
+ }
2690
+ });
2691
+
2118
2692
  // src/cli/commands/report.ts
2119
2693
  var report_exports = {};
2120
2694
  __export(report_exports, {
@@ -2128,10 +2702,10 @@ function isRunResult(v) {
2128
2702
  }
2129
2703
  function handleReport(opts = {}) {
2130
2704
  const cwd = opts.cwd ?? process.cwd();
2131
- const file = path6.join(cwd, ".plune", "last-run.json");
2705
+ const file = path7.join(cwd, ".plune", "last-run.json");
2132
2706
  let raw;
2133
2707
  try {
2134
- raw = fs6.readFileSync(file, "utf8");
2708
+ raw = fs8.readFileSync(file, "utf8");
2135
2709
  } catch {
2136
2710
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
2137
2711
  }
@@ -2146,13 +2720,13 @@ function handleReport(opts = {}) {
2146
2720
  }
2147
2721
  return parsed;
2148
2722
  }
2149
- var fs6, path6, ReportNotFoundError;
2723
+ var fs8, path7, ReportNotFoundError;
2150
2724
  var init_report = __esm({
2151
2725
  "src/cli/commands/report.ts"() {
2152
2726
  "use strict";
2153
2727
  init_cjs_shims();
2154
- fs6 = __toESM(require("fs"), 1);
2155
- path6 = __toESM(require("path"), 1);
2728
+ fs8 = __toESM(require("fs"), 1);
2729
+ path7 = __toESM(require("path"), 1);
2156
2730
  ReportNotFoundError = class extends Error {
2157
2731
  code = "NO_SAVED_RUN";
2158
2732
  constructor(message) {
@@ -2175,7 +2749,7 @@ function evalStatus(e) {
2175
2749
  if (statuses.includes("errored")) return "errored";
2176
2750
  return "passed";
2177
2751
  }
2178
- function classify3(baseline, current) {
2752
+ function classify4(baseline, current) {
2179
2753
  if (current === "errored") return "errored";
2180
2754
  if (baseline === "absent") return current === "passed" ? "new-pass" : "new-fail";
2181
2755
  if (current === "absent") return "removed";
@@ -2189,19 +2763,19 @@ function diffRuns(baseline, current) {
2189
2763
  const evals = ids.map((id) => {
2190
2764
  const baselineP = baseMap.get(id) ?? "absent";
2191
2765
  const currentP = curMap.get(id) ?? "absent";
2192
- return { id, status: classify3(baselineP, currentP), baseline: baselineP, current: currentP };
2766
+ return { id, status: classify4(baselineP, currentP), baseline: baselineP, current: currentP };
2193
2767
  });
2194
- const count = (s) => evals.filter((e) => e.status === s).length;
2768
+ const count2 = (s) => evals.filter((e) => e.status === s).length;
2195
2769
  const summary = {
2196
- regressions: count("regression"),
2197
- improvements: count("improvement"),
2198
- preExistingFails: count("pre-existing-fail"),
2199
- newFails: count("new-fail"),
2200
- newPasses: count("new-pass"),
2201
- stablePasses: count("stable-pass"),
2202
- errored: count("errored"),
2203
- removed: count("removed"),
2204
- hasRegression: count("regression") > 0
2770
+ regressions: count2("regression"),
2771
+ improvements: count2("improvement"),
2772
+ preExistingFails: count2("pre-existing-fail"),
2773
+ newFails: count2("new-fail"),
2774
+ newPasses: count2("new-pass"),
2775
+ stablePasses: count2("stable-pass"),
2776
+ errored: count2("errored"),
2777
+ removed: count2("removed"),
2778
+ hasRegression: count2("regression") > 0
2205
2779
  };
2206
2780
  return { evals, summary };
2207
2781
  }
@@ -2226,7 +2800,7 @@ function isRunResult2(v) {
2226
2800
  function loadRunResult(file, label) {
2227
2801
  let raw;
2228
2802
  try {
2229
- raw = fs7.readFileSync(file, "utf8");
2803
+ raw = fs9.readFileSync(file, "utf8");
2230
2804
  } catch {
2231
2805
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
2232
2806
  }
@@ -2246,12 +2820,12 @@ function handleDiff(opts) {
2246
2820
  const current = loadRunResult(opts.currentPath, "current");
2247
2821
  return diffRuns(baseline, current);
2248
2822
  }
2249
- var fs7, DiffInputError;
2823
+ var fs9, DiffInputError;
2250
2824
  var init_diff2 = __esm({
2251
2825
  "src/cli/commands/diff.ts"() {
2252
2826
  "use strict";
2253
2827
  init_cjs_shims();
2254
- fs7 = __toESM(require("fs"), 1);
2828
+ fs9 = __toESM(require("fs"), 1);
2255
2829
  init_diff();
2256
2830
  DiffInputError = class extends Error {
2257
2831
  code = "DIFF_INPUT";
@@ -2376,8 +2950,8 @@ async function runInitWizard(cwd) {
2376
2950
  process.exit(1);
2377
2951
  return;
2378
2952
  }
2379
- const outputPath = path7.join(cwd, "plune.yaml");
2380
- if (fs8.existsSync(outputPath)) {
2953
+ const outputPath = path8.join(cwd, "plune.yaml");
2954
+ if (fs10.existsSync(outputPath)) {
2381
2955
  const overwrite = await clack.confirm({
2382
2956
  message: "plune.yaml already exists. Overwrite?"
2383
2957
  });
@@ -2416,18 +2990,18 @@ async function runInitWizard(cwd) {
2416
2990
  `;
2417
2991
  const spin = clack.spinner();
2418
2992
  spin.start("Writing plune.yaml\u2026");
2419
- fs8.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
2993
+ fs10.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
2420
2994
  spin.stop("plune.yaml created!");
2421
- clack.outro("Done! Run `plune run` to execute your evaluations.");
2995
+ clack.outro("plune.yaml is ready.");
2422
2996
  }
2423
- var clack, fs8, path7, import_yaml2;
2997
+ var clack, fs10, path8, import_yaml2;
2424
2998
  var init_wizard = __esm({
2425
2999
  "src/config/init/wizard.ts"() {
2426
3000
  "use strict";
2427
3001
  init_cjs_shims();
2428
3002
  clack = __toESM(require("@clack/prompts"), 1);
2429
- fs8 = __toESM(require("fs"), 1);
2430
- path7 = __toESM(require("path"), 1);
3003
+ fs10 = __toESM(require("fs"), 1);
3004
+ path8 = __toESM(require("path"), 1);
2431
3005
  import_yaml2 = require("yaml");
2432
3006
  init_errors();
2433
3007
  }
@@ -2435,21 +3009,21 @@ var init_wizard = __esm({
2435
3009
 
2436
3010
  // src/cli/templates/index.ts
2437
3011
  function writeTemplateFile(cwd, rel, content, force) {
2438
- const target = path8.join(cwd, rel);
2439
- if (!force && fs9.existsSync(target)) {
3012
+ const target = path9.join(cwd, rel);
3013
+ if (!force && fs11.existsSync(target)) {
2440
3014
  return "skipped";
2441
3015
  }
2442
- fs9.mkdirSync(path8.dirname(target), { recursive: true });
2443
- fs9.writeFileSync(target, content, "utf8");
3016
+ fs11.mkdirSync(path9.dirname(target), { recursive: true });
3017
+ fs11.writeFileSync(target, content, "utf8");
2444
3018
  return "written";
2445
3019
  }
2446
- var fs9, path8, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3020
+ var fs11, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
2447
3021
  var init_templates = __esm({
2448
3022
  "src/cli/templates/index.ts"() {
2449
3023
  "use strict";
2450
3024
  init_cjs_shims();
2451
- fs9 = __toESM(require("fs"), 1);
2452
- path8 = __toESM(require("path"), 1);
3025
+ fs11 = __toESM(require("fs"), 1);
3026
+ path9 = __toESM(require("path"), 1);
2453
3027
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
2454
3028
  # .env is gitignored; .env.example is safe to commit.
2455
3029
  # Plune reads whichever key matches your configured provider.
@@ -2485,6 +3059,7 @@ evals:
2485
3059
  // src/cli/commands/init.ts
2486
3060
  var init_exports = {};
2487
3061
  __export(init_exports, {
3062
+ NEXT_STEPS: () => NEXT_STEPS,
2488
3063
  initCommand: () => initCommand
2489
3064
  });
2490
3065
  function reportWrite(cwd, rel, content, force) {
@@ -2504,8 +3079,9 @@ async function initCommand({ cwd, force, yes = false }) {
2504
3079
  for (const [rel, content] of TEMPLATES) {
2505
3080
  reportWrite(cwd, rel, content, force);
2506
3081
  }
3082
+ process.stdout.write(NEXT_STEPS);
2507
3083
  }
2508
- var TEMPLATES;
3084
+ var TEMPLATES, NEXT_STEPS;
2509
3085
  var init_init = __esm({
2510
3086
  "src/cli/commands/init.ts"() {
2511
3087
  "use strict";
@@ -2516,43 +3092,14 @@ var init_init = __esm({
2516
3092
  ["datasets/example.jsonl", EXAMPLE_JSONL_TPL],
2517
3093
  [".env.example", ENV_EXAMPLE_TPL]
2518
3094
  ];
2519
- }
2520
- });
2521
-
2522
- // src/cli/credentials.ts
2523
- function credentialsFile() {
2524
- const base = process.env["XDG_CONFIG_HOME"]?.trim() || (0, import_node_path.join)((0, import_node_os.homedir)(), ".config");
2525
- return (0, import_node_path.join)(base, "plune", "credentials.json");
2526
- }
2527
- function saveToken(token) {
2528
- const file = credentialsFile();
2529
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(file), { recursive: true, mode: 448 });
2530
- (0, import_node_fs.writeFileSync)(file, JSON.stringify({ token }, null, 2) + "\n", { mode: 384 });
2531
- (0, import_node_fs.chmodSync)(file, 384);
2532
- return file;
2533
- }
2534
- function loadToken() {
2535
- try {
2536
- const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
2537
- return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
2538
- } catch {
2539
- return null;
2540
- }
2541
- }
2542
- function clearToken() {
2543
- const file = credentialsFile();
2544
- if (!(0, import_node_fs.existsSync)(file)) return { removed: false, path: file };
2545
- (0, import_node_fs.rmSync)(file);
2546
- return { removed: true, path: file };
2547
- }
2548
- var import_node_fs, import_node_os, import_node_path;
2549
- var init_credentials = __esm({
2550
- "src/cli/credentials.ts"() {
2551
- "use strict";
2552
- init_cjs_shims();
2553
- import_node_fs = require("fs");
2554
- import_node_os = require("os");
2555
- import_node_path = require("path");
3095
+ NEXT_STEPS = [
3096
+ "",
3097
+ "Next:",
3098
+ " 1. plune run \u2014 runs locally. No account, no token needed.",
3099
+ " 2. plune login \u2014 only if you want history and trends: paste a token from the dashboard.",
3100
+ " 3. plune sync \u2014 sends the last run to the platform.",
3101
+ ""
3102
+ ].join("\n");
2556
3103
  }
2557
3104
  });
2558
3105
 
@@ -2560,25 +3107,81 @@ var init_credentials = __esm({
2560
3107
  var login_exports = {};
2561
3108
  __export(login_exports, {
2562
3109
  EmptyTokenError: () => EmptyTokenError,
2563
- handleLogin: () => handleLogin
3110
+ LoginHttpError: () => LoginHttpError,
3111
+ LoginNetworkError: () => LoginNetworkError,
3112
+ TokenRejectedError: () => TokenRejectedError,
3113
+ handleLogin: () => handleLogin,
3114
+ reportLoginFailure: () => reportLoginFailure
2564
3115
  });
2565
- function handleLogin(opts) {
2566
- const token = opts.token.trim();
2567
- if (token.length === 0) throw new EmptyTokenError();
2568
- return { path: saveToken(token) };
3116
+ async function handleLogin(deps) {
3117
+ const token = deps.token.trim();
3118
+ const apiUrl = resolveApiUrl(deps.apiUrl);
3119
+ if (token.length === 0) throw new EmptyTokenError(apiUrl);
3120
+ if (deps.skipVerify === true) return { path: saveToken(token), verified: false };
3121
+ const doFetch = deps.fetchImpl ?? fetch;
3122
+ let res;
3123
+ try {
3124
+ res = await doFetch(`${apiUrl}/v1/runs?limit=1`, {
3125
+ headers: { authorization: `Bearer ${token}` }
3126
+ });
3127
+ } catch {
3128
+ throw new LoginNetworkError(apiUrl);
3129
+ }
3130
+ await res.text().catch(() => "");
3131
+ if (res.status === 401) throw new TokenRejectedError(apiUrl);
3132
+ if (!res.ok) throw new LoginHttpError(res.status);
3133
+ return { path: saveToken(token), verified: true };
3134
+ }
3135
+ function reportLoginFailure(err, write) {
3136
+ if (err instanceof EmptyTokenError || err instanceof TokenRejectedError) {
3137
+ write(`${err.message}
3138
+ `);
3139
+ return 2;
3140
+ }
3141
+ if (err instanceof LoginNetworkError || err instanceof LoginHttpError) {
3142
+ write(`${err.message}
3143
+ `);
3144
+ return 1;
3145
+ }
3146
+ return null;
2569
3147
  }
2570
- var EmptyTokenError;
3148
+ var EmptyTokenError, TokenRejectedError, LoginNetworkError, LoginHttpError;
2571
3149
  var init_login = __esm({
2572
3150
  "src/cli/commands/login.ts"() {
2573
3151
  "use strict";
2574
3152
  init_cjs_shims();
3153
+ init_api_url();
2575
3154
  init_credentials();
2576
3155
  EmptyTokenError = class extends Error {
2577
- constructor() {
2578
- super("No API token provided. Pass --token <token> or pipe it via stdin.");
3156
+ constructor(apiUrl = resolveApiUrl()) {
3157
+ super(
3158
+ `No API token provided. ${whereToGetAToken(apiUrl)} Then pass --token <token> or pipe it via stdin.`
3159
+ );
2579
3160
  this.name = "EmptyTokenError";
2580
3161
  }
2581
3162
  };
3163
+ TokenRejectedError = class extends Error {
3164
+ constructor(apiUrl = resolveApiUrl()) {
3165
+ super(`That API token was rejected (401). Nothing was saved. ${whereToGetAToken(apiUrl)}`);
3166
+ this.name = "TokenRejectedError";
3167
+ }
3168
+ };
3169
+ LoginNetworkError = class extends Error {
3170
+ constructor(apiUrl) {
3171
+ super(
3172
+ `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.`
3173
+ );
3174
+ this.name = "LoginNetworkError";
3175
+ }
3176
+ };
3177
+ LoginHttpError = class extends Error {
3178
+ constructor(status) {
3179
+ super(
3180
+ `The Plune API returned ${status} while checking the token, so it was NOT saved. Try again.`
3181
+ );
3182
+ this.name = "LoginHttpError";
3183
+ }
3184
+ };
2582
3185
  }
2583
3186
  });
2584
3187
 
@@ -2599,8 +3202,8 @@ var init_logout = __esm({
2599
3202
  });
2600
3203
 
2601
3204
  // src/types/run-result-schema.ts
2602
- function parseRunResult(value) {
2603
- return runResultSchema.parse(value);
3205
+ function parseRunResult(value2) {
3206
+ return runResultSchema.parse(value2);
2604
3207
  }
2605
3208
  var import_zod2, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
2606
3209
  var init_run_result_schema = __esm({
@@ -2630,7 +3233,7 @@ var init_run_result_schema = __esm({
2630
3233
  message: import_zod2.z.string()
2631
3234
  });
2632
3235
  rowResultSchema = import_zod2.z.object({
2633
- vars: import_zod2.z.record(import_zod2.z.unknown()),
3236
+ vars: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()),
2634
3237
  output: import_zod2.z.string().nullable(),
2635
3238
  cached: import_zod2.z.boolean(),
2636
3239
  usage: usageSchema.optional(),
@@ -2671,19 +3274,15 @@ __export(sync_exports, {
2671
3274
  SyncFileError: () => SyncFileError,
2672
3275
  SyncHttpError: () => SyncHttpError,
2673
3276
  SyncNetworkError: () => SyncNetworkError,
2674
- TokenRejectedError: () => TokenRejectedError,
3277
+ TokenRejectedError: () => TokenRejectedError2,
2675
3278
  handleSync: () => handleSync,
2676
3279
  reportSyncFailure: () => reportSyncFailure
2677
3280
  });
2678
- function resolveApiUrl(explicit) {
2679
- const raw = explicit ?? process.env["PLUNE_API_URL"] ?? DEFAULT_API_URL;
2680
- return raw.replace(/\/+$/, "");
2681
- }
2682
3281
  function readRun(cwd, file) {
2683
- const target = file !== void 0 ? path9.resolve(cwd, file) : path9.join(cwd, ".plune", "last-run.json");
3282
+ const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
2684
3283
  let raw;
2685
3284
  try {
2686
- raw = fs10.readFileSync(target, "utf8");
3285
+ raw = fs12.readFileSync(target, "utf8");
2687
3286
  } catch {
2688
3287
  throw new SyncFileError(
2689
3288
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -2729,7 +3328,7 @@ async function handleSync(deps = {}) {
2729
3328
  }
2730
3329
  if (!res.ok) {
2731
3330
  const detail = await safeErrorDetail(res);
2732
- if (res.status === 401) throw new TokenRejectedError();
3331
+ if (res.status === 401) throw new TokenRejectedError2();
2733
3332
  throw new SyncHttpError(res.status, detail);
2734
3333
  }
2735
3334
  const body = await res.json().catch(() => ({}));
@@ -2737,7 +3336,7 @@ async function handleSync(deps = {}) {
2737
3336
  return { id, url: `${endpoint}/${id}` };
2738
3337
  }
2739
3338
  function reportSyncFailure(err, write) {
2740
- if (err instanceof NotLoggedInError || err instanceof TokenRejectedError || err instanceof SyncFileError) {
3339
+ if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
2741
3340
  write(err.message + "\n");
2742
3341
  return 2;
2743
3342
  }
@@ -2747,23 +3346,23 @@ function reportSyncFailure(err, write) {
2747
3346
  }
2748
3347
  return null;
2749
3348
  }
2750
- var fs10, path9, DEFAULT_API_URL, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError;
3349
+ var fs12, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
2751
3350
  var init_sync = __esm({
2752
3351
  "src/cli/commands/sync.ts"() {
2753
3352
  "use strict";
2754
3353
  init_cjs_shims();
2755
- fs10 = __toESM(require("fs"), 1);
2756
- path9 = __toESM(require("path"), 1);
3354
+ fs12 = __toESM(require("fs"), 1);
3355
+ path10 = __toESM(require("path"), 1);
2757
3356
  init_run_result_schema();
3357
+ init_api_url();
2758
3358
  init_credentials();
2759
- DEFAULT_API_URL = "https://beta-api.plune.ai";
2760
3359
  NotLoggedInError = class extends Error {
2761
3360
  constructor() {
2762
3361
  super('Not logged in. Run "plune login" first to save your API token.');
2763
3362
  this.name = "NotLoggedInError";
2764
3363
  }
2765
3364
  };
2766
- TokenRejectedError = class extends Error {
3365
+ TokenRejectedError2 = class extends Error {
2767
3366
  constructor() {
2768
3367
  super(
2769
3368
  'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
@@ -2796,6 +3395,273 @@ var init_sync = __esm({
2796
3395
  }
2797
3396
  });
2798
3397
 
3398
+ // src/cli/commands/cairn-artifact.ts
3399
+ async function findLatestCairnRun(runsDir = "runs") {
3400
+ const entries = await fs13.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3401
+ if (entries === null) throw new NoCairnRunsError(runsDir);
3402
+ let newest;
3403
+ for (const entry of entries) {
3404
+ if (!entry.isDirectory()) continue;
3405
+ const dir = path11.join(runsDir, entry.name);
3406
+ try {
3407
+ const report = await fs13.lstat(path11.join(dir, "report.json"));
3408
+ if (!report.isFile()) continue;
3409
+ if (newest === void 0 || report.mtimeMs > newest.at) {
3410
+ newest = { dir, at: report.mtimeMs };
3411
+ }
3412
+ } catch {
3413
+ }
3414
+ }
3415
+ if (newest === void 0) throw new NoCairnRunsError(runsDir);
3416
+ return newest.dir;
3417
+ }
3418
+ async function readJson(file) {
3419
+ try {
3420
+ return JSON.parse(await fs13.readFile(file, "utf8"));
3421
+ } catch {
3422
+ return void 0;
3423
+ }
3424
+ }
3425
+ async function defaultReadEvidence(dir) {
3426
+ const raw = await readJson(path11.join(dir, "api-evidence.json"));
3427
+ if (!Array.isArray(raw)) return void 0;
3428
+ return raw.map((r) => {
3429
+ const rec = asRecord(r);
3430
+ return { name: str(rec["name"]), passed: rec["passed"] === true };
3431
+ });
3432
+ }
3433
+ function casesFromDesign(report) {
3434
+ const list = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3435
+ return list.map((raw) => {
3436
+ const c = asRecord(raw);
3437
+ const steps = (Array.isArray(c["steps"]) ? c["steps"] : []).map((s) => str(s)).filter((s) => s !== "");
3438
+ return {
3439
+ stableId: str(c["stableId"]),
3440
+ title: str(c["title"], "untitled case"),
3441
+ execution: c["execution"] === "manual" ? "manual" : "auto",
3442
+ steps: steps.length > 0 ? steps : ["(no steps recorded)"],
3443
+ expected: str(c["expected"], "(no expected result recorded)"),
3444
+ ...typeof c["technique"] === "string" ? { technique: c["technique"] } : {},
3445
+ ...typeof c["priority"] === "string" ? { priority: c["priority"] } : {}
3446
+ };
3447
+ });
3448
+ }
3449
+ function casesFromApi(report, verdicts) {
3450
+ const list = Array.isArray(report["cases"]) ? report["cases"] : [];
3451
+ return list.map((raw) => {
3452
+ const c = asRecord(raw);
3453
+ const name = str(c["name"], "unnamed operation");
3454
+ const request = `${str(c["method"], "GET")} ${str(c["path"], "/")}`;
3455
+ const verdict = verdicts.get(name);
3456
+ return {
3457
+ stableId: str(c["stableId"]),
3458
+ title: name,
3459
+ execution: "auto",
3460
+ steps: [request],
3461
+ expected: `HTTP ${str(c["expectedStatus"], "default")}`,
3462
+ ...verdict !== void 0 ? { verdict } : {},
3463
+ ...typeof c["technique"] === "string" ? { technique: c["technique"] } : {}
3464
+ };
3465
+ });
3466
+ }
3467
+ async function readCairnRun(dir, deps = {}) {
3468
+ const raw = await readJson(path11.join(dir, "report.json"));
3469
+ if (raw === void 0) throw new CairnRunNotFoundError(dir);
3470
+ const report = asRecord(raw);
3471
+ if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
3472
+ throw new CairnRunVersionError(report["schemaVersion"]);
3473
+ }
3474
+ if (report["partial"] === true) {
3475
+ throw new CairnRunPartialError(str(report["error"], "no reason recorded"));
3476
+ }
3477
+ const mode = report["mode"];
3478
+ if (mode !== "design" && mode !== "explore" && mode !== "api") {
3479
+ throw new CairnRunVersionError(
3480
+ `${SUPPORTED_ARTIFACT_VERSION} (unknown run mode ${String(mode)})`
3481
+ );
3482
+ }
3483
+ let cases;
3484
+ if (mode === "api") {
3485
+ const evidence = await (deps.readEvidence ?? defaultReadEvidence)(dir);
3486
+ const verdicts = /* @__PURE__ */ new Map();
3487
+ for (const e of evidence ?? []) {
3488
+ if (verdicts.has(e.name)) throw new CairnEvidenceAmbiguousError(e.name);
3489
+ verdicts.set(e.name, e.passed ? "passed" : "failed");
3490
+ }
3491
+ cases = casesFromApi(report, verdicts);
3492
+ } else {
3493
+ cases = casesFromDesign(report);
3494
+ }
3495
+ return {
3496
+ schemaVersion: INGEST_SCHEMA_VERSION,
3497
+ run: {
3498
+ runId: str(report["runId"], path11.basename(dir)),
3499
+ mode,
3500
+ target: str(report["url"], "unknown")
3501
+ },
3502
+ cases
3503
+ };
3504
+ }
3505
+ var fs13, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
3506
+ var init_cairn_artifact = __esm({
3507
+ "src/cli/commands/cairn-artifact.ts"() {
3508
+ "use strict";
3509
+ init_cjs_shims();
3510
+ fs13 = __toESM(require("fs/promises"), 1);
3511
+ path11 = __toESM(require("path"), 1);
3512
+ SUPPORTED_ARTIFACT_VERSION = 1;
3513
+ INGEST_SCHEMA_VERSION = 1;
3514
+ NoCairnRunsError = class extends Error {
3515
+ constructor(runsDir) {
3516
+ super(`No Cairn run found under ${runsDir} \u2014 name the directory instead: plune ingest <dir>.`);
3517
+ this.name = "NoCairnRunsError";
3518
+ }
3519
+ };
3520
+ CairnRunNotFoundError = class extends Error {
3521
+ constructor(dir) {
3522
+ super(
3523
+ `No Cairn run found in ${dir} \u2014 expected a report.json written by "cairn design|explore|api".`
3524
+ );
3525
+ this.name = "CairnRunNotFoundError";
3526
+ }
3527
+ };
3528
+ CairnRunVersionError = class extends Error {
3529
+ constructor(found) {
3530
+ super(
3531
+ found === void 0 ? "This run predates versioned Cairn artifacts. Re-run it with Cairn 0.7.0 or newer, which stamps the format so it can be read safely." : `Unsupported Cairn artifact version ${String(found)} (this CLI reads version ${SUPPORTED_ARTIFACT_VERSION}). Upgrade @plune-ai/cli.`
3532
+ );
3533
+ this.name = "CairnRunVersionError";
3534
+ }
3535
+ };
3536
+ CairnRunPartialError = class extends Error {
3537
+ constructor(reason) {
3538
+ super(`That Cairn run did not finish, so its cases are incomplete: ${reason}`);
3539
+ this.name = "CairnRunPartialError";
3540
+ }
3541
+ };
3542
+ CairnEvidenceAmbiguousError = class extends Error {
3543
+ constructor(name) {
3544
+ super(
3545
+ `Cannot attach results safely: the operation "${name}" appears more than once in this run's evidence, and the evidence file carries no case identity to disambiguate it. Nothing was uploaded.`
3546
+ );
3547
+ this.name = "CairnEvidenceAmbiguousError";
3548
+ }
3549
+ };
3550
+ asRecord = (v) => typeof v === "object" && v !== null ? v : {};
3551
+ str = (v, fallback = "") => typeof v === "string" && v !== "" ? v : fallback;
3552
+ }
3553
+ });
3554
+
3555
+ // src/cli/commands/ingest.ts
3556
+ var ingest_exports = {};
3557
+ __export(ingest_exports, {
3558
+ IngestHttpError: () => IngestHttpError,
3559
+ IngestNetworkError: () => IngestNetworkError,
3560
+ IngestNotLoggedInError: () => IngestNotLoggedInError,
3561
+ IngestTokenRejectedError: () => IngestTokenRejectedError,
3562
+ formatIngestResult: () => formatIngestResult,
3563
+ handleIngest: () => handleIngest,
3564
+ reportIngestFailure: () => reportIngestFailure
3565
+ });
3566
+ async function safeErrorDetail2(res) {
3567
+ try {
3568
+ const body = await res.json();
3569
+ return typeof body.error === "string" ? body.error : "";
3570
+ } catch {
3571
+ return "";
3572
+ }
3573
+ }
3574
+ async function handleIngest(deps) {
3575
+ const apiUrl = resolveApiUrl(deps.apiUrl);
3576
+ const token = (deps.loadToken ?? loadToken)();
3577
+ if (token === null) throw new IngestNotLoggedInError();
3578
+ const dir = deps.dir ?? await findLatestCairnRun(deps.runsDir);
3579
+ const payload = await readCairnRun(dir);
3580
+ const doFetch = deps.fetchImpl ?? fetch;
3581
+ const endpoint = `${apiUrl}/v1/ingest/cairn`;
3582
+ let res;
3583
+ try {
3584
+ res = await doFetch(endpoint, {
3585
+ method: "POST",
3586
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3587
+ body: JSON.stringify(payload)
3588
+ });
3589
+ } catch {
3590
+ throw new IngestNetworkError(apiUrl);
3591
+ }
3592
+ if (!res.ok) {
3593
+ const detail = await safeErrorDetail2(res);
3594
+ if (res.status === 401) throw new IngestTokenRejectedError();
3595
+ throw new IngestHttpError(res.status, detail);
3596
+ }
3597
+ const body = await res.json().catch(() => ({}));
3598
+ return {
3599
+ runId: typeof body.runId === "string" ? body.runId : "",
3600
+ linked: typeof body.linked === "number" ? body.linked : 0,
3601
+ proposed: typeof body.proposed === "number" ? body.proposed : 0,
3602
+ skipped: typeof body.skipped === "number" ? body.skipped : 0
3603
+ };
3604
+ }
3605
+ function reportIngestFailure(err, write) {
3606
+ if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof NoCairnRunsError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
3607
+ write(`${err.message}
3608
+ `);
3609
+ return 2;
3610
+ }
3611
+ if (err instanceof IngestNetworkError || err instanceof IngestHttpError) {
3612
+ write(`${err.message}
3613
+ `);
3614
+ return 1;
3615
+ }
3616
+ return null;
3617
+ }
3618
+ function formatIngestResult(r) {
3619
+ const lines = [
3620
+ `Ingested Cairn run \u2192 ${r.runId}`,
3621
+ ` ${r.linked} result(s) attached to existing cases`,
3622
+ ` ${r.proposed} case(s) proposed \u2014 review them in Plune to accept`,
3623
+ ` ${r.skipped} skipped (already queued, already rejected, repeated, or never executed)`
3624
+ ];
3625
+ return `${lines.join("\n")}
3626
+ `;
3627
+ }
3628
+ var IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
3629
+ var init_ingest = __esm({
3630
+ "src/cli/commands/ingest.ts"() {
3631
+ "use strict";
3632
+ init_cjs_shims();
3633
+ init_api_url();
3634
+ init_credentials();
3635
+ init_cairn_artifact();
3636
+ IngestNotLoggedInError = class extends Error {
3637
+ constructor() {
3638
+ super('Not logged in. Run "plune login" first to save your API token.');
3639
+ this.name = "IngestNotLoggedInError";
3640
+ }
3641
+ };
3642
+ IngestTokenRejectedError = class extends Error {
3643
+ constructor() {
3644
+ super('Your API token was rejected (401). Run "plune login" again with a fresh token.');
3645
+ this.name = "IngestTokenRejectedError";
3646
+ }
3647
+ };
3648
+ IngestNetworkError = class extends Error {
3649
+ constructor(apiUrl) {
3650
+ super(`Could not reach the Plune API at ${apiUrl}. Check your connection, then try again.`);
3651
+ this.name = "IngestNetworkError";
3652
+ }
3653
+ };
3654
+ IngestHttpError = class extends Error {
3655
+ constructor(status, detail) {
3656
+ super(`Plune API returned ${status}${detail !== "" ? ` \u2014 ${detail}` : ""}.`);
3657
+ this.status = status;
3658
+ this.name = "IngestHttpError";
3659
+ }
3660
+ status;
3661
+ };
3662
+ }
3663
+ });
3664
+
2799
3665
  // src/cli.ts
2800
3666
  var cli_exports = {};
2801
3667
  __export(cli_exports, {
@@ -2803,6 +3669,7 @@ __export(cli_exports, {
2803
3669
  });
2804
3670
  module.exports = __toCommonJS(cli_exports);
2805
3671
  init_cjs_shims();
3672
+ init_api_url();
2806
3673
  var import_commander = require("commander");
2807
3674
  var import_node_fs2 = require("fs");
2808
3675
  var import_node_path2 = require("path");
@@ -2833,10 +3700,17 @@ function failUnexpected(err, verbose) {
2833
3700
  } else {
2834
3701
  process.stderr.write(String(err) + "\n");
2835
3702
  }
3703
+ process.stderr.write(
3704
+ `This one is on us. Run again with --verbose and send the output: ${FEEDBACK_URL}
3705
+ `
3706
+ );
2836
3707
  process.exit(1);
2837
3708
  }
2838
3709
  async function readTokenFromStdin() {
2839
3710
  if (process.stdin.isTTY === true) {
3711
+ const { resolveApiUrl: resolveApiUrl2, whereToGetAToken: whereToGetAToken2 } = await Promise.resolve().then(() => (init_api_url(), api_url_exports));
3712
+ process.stderr.write(`${whereToGetAToken2(resolveApiUrl2())}
3713
+ `);
2840
3714
  process.stderr.write("Paste your API token, then press Enter:\n");
2841
3715
  }
2842
3716
  const { createInterface } = await import("readline");
@@ -2852,7 +3726,7 @@ function createProgram() {
2852
3726
  const program = new import_commander.Command();
2853
3727
  program.name("plune").description("Plune \u2014 AI-powered assertion testing CLI").version(pkg.version);
2854
3728
  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");
2855
- 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(
3729
+ 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(
2856
3730
  "--only <selector>",
2857
3731
  "Run a subset by eval id or tag (repeatable)",
2858
3732
  (val, prev) => [...prev, val],
@@ -2928,6 +3802,58 @@ function createProgram() {
2928
3802
  }
2929
3803
  }
2930
3804
  );
3805
+ const failRunCommand = async (err, verbose) => {
3806
+ const { NoTokenError: NoTokenError2, RunCommandError: RunCommandError2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3807
+ if (err instanceof NoTokenError2) {
3808
+ process.stderr.write(err.message + "\n");
3809
+ process.exit(2);
3810
+ }
3811
+ if (err instanceof RunCommandError2) {
3812
+ process.stderr.write(err.message + "\n");
3813
+ process.exit(1);
3814
+ }
3815
+ failUnexpected(err, verbose);
3816
+ process.exit(1);
3817
+ };
3818
+ const verboseOf = (command) => command.optsWithGlobals().verbose === true;
3819
+ 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) => {
3820
+ try {
3821
+ const { handleRunStart: handleRunStart2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3822
+ await handleRunStart2({ ...options.key !== void 0 ? { key: options.key } : {}, json: options.json });
3823
+ } catch (err) {
3824
+ await failRunCommand(err, verboseOf(command));
3825
+ }
3826
+ });
3827
+ 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) => {
3828
+ try {
3829
+ const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3830
+ await handleRunFinish2(id, {
3831
+ terminate: options.terminate,
3832
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
3833
+ });
3834
+ } catch (err) {
3835
+ await failRunCommand(err, verboseOf(command));
3836
+ }
3837
+ });
3838
+ 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) => {
3839
+ try {
3840
+ const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3841
+ process.exitCode = await handleRunExec2({
3842
+ argv,
3843
+ ...options.key !== void 0 ? { key: options.key } : {}
3844
+ });
3845
+ } catch (err) {
3846
+ await failRunCommand(err, verboseOf(command));
3847
+ }
3848
+ });
3849
+ 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) => {
3850
+ try {
3851
+ const { handleRunReport: handleRunReport2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
3852
+ await handleRunReport2({ ...options.file !== void 0 ? { file: options.file } : {} });
3853
+ } catch (err) {
3854
+ await failRunCommand(err, verboseOf(command));
3855
+ }
3856
+ });
2931
3857
  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) => {
2932
3858
  const globals = command.optsWithGlobals();
2933
3859
  const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
@@ -2991,18 +3917,26 @@ function createProgram() {
2991
3917
  failUnexpected(err, verbose);
2992
3918
  }
2993
3919
  });
2994
- 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) => {
3920
+ 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(
3921
+ "--skip-verify",
3922
+ "Save without checking the token against the API (offline setup)",
3923
+ false
3924
+ ).action(async (options, command) => {
2995
3925
  const verbose = command.optsWithGlobals().verbose === true;
2996
- const { handleLogin: handleLogin2, EmptyTokenError: EmptyTokenError2 } = await Promise.resolve().then(() => (init_login(), login_exports));
3926
+ const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
2997
3927
  try {
2998
3928
  const token = options.token ?? await readTokenFromStdin();
2999
- const { path: path10 } = handleLogin2({ token });
3000
- process.stdout.write(`Logged in. Token saved to ${path10}
3001
- `);
3929
+ const { path: path12, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
3930
+ process.stdout.write(
3931
+ verified ? `Logged in. Token checked against the API and saved to ${path12}
3932
+ ` : `Token saved to ${path12} WITHOUT being checked (--skip-verify).
3933
+ `
3934
+ );
3002
3935
  } catch (err) {
3003
- if (err instanceof EmptyTokenError2) {
3004
- process.stderr.write(err.message + "\n");
3005
- process.exit(2);
3936
+ const code = reportLoginFailure2(err, (s) => process.stderr.write(s));
3937
+ if (code !== null) {
3938
+ maybeStack(err, verbose);
3939
+ process.exitCode = code;
3006
3940
  return;
3007
3941
  }
3008
3942
  failUnexpected(err, verbose);
@@ -3012,9 +3946,9 @@ function createProgram() {
3012
3946
  const verbose = command.optsWithGlobals().verbose === true;
3013
3947
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
3014
3948
  try {
3015
- const { removed, path: path10 } = handleLogout2();
3949
+ const { removed, path: path12 } = handleLogout2();
3016
3950
  process.stdout.write(
3017
- removed ? `Logged out. Removed ${path10}
3951
+ removed ? `Logged out. Removed ${path12}
3018
3952
  ` : "Not logged in \u2014 nothing to remove.\n"
3019
3953
  );
3020
3954
  } catch (err) {
@@ -3043,6 +3977,24 @@ function createProgram() {
3043
3977
  failUnexpected(err, verbose);
3044
3978
  }
3045
3979
  });
3980
+ 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) => {
3981
+ const globals = command.optsWithGlobals();
3982
+ const verbose = globals.verbose === true;
3983
+ const { handleIngest: handleIngest2, reportIngestFailure: reportIngestFailure2, formatIngestResult: formatIngestResult2 } = await Promise.resolve().then(() => (init_ingest(), ingest_exports));
3984
+ try {
3985
+ process.stdout.write(
3986
+ formatIngestResult2(await handleIngest2({ ...dir !== void 0 ? { dir } : {} }))
3987
+ );
3988
+ } catch (err) {
3989
+ const code = reportIngestFailure2(err, (s) => process.stderr.write(s));
3990
+ if (code !== null) {
3991
+ maybeStack(err, verbose);
3992
+ process.exitCode = code;
3993
+ return;
3994
+ }
3995
+ failUnexpected(err, verbose);
3996
+ }
3997
+ });
3046
3998
  program.on("command:*", (operands) => {
3047
3999
  process.stderr.write(`Unknown command: ${operands.join(" ")}
3048
4000
  `);