Hooks and CI
Where opi runs besides the terminal — Git hooks, CI pipelines, deploy steps and anything else that reads an exit code.
opi is built to be used by hand: you open the list, pick a script, press H for the
checks. Every one of those areas is also a plain command with a meaningful exit code,
so the same checks run wherever a command can run: a Git hook, a CI job, a deploy step,
a Makefile, an editor task or a coding agent’s hook.
| Where | Command | What it guards |
|---|---|---|
| By hand | opi, opi --health, opi --security |
Nothing: you read the result |
| Pre-commit hook | opi --check commit |
Staged secrets, whitespace errors, conflict markers, large new files, fast project checks |
| Pre-push hook | opi --hooks installs opi --check push |
Everything a CI would run, plus the build |
| CI job | opi --health or opi --check push |
The same checks as locally, on a clean checkout |
| Before a deploy | opi --check deploy && opi --yes deploy |
Checks and build first, then the project’s deploy script |
| Before a release | opi --check release |
Clean tree, untagged version, every check, the build |
| Scheduled audit | opi --security |
Secrets and known vulnerabilities in the lockfiles |
| SBOM step | opi --sbom dist/ |
CycloneDX documents per ecosystem |
The checks come from the project’s own tools either way. opi adds no rules of its own
that would make a hook disagree with the terminal. The one exception is the bundled
nosecrets engine, which steps in when a project has no secret scanner.
Without a terminal
Hooks and CI give opi no terminal, and it changes behaviour in a few deliberate ways:
- No menu. A bare
opiprints its list as plain text instead of opening the interactive list. Pipe it intogrepor keep it in a log. - No colour. Colour is used only when both output streams are terminals and
NO_COLORis unset. A failed step still reads as failed in plain text: results carry a mark such as✗or[FAIL], not only a colour. - No silent yes. A script marked
"confirm": truerefuses to run without a terminal to ask in. Sayopi --yes <script>when that is what the pipeline means. The protection exists for exactly the case where nobody is watching. - No configuration.
opi --checksneeds a terminal and fails elsewhere without writing anything. Pick the checks locally and commit.opi/checks.json.
The exit codes are listed in the command line reference. In short, every check command exits non-zero when something needs acting on, including an audit that could not run at all.
Sharing the selection
A workflow runs its defaults until you choose otherwise with
opi --checks <workflow>. The choice is stored in .opi/checks.json, and that file is
meant to be committed. opi keeps .opi/.gitignore so that checks.json is shared
while generated SBOMs stay ignored.
One file then decides what runs on every machine and every pipeline:
{
"workflows": {
"commit": { "checks": ["Secrets", "Staged files", "Lint & format"], "build": false },
"push": { "checks": ["Secrets", "Lint & format", "TypeScript", "Tests"], "build": true }
}
}
A selected check the project cannot run fails the workflow instead of being skipped. A CI
image without the test runner therefore cannot pass on a selection that names Tests.
Configuration has the details.
Pre-commit
The commit workflow is built for this hook. It reads Git’s index rather than the working tree for the parts where that matters:
- Secrets come from the staged contents. A key that was staged and then deleted in the editor still blocks the commit, because the deleted copy is the one that would be committed.
- Staged files reports whitespace errors and conflict markers in added lines, and new files over 500 KiB, measured by their size in the index.
- Lockfile, lint, format, types and docs run as usual, on the working tree. Tests and dead-code checks are left out because they are too slow for every commit.
The hook changes nothing in the repository. It stages nothing and fixes nothing.
Plain Git
printf '#!/bin/sh\nexec opi --check commit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
That hook lives only in your clone. To share it, use one of the hook managers below.
Husky
Projects with a .husky directory commit their hooks:
echo "opi --check commit" > .husky/pre-commit
Lefthook
# lefthook.yml
pre-commit:
commands:
opi:
run: opi --check commit
pre-commit
The pre-commit framework runs local hooks with
language: system. opi decides which files matter, so tell the framework to call it
once instead of once per file:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: opi
name: opi --check commit
entry: opi --check commit
language: system
pass_filenames: false
always_run: true
Git runs hooks at the repository root. When the project lives in a subdirectory, step
into it first, for example cd app && opi --check commit.
In every variant git commit --no-verify skips the hook. That escape hatch is Git’s own,
and opi does not try to close it.
Pre-push
opi --hooks
installs opi --check push as the pre-push hook. It writes into .husky/pre-push when
the repository has a .husky directory, so every clone gets it, and into Git’s hook
directory otherwise. Existing lines in the hook are kept. The
Workflows guide describes the gates.
Push is the workflow for a repository without CI. It runs every check and the build, so a red state cannot leave the machine by accident. With CI it shortens the loop: the push that CI would reject is stopped before it leaves.
CI
On a CI runner opi is one more binary. Install the project’s dependencies first:
opi runs the project’s own tools from node_modules/.bin and Cargo, and does not
install anything itself. Then install opi at a pinned version and call it.
# .github/workflows/check.yml
name: check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: npm ci
- name: Install opi
run: curl -fsSL https://raw.githubusercontent.com/casoon/opi/main/install.sh | sh
env:
OPI_VERSION: 0.13.0
- run: ~/.local/bin/opi --health
The install script checks the download against the release’s published SHA-256. The
Linux binaries are static musl builds, so they run on any distribution image. cargo install opi --locked works as well but compiles for a while. For Rust projects, install
the toolchain with the rustfmt and clippy components so that the format and lint
checks can run.
Which command to call depends on what the job is for:
opi --healthruns every detected check and asks no questions about Git. That is the usual choice for a pull request.opi --check pushruns the push selection from.opi/checks.json, so CI and the pre-push hook run exactly the same list. The “not behind upstream” gate is skipped on a CI checkout, which has no upstream branch. The working tree has to be clean, which a fresh checkout followed by a frozen install normally is.opi --securityqueries audit services over the network and exits non-zero when one of them cannot be reached. That makes it a good scheduled job and a poor required check on every pull request.
Do not run opi --check release in a pipeline triggered by the release tag. Its version
gate fails once v<version> exists, and in that pipeline it always does. Run the release
workflow before tagging, and the push or deploy workflow in the tag’s pipeline.
Before a deploy
opi --check deploy && opi --yes deploy
opi --check deploy runs the deploy selection and the build on a clean working tree, and
never runs a deploy script itself. The second command runs the project’s own deploy
script. --yes answers its confirmation when the script asks for one, which a pipeline
has to say explicitly.
Anything else
Everything above is a command with an exit code, so the same calls fit wherever one is expected:
- a
Makefiletarget or ajustfilerecipe, such ascheck: ; opi --health; - an editor task that runs
opi --check commitbefore you stage; - a coding agent’s hook that runs
opi --check commitafter it edits files, so the agent sees the same findings you would; - a release script that writes
opi --sbomnext to its artefacts.
When a program rather than a person reads the result, rely on the exit code. The output is written for people: findings name their file and line, but there is no JSON report.