The bandura command runs the same execution engine as the desktop app, with no
Electron and no UI. It’s built for terminals and CI. A flow that passes in the editor passes in
the pipeline, because it’s the same engine reading the same file.
Install
Release candidate. A plain
npm i -g @bandura/cliinstalls the current release candidate.1.0.0is reserved for the launch build and nothing carries that number yet; the changelog tracks what exists. To see exactly whatlatestresolves to today, runnpm view @bandura/cli dist-tags.
bandura is distributed on npm as a small installer that pulls a native, self-contained
binary for your platform. There’s no JavaScript to load and, once installed, no Node.js
runtime is needed to run it.
# Global install: puts `bandura` on your PATH
npm install -g @bandura/cli
bandura --version
# Or run without installing (CI-friendly)
npx @bandura/cli run
The installer selects the right prebuilt binary automatically. Supported platforms:
| OS | Architectures |
|---|---|
| macOS | Apple Silicon (arm64), Intel (x64) |
| Linux | x64 and arm64, both glibc and musl (e.g. Alpine) |
| Windows | x64 |
Installing with
--ignore-scriptsskips the step that places the binary. If you must, run the CLI via the bundled fallback:node node_modules/@bandura/cli/cli-wrapper.cjs <args>.
In CI, prefer npx @bandura/cli@<version> (pin the version) or the official
bandura-io/run@v1 action (publishing to the GitHub Marketplace at launch), which wraps the
same binary.
Upgrading from rc.1 or rc.2. Upgrade; both are deprecated on npm because of two real defects.
subflownodes did not run at all on any version before rc.4 (the node failed with “subflow requires a flow resolver”), and on rc.1 and rc.2log()was undefined inside ascriptnode, so a flow that logs failed the node instead of printing.
bandura run
bandura run # the manifest's flows globs, or **/*.aether
bandura run flows/smoke.aether # one flow
bandura run "flows/**/*.aether" # a glob
With no globs, run uses the flows field from bandura.json when there is one.
Options:
| Option | What it does |
|---|---|
-r, --reporter <names> | Output format: pretty (default, human), json, junit, github; comma-separate to emit several at once. pretty prints a node’s log() lines dimmed underneath it; junit carries them in <system-out>. |
-o, --output <file> | Write the machine report (json/junit) to a file. |
-e, --env <name> | Load a manifest environment’s non-secret values (default: the manifest’s defaultEnvironment; an unknown name exits 2). |
--var KEY=VALUE | Inject or override a variable (repeatable; highest precedence). |
--env-file <path> | The dotenv file with secret values (default: .env). |
--concurrency <n> | Run up to N flows in parallel (default 1; each flow stays sequential inside). Output is buffered into input order, so reports stay deterministic. |
--collection <name> | Run a named collection from bandura.json (an ordered suite reported as one result), honoring the collection’s pinned environment and continue-on-error setting. |
--no-redact | Keep resolved credentials in the request and response snapshots the report carries. Off by default, meaning reports are redacted. See what redaction covers. |
--timeout <ms> | Whole-run deadline per flow: a flow that exceeds it fails with run timed out after <ms>ms. (Per-request ceilings are the node’s own timeout field, default 30 s.) |
--bail | Stop at the first failing flow. |
--watch | Rerun whenever a matched flow, or a file it subflows into, changes on disk. See watch mode below. |
--repeat <n> | Run each matched flow N times and report latency percentiles. See repeat runs below. |
--threshold <expr> | Fail the process if a repeat run misses a latency target, for example "p95<500". |
Examples:
# CI: JUnit for the test dashboard, plus GitHub annotations
bandura run "flows/**/*.aether" -r junit,github -o report.xml
# Point the suite at a different host without touching any file
bandura run smoke.aether --var baseUrl=https://staging.api.example.com
# The staging environment from bandura.json, four flows at a time
bandura run -e staging --concurrency 4
Flows with ai-action nodes run headlessly too: set
ANTHROPIC_API_KEY in the environment (or .env) and they execute with your key;
ANTHROPIC_MODEL optionally picks the model. GEMINI_API_KEY selects Gemini the same way, on
1.0.0-rc.4 and newer (see Connect an AI provider), and
any OpenAI-compatible endpoint works instead: BANDURA_AI_BASE_URL alone is enough to select it,
so a CI job can run against OpenAI, Groq, OpenRouter, or a
local Ollama on the runner:
export BANDURA_AI_BASE_URL=https://api.groq.com/openai/v1
export BANDURA_AI_API_KEY=gsk_...
export BANDURA_AI_MODEL=llama-3.3-70b-versatile
bandura run "flows/**/*.aether"
The full variable list is in
Connect an AI provider. With nothing
configured, only the ai-action node fails; the rest of the suite is unaffected.
Reports are redacted by default
The json and junit reports carry the request and response snapshots for each node, and by the
time a request is sent its ${{ env.API_KEY }} has become the real key. A CI report is routinely
uploaded as a build artifact that anyone with repository access can download, so redaction is the
default rather than a flag you have to remember.
What is replaced: credential-shaped header names (Authorization, Cookie, API-key headers and
their variants), credentials in a URL’s userinfo or query string, and values under field names that
look like secrets.
What is not, stated plainly because a partial guarantee you mistake for a total one is worse than none:
- Request and response bodies are untouched. A body is the payload under test, and redacting it would leave you unable to read the thing you are testing.
- Failure messages are untouched, so an assertion’s expected-versus-actual diff stays readable.
- A bespoke opaque value under an ordinary field name is not recognisable as a credential and is not caught.
--no-redact turns it off for local debugging, when you want to see the exact key that was sent.
Think twice before putting it in a pipeline.
Watch mode
--watch turns a one-shot run into a loop, which is what you want while you are actually writing
a flow:
bandura run checkout.aether --watch
It runs once immediately, then reruns whenever a matched flow changes on disk, plus every file those flows subflow into, transitively. So editing a shared login subflow reruns the parent that calls it.
- Editors emit several change events per save, so bursts are debounced into a single rerun. Each
one prints a
rerunning... (change detected)line so the scrollback stays readable. - A broken edit does not end the session. A parse error is reported and the watcher keeps going, which is the entire point when you are saving mid-thought.
- Ctrl+C closes the watcher and exits
0.
The watch set is fixed when the session starts. A flow that begins referencing a new subflow file needs a restart before that file is watched.
Repeat runs and latency thresholds
--repeat runs each matched flow N times and reports where the timings landed. It is lite local
load testing, not a replacement for k6, and the point is to catch “this endpoint got slow” in the
same suite that already checks “this endpoint is correct”:
bandura run login.aether --repeat 50 --concurrency 10
The runs go through the same --concurrency worker pool a multi-file run uses, and the report
gains a latency block:
Latency (n=50 runs)
min 82ms
mean 141ms
p50 129ms
p95 287ms
p99 402ms
max 511ms
Percentiles are nearest-rank with no interpolation, the convention k6, oha and vegeta use, so a
reported p95 is always a duration that was actually observed rather than one computed between two
samples. The json reporter carries the same numbers on report.repeat.
Failing the build on a slow run
--threshold turns that report into a gate:
bandura run login.aether --repeat 50 --threshold "p95<500"
The expression is one metric, one operator, and a limit in milliseconds. Metrics are p50, p95,
p99, mean and max; operators are <, <=, > and >=. So "mean<=200" and "max<1000"
both work.
A missed threshold exits 1, the same code a failed assertion uses, even when every individual
run passed. Correct but too slow is still a failure, which is the only reading that makes the
flag useful in CI.
--threshold on its own implies --repeat 1, so you can gate a single baseline run without
picking a repeat count. A malformed --repeat or --threshold value is a usage error, exit 2.
Exit codes
Deterministic, so CI can tell “the tests failed” apart from “the tool broke”:
| Code | Meaning |
|---|---|
0 | All flows passed. |
1 | One or more assertions/requests failed (a test failure). |
2 | Usage or config error (bad flags, no flows matched, missing env). |
3 | Parse/validation error in an .aether file or bandura.json. |
4 | Internal error. |
bandura validate
Parses and schema-validates flows (and bandura.json) without executing anything:
bandura validate # everything the manifest resolves to
bandura validate "flows/**/*.aether" # or an explicit glob
One ✓/✗ line per file; exit 0 when everything is valid, 3 on any schema error, 2 when
nothing matches. Fast enough for a pre-commit hook or a PR gate that doesn’t need a network.
bandura lint
Where validate asks whether a flow parses, lint asks whether it is a good flow, still
without executing anything:
bandura lint # everything the manifest resolves to
bandura lint --severity warning # treat warnings as failures too
bandura lint --json # machine-readable, for a dashboard
It reports nodes that nothing routes to, loops with no body and parallel nodes with no branches,
certificate verification turned off, credentials written as literals where an ${{ env.* }}
reference belongs, and flows with no assertions at all.
--severity sets what actually fails the process, defaulting to error, so warnings and hints
are reported without breaking a build that has not opted into them. Exit 0 when nothing at or
above the threshold was found, 1 when something was, 2 when nothing matched. A file that does
not parse always exits 3, whatever the severity: a build whose flows no longer load must never
report success.
The same rules back the lint_flow tool in the MCP server and the assistant
in the app, so a check you see in CI is the check your agent runs.
bandura info
Prints the .aether schema this build implements: every node type with its fields and which
of them are required, the flow-level fields, and the shared auth, tls, proxy, hooks,
retry and data blocks.
bandura info # everything
bandura info loop # just the loop node
bandura info --json # as data
It is generated by walking the parser’s own validator, so it cannot disagree with what the parser will accept. Useful when you are hand-writing YAML, and useful to pipe at a script or an assistant that needs the exact shape rather than an approximation of it.
bandura list
Prints the flows the manifest resolves to: one path name (N nodes) line each, sorted by
path, headed by the project name. Handy for verifying what a glob will actually run.
bandura init
Scaffolds a project in the current directory:
bandura.json, withrequiredEnvinferred by scanning existing.aetherfiles for${{ env.* }}references..env.example, holding the inferred names with blank values, safe to commit..gitignore, with.envand friends merged in (existing entries kept).
It never overwrites an existing manifest. This is the CLI twin of the desktop app’s
clone-and-run setup. Run it once and a teammate’s git clone knows exactly which variables
to ask for.
bandura import
The same importers as the desktop app’s Import modal, headless:
bandura import ./collection.json --out flows/ # Postman
bandura import ./openapi.yaml --out flows/ # OpenAPI / Swagger
bandura import ./get-user.bru --out flows/ # a Bruno request file
bandura import ./insomnia_export.json --out flows/ # an Insomnia v4 export
bandura import ./session.har --out flows/ # a browser/proxy HAR capture
bandura import ./request.curl # a saved curl command
--from openapi|postman|postman-env|insomnia|bruno|har|curl forces the format when
auto-detection guesses wrong; --out picks the output directory (default: current
directory).
An import that yields two or more flows writes them into a subfolder of --out named after
the collection (the document’s info.name / info.title, or the file name with its format
suffix stripped), so a collection arrives as a unit instead of scattering N files. A single-flow
import (a curl command) still lands directly in --out. Pass --flat to opt back into the
old behaviour and write every flow straight into --out.
An Insomnia export usually carries requests and environments; both halves import in the
same command. The requests become flows; the environment values go to your .env and their names
to the manifest’s requiredEnv, exactly as a Postman environment file does.
A HAR is the one source that doesn’t become one flow per request. A capture is a recording of
a session in order, so it imports as a single flow that replays those requests in sequence, each
followed by an assertion on the status the server actually returned. Credential headers
(Authorization, Cookie, anything ending in -token/-key/-secret) keep their names but
their values are replaced with variables, so the generated file is safe to commit; static assets
and CORS preflights are filtered out.
bandura export openapi
The reverse of import: generate an OpenAPI 3.0 spec from your flows.
bandura export openapi # print the spec to stdout
bandura export openapi --out openapi.yaml # write it into the repo
bandura export openapi --check # CI: fail if the committed spec is stale
Paths, methods, parameters and request bodies come off your request nodes; response status codes
and body shapes come off the assertions and capture expressions that follow them. It’s
deterministic and offline (no AI, no API key, no network), so it produces the same bytes every
time, which is what makes --check usable as a CI gate: commit openapi.yaml, add the check to
your workflow, and the spec can never quietly drift behind the tests.
Two things worth knowing. If your team has tests but no spec, this bootstraps one, after which
self-healing works for you too. And generated specs are marked, so
regenerating over a previous export is silent while a hand-written or imported spec is never
overwritten without --force.
--title sets the spec’s info.title (it defaults to the manifest name) and --api-version sets
info.version (default 1.0.0).
bandura docs generate
Turn your flows into readable documentation, offline:
bandura docs generate # docs/, one .md per flow plus an index
bandura docs generate --format html # docs/index.html, a single self-contained file
bandura docs generate --out site/api-docs # write somewhere else
bandura docs generate "flows/checkout/*.aether" --format html --out public/docs
| Option | What it does |
|---|---|
--format <fmt> | markdown (default) or html |
--out <dir> | Output directory (default: docs/) |
This walks the parsed flows directly rather than routing through OpenAPI, which is why it covers
all ten node types rather than the HTTP-shaped subset OpenAPI has vocabulary for. A grpc
call, a loop, a condition and a script node all get documented; export openapi can only
describe the requests.
Each node shows its type-specific fields (a request’s method, URL, headers, body, capture, auth and
retry; an assertion’s check; a loop’s over and itemVar) alongside its routing, so the
document says what runs next as well as what each step does. A Flow structure section then
walks the graph from the flow’s start node, indenting under branches so the sequence reads like
the shape of the canvas. Nodes that are unreachable from start are listed separately rather than
quietly dropped.
Two output shapes, one generator:
- Markdown, the default, writes one
<slug>.mdper flow plus anindex.mdlinking them. Each file is something a Git host renders on its own, so the set works well committed next to the flows it documents. --format htmlwrites a single self-containedindex.html: inline CSS, no external assets, no CDN. Open it straight from disk, or drop it into a docs site as one file.
Like export openapi and drift detection, it is deterministic and offline. No AI, no key, no
network, and the same input produces the same bytes, so regenerating it in CI is a diff rather than
a surprise.
The same generator is behind the desktop’s Project: Generate docs (Markdown) and (HTML) palette commands.
bandura mock
Serve a local HTTP stub of the API your flows describe:
bandura mock # the manifest's flows, or **/*.aether
bandura mock "flows/**/*.aether" -p 4010
bandura mock --spec openapi.yaml # serve an existing spec instead
It starts, prints the routes it’s serving, and stays up until Ctrl+C. Your frontend can develop against it before the backend exists; your flows can run against it in a pipeline that has no staging environment.
The mock is built from the same OpenAPI document bandura export openapi
emits, which is why the mock and the spec can’t disagree. There is no second description of your
API to keep in sync.
| Option | What it does |
|---|---|
[globs...] | Which flows to build the mock from (default: the manifest’s, or **/*.aether). |
-p, --port <port> | Port to listen on (default: a free port the OS picks). |
--host <host> | Interface to bind (default: 127.0.0.1, loopback only unless you change it). |
--spec <file> | Serve an existing OpenAPI document instead of deriving one from the flows. |
--delay <ms> | Add artificial latency to every response, for testing spinners and timeouts. |
Four behaviours are worth knowing:
- Responses are deterministic. The same call returns the same bytes every time: dates, UUIDs and strings are fixed sample values, never randomized. A mock you can’t write an assertion against isn’t much of a mock.
- CORS is permissive, so a browser app can call it from
localhost:3000without a proxy. - A 404 tells you what is mocked. The body carries the full list of routes, so a typo’d path answers itself instead of leaving you guessing.
- You can ask for an error. Send the header
x-bandura-mock-status: 404(or409, or500) and you get that response as the spec declares it. That’s the way to exercise your error handling. A status the spec doesn’t declare comes back as501, listing the ones that are declared.
One honest limitation: the mock is only as detailed as your assertions. It knows a field
exists because a flow captured or compared it. A field you capture but never compare mocks as
null; an endpoint whose response body no assertion touches mocks as an empty response. Tightening
the assertions makes the mock sharper. It’s the same work, counted twice.
In the desktop app the same server is one command away: Project: Start mock server in the command palette (⌘⇧P) starts it on a free port and shows the URL; running it again stops it.
bandura mcp
Starts the bundled MCP server on stdio, so an AI agent (Claude Code,
Cursor, …) can list, read, run, search, lint, drift-check, import, export, create, and edit your
flows, and read the .aether schema itself rather than guessing at it. The engine
is compiled into the same binary, so there’s no separate install:
bandura mcp # speaks the Model Context Protocol over stdio
See the MCP server page for the tool list and client configuration.
A CI recipe
Any CI system works. It’s one command with an exit code. If you’re still weighing whether the suite belongs in the pipeline at all, API testing in CI makes that case:
# GitHub Actions
- run: npx @bandura/[email protected] run "flows/**/*.aether" -r junit,github -o report.xml
env:
API_KEY: ${{ secrets.API_KEY }} # process env is visible to flows as env.API_KEY
Flows read env.* from the process environment plus the dotenv file (--env-file,
default .env), never from inside the .aether files, which stay clean in the repo.
Run flows in CI has the full pipeline recipes: GitHub Actions and the
official action, GitLab, secrets, JUnit artifacts, the spec-drift gate, and the environment
variables that make ai-action nodes work headlessly.