@plune-ai/cli 0.11.1 → 0.13.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
@@ -100,7 +100,7 @@ provider API key is read from the environment based on `provider.type`:
100
100
  | `plune report` | Re-render the most recent run. Flags: `--format`, `-o`. |
101
101
  | `plune diff <baseline> <current>` | Compare two `plune run --format json` outputs and report pass→fail regressions. Flags: `--fail-on-regression`, `--format`, `-o`. |
102
102
  | `plune init` | Scaffold `plune.yaml`, a sample dataset, and `.env.example`. Flags: `--yes` (non-interactive), `--force`. |
103
- | `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). |
103
+ | `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). In CI, skip this step: every platform command reads `PLUNE_TOKEN` from the environment first, and the saved login only when it is not set. |
104
104
  | `plune logout` | Remove the saved token. |
105
105
  | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
106
106
  | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
@@ -110,10 +110,13 @@ 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. |
115
+ | `plune plan grep <id>` | Print a [test plan](https://docs.plune.ai/platform/plans/) as **one `--grep` pattern** — `npx playwright test --grep "$(plune plan grep <id>)"` or `npx vitest run -t "$(plune plan grep <id>)"` runs exactly what the plan collects. Each case contributes its title path from the `path-title` key the reporter wrote (the file left out, `[ >#]+` between the segments — a space for Playwright, jest, mocha and vitest ≤ 4, ` > ` for vitest 5), escaped, joined with `\|`; a case without one goes in by title. The pattern is all that reaches stdout; the summary — how many cases, how many keyed — goes to stderr. An empty plan prints `(?!)`, which matches nothing, because an empty `--grep` would run everything. |
113
116
 
114
117
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
115
118
 
116
- **Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error.
119
+ **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
120
 
118
121
  ## Already running tests? Bring the results in
119
122
 
@@ -122,7 +125,7 @@ suite, there is nothing to generate — the results exist and only have to arriv
122
125
  costs nothing beyond a Plune token.
123
126
 
124
127
  ```bash
125
- plune login
128
+ plune login # once, on your machine; in CI export PLUNE_TOKEN instead
126
129
  plune run import ./junit.xml # Jest, Vitest, pytest, PHPUnit, Surefire, Cypress, …
127
130
  plune run import ./playwright.json # or Playwright's own JSON report
128
131
  ```
@@ -158,6 +161,23 @@ job closes the run instead of the explicit `finish`.
158
161
  `plune run exec` sets it for you — anything reporting inside it, this command included, joins
159
162
  without closing.
160
163
 
164
+ ### When a test is deleted
165
+
166
+ A test that vanishes from the code stays in Plune as an active case until something says otherwise.
167
+ The run that covers the whole suite is what says it:
168
+
169
+ ```bash
170
+ PLUNE_FULL_RUN=1 plune run import ./junit.xml # or the same variable on the Playwright reporter's job
171
+ ```
172
+
173
+ When that run finishes having reported everything it expected, every case its source used to
174
+ report and did not this time is marked **detached** — not deleted, not retired: it keeps its history
175
+ and its place, and the dashboard shows it with the run as the reason. The next result on it brings
176
+ it back by itself. Set the variable only on the job that runs everything: a run of one file or a
177
+ `--grep` also reports everything it expected, and with the flag it would detach the rest. A run that
178
+ lost a shard (`notRun` is not empty) never detaches anything. `plune run start` has no list of
179
+ tests to compare against, so the flag means nothing there.
180
+
161
181
  ### What the run is called
162
182
 
163
183
  A run nobody named is called after the directory, the minute it started and where it ran —
@@ -177,11 +197,14 @@ the [Plune platform](https://plune.ai/platform):
177
197
  plune login # paste the API token from your platform settings page
178
198
  plune run # exactly as before — the run is saved locally
179
199
  plune sync # upload .plune/last-run.json, print the read-back URL
200
+ plune pull # the test cases as plune/cases.md — edit them anywhere
201
+ plune push --dry-run # what a push would change, before it changes anything
180
202
  ```
181
203
 
182
204
  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.
205
+ and is never printed or logged. `PLUNE_API_URL` points `sync`, `pull` and `push` at a different server —
206
+ useful for a self-hosted backend. The Markdown format `pull` writes and `push` reads is documented at
207
+ [docs.plune.ai/platform/cases/markdown](https://docs.plune.ai/platform/cases/markdown/).
185
208
 
186
209
  ## Programmatic API
187
210
 
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
 
@@ -2159,6 +2159,8 @@ function saveToken(token) {
2159
2159
  return file;
2160
2160
  }
2161
2161
  function loadToken() {
2162
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
2163
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
2162
2164
  try {
2163
2165
  const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
2164
2166
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -2210,7 +2212,7 @@ function createClient(opts) {
2210
2212
  const wait = opts.wait ?? sleep;
2211
2213
  const base = opts.apiUrl.replace(/\/+$/, "");
2212
2214
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2213
- async function send(method, path13, body) {
2215
+ async function send(method, path14, body) {
2214
2216
  let last = {
2215
2217
  kind: "unavailable",
2216
2218
  status: null,
@@ -2219,7 +2221,7 @@ function createClient(opts) {
2219
2221
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2220
2222
  let res;
2221
2223
  try {
2222
- res = await doFetch(`${base}${path13}`, {
2224
+ res = await doFetch(`${base}${path14}`, {
2223
2225
  method,
2224
2226
  headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
2225
2227
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2247,8 +2249,8 @@ function createClient(opts) {
2247
2249
  return { ok: false, ...last };
2248
2250
  }
2249
2251
  return {
2250
- post: (path13, body) => send("POST", path13, body),
2251
- del: (path13) => send("DELETE", path13)
2252
+ post: (path14, body) => send("POST", path14, body),
2253
+ del: (path14) => send("DELETE", path14)
2252
2254
  };
2253
2255
  }
2254
2256
  var RETRY_DELAYS_MS, sleep;
@@ -2292,6 +2294,7 @@ function readEnv(env = process.env) {
2292
2294
  const environment = value(env, "PLUNE_ENV");
2293
2295
  const labels = list(env, "PLUNE_LABELS");
2294
2296
  const offerDiscovered = flag(env, "PLUNE_CREATE");
2297
+ const full = flag(env, "PLUNE_FULL_RUN");
2295
2298
  return {
2296
2299
  config: {
2297
2300
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -2304,7 +2307,8 @@ function readEnv(env = process.env) {
2304
2307
  ...labels !== void 0 ? { labels } : {},
2305
2308
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
2306
2309
  // variable is not an instruction.
2307
- ...offerDiscovered ? { offerDiscovered } : {}
2310
+ ...offerDiscovered ? { offerDiscovered } : {},
2311
+ ...full ? { full } : {}
2308
2312
  },
2309
2313
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2310
2314
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -2459,7 +2463,11 @@ async function startRun(passed, declared) {
2459
2463
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
2460
2464
  if (typeof hit === "string") return [{ testCaseId: hit }];
2461
2465
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
2462
- })
2466
+ }),
2467
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
2468
+ // its tests has no list, and a claim without one is nothing the platform can check —
2469
+ // so it is not sent, rather than sent and silently meaningless.
2470
+ ...cfg.full === true ? { full: true } : {}
2463
2471
  };
2464
2472
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2465
2473
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -2626,6 +2634,9 @@ async function startRun(passed, declared) {
2626
2634
  ...reason !== void 0 ? { reason } : {}
2627
2635
  });
2628
2636
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
2637
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
2638
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
2639
+ }
2629
2640
  }
2630
2641
  summarise();
2631
2642
  },
@@ -2754,14 +2765,14 @@ async function handleRunExec(options) {
2754
2765
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
2755
2766
  const run = await handleRunStart(options);
2756
2767
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
2757
- const code = await new Promise((resolve6) => {
2768
+ const code = await new Promise((resolve7) => {
2758
2769
  const child = spawnImpl(command, args, {
2759
2770
  stdio: "inherit",
2760
2771
  shell: process.platform === "win32",
2761
2772
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
2762
2773
  });
2763
- child.on("close", (exitCode) => resolve6(exitCode ?? 1));
2764
- child.on("error", () => resolve6(1));
2774
+ child.on("close", (exitCode) => resolve7(exitCode ?? 1));
2775
+ child.on("error", () => resolve7(1));
2765
2776
  });
2766
2777
  await handleRunFinish(run.id, options);
2767
2778
  return code;
@@ -4020,7 +4031,7 @@ var init_sync = __esm({
4020
4031
  init_credentials();
4021
4032
  NotLoggedInError = class extends Error {
4022
4033
  constructor() {
4023
- super('Not logged in. Run "plune login" first to save your API token.');
4034
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
4024
4035
  this.name = "NotLoggedInError";
4025
4036
  }
4026
4037
  };
@@ -4297,7 +4308,7 @@ var init_ingest = __esm({
4297
4308
  init_cairn_artifact();
4298
4309
  IngestNotLoggedInError = class extends Error {
4299
4310
  constructor() {
4300
- super('Not logged in. Run "plune login" first to save your API token.');
4311
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
4301
4312
  this.name = "IngestNotLoggedInError";
4302
4313
  }
4303
4314
  };
@@ -4324,6 +4335,207 @@ var init_ingest = __esm({
4324
4335
  }
4325
4336
  });
4326
4337
 
4338
+ // src/cli/commands/cases.ts
4339
+ var cases_exports = {};
4340
+ __export(cases_exports, {
4341
+ DEFAULT_CASES_FILE: () => DEFAULT_CASES_FILE,
4342
+ DirtyFileError: () => DirtyFileError,
4343
+ formatReport: () => formatReport,
4344
+ handlePull: () => handlePull,
4345
+ handlePush: () => handlePush,
4346
+ reportCasesFailure: () => reportCasesFailure
4347
+ });
4348
+ function gitDirty(file) {
4349
+ try {
4350
+ const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4351
+ cwd: path13.dirname(file),
4352
+ stdio: ["ignore", "pipe", "ignore"],
4353
+ encoding: "utf8"
4354
+ });
4355
+ return out.trim() !== "";
4356
+ } catch {
4357
+ return false;
4358
+ }
4359
+ }
4360
+ async function safeErrorDetail3(res) {
4361
+ try {
4362
+ const body = await res.json();
4363
+ return typeof body.error === "string" ? body.error : "";
4364
+ } catch {
4365
+ return "";
4366
+ }
4367
+ }
4368
+ function tokenOf(deps) {
4369
+ const token = (deps.loadToken ?? loadToken)();
4370
+ if (token === null) throw new NotLoggedInError();
4371
+ return token;
4372
+ }
4373
+ async function handlePull(deps = {}) {
4374
+ const cwd = deps.cwd ?? process.cwd();
4375
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4376
+ const token = tokenOf(deps);
4377
+ const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4378
+ if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4379
+ throw new DirtyFileError(path13.relative(cwd, file) || file);
4380
+ const doFetch = deps.fetchImpl ?? fetch;
4381
+ const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4382
+ let res;
4383
+ try {
4384
+ res = await doFetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
4385
+ } catch {
4386
+ throw new SyncNetworkError(apiUrl);
4387
+ }
4388
+ if (!res.ok) {
4389
+ const detail = await safeErrorDetail3(res);
4390
+ if (res.status === 401) throw new TokenRejectedError2();
4391
+ throw new SyncHttpError(res.status, detail);
4392
+ }
4393
+ const text2 = await res.text();
4394
+ fs15.mkdirSync(path13.dirname(file), { recursive: true });
4395
+ fs15.writeFileSync(file, text2, "utf8");
4396
+ return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4397
+ }
4398
+ async function handlePush(deps = {}) {
4399
+ const cwd = deps.cwd ?? process.cwd();
4400
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4401
+ const token = tokenOf(deps);
4402
+ const file = path13.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4403
+ let text2;
4404
+ try {
4405
+ text2 = fs15.readFileSync(file, "utf8");
4406
+ } catch {
4407
+ throw new SyncFileError(
4408
+ `No document to push: ${file}. Run "plune pull" first (or pass a file).`
4409
+ );
4410
+ }
4411
+ if (text2.trim() === "") throw new SyncFileError(`The document is empty: ${file}`);
4412
+ const doFetch = deps.fetchImpl ?? fetch;
4413
+ let res;
4414
+ try {
4415
+ res = await doFetch(`${apiUrl}/v1/test-cases/markdown${deps.dryRun ? "?dryRun=true" : ""}`, {
4416
+ method: "POST",
4417
+ headers: { "content-type": "text/markdown", authorization: `Bearer ${token}` },
4418
+ body: text2
4419
+ });
4420
+ } catch {
4421
+ throw new SyncNetworkError(apiUrl);
4422
+ }
4423
+ if (!res.ok) {
4424
+ const detail = await safeErrorDetail3(res);
4425
+ if (res.status === 401) throw new TokenRejectedError2();
4426
+ throw new SyncHttpError(res.status, detail);
4427
+ }
4428
+ return await res.json();
4429
+ }
4430
+ function formatReport(report) {
4431
+ const verb = (past, future) => report.dryRun ? future : past;
4432
+ const lines = [
4433
+ `${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")}` : "")
4434
+ ];
4435
+ for (const r of report.refused)
4436
+ lines.push(` refused line ${r.line} ${r.title || "(no title)"} \u2014 ${r.error}`);
4437
+ for (const w of report.warnings)
4438
+ lines.push(` warning ${w.line !== void 0 ? `line ${w.line} ` : ""}${w.message}`);
4439
+ return lines.join("\n") + "\n";
4440
+ }
4441
+ function reportCasesFailure(err, write) {
4442
+ if (err instanceof DirtyFileError) {
4443
+ write(err.message + "\n");
4444
+ return 3;
4445
+ }
4446
+ if (err instanceof NotLoggedInError || err instanceof TokenRejectedError2 || err instanceof SyncFileError) {
4447
+ write(err.message + "\n");
4448
+ return 2;
4449
+ }
4450
+ if (err instanceof SyncNetworkError || err instanceof SyncHttpError) {
4451
+ write(err.message + "\n");
4452
+ return 1;
4453
+ }
4454
+ return null;
4455
+ }
4456
+ var import_node_child_process2, fs15, path13, DEFAULT_CASES_FILE, DirtyFileError;
4457
+ var init_cases = __esm({
4458
+ "src/cli/commands/cases.ts"() {
4459
+ "use strict";
4460
+ init_cjs_shims();
4461
+ import_node_child_process2 = require("child_process");
4462
+ fs15 = __toESM(require("fs"), 1);
4463
+ path13 = __toESM(require("path"), 1);
4464
+ init_api_url();
4465
+ init_credentials();
4466
+ init_sync();
4467
+ DEFAULT_CASES_FILE = path13.join("plune", "cases.md");
4468
+ DirtyFileError = class extends Error {
4469
+ constructor(file) {
4470
+ super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
4471
+ this.name = "DirtyFileError";
4472
+ }
4473
+ };
4474
+ }
4475
+ });
4476
+
4477
+ // src/cli/commands/plan.ts
4478
+ var plan_exports = {};
4479
+ __export(plan_exports, {
4480
+ formatGrep: () => formatGrep,
4481
+ grepFragment: () => grepFragment,
4482
+ handlePlanGrep: () => handlePlanGrep
4483
+ });
4484
+ function grepFragment(c) {
4485
+ const pathTitle = c.externalKeys.find((k) => k.kind === "path-title")?.value;
4486
+ const segments = pathTitle?.split("#") ?? [c.title];
4487
+ return (segments.length > 1 ? segments.slice(1) : segments).flatMap((s) => s.split(/ [>›] /)).map(escape).join(BOUNDARY);
4488
+ }
4489
+ function formatGrep(cases) {
4490
+ if (cases.length === 0) return "(?!)";
4491
+ return cases.map(grepFragment).join("|");
4492
+ }
4493
+ async function safeErrorDetail4(res) {
4494
+ try {
4495
+ const body = await res.json();
4496
+ return typeof body.error === "string" ? body.error : "";
4497
+ } catch {
4498
+ return "";
4499
+ }
4500
+ }
4501
+ async function handlePlanGrep(deps) {
4502
+ const apiUrl = resolveApiUrl(deps.apiUrl);
4503
+ const token = (deps.loadToken ?? loadToken)();
4504
+ if (token === null) throw new NotLoggedInError();
4505
+ const doFetch = deps.fetchImpl ?? fetch;
4506
+ let res;
4507
+ try {
4508
+ res = await doFetch(`${apiUrl}/v1/plans/${encodeURIComponent(deps.planId)}/cases`, {
4509
+ headers: { authorization: `Bearer ${token}` }
4510
+ });
4511
+ } catch {
4512
+ throw new SyncNetworkError(apiUrl);
4513
+ }
4514
+ if (!res.ok) {
4515
+ const detail = await safeErrorDetail4(res);
4516
+ if (res.status === 401) throw new TokenRejectedError2();
4517
+ throw new SyncHttpError(res.status, detail);
4518
+ }
4519
+ const { cases } = await res.json();
4520
+ return {
4521
+ pattern: formatGrep(cases),
4522
+ cases: cases.length,
4523
+ keyed: cases.filter((c) => c.externalKeys.some((k) => k.kind === "path-title")).length
4524
+ };
4525
+ }
4526
+ var escape, BOUNDARY;
4527
+ var init_plan = __esm({
4528
+ "src/cli/commands/plan.ts"() {
4529
+ "use strict";
4530
+ init_cjs_shims();
4531
+ init_api_url();
4532
+ init_credentials();
4533
+ init_sync();
4534
+ escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4535
+ BOUNDARY = "[ >#]+";
4536
+ }
4537
+ });
4538
+
4327
4539
  // src/cli.ts
4328
4540
  var cli_exports = {};
4329
4541
  __export(cli_exports, {
@@ -4481,23 +4693,30 @@ function createProgram() {
4481
4693
  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
4694
  try {
4483
4695
  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 } : {}
4696
+ await handleRunStart2({
4697
+ ...options.key !== void 0 ? { key: options.key } : {},
4698
+ json: options.json
4495
4699
  });
4496
4700
  } catch (err) {
4497
4701
  await failRunCommand(err, verboseOf(command));
4498
4702
  }
4499
4703
  });
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) => {
4704
+ 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(
4705
+ async (id, options, command) => {
4706
+ try {
4707
+ const { handleRunFinish: handleRunFinish2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4708
+ await handleRunFinish2(id, {
4709
+ terminate: options.terminate,
4710
+ ...options.reason !== void 0 ? { reason: options.reason } : {}
4711
+ });
4712
+ } catch (err) {
4713
+ await failRunCommand(err, verboseOf(command));
4714
+ }
4715
+ }
4716
+ );
4717
+ runCommand.command("exec").argument("<command...>", "The command to run inside the run, after `--`").description(
4718
+ "Open a run, run a command inside it, close the run \u2014 whatever the command returns"
4719
+ ).option("--key <externalKey>", "The key several processes share; generated when absent").action(async (argv, options, command) => {
4501
4720
  try {
4502
4721
  const { handleRunExec: handleRunExec2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4503
4722
  process.exitCode = await handleRunExec2({
@@ -4508,7 +4727,10 @@ function createProgram() {
4508
4727
  await failRunCommand(err, verboseOf(command));
4509
4728
  }
4510
4729
  });
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) => {
4730
+ runCommand.command("delete").argument(
4731
+ "<id>",
4732
+ "The run id \u2014 the one `plune run import` printed, or the one in the dashboard URL"
4733
+ ).description("Delete a run and everything it produced \u2014 recoverable for six months").action(async (id, _options, command) => {
4512
4734
  try {
4513
4735
  const { handleRunDelete: handleRunDelete2 } = await Promise.resolve().then(() => (init_run_lifecycle(), run_lifecycle_exports));
4514
4736
  await handleRunDelete2(id);
@@ -4526,14 +4748,19 @@ function createProgram() {
4526
4748
  });
4527
4749
  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
4750
  async (file, options, command) => {
4529
- const [{ handleRunImport: handleRunImport2 }, { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }] = await Promise.all([
4751
+ const [
4752
+ { handleRunImport: handleRunImport2 },
4753
+ { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
4754
+ ] = await Promise.all([
4530
4755
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
4531
4756
  Promise.resolve().then(() => (init_importers(), importers_exports))
4532
4757
  ]);
4533
4758
  const format = options.format;
4534
4759
  if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
4535
- process.stderr.write(`Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
4536
- `);
4760
+ process.stderr.write(
4761
+ `Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
4762
+ `
4763
+ );
4537
4764
  process.exit(2);
4538
4765
  return;
4539
4766
  }
@@ -4626,10 +4853,10 @@ function createProgram() {
4626
4853
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
4627
4854
  try {
4628
4855
  const token = options.token ?? await readTokenFromStdin();
4629
- const { path: path13, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4856
+ const { path: path14, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
4630
4857
  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).
4858
+ verified ? `Logged in. Token checked against the API and saved to ${path14}
4859
+ ` : `Token saved to ${path14} WITHOUT being checked (--skip-verify).
4633
4860
  `
4634
4861
  );
4635
4862
  } catch (err) {
@@ -4646,9 +4873,9 @@ function createProgram() {
4646
4873
  const verbose = command.optsWithGlobals().verbose === true;
4647
4874
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
4648
4875
  try {
4649
- const { removed, path: path13 } = handleLogout2();
4876
+ const { removed, path: path14 } = handleLogout2();
4650
4877
  process.stdout.write(
4651
- removed ? `Logged out. Removed ${path13}
4878
+ removed ? `Logged out. Removed ${path14}
4652
4879
  ` : "Not logged in \u2014 nothing to remove.\n"
4653
4880
  );
4654
4881
  } catch (err) {
@@ -4695,6 +4922,77 @@ function createProgram() {
4695
4922
  failUnexpected(err, verbose);
4696
4923
  }
4697
4924
  });
4925
+ program.command("pull").argument("[file]", "Where to write the document (default: plune/cases.md)").description(
4926
+ "Write the project's test cases as one Markdown document (GET /v1/test-cases/markdown)"
4927
+ ).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(
4928
+ async (file, options, command) => {
4929
+ const verbose = command.optsWithGlobals().verbose === true;
4930
+ const { handlePull: handlePull2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
4931
+ try {
4932
+ const result = await handlePull2({
4933
+ ...file !== void 0 ? { file } : {},
4934
+ ...options.suite !== void 0 ? { suiteId: options.suite } : {},
4935
+ force: options.force
4936
+ });
4937
+ process.stdout.write(`Pulled ${result.cases} case(s) \u2192 ${result.file}
4938
+ `);
4939
+ } catch (err) {
4940
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
4941
+ if (code !== null) {
4942
+ maybeStack(err, verbose);
4943
+ process.exitCode = code;
4944
+ return;
4945
+ }
4946
+ failUnexpected(err, verbose);
4947
+ }
4948
+ }
4949
+ );
4950
+ program.command("push").argument("[file]", "The document to send (default: plune/cases.md)").description(
4951
+ "Send a Markdown document of test cases back to Plune (POST /v1/test-cases/markdown)"
4952
+ ).option("--dry-run", "Show what would change and write nothing", false).action(async (file, options, command) => {
4953
+ const verbose = command.optsWithGlobals().verbose === true;
4954
+ const { handlePush: handlePush2, formatReport: formatReport2, reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
4955
+ try {
4956
+ const report = await handlePush2({
4957
+ ...file !== void 0 ? { file } : {},
4958
+ dryRun: options.dryRun
4959
+ });
4960
+ process.stdout.write(formatReport2(report));
4961
+ if (report.refused.length > 0) process.exitCode = 4;
4962
+ } catch (err) {
4963
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
4964
+ if (code !== null) {
4965
+ maybeStack(err, verbose);
4966
+ process.exitCode = code;
4967
+ return;
4968
+ }
4969
+ failUnexpected(err, verbose);
4970
+ }
4971
+ });
4972
+ const planCommand = program.command("plan").description("Test plans on the platform \u2014 what a plan collects, as something a runner can take");
4973
+ planCommand.command("grep").argument("<id>", "The plan").description(
4974
+ "Print the plan's cases as one --grep pattern for the runner (GET /v1/plans/:id/cases)"
4975
+ ).action(async (id, _options, command) => {
4976
+ const verbose = command.optsWithGlobals().verbose === true;
4977
+ const { handlePlanGrep: handlePlanGrep2 } = await Promise.resolve().then(() => (init_plan(), plan_exports));
4978
+ const { reportCasesFailure: reportCasesFailure2 } = await Promise.resolve().then(() => (init_cases(), cases_exports));
4979
+ try {
4980
+ const out = await handlePlanGrep2({ planId: id });
4981
+ process.stdout.write(out.pattern + "\n");
4982
+ process.stderr.write(
4983
+ out.cases === 0 ? "plune: the plan matches no case \u2014 the pattern matches nothing.\n" : `plune: ${out.cases} case(s) in the plan, ${out.keyed} with a test key; the rest by title.
4984
+ `
4985
+ );
4986
+ } catch (err) {
4987
+ const code = reportCasesFailure2(err, (s) => process.stderr.write(s));
4988
+ if (code !== null) {
4989
+ maybeStack(err, verbose);
4990
+ process.exitCode = code;
4991
+ return;
4992
+ }
4993
+ failUnexpected(err, verbose);
4994
+ }
4995
+ });
4698
4996
  program.on("command:*", (operands) => {
4699
4997
  process.stderr.write(`Unknown command: ${operands.join(" ")}
4700
4998
  `);
package/dist/index.cjs CHANGED
@@ -1595,6 +1595,8 @@ function credentialsFile() {
1595
1595
  return (0, import_node_path.join)(base, "plune", "credentials.json");
1596
1596
  }
1597
1597
  function loadToken() {
1598
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
1599
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
1598
1600
  try {
1599
1601
  const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
1600
1602
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -1606,7 +1608,7 @@ function loadToken() {
1606
1608
  // src/cli/commands/sync.ts
1607
1609
  var NotLoggedInError = class extends Error {
1608
1610
  constructor() {
1609
- super('Not logged in. Run "plune login" first to save your API token.');
1611
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1610
1612
  this.name = "NotLoggedInError";
1611
1613
  }
1612
1614
  };
@@ -1862,7 +1864,7 @@ async function readCairnRun(dir, deps = {}) {
1862
1864
  // src/cli/commands/ingest.ts
1863
1865
  var IngestNotLoggedInError = class extends Error {
1864
1866
  constructor() {
1865
- super('Not logged in. Run "plune login" first to save your API token.');
1867
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1866
1868
  this.name = "IngestNotLoggedInError";
1867
1869
  }
1868
1870
  };
package/dist/index.js CHANGED
@@ -1542,6 +1542,8 @@ function credentialsFile() {
1542
1542
  return join3(base, "plune", "credentials.json");
1543
1543
  }
1544
1544
  function loadToken() {
1545
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
1546
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
1545
1547
  try {
1546
1548
  const parsed = JSON.parse(readFileSync3(credentialsFile(), "utf-8"));
1547
1549
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -1553,7 +1555,7 @@ function loadToken() {
1553
1555
  // src/cli/commands/sync.ts
1554
1556
  var NotLoggedInError = class extends Error {
1555
1557
  constructor() {
1556
- super('Not logged in. Run "plune login" first to save your API token.');
1558
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1557
1559
  this.name = "NotLoggedInError";
1558
1560
  }
1559
1561
  };
@@ -1809,7 +1811,7 @@ async function readCairnRun(dir, deps = {}) {
1809
1811
  // src/cli/commands/ingest.ts
1810
1812
  var IngestNotLoggedInError = class extends Error {
1811
1813
  constructor() {
1812
- super('Not logged in. Run "plune login" first to save your API token.');
1814
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1813
1815
  this.name = "IngestNotLoggedInError";
1814
1816
  }
1815
1817
  };
@@ -60,6 +60,8 @@ function credentialsFile() {
60
60
  return (0, import_node_path.join)(base, "plune", "credentials.json");
61
61
  }
62
62
  function loadToken() {
63
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
64
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
63
65
  try {
64
66
  const parsed = JSON.parse((0, import_node_fs.readFileSync)(credentialsFile(), "utf-8"));
65
67
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -172,6 +174,7 @@ function readEnv(env = process.env) {
172
174
  const environment = value(env, "PLUNE_ENV");
173
175
  const labels = list(env, "PLUNE_LABELS");
174
176
  const offerDiscovered = flag(env, "PLUNE_CREATE");
177
+ const full = flag(env, "PLUNE_FULL_RUN");
175
178
  return {
176
179
  config: {
177
180
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -184,7 +187,8 @@ function readEnv(env = process.env) {
184
187
  ...labels !== void 0 ? { labels } : {},
185
188
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
186
189
  // variable is not an instruction.
187
- ...offerDiscovered ? { offerDiscovered } : {}
190
+ ...offerDiscovered ? { offerDiscovered } : {},
191
+ ...full ? { full } : {}
188
192
  },
189
193
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
190
194
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -330,7 +334,11 @@ async function startRun(passed, declared) {
330
334
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
331
335
  if (typeof hit === "string") return [{ testCaseId: hit }];
332
336
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
333
- })
337
+ }),
338
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
339
+ // its tests has no list, and a claim without one is nothing the platform can check —
340
+ // so it is not sent, rather than sent and silently meaningless.
341
+ ...cfg.full === true ? { full: true } : {}
334
342
  };
335
343
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
336
344
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -497,6 +505,9 @@ async function startRun(passed, declared) {
497
505
  ...reason !== void 0 ? { reason } : {}
498
506
  });
499
507
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
508
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
509
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
510
+ }
500
511
  }
501
512
  summarise();
502
513
  },
@@ -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. */
@@ -17,6 +17,8 @@ function credentialsFile() {
17
17
  return join(base, "plune", "credentials.json");
18
18
  }
19
19
  function loadToken() {
20
+ const fromEnv = process.env["PLUNE_TOKEN"]?.trim();
21
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
20
22
  try {
21
23
  const parsed = JSON.parse(readFileSync(credentialsFile(), "utf-8"));
22
24
  return typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null;
@@ -129,6 +131,7 @@ function readEnv(env = process.env) {
129
131
  const environment = value(env, "PLUNE_ENV");
130
132
  const labels = list(env, "PLUNE_LABELS");
131
133
  const offerDiscovered = flag(env, "PLUNE_CREATE");
134
+ const full = flag(env, "PLUNE_FULL_RUN");
132
135
  return {
133
136
  config: {
134
137
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -141,7 +144,8 @@ function readEnv(env = process.env) {
141
144
  ...labels !== void 0 ? { labels } : {},
142
145
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
143
146
  // variable is not an instruction.
144
- ...offerDiscovered ? { offerDiscovered } : {}
147
+ ...offerDiscovered ? { offerDiscovered } : {},
148
+ ...full ? { full } : {}
145
149
  },
146
150
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
147
151
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -287,7 +291,11 @@ async function startRun(passed, declared) {
287
291
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
288
292
  if (typeof hit === "string") return [{ testCaseId: hit }];
289
293
  return keys[0] === void 0 ? [] : [{ externalKey: keys[0] }];
290
- })
294
+ }),
295
+ // The claim travels with the list it is about (D20). A runner that could not enumerate
296
+ // its tests has no list, and a claim without one is nothing the platform can check —
297
+ // so it is not sent, rather than sent and silently meaningless.
298
+ ...cfg.full === true ? { full: true } : {}
291
299
  };
292
300
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
293
301
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
@@ -454,6 +462,9 @@ async function startRun(passed, declared) {
454
462
  ...reason !== void 0 ? { reason } : {}
455
463
  });
456
464
  if (!out.ok) log(`plune: could not close run ${runId} (${out.detail || out.kind}).`);
465
+ else if (typeof out.body.detached === "number" && out.body.detached > 0) {
466
+ log(`plune: ${out.body.detached} test case(s) marked detached \u2014 this full run no longer reports them.`);
467
+ }
457
468
  }
458
469
  summarise();
459
470
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.11.1",
3
+ "version": "0.13.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",