A Go CLI that captures and compares execution environments, answering why an application behaves differently across local, CI, staging, and production.
Early development. The commands listed under Usage are what works
today: capture, diff, reading CI configuration, importing a run that happened,
and deterministic diagnosis. nyrvo ci replay prints what a job does; it never
runs it, and nyrvo doctor --ai prints a request for your own agent while
--agent= hands it to an agent CLI you already installed. Not yet available:
inspecting a database's own schema or settings, configuration files, and
executing anything else on your behalf.
Code that passes on a laptop and fails in CI is usually not a code problem: it is environment drift. Different Go, Node, or Python versions, a different kernel, an uncommitted working tree, or a missing environment variable silently change behavior between environments. Nyrvo snapshots each environment as a small, shareable JSON document and diffs them so you can see exactly what differs between local and CI, staging, and production.
Every release attaches prebuilt binaries for macOS (amd64, arm64), Linux (amd64, arm64) and Windows (amd64, arm64). Download the archive for your platform from the releases page and unpack it:
VERSION=X.Y.Z # the release you want, without the leading v
curl -LO https://github.com/nyrvo-dev/nyrvo/releases/download/v$VERSION/nyrvo_${VERSION}_darwin_arm64.tar.gz
tar -xzf nyrvo_${VERSION}_darwin_arm64.tar.gz
./nyrvo version
On Windows, the .zip archive unpacks to nyrvo.exe. Every archive contains
the binary, the LICENSE and the README, and each release also carries a
SHA256SUMS you can use to verify the download.
Or, if you have a Go toolchain, install the command directly:
go install github.com/nyrvo-dev/nyrvo/cmd/nyrvo@latest
or build the binary locally:
make build
Capture two environments and diff them:
$ nyrvo capture local
$ nyrvo capture ci
$ nyrvo diff local ci
--json prints machine-readable output instead of human text:
$ nyrvo capture local --json
$ nyrvo diff local ci --json
Compare this machine against what CI declares:
$ nyrvo ci inspect # what each workflow job says it needs
$ nyrvo ci capture lint # save that job's declared environment as "ci"
$ nyrvo capture local
$ nyrvo diff local ci
ci inspect and ci capture read .github/workflows/*.yml and never run a
workflow, resolve an action, or call the GitHub API. Anything Nyrvo recognizes
but does not model is listed under "not modelled" instead of being silently
dropped, and a value it cannot know — node-version: 20.x, runs-on: ${{ matrix.os }} — is reported as unknown rather than guessed.
Or import a run that actually happened:
$ nyrvo ci import 31921289286
$ nyrvo ci import https://github.com/owner/repo/actions/runs/31921289286
$ nyrvo ci import <run> "test (ubuntu-latest)" # pick a specific job
A run reports what a job got rather than what it asked for: the commit that
was checked out and the runner it landed on, so a ${{ matrix.os }} that the
workflow file could only leave unknown becomes a real platform. It defaults to
the job that failed — the one you are asking about — and refuses to guess when
more than one did.
Importing uses your own gh CLI. Nyrvo never reads, stores, or asks for a
GitHub token. Run metadata does not include installed runtime versions (those
live in the job logs), and the snapshot says so rather than implying it knows.
Ask what the differences mean:
$ nyrvo doctor # diagnoses local against ci
$ nyrvo doctor 31921289286 # import a run and diagnose it, in one step
$ nyrvo doctor https://github.com/owner/repo/actions/runs/31921289286
$ nyrvo doctor local prod
$ nyrvo doctor --json
Given a run reference, doctor imports the run and captures this machine in
memory and diagnoses both — nothing is saved, so a one-shot question never
overwrites snapshots you captured deliberately. The report opens with what the
evidence itself reported: the run's conclusion, the step that failed, the error
line from the job log, and anything Nyrvo could not model.
doctor applies deterministic rules — no model, no network, no clock — and
ranks findings by how plausibly they explain a failure rather than by how large
the difference is. A version a workflow declares as a prefix (go-version: "1.26") is satisfied by a machine running 1.26.6, so a correct setup stays
quiet. When nothing matches, Nyrvo says no rule matched; it does not claim the
environments are equivalent.
doctor exits 0 even when it reports findings: a diagnosis is an answer, not
a failure, and a CI job opts in when it wants the exit code to depend on them.
--fail-on=high (or medium, low) exits 1 when a finding at that severity
or worse exists:
$ nyrvo doctor local ci --fail-on=high # exit 0 unless a high finding exists
It must be written with an equals sign. Every other nyrvo flag is boolean and
may appear anywhere on the line (nyrvo diff local ci --json), so the separate
form --fail-on high cannot be told apart from a snapshot named high and is
rejected with a usage error. A job that wants an unsound environment to fail the
build runs it against the defaults:
$ nyrvo capture local
$ nyrvo doctor --fail-on=high
Two rules judge a CI job's backing services rather than comparing environments.
service.missing reports a service a job declares — a postgres, a redis — that
no container running here provides, and service.image_mismatch reports the
same image name running at a different tag. Nyrvo only sees containers, so the
finding says as much: a natively installed service is invisible to it, and a
machine whose Docker daemon is not answering produces no finding at all rather
than a false one.
Services are never compared between environments. A laptop runs whatever its owner has up and a job runs the two sidecars it asked for; listing the difference describes a desk, not a defect.
One rule never compares two environments at all.
runtime.requirement_unsatisfied (always high) judges an environment against
what the checked-out project declares it needs, and reports when it does not
meet it — the only rule that can call an environment wrong rather than merely
different. It understands comparators (>=, >, <=, <, =), caret and
tilde ranges, wildcards ("20.x"), bare prefixes, and comma- or space-separated
conjunctions with || alternatives. It also knows which declarations are floors
rather than pins: the go directive in go.mod states the lowest version the
module accepts, so a project declaring go 1.25 and built on 1.26.6 is correct,
not broken. A constraint it cannot fully parse —
lts/iron, workspace:*, a hyphen range — produces no finding rather than a
guess.
nyrvo doctor --ai prints the deterministic report, then an analysis request to
hand to whatever agent you already use:
$ nyrvo doctor --ai
NYRVO DOCTOR
...
2 findings: 2 high
AI ANALYSIS REQUEST (NOT EXECUTED)
This is a request for analysis, not output from a model.
DISCLOSURE
Agent No agent was selected or run.
Data Two environment snapshots, their differences, and Nyrvo's own findings.
Execution Nothing was executed, and nothing left this machine.
Command No command was invoked.
Privacy Environment variable values are never recorded, and only the names this diagnosis refers to are included.
Paths Home directory prefixes in paths are replaced with ~.
----- BEGIN AI REQUEST -----
...
----- END AI REQUEST -----
Nyrvo gathers the evidence; your agent reasons about it. No model is called, no
network request is made, and the package that builds the request cannot make
one — so Nyrvo owns no API credentials and is tied to no vendor. nyrvo doctor
without the flag never produces a request at all.
What the request carries is chosen rather than inherited. A snapshot has no
field able to hold an environment variable's value, so a request cannot carry
one; on top of that the home directory prefix of a runtime path becomes ~, and
the environment variable names are reduced to the ones a difference or a finding
actually refers to — on a laptop that is typically one name out of seventy.
If you would rather Nyrvo hand the request over for you, name an agent CLI you already have installed:
$ nyrvo doctor --agent=claude
$ nyrvo doctor --agent=codex
$ nyrvo doctor --agent=opencode
Nyrvo shells out to your own tool, authenticated on your own account — it owns
no API keys and picks no vendor. Before anything runs it states which agent it
will invoke, what the request contains, and the exact command, and it does not
claim to know whether your tool answers locally or over the network, because
that is your configuration and not Nyrvo's business. The answer is printed under
AI ANALYSIS — <agent>, verbatim, marked as model inference rather than
observation.
The agent runs in an empty temporary directory. These CLIs are coding agents with file tools, and Nyrvo's whole design is that it supplies the evidence instead of letting a model go rummaging through your machine; running it where there is nothing to read is how that is enforced without depending on any one vendor's flags. The trade is real: an agent cannot look at the code that failed, so it answers from the evidence Nyrvo vouches for or says it cannot.
To stop typing it every time, configure a default:
$ nyrvo config set ai.agent opencode
$ nyrvo doctor --ai # now runs opencode
$ nyrvo doctor --agent=claude # an explicit flag still wins
$ nyrvo doctor --agent=none # print the request instead, just this once
$ nyrvo config list # what is set, and where it lives
The file is user-level only, at os.UserConfigDir()/nyrvo/config.json. There is
deliberately no project-level config, and that is a security decision rather
than a simplification: this file selects a program Nyrvo will execute, so a copy
of it living in a repository would let whoever opens a pull request choose that
program. For the same reason, configuring an arbitrary command as an agent is
not supported at all. Adding another known agent is a line in a table — see
CONTRIBUTING.md.
--json prints one document either way, replacing the diagnosis document rather
than following it: two JSON documents on one stream is not something a consumer
can parse, and the request already carries the findings. It reports
"executed": false for a request nobody ran, and "executed": true alongside
agent, command and analysis when one did.
nyrvo ci replay <job> prints what a job does, in order, and marks every step
it cannot reproduce here with the reason:
$ nyrvo ci replay test
REPLAY test
PREREQUISITES
this job runs once per matrix combination (go-version=[1.25 1.26], os=[ubuntu-latest macos-latest]); the steps below describe one of them
STEPS
1. Checkout
uses actions/checkout@v4
not reproducible uses actions/checkout@v4; Nyrvo does not run actions, but your working tree is already the checkout, so there is nothing to do
2. Set up Go
uses actions/setup-go@v5
not reproducible actions/setup-go@v5 installs the go named by ${{ matrix.go-version }}, which the runner decides; install it yourself
3. Build
run
go build ./...
It executes nothing, by design. A tool that runs a workflow's steps outside the
runner diverges from CI in ways it cannot see — no runner image, no actions, no
secrets, no service containers — and produces a run that proves nothing while
looking authoritative. A plan you read and paste stays honest about the gap.
Environment values come from the workflow file and are printed as written, but a
value referencing a secret is replaced with <secret>.
Other commands:
$ nyrvo list
$ nyrvo version
- OS, CPU architecture, and kernel
- Git commit SHA, branch, and whether the working tree is dirty
- Go, Node.js, npm, pnpm, Yarn, Python, Ruby, PHP, Composer, Rust, Java and .NET versions (including install paths)
- Docker client, server, and compose versions, and whether the daemon answers
- The container images running here, and the services a CI job declares
- The version constraints the checked-out project declares
- Environment variable names
- From CI: the runner platform, setup-action runtime versions, declared environment variable names, and service containers a job asks for
A probe can also fail to answer, and that is not the same as finding nothing. Nyrvo records three outcomes, not two. A tool that is not on PATH is absent. A probe that ran out of time is recorded as unmeasured: the question was asked and never answered, so the diff declines to compare it rather than reporting the tool as having vanished — run the capture again to settle it. A tool that is installed and refuses to report a version is recorded as unusable, and is reported as "installed, not usable" rather than missing; the usual cause is a pinned toolchain the machine does not have, and telling you to install software you already have would be worse than saying nothing.
An absent Docker section means Docker is not installed; a present section with
daemon_running false means the CLI is there but the daemon is not answering.
Those are different facts, and the second is a common reason a compose-backed
suite passes in CI and fails on a laptop. Docker versions are compared by
nyrvo diff like everything else.
The declared constraints come from engines.node, engines.npm and
packageManager in package.json, the go directive in go.mod, .nvmrc,
.python-version, requires-python in pyproject.toml, .tool-versions,
.ruby-version, the ruby directive in a Gemfile, require.php in
composer.json, rust-toolchain.toml, rust-version in Cargo.toml,
global.json, and .java-version. They are stored as written, except where a
format carries a checksum that is not part of the version — corepack's
packageManager is recorded as pnpm@9.1.0, not with its +sha512… suffix.
Nyrvo also records whether a constraint is a floor or a pin, because the two
warrant different findings: a bare go 1.25 in go.mod is a minimum, while
^8.3 in composer.json and pnpm@9.1.0 are not.
They are never compared between environments:
both sides normally read the same repository, so diffing them would manufacture
drift out of provenance. They exist so a rule can call a version wrong, which is
what runtime.requirement_unsatisfied does.
Snapshots are stored as plain JSON files in .nyrvo/snapshots/<name>.json in
the working directory, so they follow the repository they describe. Each file
carries a schema_version so an older Nyrvo can refuse to misread a newer
snapshot.
The first capture also writes .nyrvo/.gitignore, so capturing inside a
repository does not leave untracked files that would make Nyrvo report its own
working tree as dirty. Empty that file if you want to commit your snapshots;
Nyrvo will not recreate it.
The full policy, including how to report a vulnerability privately, is in SECURITY.md.
Nyrvo never stores environment variable values — only their names. Presence is enough to detect drift ("REDIS_URL is missing in CI"), and never recording values means credentials never end up in a file you share in a bug report or commit by accident. Snapshot files are written with 0600 permissions so they remain owner-readable.
Verified on Linux, macOS and Windows. CI runs the whole suite on all three,
across Go 1.25 and 1.26 — no test is skipped to get a platform green, with one
stated exception: the check that snapshot and config files carry 0600
permissions runs where POSIX permission bits exist. Windows has none, and
asserting them there would claim a guarantee the platform does not offer.
On Windows the kernel string is left empty, because uname is how Nyrvo reads
it. OS and architecture come from the Go runtime and are always present.
Run the full local checks with make check (test, race detector, vet, gofmt
and golangci-lint).
Go 1.25 is the minimum supported version; development runs on 1.26. CI verifies
both.
Nyrvo is written by @lucascnunes with Claude (Anthropic) as the implementing tool, under review. Direction, design decisions and acceptance are human; a large share of the code and prose is model-written.
That arrangement is disclosed because the project's whole argument is refusing to claim what has not been verified, and how the code came to exist is part of what a reader is entitled to check. It changes nothing about the standard the code is held to: every commit builds and tests in isolation, CI runs the suite on three operating systems, and every serious defect this project has found was found by running the tool against real data rather than by reading a diff.