---
title: CLI
description: Install @izri/cli, configure it with three environment variables, and run the four signals from your terminal.
order: 20
---

# CLI

`@izri/cli` is the terminal surface for Izri, and the foundation every other integration shells out to — the GitHub Action installs this CLI and calls it.

## Install

```bash
# One-shot, no install
npx @izri/cli@latest --version

# Global
npm install -g @izri/cli
izri --version
```

Requires **Node.js 18 or newer**.

## Configure

Three environment variables:

| Variable | Required | Example |
| --- | --- | --- |
| `IZRI_API_URL` | yes | `https://api.izri.ai` |
| `IZRI_API_TOKEN` | yes | `izri_xxxxxxxxxxxxxxxx` |
| `IZRI_PROJECT_ID` | no | `01HK0...` (ULID) |

Mint the token from your organization settings with the `tests:run` scope (`izri upload`, `izri verify`, `izri run`) and `tests:read` (`izri status`, `--watch`); a token without them gets `403`. It must start with `izri_` — the API short-circuits to bearer validation on that prefix, so a token without it produces an opaque `401` rather than a useful message.

### How the project gets resolved

`IZRI_PROJECT_ID` is optional. Resolution order:

1. `--project <id>` on the command
2. `IZRI_PROJECT_ID`
3. auto-match — inside a git checkout with a GitHub `origin` remote, the CLI matches `owner/repo` against the projects your token can see

Auto-match uses the same comparison the GitHub App webhook does, so the CLI and your PR checks always agree about which project a repository belongs to.

## Commands

### `izri run`

Trigger a test run for the current repository.

```bash
izri run                        # fire and forget
izri run --watch                # poll until terminal
izri run --project 01HK...      # explicit project
```

| Flag | Description |
| --- | --- |
| `-p, --project <id>` | Project ULID. Otherwise auto-resolved from the git remote. |
| `--watch` | Poll until the run reaches a terminal status. |

The current `HEAD` SHA and branch are sent automatically.

`--watch` polls every 3 seconds until the run reaches `PASSED`, `FAILED`, `ERROR`, or `CANCELLED`, with a 30-minute hard cap. It exits **non-zero on any terminal status except `PASSED`**, so CI scripts can branch on it directly.

### `izri upload`

Send your CI's own test results — and optionally a coverage report — to Izri as the evidence for [`izri/tests`](/docs/signals/tests).

```bash
izri upload --junit junit.xml                                   # results only
izri upload --junit junit.xml --coverage coverage/lcov.info     # plus diff coverage
izri upload --skipped "docs-only change"                        # nothing ran, say so
```

| Flag | Description |
| --- | --- |
| `--junit <path>` | JUnit XML from your test runner. Required unless `--skipped`. |
| `--coverage <path>` | LCOV or Cobertura report. Format is inferred from the extension; override with `--coverage-format`. |
| `--skipped <reason>` | Record an explicit empty run. Always upload something: silence reads as a broken step. |
| `--head-sha`, `--base-sha` | Default from the GitHub Actions environment (the PR head, not the merge commit), then from git. |
| `-p, --project <id>` | Project ULID. Otherwise auto-resolved from the git remote. |

In GitHub Actions the CLI requests the job's id-token (the job needs `permissions: id-token: write`) and sends it with the upload; its signed claims are what let Izri trust the evidence, as [`izri/tests`](/docs/signals/tests#evidence-the-pr-could-have-shaped) explains. Without that permission, or outside Actions, the upload still goes through and is recorded as untrusted, with a line on stderr saying so. A token Izri refuses is different: that is a configuration error (usually the id-token's audience, the origin of `IZRI_API_URL`, not matching the Izri deployment's `API_URL` or `APP_URL`), and the command fails with a message naming it rather than quietly uploading untrusted evidence. A `409` (Izri has not seen the commit yet) is retried three times, then warned about — it is never your build's failure.

### `izri verify`

