Skip to content

Sync runs to the Plune platform

Four commands run fully local — run, report, diff and init: no account, no network, no data leaving your machine. The rest talk to a server and are entirely opt-in: login, logout, sync, ingest, and the plune run start | finish | exec | report | import group that reports a suite’s results to the platform. You can use Plune forever without any of them.

This list grew: until 0.7.0 it really was three. Naming the local four is the more durable way round — the local half is the promise, and it is the half that has not changed.

What they add is history. plune run writes its result to .plune/last-run.json and that is where it stays; plune sync uploads that file to the Plune platform, where runs accumulate into a pass-rate and cost trend you can look at over time.

  1. Sign in to the dashboard — it uses a magic link, so there is no password to manage.

  2. Open Account → API tokens — the avatar menu at the top right → Account — pick the project the runs belong to and generate one. The token is shown once; copy it then.

  3. Hand it to the CLI (see below). Tokens are per-user and can be revoked from the same screen.

Saves the token so every later command can use it.

Terminal window
plune login --token plune_xxxxxxxx

Passing a secret as an argument leaves it in your shell history, so login also reads the token from standard input — piped or pasted:

Terminal window
echo "$PLUNE_TOKEN" | plune login # scripted / CI
plune login # interactive: paste it, press Enter

The token is written to ~/.config/plune/credentials.json (honouring XDG_CONFIG_HOME) with 0600 permissions inside a 0700 directory. The command prints only the path — the token itself is never echoed to a terminal or a log:

Logged in. Token saved to /home/you/.config/plune/credentials.json

An empty token exits 2 rather than saving an unusable file.

Removes the saved token. It is safe to run when you are not logged in — that is not an error:

Terminal window
plune logout
# Logged out. Removed /home/you/.config/plune/credentials.json
# …or: Not logged in — nothing to remove.

Uploads a run to POST /v1/runs and prints the id the server assigned, plus the URL to read it back:

Terminal window
plune run # produces .plune/last-run.json
plune sync # uploads it

By default it syncs the most recent run. Point it at a specific file to upload an older or archived one:

Terminal window
plune sync --file reports/nightly-2026-07-27.json

The global -c, --config flag works the way it does for plune report: it makes sync look for the run saved next to that config rather than in the current directory.

Terminal window
plune -c packages/api/plune.yaml sync

The uploaded file is the run exactly as the CLI wrote it — the platform validates it against the same frozen RunResult contract the CLI produces, so nothing is reshaped on the way out.

sync separates “you need to do something” from “the environment failed”, so a CI step can react to the difference instead of retrying a hopeless upload:

CodeMeaningTypical cause
0Uploaded—
2Your input needs fixingNot logged in, token rejected or revoked, run file missing or invalid
1Environment or serverNetwork unreachable, API returned 5xx

Errors are printed as a single readable line; the token never appears in the output, and neither does a stack trace.

sync reads PLUNE_TOKEN from the environment — or from a .env in the current directory, from 0.16.0 — and the saved login only when the variable is set nowhere. So a CI job hands it the secret directly, with no login step:

- name: Upload the run to Plune
env:
PLUNE_TOKEN: ${{ secrets.PLUNE_TOKEN }}
run: |
npx -y @plune-ai/cli run
npx -y @plune-ai/cli sync

Before 0.13.0 sync read the credentials file and nothing else, and a job logged in first, from a secret — which is what the stdin form of plune login is for.

The same upload is exported from the package, alongside typed errors — so a caller can tell “log in” apart from “the network is down” without parsing messages:

import { run, sync, NotLoggedInError, SyncNetworkError } from "@plune-ai/cli";
await run({ configPath: "plune.yaml" });
try {
const { id, url } = await sync();
console.log(`uploaded ${id} → ${url}`);
} catch (err) {
if (err instanceof NotLoggedInError) {
// ask for a token, then retry
} else if (err instanceof SyncNetworkError) {
// transient — safe to retry later
} else {
throw err;
}
}

sync() accepts the same choices the flags expose (file, apiUrl, and injectable fetch/token loading for tests), and reportSyncFailure(err, write) maps any of the typed errors to the exit code the CLI itself would use.