@plune-ai/cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,10 +110,12 @@ provider API key is read from the environment based on `provider.type`:
110
110
  | `plune run delete <id>` | Delete a run and everything it produced — its results and the review-queue entries it raised. Approved test cases and the audit log stay. **Recoverable for six months** (ask Plune to put it back), then gone for good. No prompt: it is your data, and this command belongs in scripts. |
111
111
  | `plune run report` | Replay `.plune/pending-results.jsonl` — send what the reporter could not. Flags: `--file <path>`. |
112
112
  | `plune ingest [dir]` | Record a [Cairn](https://github.com/plune-ai/cairn) run in Plune. Omit `[dir]` for the newest run under `./runs`, or name the directory holding `report.json`. Generated cases arrive as **review proposals** — nothing is created until a person approves it. |
113
+ | `plune pull [file]` | Write the project's test cases as **one Markdown document** — Testomat's classical format, plus Plune's own columns — to `plune/cases.md` (or `[file]`). Refuses to overwrite a file git sees as modified; `--force` overrides, `--suite <id>` takes one suite or folder and what is under it. |
114
+ | `plune push [file] [--dry-run]` | Send the document back. Cases are matched by `id`; a block without one becomes a draft; an unknown id is refused by line; **nothing is deleted**. Prints the report — created, updated, unchanged, refused, warnings — and with `--dry-run` writes nothing. Exit `4` when anything was refused. |
113
115
 
114
116
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
115
117
 
116
- **Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error.
118
+ **Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error · `3` `pull` refused to overwrite uncommitted changes · `4` `push` had refusals.
117
119
 
118
120
  ## Already running tests? Bring the results in
119
121
 
@@ -158,6 +160,23 @@ job closes the run instead of the explicit `finish`.
158
160
  `plune run exec` sets it for you — anything reporting inside it, this command included, joins
159
161
  without closing.
160
162
 
163
+ ### When a test is deleted
164
+
165
+ A test that vanishes from the code stays in Plune as an active case until something says otherwise.
166
+ The run that covers the whole suite is what says it:
167
+
168
+ ```bash
169
+ PLUNE_FULL_RUN=1 plune run import ./junit.xml # or the same variable on the Playwright reporter's job
170
+ ```
171
+
172
+ When that run finishes having reported everything it expected, every case its source used to
173
+ report and did not this time is marked **detached** — not deleted, not retired: it keeps its history
174
+ and its place, and the dashboard shows it with the run as the reason. The next result on it brings
175
+ it back by itself. Set the variable only on the job that runs everything: a run of one file or a
176
+ `--grep` also reports everything it expected, and with the flag it would detach the rest. A run that
177
+ lost a shard (`notRun` is not empty) never detaches anything. `plune run start` has no list of
178
+ tests to compare against, so the flag means nothing there.
179
+
161
180
  ### What the run is called
162
181
 
163
182
  A run nobody named is called after the directory, the minute it started and where it ran —
@@ -177,11 +196,14 @@ the [Plune platform](https://plune.ai/platform):
177
196
  plune login # paste the API token from your platform settings page
178
197
  plune run # exactly as before — the run is saved locally
179
198
  plune sync # upload .plune/last-run.json, print the read-back URL
199
+ plune pull # the test cases as plune/cases.md — edit them anywhere
200
+ plune push --dry-run # what a push would change, before it changes anything
180
201
  ```
181
202
 
182
203
  The token is stored at `~/.config/plune/credentials.json` (mode `0600`, honours `XDG_CONFIG_HOME`)
183
- and is never printed or logged. `PLUNE_API_URL` points `sync` at a different server — useful for a
184
- self-hosted backend.
204
+ and is never printed or logged. `PLUNE_API_URL` points `sync`, `pull` and `push` at a different server —
205
+ useful for a self-hosted backend. The Markdown format `pull` writes and `push` reads is documented at
206
+ [docs.plune.ai/platform/cases/markdown](https://docs.plune.ai/platform/cases/markdown/).
185
207
 
186
208
  ## Programmatic API
187
209
 
package/dist/cli.cjs CHANGED
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
515
+ defaultSleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
516
516
  }
517
517
  });
518
518
 
@@ -597,7 +597,7 @@ function makeAnthropicProvider(config, env, pricing) {
597
597
  () => client.messages.create({
598
598
  model: req.model,
599
599
  max_tokens: req.max_tokens,
600
- temperature: req.temperature,
600
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
601
601
  messages: [{ role: "user", content: req.prompt_resolved }]
602
602
  }),
603
603
  { max_retries: maxRetries }
@@ -657,7 +657,7 @@ function makeOpenAiCompatibleProvider(opts) {
657
657
  const res = await withRetry(
658
658
  () => client.chat.completions.create({
659
659
  model: req.model,
660
- temperature: req.temperature,
660
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
661
661
  max_tokens: req.max_tokens,
662
662
  messages: [{ role: "user", content: req.prompt_resolved }],
663
663
  // OpenRouter extension: ask it to include the call's actual cost in `usage` (ADR-PRC02).
@@ -1547,7 +1547,7 @@ function buildJudge(provider, cfg, onUsage) {
1547
1547
  const res = await provider.complete({
1548
1548
  provider: cfg.type,
1549
1549
  model: cfg.model,
1550
- temperature: 0,
1550
+ ...cfg.temperature !== void 0 ? { temperature: cfg.temperature } : {},
1551
1551
  max_tokens: cfg.max_tokens ?? DEFAULT_MAX_TOKENS,
1552
1552
  prompt_resolved: prompt5
1553
1553
  });
@@ -1609,7 +1609,7 @@ function estimateUsage(prompt5, maxTokens) {
1609
1609
  async function runRow(p) {
1610
1610
  const t0 = p.now();
1611
1611
  const prompt5 = resolvePrompt(p.template, p.row.vars);
1612
- const temperature = p.providerConfig.temperature ?? 0;
1612
+ const temperature = p.providerConfig.temperature;
1613
1613
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1614
1614
  if (p.dryRun) {
1615
1615
  const est = estimateUsage(prompt5, maxTokens);
@@ -1619,7 +1619,7 @@ async function runRow(p) {
1619
1619
  const key = cacheKey({
1620
1620
  provider: p.providerConfig.type,
1621
1621
  model: p.providerConfig.model,
1622
- temperature,
1622
+ temperature: temperature ?? 0,
1623
1623
  max_tokens: maxTokens,
1624
1624
  prompt_resolved: prompt5
1625
1625
  });
@@ -1640,7 +1640,7 @@ async function runRow(p) {
1640
1640
  const res = await p.provider.complete({
1641
1641
  provider: p.providerConfig.type,
1642
1642
  model: p.providerConfig.model,
1643
- temperature,
1643
+ ...temperature !== void 0 ? { temperature } : {},
1644
1644
  max_tokens: maxTokens,
1645
1645
  prompt_resolved: prompt5
1646
1646
  });
@@ -2210,7 +2210,7 @@ function createClient(opts) {
2210
2210
  const wait = opts.wait ?? sleep;
2211
2211
  const base = opts.apiUrl.replace(/\/+$/, "");
2212
2212
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2213
- async function send(method, path13, body) {
2213
+ async function send(method, path14, body) {
2214
2214
  let last = {
2215
2215
  kind: "unavailable",
2216
2216
  status: null,
@@ -2219,7 +2219,7 @@ function createClient(opts) {
2219
2219
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2220
2220
  let res;
2221
2221
  try {
2222
- res = await doFetch(`${base}${path13}`, {
2222
+ res = await doFetch(`${base}${path14}`, {
2223
2223
  method,
2224
2224
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2225
2225
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2247,8 +2247,8 @@ function createClient(opts) {
2247
2247
  return { ok: false, ...last };
2248
2248
  }
2249
2249
  return {
2250
- post: (path13, body) => send("POST", path13, body),
2251
- del: (path13) => send("DELETE", path13)
2250
+ post: (path14, body) => send("POST", path14, body),
2251
+ del: (path14) => send("DELETE", path14)
2252
2252
  };
2253
2253
  }
2254
2254
  var RETRY_DELAYS_MS, sleep;
@@ -2292,6 +2292,7 @@ function readEnv(env = process.env) {
2292
2292
  const environment = value(env, "PLUNE_ENV");
2293
2293
  const labels = list(env, "PLUNE_LABELS");
2294
2294
  const offerDiscovered = flag(env, "PLUNE_CREATE");
2295
+ const full = flag(env, "PLUNE_FULL_RUN");
2295
2296
  return {
2296
2297
  config: {
2297
2298
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -2304,7 +2305,8 @@ function readEnv(env = process.env) {
2304
2305
  ...labels !== void 0 ? { labels } : {},
2305
2306
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
2306
2307
  // variable is not an instruction.
2307
- ...offerDiscovered ? { offerDiscovered } : {}
2308
+ ...offerDiscovered ? { offerDiscovered } : {},
2309
+ ...full ? { full } : {}
2308
2310
  },
2309
2311
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2310
2312
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -2459,7 +2461,11 @@ async function startRun(passed, declared) {
2459
2461
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2460
2462
  if (typeof hit === "string") return [{ testCaseId: hit }];
2461
2463
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
2462
- })
2464
+ }),
2465
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
2466
+ // its tests has no list, and a claim without one is nothing the platform can check —
2467
+ // so it is not sent, rather than sent and silently meaningless.
2468
+ ...cfg.full === true ? { full: true } : {}
2463
2469
  };
2464
2470
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2465
2471
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -2626,6 +2632,9 @@ async function startRun(passed, declared) {
2626
2632
  ...reason !== void 0 ? { reason } : {}
2627
2633
  });
2628
2634
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
2635
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
2636
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
2637
+ }
2629
2638
  }
2630
2639
  summarise();
2631
2640
  },
@@ -2754,14 +2763,14 @@ async function handleRunExec(options) {
2754
2763
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
2755
2764
  const run = await handleRunStart(options);
2756
2765
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
2757
- const code = await new Promise((resolve6) => {
2766
+ const code = await new Promise((resolve7) => {
2758
2767
  const child = spawnImpl(command, args, {
2759
2768
  stdio: "inherit",
2760
2769
  shell: process.platform === "win32",
2761
2770
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
2762
2771
  });
2763
- child.on("close", (exitCode) => resolve6(exitCode ?? 1));
2764
- child.on("error", () => resolve6(1));
2772
+ child.on("close", (exitCode) => resolve7(exitCode ?? 1));
2773
+ child.on("error", () => resolve7(1));
2765
2774
  });
2766
2775
  await handleRunFinish(run.id, options);
2767
2776
  return code;
@@ -4324,6 +4333,145 @@ var init_ingest = __esm({
4324
4333
  }
4325
4334
  });
4326
4335
 
4336
+ // src/cli/commands/cases.ts
4337
+ var cases_exports = {};
4338
+ __export(cases_exports, {
4339
+ DEFAULT_CASES_FILE: () => DEFAULT_CASES_FILE,
4340
+ DirtyFileError: () => DirtyFileError,
4341
+ formatReport: () => formatReport,
4342
+ handlePull: () => handlePull,
4343
+ handlePush: () => handlePush,
4344
+ reportCasesFailure: () => reportCasesFailure
4345
+ });
4346
+ function gitDirty(file) {
4347
+ try {
4348
+ const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4349
+ cwd: path13.dirname(file),
4350
+ stdio: ["ignore", "pipe", "ignore"],
4351
+ encoding: "utf8"
4352
+ });
4353
+ return out.trim() !== "";
4354
+ } catch {
4355
+ return false;
4356
+ }
4357
+ }
4358
+ async function safeErrorDetail3(res) {
4359
+ try {
4360
+ const body = await res.json();
4361
+ return typeof body.error === "string" ? body.error : "";
4362
+ } catch {
4363
+ return "";
4364
+ }
4365
+ }
4366
+ function tokenOf(deps) {
4367
+ const token = (deps.loadToken ?? loadToken)();
4368
+ if (token === null) throw new NotLoggedInError();
4369
+ return token;
4370
+ }
4371
+ async function handlePull(deps = {}) {
4372
+ const cwd = deps.cwd ?? process.cwd();
4373
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4374
+ const token = tokenOf(deps);
4375
+ const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4376
+ if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4377
+ throw new DirtyFileError(path13.relative(cwd, file) || file);
4378
+ const doFetch = deps.fetchImpl ?? fetch;
4379
+ const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4380
+ let res;
4381
+ try {
4382
+ res = await doFetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
4383
+ } catch {
4384
+ throw new SyncNetworkError(apiUrl);
4385
+ }
4386
+ if (!res.ok) {
4387
+ const detail = await safeErrorDetail3(res);
4388
+ if (res.status === 401) throw new TokenRejectedError2();
4389
+ throw new SyncHttpError(res.status, detail);
4390
+ }
4391
+ const text2 = await res.text();
4392
+ fs15.mkdirSync(path13.dirname(file), { recursive: true });
4393
+ fs15.writeFileSync(file, text2, "utf8");
4394
+ return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4395
+ }
4396
+ async function handlePush(deps = {}) {
4397
+ const cwd = deps.cwd ?? process.cwd();
4398
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4399
+ const token = tokenOf(deps);
4400
+ const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4401
+ let text2;
4402
+ try {
4403
+ text2 = fs15.readFileSync(file, "utf8");
4404
+ } catch {
4405
+ throw new SyncFileError(
4406
+ `No document to push: ${file}. Run "plune pull" first (or pass a file).`
4407
+ );
4408
+ }
4409
+ if (text2.trim() === "") throw new SyncFileError(`The document is empty: ${file}`);
4410
+ const doFetch = deps.fetchImpl ?? fetch;
4411
+ let res;
4412
+ try {
4413
+ res = await doFetch(`${apiUrl}/v1/test-cases/markdown${deps.dryRun ? "?dryRun=true" : ""}`, {
4414
+ method: "POST",
4415
+ headers: { "content-type": "text/markdown", authorization: `Bearer ${token}` },
4416
+ body: text2
4417
+ });
4418
+ } catch {
4419
+ throw new SyncNetworkError(apiUrl);
4420
+ }
4421
+ if (!res.ok) {
4422
+ const detail = await safeErrorDetail3(res);
4423
+ if (res.status === 401) throw new TokenRejectedError2();
4424
+ throw new SyncHttpError(res.status, detail);
4425
+ }
4426
+ return await res.json();
4427
+ }
4428
+ function formatReport(report) {
4429
+ const verb = (past, future) => report.dryRun ? future : past;
4430
+ const lines = [
4431
+ `${report.dryRun ? "Dry run \u2014 nothing written." : "Pushed."} ${report.created.length} ${verb("created", "to create")} \xB7 ${report.updated.length} ${verb("updated", "to update")} \xB7 ${report.unchanged.length} unchanged \xB7 ${report.refused.length} refused` + (report.suites.created.length > 0 ? ` \xB7 ${report.suites.created.length} suite(s) ${verb("created", "to create")}` : "")
4432
+ ];
4433
+ for (const r of report.refused)
4434
+ lines.push(` refused line ${r.line} ${r.title || "(no title)"} \u2014 ${r.error}`);
4435
+ for (const w of report.warnings)
4436
+ lines.push(` warning ${w.line !== void 0 ? `line ${w.line} ` : ""}${w.message}`);
4437
+ return lines.join("\n") + "\n";
4438
+ }
4439
+ function reportCasesFailure(err, write) {
4440
+ if (err instanceof DirtyFileError) {
4441
+ write(err.message + "\n");
4442
+ return 3;
4443
+ }
4444
+ if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
4445
+ write(err.message + "\n");
4446
+ return 2;
4447
+ }
4448
+ if (err instanceof SyncNetworkError || err instanceof SyncHttpError) {
4449
+ write(err.message + "\n");
4450
+ return 1;
4451
+ }
4452
+ return null;
4453
+ }
4454
+ var import_node_child_process2, fs15, path13, DEFAULT_CASES_FILE, DirtyFileError;
4455
+ var init_cases = __esm({
4456
+ "src/cli/commands/cases.ts"() {
4457
+ "use strict";
4458
+ init_cjs_shims();
4459
+ import_node_child_process2 = require("child_process");
4460
+ fs15 = __toESM(require("fs"), 1);
4461
+ path13 = __toESM(require("path"), 1);
4462
+ init_api_url();
4463
+ init_credentials();
4464
+ init_sync();
4465
+ DEFAULT_CASES_FILE = path13.join("plune", "cases.md");
4466
+ DirtyFileError = class extends Error {
4467
+ constructor(file) {
4468
+ super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
4469
+ this.name = "DirtyFileError";
4470
+ }
4471
+ };
4472
+ }
4473
+ });
4474
+
4327
4475
  // src/cli.ts
4328
4476
  var cli_exports = {};
4329
4477
  __export(cli_exports, {
@@ -4481,23 +4629,30 @@ function createProgram() {
4481
4629
  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) => {
4482
4630
  try {
4483
4631
  const { handleRunStart: handleRunStart2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4484
- await handleRunStart2({ ...options.key !== void 0 ? { key: options.key } : {}, json: options.json });
4485
- } catch (err) {
4486
- await failRunCommand(err, verboseOf(command));
4487
- }
4488
- });
4489
- 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) => {
4490
- try {
4491
- const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4492
- await handleRunFinish2(id, {
4493
- terminate: options.terminate,
4494
- ...options.reason !== void 0 ? { reason: options.reason } : {}
4632
+ await handleRunStart2({
4633
+ ...options.key !== void 0 ? { key: options.key } : {},
4634
+ json: options.json
4495
4635
  });
4496
4636
  } catch (err) {
4497
4637
  await failRunCommand(err, verboseOf(command));
4498
4638
  }
4499
4639
  });
4500
- 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) => {
4640
+ 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(
4641
+ async (id, options, command) => {
4642
+ try {
4643
+ const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4644
+ await handleRunFinish2(id, {
4645
+ terminate: options.terminate,
4646
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
4647
+ });
4648
+ } catch (err) {
4649
+ await failRunCommand(err, verboseOf(command));
4650
+ }
4651
+ }
4652
+ );
4653
+ runCommand.command("exec").argument("<command...>", "The command to run inside the run, after `--`").description(
4654
+ "Open a run, run a command inside it, close the run \u2014 whatever the command returns"
4655
+ ).option("--key <externalKey>", "The key several processes share; generated when absent").action(async (argv, options, command) => {
4501
4656
  try {
4502
4657
  const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4503
4658
  process.exitCode = await handleRunExec2({
@@ -4508,7 +4663,10 @@ function createProgram() {
4508
4663
  await failRunCommand(err, verboseOf(command));
4509
4664
  }
4510
4665
  });
4511
- runCommand.command("delete").argument("<id>", "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL").description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
4666
+ runCommand.command("delete").argument(
4667
+ "<id>",
4668
+ "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL"
4669
+ ).description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
4512
4670
  try {
4513
4671
  const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4514
4672
  await handleRunDelete2(id);
@@ -4526,14 +4684,19 @@ function createProgram() {
4526
4684
  });
4527
4685
  runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json; detected from the file when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--create", "Offer tests nothing matched to the review queue", false).action(
4528
4686
  async (file, options, command) => {
4529
- const [{ handleRunImport: handleRunImport2 }, { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }] = await Promise.all([
4687
+ const [
4688
+ { handleRunImport: handleRunImport2 },
4689
+ { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
4690
+ ] = await Promise.all([
4530
4691
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
4531
4692
  Promise.resolve().then(() => (init_importers(), importers_exports))
4532
4693
  ]);
4533
4694
  const format = options.format;
4534
4695
  if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
4535
- process.stderr.write(`Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
4536
- `);
4696
+ process.stderr.write(
4697
+ `Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
4698
+ `
4699
+ );
4537
4700
  process.exit(2);
4538
4701
  return;
4539
4702
  }
@@ -4626,10 +4789,10 @@ function createProgram() {
4626
4789
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
4627
4790
  try {
4628
4791
  const token = options.token ?? await readTokenFromStdin();
4629
- const { path: path13, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4792
+ const { path: path14, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4630
4793
  process.stdout.write(
4631
- verified ? `Logged in. Token checked against the API and saved to ${path13}
4632
- ` : `Token saved to ${path13} WITHOUT being checked (--skip-verify).
4794
+ verified ? `Logged in. Token checked against the API and saved to ${path14}
4795
+ ` : `Token saved to ${path14} WITHOUT being checked (--skip-verify).
4633
4796
  `
4634
4797
  );
4635
4798
  } catch (err) {
@@ -4646,9 +4809,9 @@ function createProgram() {
4646
4809
  const verbose = command.optsWithGlobals().verbose === true;
4647
4810
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
4648
4811
  try {
4649
- const { removed, path: path13 } = handleLogout2();
4812
+ const { removed, path: path14 } = handleLogout2();
4650
4813
  process.stdout.write(
4651
- removed ? `Logged out. Removed ${path13}
4814
+ removed ? `Logged out. Removed ${path14}
4652
4815
  ` : "Not logged in \u2014 nothing to remove.\n"
4653
4816
  );
4654
4817
  } catch (err) {
@@ -4695,6 +4858,53 @@ function createProgram() {
4695
4858
  failUnexpected(err, verbose);
4696
4859
  }
4697
4860
  });
4861
+ program.command("pull").argument("[file]", "Where to write the document (default: plune/cases.md)").description(
4862
+ "Write the project's test cases as one Markdown document (GET /v1/test-cases/markdown)"
4863
+ ).option("--suite <id>", "Only this suite or folder and what is under it").option("--force", "Overwrite the file even when git says it has uncommitted changes", false).action(
4864
+ async (file, options, command) => {
4865
+ const verbose = command.optsWithGlobals().verbose === true;
4866
+ const { handlePull: handlePull2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
4867
+ try {
4868
+ const result = await handlePull2({
4869
+ ...file !== void 0 ? { file } : {},
4870
+ ...options.suite !== void 0 ? { suiteId: options.suite } : {},
4871
+ force: options.force
4872
+ });
4873
+ process.stdout.write(`Pulled ${result.cases} case(s) \u2192 ${result.file}
4874
+ `);
4875
+ } catch (err) {
4876
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
4877
+ if (code !== null) {
4878
+ maybeStack(err, verbose);
4879
+ process.exitCode = code;
4880
+ return;
4881
+ }
4882
+ failUnexpected(err, verbose);
4883
+ }
4884
+ }
4885
+ );
4886
+ program.command("push").argument("[file]", "The document to send (default: plune/cases.md)").description(
4887
+ "Send a Markdown document of test cases back to Plune (POST /v1/test-cases/markdown)"
4888
+ ).option("--dry-run", "Show what would change and write nothing", false).action(async (file, options, command) => {
4889
+ const verbose = command.optsWithGlobals().verbose === true;
4890
+ const { handlePush: handlePush2, formatReport: formatReport2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
4891
+ try {
4892
+ const report = await handlePush2({
4893
+ ...file !== void 0 ? { file } : {},
4894
+ dryRun: options.dryRun
4895
+ });
4896
+ process.stdout.write(formatReport2(report));
4897
+ if (report.refused.length > 0) process.exitCode = 4;
4898
+ } catch (err) {
4899
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
4900
+ if (code !== null) {
4901
+ maybeStack(err, verbose);
4902
+ process.exitCode = code;
4903
+ return;
4904
+ }
4905
+ failUnexpected(err, verbose);
4906
+ }
4907
+ });
4698
4908
  program.on("command:*", (operands) => {
4699
4909
  process.stderr.write(`Unknown command: ${operands.join(" ")}
4700
4910
  `);
package/dist/index.cjs CHANGED
@@ -480,7 +480,7 @@ function makeAnthropicProvider(config, env, pricing) {
480
480
  () => client.messages.create({
481
481
  model: req.model,
482
482
  max_tokens: req.max_tokens,
483
- temperature: req.temperature,
483
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
484
484
  messages: [{ role: "user", content: req.prompt_resolved }]
485
485
  }),
486
486
  { max_retries: maxRetries }
@@ -529,7 +529,7 @@ function makeOpenAiCompatibleProvider(opts) {
529
529
  const res = await withRetry(
530
530
  () => client.chat.completions.create({
531
531
  model: req.model,
532
- temperature: req.temperature,
532
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
533
533
  max_tokens: req.max_tokens,
534
534
  messages: [{ role: "user", content: req.prompt_resolved }],
535
535
  // OpenRouter extension: ask it to include the call's actual cost in `usage` (ADR-PRC02).
@@ -1216,7 +1216,7 @@ function buildJudge(provider, cfg, onUsage) {
1216
1216
  const res = await provider.complete({
1217
1217
  provider: cfg.type,
1218
1218
  model: cfg.model,
1219
- temperature: 0,
1219
+ ...cfg.temperature !== void 0 ? { temperature: cfg.temperature } : {},
1220
1220
  max_tokens: cfg.max_tokens ?? DEFAULT_MAX_TOKENS,
1221
1221
  prompt_resolved: prompt5
1222
1222
  });
@@ -1273,7 +1273,7 @@ function estimateUsage(prompt5, maxTokens) {
1273
1273
  async function runRow(p) {
1274
1274
  const t0 = p.now();
1275
1275
  const prompt5 = resolvePrompt(p.template, p.row.vars);
1276
- const temperature = p.providerConfig.temperature ?? 0;
1276
+ const temperature = p.providerConfig.temperature;
1277
1277
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1278
1278
  if (p.dryRun) {
1279
1279
  const est = estimateUsage(prompt5, maxTokens);
@@ -1283,7 +1283,7 @@ async function runRow(p) {
1283
1283
  const key = cacheKey({
1284
1284
  provider: p.providerConfig.type,
1285
1285
  model: p.providerConfig.model,
1286
- temperature,
1286
+ temperature: temperature ?? 0,
1287
1287
  max_tokens: maxTokens,
1288
1288
  prompt_resolved: prompt5
1289
1289
  });
@@ -1304,7 +1304,7 @@ async function runRow(p) {
1304
1304
  const res = await p.provider.complete({
1305
1305
  provider: p.providerConfig.type,
1306
1306
  model: p.providerConfig.model,
1307
- temperature,
1307
+ ...temperature !== void 0 ? { temperature } : {},
1308
1308
  max_tokens: maxTokens,
1309
1309
  prompt_resolved: prompt5
1310
1310
  });
package/dist/index.d.cts CHANGED
@@ -146,7 +146,12 @@ interface Config {
146
146
  interface CompletionRequest {
147
147
  provider: string;
148
148
  model: string;
149
- temperature: number;
149
+ /**
150
+ * Only when the config asked for one. Models released after Claude Opus 4.6 do not take a
151
+ * temperature at all — any value but 1.0 is a 400 — so a default here would be a default that
152
+ * breaks every eval on a current model. Absent means "the model's own".
153
+ */
154
+ temperature?: number;
150
155
  max_tokens: number;
151
156
  prompt_resolved: string;
152
157
  }
package/dist/index.d.ts CHANGED
@@ -146,7 +146,12 @@ interface Config {
146
146
  interface CompletionRequest {
147
147
  provider: string;
148
148
  model: string;
149
- temperature: number;
149
+ /**
150
+ * Only when the config asked for one. Models released after Claude Opus 4.6 do not take a
151
+ * temperature at all — any value but 1.0 is a 400 — so a default here would be a default that
152
+ * breaks every eval on a current model. Absent means "the model's own".
153
+ */
154
+ temperature?: number;
150
155
  max_tokens: number;
151
156
  prompt_resolved: string;
152
157
  }
package/dist/index.js CHANGED
@@ -427,7 +427,7 @@ function makeAnthropicProvider(config, env, pricing) {
427
427
  () => client.messages.create({
428
428
  model: req.model,
429
429
  max_tokens: req.max_tokens,
430
- temperature: req.temperature,
430
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
431
431
  messages: [{ role: "user", content: req.prompt_resolved }]
432
432
  }),
433
433
  { max_retries: maxRetries }
@@ -476,7 +476,7 @@ function makeOpenAiCompatibleProvider(opts) {
476
476
  const res = await withRetry(
477
477
  () => client.chat.completions.create({
478
478
  model: req.model,
479
- temperature: req.temperature,
479
+ ...req.temperature !== void 0 ? { temperature: req.temperature } : {},
480
480
  max_tokens: req.max_tokens,
481
481
  messages: [{ role: "user", content: req.prompt_resolved }],
482
482
  // OpenRouter extension: ask it to include the call's actual cost in `usage` (ADR-PRC02).
@@ -1163,7 +1163,7 @@ function buildJudge(provider, cfg, onUsage) {
1163
1163
  const res = await provider.complete({
1164
1164
  provider: cfg.type,
1165
1165
  model: cfg.model,
1166
- temperature: 0,
1166
+ ...cfg.temperature !== void 0 ? { temperature: cfg.temperature } : {},
1167
1167
  max_tokens: cfg.max_tokens ?? DEFAULT_MAX_TOKENS,
1168
1168
  prompt_resolved: prompt5
1169
1169
  });
@@ -1220,7 +1220,7 @@ function estimateUsage(prompt5, maxTokens) {
1220
1220
  async function runRow(p) {
1221
1221
  const t0 = p.now();
1222
1222
  const prompt5 = resolvePrompt(p.template, p.row.vars);
1223
- const temperature = p.providerConfig.temperature ?? 0;
1223
+ const temperature = p.providerConfig.temperature;
1224
1224
  const maxTokens = p.providerConfig.max_tokens ?? DEFAULT_MAX_TOKENS2;
1225
1225
  if (p.dryRun) {
1226
1226
  const est = estimateUsage(prompt5, maxTokens);
@@ -1230,7 +1230,7 @@ async function runRow(p) {
1230
1230
  const key = cacheKey({
1231
1231
  provider: p.providerConfig.type,
1232
1232
  model: p.providerConfig.model,
1233
- temperature,
1233
+ temperature: temperature ?? 0,
1234
1234
  max_tokens: maxTokens,
1235
1235
  prompt_resolved: prompt5
1236
1236
  });
@@ -1251,7 +1251,7 @@ async function runRow(p) {
1251
1251
  const res = await p.provider.complete({
1252
1252
  provider: p.providerConfig.type,
1253
1253
  model: p.providerConfig.model,
1254
- temperature,
1254
+ ...temperature !== void 0 ? { temperature } : {},
1255
1255
  max_tokens: maxTokens,
1256
1256
  prompt_resolved: prompt5
1257
1257
  });
@@ -172,6 +172,7 @@ function readEnv(env = process.env) {
172
172
  const environment = value(env, "PLUNE_ENV");
173
173
  const labels = list(env, "PLUNE_LABELS");
174
174
  const offerDiscovered = flag(env, "PLUNE_CREATE");
175
+ const full = flag(env, "PLUNE_FULL_RUN");
175
176
  return {
176
177
  config: {
177
178
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -184,7 +185,8 @@ function readEnv(env = process.env) {
184
185
  ...labels !== void 0 ? { labels } : {},
185
186
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
186
187
  // variable is not an instruction.
187
- ...offerDiscovered ? { offerDiscovered } : {}
188
+ ...offerDiscovered ? { offerDiscovered } : {},
189
+ ...full ? { full } : {}
188
190
  },
189
191
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
190
192
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -330,7 +332,11 @@ async function startRun(passed, declared) {
330
332
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
331
333
  if (typeof hit === "string") return [{ testCaseId: hit }];
332
334
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
333
- })
335
+ }),
336
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
337
+ // its tests has no list, and a claim without one is nothing the platform can check —
338
+ // so it is not sent, rather than sent and silently meaningless.
339
+ ...cfg.full === true ? { full: true } : {}
334
340
  };
335
341
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
336
342
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -497,6 +503,9 @@ async function startRun(passed, declared) {
497
503
  ...reason !== void 0 ? { reason } : {}
498
504
  });
499
505
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
506
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
507
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
508
+ }
500
509
  }
501
510
  summarise();
502
511
  },
@@ -188,6 +188,16 @@ interface ReporterConfig {
188
188
  * how somebody says "yes, tell me what we are running that you do not know about".
189
189
  */
190
190
  offerDiscovered?: boolean;
191
+ /**
192
+ * This run is the whole suite (`PLUNE_FULL_RUN=1`, D20).
193
+ *
194
+ * Sent as `configuration.full` beside `expected`. When such a run finishes having reported
195
+ * everything it expected, the platform moves the cases this source used to report and did not
196
+ * this time to `detached` — a test that vanished from the code stops looking alive. Off by
197
+ * default because nothing here can tell a suite that shrank from `-g smoke` or a single file:
198
+ * only whoever typed the command knows, so the CI job that runs everything is the one to set it.
199
+ */
200
+ full?: boolean;
191
201
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
192
202
  fetchImpl?: typeof fetch;
193
203
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -188,6 +188,16 @@ interface ReporterConfig {
188
188
  * how somebody says "yes, tell me what we are running that you do not know about".
189
189
  */
190
190
  offerDiscovered?: boolean;
191
+ /**
192
+ * This run is the whole suite (`PLUNE_FULL_RUN=1`, D20).
193
+ *
194
+ * Sent as `configuration.full` beside `expected`. When such a run finishes having reported
195
+ * everything it expected, the platform moves the cases this source used to report and did not
196
+ * this time to `detached` — a test that vanished from the code stops looking alive. Off by
197
+ * default because nothing here can tell a suite that shrank from `-g smoke` or a single file:
198
+ * only whoever typed the command knows, so the CI job that runs everything is the one to set it.
199
+ */
200
+ full?: boolean;
191
201
  /** Transport seam — the same one `sync.ts` uses so tests need no server. */
192
202
  fetchImpl?: typeof fetch;
193
203
  /** Where a line for the operator goes. Defaults to stderr. */
@@ -129,6 +129,7 @@ function readEnv(env = process.env) {
129
129
  const environment = value(env, "PLUNE_ENV");
130
130
  const labels = list(env, "PLUNE_LABELS");
131
131
  const offerDiscovered = flag(env, "PLUNE_CREATE");
132
+ const full = flag(env, "PLUNE_FULL_RUN");
132
133
  return {
133
134
  config: {
134
135
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -141,7 +142,8 @@ function readEnv(env = process.env) {
141
142
  ...labels !== void 0 ? { labels } : {},
142
143
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
143
144
  // variable is not an instruction.
144
- ...offerDiscovered ? { offerDiscovered } : {}
145
+ ...offerDiscovered ? { offerDiscovered } : {},
146
+ ...full ? { full } : {}
145
147
  },
146
148
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
147
149
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -287,7 +289,11 @@ async function startRun(passed, declared) {
287
289
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
288
290
  if (typeof hit === "string") return [{ testCaseId: hit }];
289
291
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
290
- })
292
+ }),
293
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
294
+ // its tests has no list, and a claim without one is nothing the platform can check —
295
+ // so it is not sent, rather than sent and silently meaningless.
296
+ ...cfg.full === true ? { full: true } : {}
291
297
  };
292
298
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
293
299
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -454,6 +460,9 @@ async function startRun(passed, declared) {
454
460
  ...reason !== void 0 ? { reason } : {}
455
461
  });
456
462
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
463
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
464
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
465
+ }
457
466
  }
458
467
  summarise();
459
468
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",