Run [the revert check](/docs/signals/hallucination#the-revert-check): the tests that depend on the change, once at `HEAD` and once with the change's source files restored to the merge base, and upload both results.

```bash
izri verify --test-command "npx vitest run {files} --reporter=junit --outputFile={junit}"
izri verify --base origin/develop --max-test-files 20 --test-command "…"
```

| Flag | Default | Description |
| --- | --- | --- |
| `--test-command <cmd>` | *(required)* | Shell command run in each worktree. `{files}` becomes the selected test files, each shell-quoted; `{junit}` becomes a path the CLI owns, where the command must write JUnit XML. |
| `-b, --base <ref>` | `origin/$GITHUB_BASE_REF`, else the merge base with `origin/main` | Base of the change. |
| `--max-test-files <n>` | `50` | Budget. Over it, only the test files the PR changed run; still over, nothing runs and a budget skip is uploaded. |
| `--head-sha <sha>` | *(as `izri upload`)* | Commit the evidence is about. |
| `-p, --project <id>` | *(auto)* | Project ULID. |

What it selects: the test files the PR added or changed, plus every tracked test file whose imports suffix-match a changed source file — the same rule Izri's static linkage uses. Only TypeScript/JavaScript files count, as sources and as tests: a changed fixture or snapshot under a test directory is not run. Imports are read from the first megabyte of each regular (non-symlinked) test file. With no changed source file, or no selected test, it uploads an explicit empty check rather than nothing. Like `izri upload` it needs the base fetched (`actions/checkout` with `fetch-depth: 0`).

Both runs happen in throwaway `git worktree`s, so your tracked files are never modified; the worktrees are removed when the command ends, including when it is interrupted (`SIGINT`, `SIGTERM`; a second signal kills a test command that ignores the first) — only a `SIGKILL` of the CLI itself leaves them behind. Each `node_modules` directory your checkout has is linked into the worktrees, so install dependencies before running it; a runner that writes caches into `node_modules` writes them into your checkout's. Imports that resolve through a `node_modules` link into another workspace package see that package as it is in the checkout, not reverted. A selected test file whose path contains a newline aborts the command; a path starting with `-` is passed as `./-…`, never as an option. The test command's exit status is ignored — the reverted run is expected to fail — and its output goes to stderr; there is no timeout of its own, so rely on the job's. It runs without `IZRI_API_TOKEN` and the `ACTIONS_ID_TOKEN_REQUEST_*` variables, so the PR's tests cannot use Izri's credentials; `IZRI_VERIFY_RUN` is `head` or `reverted`. It needs a POSIX `sh`.

The upload is judged against [`izri/tests`](/docs/signals/tests) evidence for the same commit: run `izri upload` (or the Izri runner) too, in either order. The first stdout line is `<verificationRunId>  JUDGED` or `NOT_JUDGED`, followed by the reason. Like `izri upload` it sends the job's id-token (an id-token Izri refuses is a configuration error and fails the command) and retries a `409`, then only warns. It also fails on a malformed invocation, a base it cannot resolve, a project it cannot resolve, a git worktree it cannot create, a test command that writes no JUnit at head, and an upload Izri rejects as `400`, `401`, `403`, `404` or `413`. An unreachable Izri, or one answering `5xx`, only warns.

### `izri check-scope`

Run scope analysis on your local diff without triggering a test run. Deterministic and fast.

```bash
izri check-scope
izri check-scope --base origin/develop
izri check-scope --semantic
izri check-scope --json
```

| Flag | Default | Description |
| --- | --- | --- |
| `-p, --project <id>` | *(auto)* | Project ULID. |
| `-b, --base <ref>` | `origin/main` | Base ref to diff against. |
| `--semantic` | off | Opt into the LLM semantic layer. Tier-gated server-side. |
| `--json` | off | Emit raw JSON instead of the formatted summary. |

The default base is `origin/main`. If your trunk is named anything else, pass `--base` or the diff will be wrong rather than empty — worth wiring into a shell alias.

`--semantic` costs AI tokens and is gated by your plan. Without it you still get the full deterministic rule layer: path categorization, commit-vs-files alignment, sensitive-path hints, and scale anomalies.

### `izri status`

Fetch the latest delta report for the current `HEAD`.

```bash
izri status
izri status --json
```

| Flag | Default | Description |
| --- | --- | --- |
| `-p, --project <id>` | *(auto)* | Project ULID. |
| `--json` | off | Emit raw JSON instead of the formatted summary. |

Use this after a push to read the verdict without opening GitHub.

## Scripting against it

`--json` plus a non-zero exit on failure is the whole contract:

```bash
if ! izri run --watch; then
  echo "Izri verdict is not passing"
  izri status --json > izri-report.json
  exit 1
fi
```

See [verdicts and exit codes](/docs/reference/verdicts-and-exit-codes) for the exact status-to-exit-code mapping.

## Related

- [GitHub Action](/docs/install/github-action) — the same CLI, wired into pull requests.
- [MCP server](/docs/install/mcp) — the same signals, exposed to coding agents.
