CLI

CLI commands

perfscale run          Run a load test with k6, locust, JMeter, or the native step engine
perfscale serve        Start a local dev server that receives metrics from `run --report`
perfscale lint         Validate test/config YAML files without running them
perfscale man          Print the bundled man page (or install it for `man perfscale`)
perfscale self-update  Update perfscale to the latest release for this platform

perfscale run

Exactly one engine flag is required: --k6, --locust, --jmeter, or -f.

FlagValueDescription
--k6 <FILE>.js scriptRun via the k6 binary on PATH
--locust <FILE>locustfileRun via the locust binary on PATH, headless
--jmeter <FILE>.jmx test planRun via the jmeter binary on PATH, non-GUI (jmeter -n -t). The plan owns the load shape — no perfscale config applies
-f, --file <FILE>test.yamlRun with the built-in native engine (requires -c)
-c, --config <FILE>config.yamlLoad config: vus, duration, optional report.url. Required with -f, optional load hint for --locust, ignored by --k6 and --jmeter
--host <URL>base URLTarget host for --locust (locust's --host)
--report <URL>base URLPOST the summary to a perfscale serve instance after the run; overrides report.url from the config file
-q, --quietSuppress per-request output; errors and the final metric summary still print. The native engine drops the lines at the source, which also removes their formatting/IO cost under high load
--summary-export <FILE>path, repeatableAfter the run, write the parsed metric summary (requests, RPS, latency percentiles, error rate) plus run metadata (engine, VUs, duration, timestamp, perfscale version) to this file. Repeat the flag to write several exports from one run. A .md/.json extension picks the format per file (default JSON)
--summary-format <json|md>with --summary-exportFormat for export files without a recognized extension. md renders a table for CI job summaries: --summary-export "$GITHUB_STEP_SUMMARY" --summary-format md
--allow-remote-importLet import: in -f/-c documents fetch over the network (http(s) URLs, git remotes). Fail-closed by default — a document cannot grant itself network access. See imports
--refresh-importsRefetch cached tag/branch git imports instead of trusting the cache (commit-SHA imports are immutable and never refetch)

Exit code semantics

  • 0 — the run completed, even if requests or checks failed. Failed checks are load-test feedback (visible in http_req_failed and stderr), not a CLI error — mirroring k6's default behaviour without thresholds. This also covers engines that exit non-zero on failed requests (k6 with thresholds, locust) as long as they produced a results summary.
  • 1 — the run could not execute: missing file, invalid YAML, engine binary not found, or the engine crashed before producing any results (non-zero exit with zero metrics — a script error or broken install, not test feedback): error: engine exited with code 1 before producing any results. Also: a native run whose std/thresholds@v1 gate evaluated to fail (error: thresholds failed: …) — gates are how CI turns SLO violations into a failing build, like k6 thresholds.
  • 2 — invalid command-line arguments.

Output streams

  • stdout — engine output and the final metric summary (machine-friendly)
  • stderr — errors, failed checks, and [system] progress markers. Runs executed through the machine agent end with a final [sys] __perfscale_exit__=<code> marker carrying the engine's exit code — see Runners → exit marker

Summary export

--summary-export writes a self-describing result file after the run:

{
  "meta": {
    "perfscale_version": "0.2.0",
    "engine": "native",
    "vus": 10,
    "duration": "30s",
    "timestamp": "2026-07-08T11:11:17Z"
  },
  "summary": {
    "avg_ms": 0.09, "med_ms": 0.08, "p90_ms": 0.15, "p95_ms": 0.17,
    "p99_ms": 0.27, "min_ms": 0.02, "max_ms": 2.86,
    "error_rate": 0.0, "total_requests": 20872, "requests_per_sec": 20866.54
  }
}

vus/duration are null for --k6 and --jmeter runs (the load shape lives in the script/plan). The same holds for native runs with a stages:/arrival: profile — there is no fixed VU count, so vus is null and duration is the summed stage length (e.g. "90s"); the summary's vus line reports the observed min/max concurrency instead. summary is null when the run produced no HTTP metrics. When the config had a std/thresholds@v1 gate, the export gains a thresholds field with the gate result:

{
  "thresholds": {
    "status": "fail",
    "message": "db_query_failed rate=1 ≥ 0.05; checkout SLO",
    "violations": [{ "metric": "db_query_failed", "expr": "rate<0.05", "actual": 1.0 }]
  }
}

status is pass when every expression held, else the gate's severity (fail/warn/info). A fail status also makes perfscale run exit non-zero, so CI fails on the gate itself. With --summary-format md the same data renders as a Markdown table — pointed at $GITHUB_STEP_SUMMARY, it lands directly in the GitHub Actions job summary. A failed export write is a CLI error (exit 1) even though the run itself completed — CI should not silently continue without the artifact it asked for.

Engine availability errors

error: k6 not found in PATH — install from https://k6.io/docs/get-started/installation/
error: locust not found in PATH — install with `pip install locust` (...)
error: jmeter not found in PATH — install from https://jmeter.apache.org/download_jmeter.cgi (...)

JMeter runs emit no latency percentiles: after the run, jmeter's final console summary = line is translated into the k6-compatible summary block with avg/min/max only. There is no std/thresholds@v1 integration for --jmeter — the jmeter process exit code is the gate.

perfscale serve

FlagDefaultDescription
--port <PORT>7999Port to listen on; 0 picks a free port (printed at startup)
--tlsoffServe HTTPS with a self-signed certificate generated at startup — a local TLS target for load tests. Clients must skip verification (insecure: true in std/http@v1, k6's insecureSkipTLSVerify, locust's verify=False)

Endpoints:

MethodPathDescription
GET/healthReturns ok
POST/api/v1/metricsAccepts {"lines": ["...", ...]} and prints the batch
GET/wsWebSocket echo — every text (and binary) message is echoed back verbatim. A loopback target for WebSocket load tests and the ws benchmark suite

This is a development stand-in, not a control-plane: no persistence, no auth, no aggregation across runs. Anything that speaks these endpoints can replace it.

Benchmarking

perfscale doesn't ship a bench subcommand — engine comparisons run through scripts/bench.sh, a hyperfine-based script. See Benchmarks for methodology and usage.

perfscale lint

Validate YAML files against the same schemas run uses — plus checks a schema can't express — without executing anything. Made for editors, CI gates, and pre-commit hooks.

perfscale lint test.yaml config.yaml
perfscale lint --schema config load.yaml
FlagDefaultDescription
FILE...requiredOne or more YAML files
--schema <auto|test|config>autoauto detects per file: a top-level steps: key means test definition, anything else is a config
--offlineoffSkip the network pass (GraphQL introspection) — validate offline only
--allow-remote-importoffLet import: in the linted documents fetch over the network so the merged result can be validated
--refresh-importsoffRefetch cached tag/branch git imports instead of trusting the cache

What it checks:

  1. Imports — documents with an import: key are resolved and merged first, so validation sees what would actually run. Resolution problems (missing base, cycle, remote import without --allow-remote-import) are reported as findings at /import
  2. YAML syntax — parse errors with an indentation/quoting hint
  3. JSON Schema — required fields, types (same validation run performs)
  4. Unknown/typo'd fields — with did-you-mean suggestions (chekcheck), at every level: step fields, per-action with: parameters, check: keys, config fields, report: fields
  5. Unknown action IDsstd/htp@v1did you mean 'std/http@v1'?
  6. GraphQL documentsstd/graphql@v1 queries are parsed (syntax errors point at /steps/N/with/query) and, when the endpoint is reachable, validated against its introspected schema. schema_file steps validate against the local SDL instead, and unreachable endpoints produce an advisory note, never a failure. --offline skips the network pass

Every finding shows where (/steps/0/with), what, and what to use instead, and the output ends with a link to the YAML reference.

Exit code: 0 when every file is valid, 1 otherwise — safe to use as a CI gate:

- run: perfscale lint tests/*.yaml

perfscale schema

Print the JSON Schema perfscale validates YAML files against — the exact schemas lint and run use, as pretty JSON on stdout.

perfscale schema test     # schema for test definitions (steps)
perfscale schema config   # schema for run configs

Made for anything that authors perfscale YAML programmatically:

  • Editor autocomplete — point your YAML language server at the output
  • Agent tooling — the perfscale MCP server calls this to give models the authoring contract

Exit code: 0 on success.

perfscale man

Prints the bundled man page — the full CLI reference, embedded into the binary at build time. This is the path for Windows, which has no man(1):

perfscale man          # plain-text manual on stdout
perfscale man --raw    # the roff source instead

On Unix, install the page once so man perfscale works:

perfscale man --install                                # → ~/.local/share/man/man1
perfscale man --install --dir /usr/local/share/man/man1  # system-wide

--install writes perfscale.1 into the chosen directory and refreshes the man index when mandb is available. If man perfscale can't find the page afterwards, add the parent directory to MANPATH:

export MANPATH="$HOME/.local/share/man:$MANPATH"

A global npm install (npm install -g @perfscale/exe) links the man page automatically — npm's man field puts it under <prefix>/share/man/man1.

Exit code: 0 on success, 1 when --install can't write the directory.

perfscale self-update

Replaces the running binary with the latest GitHub release for this platform. The download's sha256 is verified against the release's sha256sums.txt before the swap, and the swap itself is atomic (staged next to the executable, then renamed) — a failed update never leaves a broken binary behind.

perfscale self-update              # update to the latest release
perfscale self-update --check      # only check; exit 10 = update available
perfscale self-update --force      # reinstall even if already up to date
FlagDescription
--checkReport whether an update exists without installing. Exit codes: 0 up to date, 10 update available — scriptable in cron/CI
--forceReinstall the latest release even when versions match

npm installs

When perfscale was installed with npm install -g @perfscale/exe, the binary belongs to npm — swapping it in place would leave npm's package metadata claiming the old version. self-update detects this (the executable lives under node_modules/@perfscale/) and refuses with the right command instead:

npm install -g @perfscale/exe@latest

--check still works, and the passive hint below suggests the npm command for npm installs automatically.

The passive "update available" hint

Other commands (run, serve, lint) print a one-line stderr hint when a newer release is known:

perfscale v0.2.0 is available (you have 0.1.0) — run `perfscale self-update`

The check is deliberately unobtrusive: at most one network call per 24h (cached in the user cache dir), only in interactive terminals (never in CI logs or pipes), never delays a command by more than ~2s, and silent when offline.

VariableEffect
PERFSCALE_NO_UPDATE_CHECK=1Disable the passive check and hint entirely
PERFSCALE_UPDATE_API_BASEOverride the release API host (default https://api.github.com) — for mirrors/proxies
PERFSCALE_UPDATE_DOWNLOAD_BASEOverride the asset download host (default https://github.com)

Environment variables

NameDescription
RUST_LOGtracing filter, e.g. RUST_LOG=debug perfscale run ...
PERFSCALE_NO_UPDATE_CHECK1 disables the update-available hint