User Guide
This guide walks through the everyday tasks a user runs against OCX — install a tool, switch versions, embed a stable path, lock a CI build, run a command with its dependency environment, authenticate to a private registry, work offline. Each section is task-named and self-contained.
For first-time setup and a guided quick-start, see Getting Started. For why the behavior is shaped the way it is — content addressing, OCI tag mechanics, environment composition, GC reachability — every section ends with Learn more links into the matching In Depth page.
Install a tool
The basic flow is one command:
ocx package install "kitware/cmake:4.2.0"OCX downloads the package, verifies its SHA-256 digest, and stores it in the content-addressed package store under ~/.ocx/packages/. A candidate symlink (candidates/3.28) is created so the version is reachable by name.
Multiple versions coexist — installing kitware/cmake:3.30 next to kitware/cmake:3.28 adds a second candidate; nothing is overwritten. The content-addressed layout dedups identical builds automatically: if kitware/cmake:3.28 and kitware/cmake:latest resolve to the same digest, they share one directory on disk.
To run a tool once without keeping it installed, skip the install step entirely:
ocx package exec "kitware/cmake:4.2.0" -- cmake --versionocx package exec downloads on demand, runs in a clean environment, and leaves no candidate symlink behind — the binary stays in the package store but no version is selected. Useful for one-off invocations and CI where persistent state is not needed.
Learn more
Storage In Depth — content addressing, layer dedup, hardlink assembly. Versioning In Depth → Tags — what :3.28 actually resolves to. Entry Points In Depth — what generated launchers do under ocx package exec.
Install without curl | sh
Some CI environments and security policies forbid piping a network request directly into a shell interpreter. OCX supports a fully equivalent setup path: download the binary from GitHub Releases, then run ocx self setup from the downloaded file. The result is identical to running the install script.
The goal here is getting from "I have a trusted binary" to "shell integration is complete" without any shell script involved.
What the setup command does
ocx self setup performs four steps in strict order:
- Bootstraps itself — installs the latest published
ocx.sh/ocx/cliinto the content-addressed package store and wires thecurrentsymlink. The loose binary you downloaded is only needed to run this step; after it completes, the managed copy in~/.ocx/takes over. - Writes the env shims — creates
$OCX_HOME/env.sh,env.fish,env.ps1,env.nu, andenv.elv. These files are byte-identical across users; no install-time substitution occurs. - Injects a source line — adds a fenced block-marker to each detected shell profile (
.bash_profile,.zprofile,.bashrc,.zshrc,$PROFILE, and the equivalent files for fish, nushell, and elvish). The fence is idempotent: re-runningocx self setupis safe. - Registers a session
PATH— writes$OCX_HOME/toolchain/active/binand the ocx installation'sbindirectory behind it into the one store per platform that a whole login session reads: the Windows user environment, anenvironment.ddrop-in on Linux, a LaunchAgent on macOS. A profile reaches login shells; this reaches everything else. See Reach what no profile reaches.
If the bootstrap fails (for example, the registry is unreachable), the command returns a non-zero exit code and writes nothing — no partial state.
POSIX (Linux, macOS)
# 1. Download the binary for your platform from GitHub Releases.
# Example: Linux x86_64.
curl -fSL https://github.com/ocx-sh/ocx/releases/latest/download/ocx-linux-amd64 \
-o /tmp/ocx
chmod +x /tmp/ocx
# 2. Run setup. The binary bootstraps the managed copy and wires shell profiles.
/tmp/ocx self setup
# 3. Reload your shell (or open a new terminal).
source ~/.bash_profile # bash — or ~/.zprofile for zshAfter step 2, the managed ocx binary is in ~/.ocx/symlinks/ocx.sh/ocx/cli/current/content/bin/. The temporary binary at /tmp/ocx can be deleted.
Windows (PowerShell)
# 1. Download the binary.
Invoke-WebRequest -Uri https://github.com/ocx-sh/ocx/releases/latest/download/ocx-windows-amd64.exe `
-OutFile "$env:TEMP\ocx.exe"
# 2. Run setup. Writes env.ps1 and a fenced block-marker to $PROFILE.
& "$env:TEMP\ocx.exe" self setup
# 3. Reload your profile.
. $PROFILEWindows execution policy
If PowerShell's execution policy is set to Restricted, the sourced env.ps1 file will not run even after ocx self setup completes. The command reports a non-fatal advisory when it detects this. To fix it:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedocx self setup never changes the execution policy automatically — that is a user security decision.
Options
| Flag | Effect |
|---|---|
--no-modify-path | Write the shims only; touch neither a shell profile nor the session PATH. Equivalent to setting OCX_NO_MODIFY_PATH=1 for that invocation. |
--toolchain-activate MODE | Write activate = "MODE" into $OCX_HOME/ocx.toml, deciding how the global toolchain reaches your shell. MODE is env, bin, or none — see Tools on PATH, nothing else composed. Omit to leave ocx.toml untouched; the file is created carrying only this key if it does not exist yet. |
--profile PATH | Target an explicit profile file instead of auto-detecting. Repeatable. |
--dry-run | Show what would be written without writing anything. Useful to preview which profiles are detected. |
--force | Overwrite a fenced block whose contents have been manually edited. |
If you plan to manage your own PATH (CI jobs, container images, package-manager installs), pass --no-modify-path to stop ocx self setup from writing either PATH surface — the profile blocks and the session-level registration:
/tmp/ocx self setup --no-modify-path
# Then add both directories to PATH yourself, toolchain bin first:
# ~/.ocx/toolchain/active/bin
# ~/.ocx/symlinks/ocx.sh/ocx/cli/current/content/binNote that --no-modify-path is not remembered between invocations. If you run ocx self setup again later without the flag, both surfaces are written. Set OCX_NO_MODIFY_PATH=1 persistently in your environment or pass the flag each time to prevent that. Each store the run did not touch still appears in the summary, so you can see what was skipped. See the environment reference for the full semantics.
Install a pinned ocx version
CI pipelines often need a specific ocx release — not "whatever is latest" — so that every runner runs the same build regardless of when the job triggers.
Pass a version to ocx self setup to install exactly that release. The optional VERSION argument accepts a tag, a content digest, or both:
# Install a specific release by tag:
/tmp/ocx self setup 0.9.2
# Install a specific release and assert the exact content (strongest guarantee):
/tmp/ocx self setup 0.9.2@sha256:ab12cd34ef56...
# Install by digest alone — no tag resolution:
/tmp/ocx self setup sha256:ab12cd34ef56...The tag@digest form is an immutability assertion. If the tag ever resolves to different content, the command fails with exit 65 and names both digests. To get the digest value, capture it from the JSON output of a prior run:
digest=$(/tmp/ocx --format json self setup 0.9.2 | jq -r .bootstrap.digest)That digest round-trips: /tmp/ocx self setup 0.9.2@$digest on the next run either confirms the install is already present (exit 0, status already_present) or downloads and verifies the exact same content.
When the pinned version is older than what is already installed, a warning appears on stderr and the downgrade proceeds. This is a signal for CI logs, not a block.
Learn more
ocx self setup reference — full VERSION grammar, JSON output shape, and all exit codes.
Learn more
Shell Activation Files reference — what the env shim files contain and how each shell sources them. OCX_NO_MODIFY_PATH reference — truthy semantics, per-invocation behavior. OCX_HOME reference — choose a non-default install root before running setup.
Choose between versions
Once two or more versions are installed, ocx package select picks the one that becomes "current":
ocx package install "kitware/cmake:4.2.0"
ocx package select "kitware/cmake:4.2.0"The current symlink is a floating pointer — it only moves when you select a different version. Installing a newer version does not advance current; updating the tag-store snapshot does not advance it either. This is intentional: tools that reference current should only change behavior when you decide they should.
Tags, variants, digests
A single OCX identifier covers what / which version / how built / which platform. Specificity in the tag signals intent:
| Tag | Meaning | Resolves to after refresh |
|---|---|---|
kitware/cmake:3.28.1_20260216120000 | Specific build, do not re-push | Same build (publisher convention) |
kitware/cmake:3.28.1 | Rolling patch | Latest 3.28.1 build |
kitware/cmake:3.28 | Rolling minor | Latest 3.28.x build |
kitware/cmake:3 | Rolling major | Latest 3.x build |
kitware/cmake:latest | Floating | Latest release |
For build flavor — debug, PGO, slim — use a variant prefix: astral-sh/python-build-standalone:debug-3.12. For exact reproducibility regardless of any tag, use a digest: kitware/cmake@sha256:abc123…. Platform is auto-detected; override with -p, --platform only for cross-arch installs.
On Linux, platform detection also identifies the libc family: OCX probes the host's dynamic linker once at startup and selects among glibc-tagged, musl-tagged, and untagged entries accordingly. A package that ships distinct glibc and musl builds under one tag co-locates them in the same image index; OCX picks the right one automatically. Run ocx about to inspect the detected libc and supported-platform set. For verbose build context including the host row, run ocx version -v. See libc Differentiation for the publisher side of this workflow.
ocx index list kitware/cmake --variants shows available variants without downloading anything.
ocx package deselect cmake clears current without uninstalling. ocx package uninstall kitware/cmake:3.28 removes the candidate; pass --purge to remove the binary too if no other reference holds it.
Learn more
Versioning In Depth — full tag hierarchy, cascade mechanics, OCI tag char rules, _build suffix convention, OCI Image Index multi-platform spec. Storage In Depth → Symlinks — why current is floating, the SDKMAN/Homebrew/update-alternatives analogy.
Namespaces
The repository half of an identifier is a path, not a single word — registry/namespace…/name. OCX uses this to separate what it ships from what it mirrors.
Every package on ocx.sh is exactly two segments: a namespace and a name. Mirrored upstream tools take the namespace of the project that publishes them upstream — kitware/cmake, astral-sh/uv, oven-sh/bun, go-task/task. OCX's own first-party binaries live under the reserved ocx/ namespace: the CLI is ocx/cli, the mirror tool is ocx/mirror. The namespace is the provenance — it names who stands behind the bits upstream, and an ocx/ name is OCX itself.
The two segments are not decoration. The index addresses a package at p/<namespace>/<name>.json, so a single-segment name has nowhere on ocx.sh for its entry to live and cannot be published or announced there. On a registry you control the shape is yours to choose — the requirement belongs to the ocx.sh index, not to OCI.
Slash-nested names are ordinary OCI repositories — ocx package install ocx/mirror:1 resolves exactly like ocx package install kitware/cmake:3.28. The extra segment reaches the filesystem too: a repository is two directories under the symlink farm, symlinks/ocx.sh/kitware/cmake/, not one.
Embed a stable path in your IDE or shell
Package-store paths are content-addressed and change on every upgrade — never embed them directly in an IDE config or shell profile. Embed a symlink instead. Three modes cover every case:
| Mode | Flag | Path | Auto-install | Use case |
|---|---|---|---|---|
| Package store (default) | (none) | ~/.ocx/packages/…/<digest>/ | yes (online) | CI, scripts, one-shot queries |
| Candidate symlink | --candidate | ~/.ocx/symlinks/…/candidates/<tag> | no | Pin a specific tag in editor or IDE config |
| Current symlink | --current | ~/.ocx/symlinks/…/current | no | "Always selected" path in shell profiles or IDE settings |
Both symlink modes target the package root directly; consumers traverse one level in (…/content/ for files, …/entrypoints/ for launchers, or read …/metadata.json).
// .vscode/settings.json — path survives every upgrade
{ "cmake.cmakePath": "~/.ocx/symlinks/ocx.sh/kitware/cmake/current/content/bin/cmake" }# ~/.bashrc — always resolves to the selected version
export PATH="$HOME/.ocx/symlinks/ocx.sh/kitware/cmake/current/content/bin:$PATH"When ocx package install --select kitware/cmake:3.32 runs later, current is re-pointed and the IDE / shell pick up the new version with no config edits.
Prefer ocx env for shells
The hand-written export PATH=…/current/content/bin above is an escape hatch for tools that cannot evaluate shell at startup (IDEs, JSON config files). For interactive shells and project envs, prefer ocx env (toolchain-tier) or ocx package env (per-package) — they compose the full env, not just PATH, and stay forward-compatible if the package adds new env entries on upgrade.
For automation, ocx package which prints the resolved package root directly:
ocx package install --select "kitware/cmake:4.2.0"
ocx package which --current "kitware/cmake:4.2.0"
ocx package which --candidate "kitware/cmake:4.2.0"Both --candidate and --current fail immediately if the required symlink is absent — they never auto-install. A digest component in the identifier is rejected.
Running an installed tool on Windows
On Windows, ocx package install (and ocx package select) generates two files per entrypoint in the package's entrypoints/ directory:
| File | Role |
|---|---|
<name>.exe | Native launcher — the sole Windows entry point for all callers |
<name>.shim | One-line sidecar carrying the absolute package root |
There is no .cmd launcher. .EXE is unconditionally present in the default Windows PATHEXT, so bare-name resolution in cmd.exe, PowerShell, and Git Bash all find <name>.exe with no PATHEXT configuration ever needed.
The .exe shim reads <name>.shim at invocation time, then calls CreateProcessW directly to spawn ocx launcher exec. It does not route through cmd.exe. This is the definitive fix for the BatBadBut / CVE-2024-24576 class of argument-injection vulnerability — caller arguments never pass through a second cmd.exe parse.
How the shim reaches ocx
The shim resolves ocx using OCX_BINARY_PIN if the variable is defined in the environment (even if empty), and falls back to PATH-resolved ocx only when the variable is completely unset — see OCX_BINARY_PIN for details.
Unsigned shim note. The committed shim blobs (~138 KiB x86_64 / ~128 KiB aarch64) are unsigned in this release. Authenticode signing via SignPath Foundation is a documented follow-on step. For backend-automation use (CI, Bazel, devcontainers), the unsigned shim is fully functional; SmartScreen friction applies only to interactive end-user downloads.
Learn more
Entry Points In Depth — launcher ABI, launcher exec wire protocol, clean-env execution. OCX_BINARY_PIN reference — pin a specific ocx binary for nested invocations.
direnv integration
For direnv-driven projects, ocx direnv init writes an .envrc file that calls ocx direnv export on each cd. The stateless export block is re-evaluated by direnv whenever ocx.toml or ocx.lock changes.
ocx init
ocx add "kitware/cmake:4.2.0"
ocx direnv initThis routes through the project toolchain, so the tools on $PATH match exactly the digests locked in ocx.lock. No ambient installs or manual export statements needed.
Learn more
Storage In Depth → Symlinks — candidate vs current design, package-root vs content traversal. Entry Points In Depth — generated launchers, synth-PATH, cross-platform shell scripting. Environments In Depth — what "clean environment" actually means.
Run a command with its dependencies
Package publishers can declare that their package needs other packages to function — a web app needs a JavaScript runtime; a build tool needs a compiler. Each dependency is pinned to an exact OCI digest by the publisher. As a user, you do not manage dependencies — OCX handles them automatically.
Pull the package; OCX resolves the closure transitively:
ocx package pull "acme/webapp:2.0.0"If acme/webapp:2.0 declares dependencies on nodejs/node:24 and oven-sh/bun:1.3, all three packages end up in the package store. Only acme/webapp:2.0 is the explicit install — the dependencies are stored but not surfaced as top-level installs.
To actually run the package with its dependency environments configured, use ocx package exec:
ocx package exec "acme/webapp:2.0.0" -- serve --versionocx package exec composes the environments of all dependencies in topological order before launching the command. ocx package env exports the same composed environment for use in your own shell.
install + select does not set up dependency environments
ocx package install --select creates a current symlink that points at the package's content directory. If you or another tool invokes a binary through that symlink directly, the dependency environments are not configured — only the package's own files are reachable. For packages with dependencies, always use ocx package exec, or ocx package env / ocx env to export the full environment first.
Inspecting the dependency tree
ocx package deps shows the declared relationships. The default tree view annotates non-public dependencies so you can see at a glance which deps cross the interface surface:
--flat shows the resolved evaluation order — the exact sequence OCX uses when composing environments. This is the primary debugging tool when env vars are not what you expect:
--why traces the path from a root package to a transitive dependency:
Conflict warnings
If two dependencies set the same scalar variable (e.g., both set JAVA_HOME to different paths), OCX applies last-writer-wins semantics and emits a warning. Inspect the order with ocx package deps --flat and decide whether the conflict is real. The same situation arises with project toolchains: if you add a package with dependencies and also declare one of those dependencies as a top-level binding in ocx.toml, both contribute env vars — either remove the redundant binding (the transitive dependency provides it) or accept the top-level entry's value winning the conflict.
Learn more
Dependencies In Depth — transitive resolution algorithm, scope philosophy (no version ranges, no auto-update). Environments In Depth — composition order, visibility model (sealed/private/public/interface), --self flag, last-writer-wins.
Lock and reproduce builds
Reproducibility in OCX has three levels, each stricter than the last.
Pin the digest. The strongest lock: kitware/cmake@sha256:abc123… bypasses tag resolution entirely. The bytes are content-addressed; the digest is the binary. Every package can be pinned this way — no lockfiles, no registry queries, just the hash.
Pin the index. The next-strongest determinism — and the one most users want — is to freeze the local index's entry for a tag. It resolves to whatever digest was recorded at the last ocx index update; that mapping does not change until you refresh. A CI runner that never refreshes its index gets the same binary on every run, even if the registry re-pushes the tag. This is version-choice determinism, not ocx.lock: a lock already records the exact digest it pinned and never reads the index back to confirm it.
Pin a bundled index. The most ergonomic option for tool authors. A local index subtree holds only metadata — small JSON files, no binaries — so it can be shipped inside a GitHub Action, Bazel rule, or DevContainer feature. Pinning the action version pins the bundled index, which pins the binary:
- uses: ocx-actions/setup-cmake@v2.1.0 # pins action → pins index → pins binary
with:
version: "3.28"A version bump to the action — proposed automatically by Dependabot or Renovate — advances the bundled index. Users get the updated binary with no config changes. The contrast with maintaining a hand-curated URL matrix (one filename → checksum entry per version × os × arch) is stark.
Learn more
Indices In Depth → Shipped copies — full bundled-index pattern, OCX_INDEX env var, Dependabot/Renovate flow. Versioning In Depth → Locking — digest pin rationale, OCI tag mutability, why ocx.lock never consults the index.
Keep everyday tools available everywhere
You want ripgrep, cmake, and shellcheck available in every shell you open — but you also want project builds to be reproducible and immune to whatever you have installed globally. These two goals conflict unless there is a hard boundary between them.
The global toolchain is that boundary. It gives you an apt-style "tools I always want around" set without letting any of those tools leak into a project's resolved environment.
Adding tools to the global toolchain
Use the root --global flag (before the subcommand) to target $OCX_HOME/ocx.toml:
ocx --global add "kitware/cmake:4.2.0"ocx --global add records the binding in $OCX_HOME/ocx.toml, re-locks, installs, and selects the package in one step. Because a tool must be on PATH to be useful globally, select is always implied.
The same root --global flag works with remove, lock, update, and pull:
ocx --global add "kitware/cmake:4.2.0"
ocx --global add "astral-sh/uv:0.10.0"The global file lives at $OCX_HOME/ocx.toml (default ~/.ocx/ocx.toml). Mutators create it automatically on first use — no ocx init step required.
--global and --project are mutually exclusive
Both flags pick a project file. Passing them together exits with code 64 (UsageError).
Shell activation for global tools
Adding a tool to the global toolchain with ocx --global add puts it on PATH in every shell you have open, not just new ones. The OCX installer writes a thin shim file — $OCX_HOME/env.sh — and a single idempotent source line in the login profile. The shim calls ocx self activate at runtime, so its content is byte-identical across users and survives OCX_HOME changes without re-running the installer.
At shell start, ocx self activate emits two PATH prepends — OCX's own binary directory and $OCX_HOME/toolchain/active/bin in front of it — shell completions (unless OCX_NO_COMPLETIONS=1), and, when the global toolchain's activate is env, an eval "$(ocx --global env --shell=sh)" call for it. The prepends happen in every mode; only the eval is the mode's to withhold. In bash, zsh, fish, PowerShell, and elvish (whose guard checks only the carrier and the working directory, not a watch-set stat), it also registers a per-prompt hook that re-checks a small watch set — the global ocx.toml, the selected binary — and only re-runs when something has actually changed, so an unchanged prompt costs a stat comparison, not a re-resolve: ocx --global add ripgrep followed by rg --version in the same terminal works at the very next prompt. nushell's directory-change hook keeps the global toolchain live the same way; the strict-POSIX shells (ash, dash, ksh) and Windows Batch have no append-safe hook point in their prompt machinery and only refresh at shell start. See Shell Integration for the full per-shell coverage table and the mechanism underneath it.
Disable the per-prompt hook entirely — for one shell, or every shell — with OCX_NO_HOOK or ocx self setup --no-hook. PATH, completions, and the global-toolchain eval at shell start are unaffected either way; only the per-prompt re-check turns off.
The installer appends a block-marker source line to the login profile so re-running it is idempotent:
# BEGIN ocx
. "$HOME/.ocx/env.sh"
# END ocxYou can inspect what the global env exports:
ocx --global add "kitware/cmake:4.2.0"
ocx --global env
ocx --format json --global env
ocx --global env --shell=bash--shell is the only eval-safe output channel. Do not eval "$(ocx --global env)" — plain table output is not sourceable.
Tools on PATH, nothing else composed
You want the toolchain's binaries reachable, and you want your shell to stop there — no per-prompt environment envelope, no variables applied on the way into a directory and reverted on the way out. On a machine where you already manage your own environment, composing one for you is more than you asked for.
activate = "bin" is that narrower contract. A toolchain in bin mode contributes its active/bin directory to PATH and composes nothing else. Each tool still gets its own package's environment — its launcher trampoline applies that at the moment the tool runs, instead of your shell applying it at every prompt.
What a trampoline does not carry is the ocx.toml's own [env] block: those variables are composed for a shell, and bin mode is the mode that composes nothing for a shell. If you keep an [env] you rely on — an SSL_CERT_FILE, a CARGO_HOME — env mode is what applies it, and ocx exec (with --global for the global toolchain) is the explicit route that composes it whatever activate says.
The key lives in an ocx.toml, and each file decides for its own toolchain — a project's for that project:
# <project>/ocx.toml
activate = "bin"
[tools]
cmake = "ocx.sh/kitware/cmake:3.28"ocx self setup --toolchain-activate bin writes the same key into $OCX_HOME/ocx.toml, and the global toolchain reads it: a shell then gets $OCX_HOME/toolchain/active/bin on PATH and no global environment envelope at all — which is the clean shell you asked for, with ocx's own tools still one name away.
For the global toolchain, none gives you the same PATH as bin. $OCX_HOME/toolchain/active/bin is registered once by ocx self setup and a prompt never withdraws it, so under either value the global tools stay reachable through their trampolines and nothing else is composed. The two part company only for a project's toolchain, whose active/bin directory a prompt does add and remove — and that is where none earns its own audience rather than standing in for bin. Setting a project's activate = "none" withdraws even the trampolines: no active/bin prompt hook, no [env] composition, nothing the shell does on its own. That is the contract for a project whose tools you only ever reach explicitly — through ocx exec, or through absolute paths a devcontainer or a Dockerfile already bakes onto PATH — and where you do not want a per-prompt hook touching your shell's PATH at all, not even to add one directory.
Commands you type are never gated by the key: ocx --global env and ocx --global exec compose the global toolchain in full whatever it says. activate decides what happens to your shell without you asking.
There is deliberately no --activate flag on the composing commands — the choice belongs in a file, not in one invocation. The three values and the tier order are in the configuration reference; OCX_TOOLCHAIN_ACTIVATE is the weakest tier of all, consulted only when no file sets the key.
Render before you expect a project on PATH. In bin mode a shell emits a project's active/bin directory only when a render stamp exists for that toolchain home and the directory still holds exactly what the stamp recorded. That gate is not bureaucracy. The directory lives inside the repository's own tree, so anything the checkout carries can write there; putting it on PATH on the strength of its existence alone would run a committed active/bin/cmake ahead of the real one. ocx pull is what writes the stamp (see toolchain render), so it comes first — after a fresh clone, and after any change to the lock:
ocx pullUntil then the prompt withholds the directory and prints the line that names the fix:
ocx: /work/acme/api: its toolchain has not been rendered for this lock; run `ocx pull` hereNothing is deleted while that line stands. The stale trampolines stay on disk untouched, and the next ocx pull reconciles them — a prompt never prunes a directory the repository can write.
The whole of bin mode is one directory of launchers and the PATH entry that reaches them — the export below is what a prompt does for you. cmake then resolves to <home>/toolchain/active/bin/cmake, and the package's own environment is applied by that launcher as the tool starts, not by your shell:
Reach what no profile reaches
Your editor, your desktop launcher and your background services never source .zshrc. They inherit their environment from the session that started them, so a PATH written into a shell profile does not reach them. That is the whole explanation for a tool that works in the terminal and is "not found" in the IDE's own run configuration.
So ocx self setup writes a second, session-level registration alongside the profile block, in the one store per platform that a whole login session reads. Two directories go there: $OCX_HOME/toolchain/active/bin, then the ocx installation's bin directory. The toolchain leads, so pinning ocx itself in the global toolchain takes effect; the installed binary is what a session falls back to when nothing pins the name. The ocx self setup reference carries the store, the mechanism and the failure semantics for each platform.
Both directories are session-level facts rather than activation decisions, so the per-prompt reconciler keeps them in every activate mode — none included. Turning activation off does not take the installed ocx off your PATH.
Six things this registration does not promise. Each is a limit stated rather than worked around, and each is something you can act on:
| Platform | Limit | What to do |
|---|---|---|
| Windows | An already-open terminal or IDE does not see the change. | Restart it. |
| Windows | The machine-wide System PATH always precedes the user PATH, and no write order changes that. | Remove the competing System entry, or invoke the tool by absolute path. |
| Linux | A desktop session that is neither a systemd --user session nor profile-sourcing — a bare i3 or sway started outside any Xsession wrapper — sees neither directory. | Start the session from a login shell, or add the two directories in that session's own startup file. |
| Linux | A Flatpak- or Snap-sandboxed application takes its PATH from the sandbox, not from the session. | Set PATH inside the sandbox, with that runtime's own mechanism. |
| macOS | A GUI application that was already running when the launch agent loaded keeps the environment it started with. | Quit and relaunch it. |
| macOS | Another tool that calls launchctl setenv PATH later in the same session wins by running last. | Order the two, or stop the other tool from setting PATH wholesale. |
Point the IDE at the resolved home. A workspace setting reads a directory, not a mechanism — and when toolchain-dir relocates project homes, that directory is not under the repository at all. ocx shell state answers where it is, and its JSON form is the supported query contract for editors, devcontainer features and CI steps:
ocx pull
ocx --format json shell stateOne field is usually all a setting needs, so pipe the report through jq: ocx --format json shell state | jq -r .toolchain_bin.
toolchain_bin is the directory to put on PATH — the trampolines, resolved for the project in effect. Paste it into the setting: in Visual Studio Code that is .vscode/settings.json under terminal.integrated.env.<platform>. toolchain_home, beside it, is the home itself — $OCX_HOME/toolchain when no project resolves. Both are always present and never null, and two more fields travel with them, resolved past the whole ladder rather than read off one tier: activate, the effective mode, and pinned, the effective boolean.
Read the field; do not build the path. toolchain_bin is not toolchain_home plus a fixed suffix, and a setting holding a hand-joined path is the failure that costs the most to find: the join succeeds, jq exits 0, the editor accepts any string, and what surfaces three steps later is cmake: command not found with no ocx process anywhere in the trace.
ocx pull runs first for the same reason it does everywhere else on this page: the query reports the home whether or not anything has ever been rendered into it, and a setting pointing at an empty directory looks exactly like a broken one.
Undo the session registration
There is no ocx self uninstall yet (#413), so the session-level registration is reversed by hand. One location per platform:
- Windows — remove ocx's two segments from the
HKCU\Environment\Pathvalue. - Linux — delete
~/.config/environment.d/ocx.conf(or$XDG_CONFIG_HOME/environment.d/ocx.conf). - macOS —
launchctl bootout gui/<uid>/sh.ocx.path, then delete~/Library/LaunchAgents/sh.ocx.path.plist.
The command-line reference carries the copy-pasteable recipe for each.
Two shortcuts that take your whole PATH with them
On Windows, edit the HKCU\Environment\Path value to drop ocx's two segments — never delete the value. It holds every other user PATH entry too, and deleting it removes every entry any other installer ever put there.
On macOS the launchctl bootout target is the service, gui/<uid>/sh.ocx.path. The bare domain gui/$(id -u) tears down your entire GUI login session. And never reach for launchctl unsetenv PATH: it deletes the whole session-wide value rather than ocx's contribution to it, stripping every other tool's segment from every GUI application launched afterwards.
Carry PATH to the next CI step
A CI step's environment does not survive to the next step. Exporting PATH in one run: block and expecting the following block to see it is the most common way a working toolchain looks broken on a runner.
GitHub Actions has a sink for exactly this: a directory appended to the $GITHUB_PATH file is prepended to PATH for every subsequent step in the job — see workflow commands. Render first, then append:
- run: ocx pull
- run: ocx --format json shell state | jq -r .toolchain_bin >> "$GITHUB_PATH"
- run: cmake --version # a later step — the toolchain is on PATHThe order is the whole recipe. ocx pull writes the trampolines and the render stamp; the append then publishes a directory that has something in it. Reversed, the append publishes an empty directory and the third step fails to find a tool that was never rendered.
$GITHUB_PATH accepts any string and never checks it, so a wrong directory here costs a green step and a failure in the next job. That is the whole reason the recipe reads a field instead of joining one: .toolchain_home + "/bin" produces a path that looks right, appends cleanly, and names nothing.
OCI-tier package operations
The individual package primitives that manage candidate and current symlinks are now grouped under ocx package:
ocx package install "kitware/cmake:4.2.0"
ocx package select "kitware/cmake:4.2.0"
ocx package exec "kitware/cmake:4.2.0" -- cmake --version
ocx package env "kitware/cmake:4.2.0"These are OCI-tier operations — they work on identifiers directly, never read ocx.toml.
Strict isolation — the hard boundary
The global toolchain is a shell convenience tier only. Project builds are hermetic: the project toolchain wins by PATH precedence when you cd into a project, and ocx exec never consults the global file without --global.
Why hard isolation, not gap-fill?
Volta pioneered this model for Node.js: "Volta covers its tracks … your npm/Yarn scripts never see what's in your toolchain." The alternative — filling in tools the project does not declare from the global set — is exactly what mise and asdf do, and it produces the reproducibility hole that OCX is designed to avoid: a collaborator without the same $OCX_HOME/ocx.toml gets different resolved tools.
Two commands that are always hermetic regardless of context:
ocx init
ocx add "kitware/cmake:4.2.0"
ocx exec -- cmake --version
ocx package exec "kitware/cmake:4.2.0" -- cmake --versionA project's ocx exec cannot resolve a tool that exists only in $OCX_HOME/ocx.toml. This is intentional and not a bug — the project declared its dependencies; anything else is ambient noise.
Learn more
Command-line reference → root --global flag — root flag before the subcommand; affects toolchain-tier commands add, remove, lock, update, pull, exec, env. Env-composition reference → Strict isolation — reference-level statement of the no-composition rule. Command-line reference → ocx env — toolchain env exporter, format options, --shell safety rule.
Pin a project's tools
A repository's contributors and CI runners need the same tool versions — cmake 3.28, shellcheck 0.11, goreleaser 2.0 — without arguing over chat or curl-piping installers. The locking mechanisms in the previous section pin a single invocation; none of them describe what the project itself expects.
A committed ocx.toml plus its sibling ocx.lock does. The pair makes "the tools this project needs" a piece of source code: reviewable, mergeable, reproducible across machines, resolvable offline once the lock is fetched.
# ocx.toml
[tools]
cmake = "ocx.sh/kitware/cmake:3.28"
shellcheck = "ocx.sh/shellcheck/shellcheck:0.11"Each value is a fully-qualified OCI identifier — registry/repo[:tag][@digest]. Bare-tag forms like cmake = "3.28" are rejected so the file is unambiguous regardless of any default-registry config. An identifier with no tag at all is the one form OCX completes for you: ocx add ocx.sh/kitware/cmake writes cmake = "ocx.sh/kitware/cmake:latest", the same default docker pull applies — the written entry is always explicit, so the file never leaves a reader guessing which tag it meant. The schema is published at https://ocx.sh/schemas/project/v1.json and wired through taplo for editor autocompletion.
Lifecycle commands
ocx init
ocx add "kitware/cmake:4.2.0"
ocx lock
ocx pull
ocx exec -- cmake --versionocx lock resolves every tag to per-platform leaf digests and writes ocx.lock. For each tool, the lock records every platform the publisher ships. Subsequent ocx pull / ocx exec runs read the lock for the host platform, never the registry, so two machines on the same commit get the same bytes. The lock carries a hash of the canonicalized ocx.toml; if you edit ocx.toml and forget to re-run ocx lock, dependent commands refuse to run with stale digests.
Edited ocx.toml by hand? Run ocx lock.
ocx add / ocx remove regenerate ocx.lock for you, but hand-edits to ocx.toml do not. The lock carries a hash over the canonicalized ocx.toml; commands that read the lock (ocx pull, ocx exec) detect the drift and exit 65 telling you the lock is stale. Re-run ocx lock to sync. The default is intentional: read paths never silently re-resolve, so CI cannot drift behind a stray editor save.
Adding or removing a tool never silently updates your other tools — ocx add and ocx remove carry every untouched lock entry forward unchanged. Only ocx update re-resolves surviving tags.
Commit your ocx.lock
Without it, every contributor and CI runner re-resolves advisory tags against whatever the registry surfaces today. To keep merge conflicts manageable on busy projects, add a .gitattributes entry that lets git union sibling lock entries:
ocx.lock merge=unionFresh clone
Just checked out a repo that already has an ocx.toml and ocx.lock? Warm the local object store with ocx pull:
ocx pullThen run direnv allow once to re-evaluate .envrc. ocx direnv export then puts the locked tools on PATH. No re-resolution, no registry writes — the lock is the only input.
Keep rendered toolchains out of the checkout
By default, that ocx pull renders directly into the project: <project>/.ocx/toolchain/ fills up with launcher trampolines and directory links to package roots, right beside your source.
Plenty of sites run a rule that no tool may write inside a checkout — the working tree carries source and nothing else, so a build can run against a read-only mount, an immutable CI workspace, or a bind-mount shared across containers. ocx gitignoring its own rendered tree keeps git status clean, but the directory still physically exists inside the checkout, which is exactly what that rule forbids.
toolchain-dir relocates every project's rendered tree under one root you choose, so nothing lands under the checkout at all:
# $OCX_HOME/config.toml — not ocx.toml; the project file rejects this key (exit 78)
toolchain-dir = "~/.cache/ocx/toolchains"A project's tree then lands at <root>/<project-key>/toolchain/ instead — <project-key> is a stable 16-hex key derived from the project's canonical directory, so one root holds every project you work in without their trees ever colliding. The global toolchain ignores this key entirely; it is always $OCX_HOME/toolchain.
Because toolchain-dir lives in config.toml, it also travels through the [managed] tier — one centrally published config update moves every host's project trees onto a chosen volume in one push, with no per-machine edit.
Learn more
toolchain-dir reference — expansion rules, the full refusal table (relative paths, .. components, system locations, ownership checks), and the fleet rollout example.
Groups
CI needs shellcheck and shfmt; a release pipeline needs goreleaser; daily development needs neither. Named groups scope subsets so workstations do not download release tooling on first checkout:
[tools]
cmake = "ocx.sh/kitware/cmake:3.28"
[group.ci.tools]
shellcheck = "ocx.sh/shellcheck/shellcheck:0.11"
[group.release.tools]
goreleaser = "ocx.sh/goreleaser/goreleaser:2.0"ocx init
ocx add "kitware/cmake:4.2.0"
ocx add -g ci "astral-sh/uv:0.10.0"
ocx pull -g ci
ocx lockThe same binding name may appear in [tools] and any [group.*.tools] table — identity is (group, name). This lets a project pin one shfmt for daily use and a different one in ci without conflict.
Environment variables
A tool binding pins which binary runs. It says nothing about the environment that binary needs — a SOURCE_DATE_EPOCH for reproducible builds, a NODE_ENV, a node_modules/.bin directory that will never be an OCX package because it does not have a publisher. Before [env], the only channel for any of this was the ambient shell (FOO=bar ocx exec -- …), and that channel does not exist on Windows — neither PowerShell nor cmd.exe has a per-invocation variable prefix, both mutate the session instead — and it does not exist for a caller that builds an argv array rather than a shell command line, which is exactly how a GitHub Action or a Bazel rule invokes a tool.
[env] declares project-wide constants; [group.<name>.env] scopes them to a group, the same way [group.<name>.tools] scopes bindings:
[env]
SOURCE_DATE_EPOCH = "0"
[group.ci.env]
CI = "1"Running with -g ci composes both — SOURCE_DATE_EPOCH from the project, CI from the group — the same layering Groups already uses for tool bindings.
A path-typed value prepends instead of replacing, for the PATH case specifically:
[env]
PATH = { type = "path", value = "node_modules/.bin" }The relative value resolves against the project root — the directory holding ocx.toml — not the shell's current directory, so ocx exec finds node_modules/.bin the same way whether it is invoked from the repo root or a subdirectory.
Comparable tools
Cargo's .cargo/config.toml has its own [env] table, and GitHub Actions has a workflow-level env: block — both are precedent for "declare environment alongside the tool config, not in a separate script." The path type mirrors direnv's PATH_add and mise's _.path: an idempotent prepend rather than a hand-rolled PATH="$X:$PATH" string, which breaks across shells and double-prepends on re-entry.
For a one-off override that should not go in the committed file, --env KEY[:TYPE[:SEP]]=VALUE wins over everything else. TYPE is constant (the default), path, or list — the same three kinds [env] supports — so --env PATH:path=node_modules/.bin prepends instead of replacing. This is the one thing the ambient-shell channel could never do: a caller that builds an argv array rather than a shell command line — a GitHub Action step, a Bazel rule, a Python subprocess.run call — has no way to splice $PATH or %PATH% into a value it constructs, so :path is how it says "prepend a directory to PATH for this invocation."
The flag is on every command that composes an environment, not just ocx exec. That matters because ocx exec never prints — it replaces itself with the child process — so the only way to see what it would run with is to ask a command that emits:
ocx exec --env PATH:path=node_modules/.bin -- vitest # execute in it
ocx env --env PATH:path=node_modules/.bin --shell=bash # print the same thingBoth compose identically, which is what makes the second useful for debugging the first. The same pairing holds one tier down, between ocx package env and ocx package exec — those read no ocx.toml, so --env is the only thing a caller contributes there. ocx exec documents the full precedence order and the environment reference documents the value grammar, including why keys starting OCX_ or __OCX_ are rejected everywhere [env] can appear.
Shell activation
Project tools should land on PATH the moment you cd into the project, and leave again when you cd back out — without a separate eval step and without leaking into whatever else that shell does afterward.
In bash, zsh, fish, PowerShell, and elvish, this rides the same per-prompt hook the global toolchain uses: cd into a project OCX has been given consent to activate, and its locked tools land on PATH at the very next prompt — no .envrc, no direnv allow, no separate eval. cd back out and they revert. The same OCX_NO_HOOK / ocx self setup --no-hook switch turns it off.
ocx pull renders the toolchain the lock describes, and from then on the tool answers to its own name — resolved through the project's toolchain rather than through whatever the machine happens to have installed. The recording below composes the environment explicitly with ocx env, because a recorded shell has no prompt to hook; in your own shell the hook does that step at the next prompt and the rest is identical:
A fresh clone stays inert until consented. Unlike the global toolchain — always trusted, since $OCX_HOME/ocx.toml is your own file — a project's ocx.toml can name any OCI registry, so OCX will not put its tools on PATH just because you cd'd in. The first ocx add, ocx lock, ocx update, ocx pull, or ocx exec you run against a project records consent automatically; an operator can also pre-authorize a checkout path or a whole namespace of registries in advance, which is how a devcontainer or a fleet skips the per-project prompt entirely. See Shell Integration → Consent grants for the full predicate and where each kind of grant can live.
nushell and the strict-POSIX shells (ash, dash, ksh) and Windows Batch have no append-safe per-prompt hook point, so a project scope on those shells needs one of two explicit entry points instead: ocx direnv export — stateless, exports only, never installs missing tools or contacts the registry, so run ocx pull first; ocx direnv init drops a ready .envrc that re-evaluates on each directory entry — or ocx exec for CI and scripts, which needs no hook at all.
Learn more
Project Toolchain In Depth — schema details, declaration-hash canonicalization (RFC 8785 JCS), in-place flock concurrency, per-group binding semantics, multi-project GC retention, SLSA roadmap. Shell Integration — the full per-shell coverage table, the consent grants, ocx shell state's diagnostic role, and how OCX yields to a live direnv or mise session.
Run tools from your project
You have an ocx.toml, the lock is current, and you want to invoke a tool from it — without translating binding names into OCI identifiers first. That is what ocx exec is for.
The simplest form runs a command in the default group ([tools]) environment:
ocx init
ocx add "kitware/cmake:4.2.0"
ocx exec -- cmake --version-- is mandatory. Every token after -- is forwarded unchanged to the child. Pass -g to scope to a named group:
ocx init
ocx add -g ci "astral-sh/uv:0.10.0"
ocx exec -g ci -- uv --versionTo compose the environment from every group at once, use the all keyword:
ocx init
ocx add "kitware/cmake:4.2.0"
ocx add -g ci "astral-sh/uv:0.10.0"
ocx exec -g all -- cmake --version-g all expands to [tools] + every declared [group.*] before env composition. The expansion order determines PATH precedence — groups listed earlier win over later ones (see Project Toolchain In Depth → Running tools).
When you only need a specific binding from the composed set, name it:
ocx init
ocx add "kitware/cmake:4.2.0"
# `ocx add` names the binding after the repository basename, so a two-segment
# identifier still binds as `cmake` — the binding name is what `ocx exec` takes.
ocx exec cmake -- cmake --versionThe name must resolve unambiguously in the selected scope; ocx exec exits 64 if a name is unknown or matches entries in more than one selected group with conflicting identifiers.
ocx exec vs ocx package exec
ocx exec is the project-tier command — it reads ocx.toml + ocx.lock and maps binding names to installed packages. ocx package exec is the OCI-tier command — it accepts an OCI identifier directly, with no project file involved.
Rule: if you have an ocx.toml, use ocx exec; otherwise use ocx package exec.
Pin a build that must not move
By default, ocx exec and ocx env resolve a tool through the toolchain's rendered links/<group>/<entry> link rather than naming its package directly. That is a convenience: run ocx update later, and the same on-disk path keeps resolving — the link's target moves, nothing that already reads through it has to recompose.
That convenience is exactly the risk for a build that has to reproduce byte-for-byte. If a release step records the tool path it resolved — in a provenance log, or because the produced artifact embeds an absolute toolchain path in its own debug info — that record has to keep naming the one package it was built with. A teammate running ocx update in the same checkout afterward, for an unrelated tool, must not be able to make the recorded path quietly answer with a different binary.
--pinned (or the equivalent pinned = true in ocx.toml) is the escape from the link: it composes the exact digest roots ocx.lock names right now, with no links/<group>/<entry> link consulted at all. There is nothing left on disk for a later ocx update to move.
# ocx.toml — this project always composes digest paths, whoever runs it.
pinned = true
[tools]
cmake = "ocx.sh/kitware/cmake:3.28"Like a digest instead of a tag
This is the same trade a registry reference makes between a rolling tag and a digest — ubuntu:24.04 keeps moving to whatever the publisher pushes under that tag next, while ubuntu@sha256:… names one image forever. --pinned makes the same choice for a locally rendered path instead of a registry reference: name the one thing you resolved, not whatever currently answers to its name.
Learn more
--pinned / --no-pinned reference — the full resolution ladder, pinned in ocx.toml, and why the flag is not on ocx pull. Project Toolchain In Depth → Running tools — composition order, PATH precedence, exit code table, all keyword semantics. Environments In Depth — what the composed environment actually contains.
Use OCX in CI
CI environments need tool binaries available with their environment variables exported — but they do not need version switching, candidate symlinks, or any of the install-store machinery that supports interactive use.
For project-toolchain CI, the recommended flow is:
ocx init
ocx add "kitware/cmake:4.2.0"
ocx pull
ocx exec -- cmake --versionFor OCI-tier CI (no ocx.toml, direct identifier pinning):
ocx package pull "kitware/cmake:4.2.0"
ocx package env "kitware/cmake:4.2.0"ocx pull (project-tier) and ocx package pull (OCI-tier) download packages into the content-addressed package store without creating any symlinks.
To export environment variables into CI runtime files (e.g. $GITHUB_PATH / $GITHUB_ENV on GitHub Actions), use ocx --format json package env or ocx --format json env to get machine-readable output, then write entries to the appropriate CI sink. A dedicated CI export command is a deferred extension point — see the env-composition reference for the current JSON schema.
Concurrent matrix builds
package pull only touches the package store — no symlinks, no symlink-store mutations. This makes it safe to run concurrently in matrix builds that share a cached OCX_HOME; content-addressed writes are inherently idempotent.
Relationship to ocx package install
ocx package install is package pull plus candidate-symlink creation (and optionally --select for the current symlink). In CI, the content-addressed package-store path that package pull reports is fully reproducible and digest-derived — symlinks add no value.
Learn more
Indices In Depth → Shipped copies — pair ocx pull with a shipped index copy for end-to-end determinism. Storage In Depth → Packages — why concurrent CI writes are safe.
Publish and announce a package
ocx package push puts bytes in a registry, and a registry holds anything — container images, Helm charts, whatever else your org already stores there. Announcing writes the public index entry that turns one of those repositories into a catalogued package: which logical <ns>/<pkg> it answers to, which tags it carries, and which are yanked or deprecated. That entry is what makes ocx package install <ns>/<pkg> resolve for someone who was never told the host.
Two commands do it: ocx package claim registers the package once, reviewed by a person, and ocx package announce publishes tags into it on every release afterwards. Both open a pull request (GitHub) or a merge request (GitLab) against the index repository, and both read OCX_ANNOUNCE_TOKEN from the environment only — never from a stored credential, and never from the ~/.docker/config.json that ocx login writes. Writing an index and authenticating to a registry are two separate trust boundaries with two separate credentials.
Announcing a package walks the whole thing end to end: what the index entry records, the four credential postures with a copy-paste CI recipe each, who reviews what, and every exit code either command can produce.
Authenticate with a private registry
OCX uses a layered approach to authentication. Most methods are scoped per registry, so different registries can use different credentials. Methods are queried in order; the first one to succeed wins:
Environment variables
Configure auth for a registry via OCX_AUTH_* variables:
export OCX_AUTH_docker_io_TYPE=bearer
export OCX_AUTH_docker_io_TOKEN="<token>"export OCX_AUTH_docker_io_TYPE=basic
export OCX_AUTH_docker_io_USER="<user>"
export OCX_AUTH_docker_io_TOKEN="<token>"The variables are:
OCX_AUTH_<REGISTRY>_TYPE— type of authentication (bearerorbasic).OCX_AUTH_<REGISTRY>_USER— username (basic only).OCX_AUTH_<REGISTRY>_TOKEN— password or token.
Registry name normalization
The registry name in the variable is normalized by replacing all non-alphanumeric characters with underscores. For docker.io, OCX looks for OCX_AUTH_docker_io_TYPE. This is stricter than the path component encoding used for filesystem paths, which preserves dots and hyphens.
If TYPE is omitted but USER or TOKEN are set, OCX infers the type — both fields means basic, token alone means bearer. See the environment variable reference for the full set.
Docker credentials
If a Docker configuration is found, OCX uses the stored credentials. The configuration is typically at ~/.docker/config.json and managed via:
docker login "<registry>"Override the location with DOCKER_CONFIG.
Learn more
Environment variable reference — every OCX_AUTH_* variant, complete normalization rules. Configuration In Depth — pair auth env vars with per-tier config defaults.
Storing credentials
ocx login REGISTRY writes credentials to the same ~/.docker/config.json that docker login and oras login use. The three tools interoperate: a credential written by any of them is readable by the others.
Storage tier (highest priority first):
credHelpers[REGISTRY]in~/.docker/config.json(per-registry helper)credsStore(global default helper)- Plaintext
auths[REGISTRY].auth(gated by--allow-insecure-store)
For headless CI without a native keychain daemon, pipe the token via --password-stdin and pass --allow-insecure-store to opt into the plaintext tier. OCX_AUTH_* environment variables still take precedence over any docker-config-stored credential at read time.
echo "test-token" | ocx login -u ci --password-stdin --allow-insecure-store "$DEMO_REGISTRY"
ocx logout "$DEMO_REGISTRY"Remove credentials with ocx logout. Logout always exits 0, even when the registry was never logged in — CI cleanup scripts are safe to run unconditionally.
Work offline
Once the local index is populated and the package store holds the binaries you need, OCX runs without network access. Two flags control how strictly the network is avoided:
| Mode | Flag | Source | Network? |
|---|---|---|---|
| Default | (none) | Local index | No (unless fetching a new binary) |
| Remote | --remote | OCI registry | Yes |
| Offline | --offline | Local index | Never |
--offline prevents any network access for that command. If the local index does not have a requested package, the command fails immediately rather than attempting a registry query — useful to verify the current index and package store are self-sufficient before a build in a restricted or air-gapped environment.
--remote queries the live registry directly without committing the result to the local index. Use it for one-off checks of currently available tags.
--index / OCX_INDEX only change which collection is read from — useful when consuming a bundled index copy from a GitHub Action or Bazel rule.
The index resolves version choice offline — which platform-manifest digest a tag currently means — not a lock: ocx.lock already records the exact digest it pinned and never consults the index to read it back. A tag's local entry points at a dispatch object — an OCI image index carrying the full platform → digest map — cached alongside the tag pointer and verified by digest; a digest-pinned reference (pkg@sha256:…) names the platform-manifest digest directly and has no dispatch object at all. Either way, a git-committed .ocx/index/ resolves the tag's version choice on a clean clone with no network. Fetching the actual manifest and layers still needs the registry the first time a given digest is installed (or an already-warm package store).
What has to travel for a home copy to work offline
A CI cache restore, a container image layer, a devcontainer feature's shipped cache — anything that copies $OCX_HOME between machines needs to know which of its stores actually matter for ocx package install, ocx package exec, and a project's ocx exec to work with zero egress on the target machine. Copy the wrong subset and a command either silently reaches back out to the network or fails outright.
Three stores make a copy fully offline-capable: blobs/ (manifests), layers/ (extracted archives), and the local index (tag → digest resolution). packages/ does not need to be part of the copy — it holds hardlink assemblies of layers/ content, built locally at install time. A fresh $OCX_HOME seeded with only blobs/, layers/, and index/ still installs, runs, and toolchain-runs every package those three stores cover: packages/ is reconstructed on demand from the local layer cache, no egress required.
Anything the copy is missing fails closed instead of reaching the network: --offline refuses with exit code 81 rather than falling through to a registry a restricted or air-gapped environment may not even be able to reach.
Learn more
Indices In Depth — wire layout, dispatch objects, shipped copies. Storage In Depth — the layer store and how packages/ is assembled from it.
Refreshing the index
ocx index update <package> syncs the local index for a specific package:
- Bare identifier (e.g.,
cmake) — downloads every tag. - Tagged identifier (e.g.,
kitware/cmake:3.28) — fetches only that single tag, ideal for lockfile workflows.
To refresh a whole registry rather than one package, ocx index sync <REGISTRY> refreshes every package that registry's own catalog lists, each as if named bare.
On a fresh machine, ocx package install kitware/cmake:3.28 does not need an explicit index update first — when the local index has no entry for the requested tag, OCX resolves it transparently against the registry, persists it, and proceeds with the install.
Packages published through index.ocx.sh
A package identified as ocx.sh/<namespace>/<package> resolves through index.ocx.sh, a pointer index that tracks which physical registry currently hosts each package. This decouples a package's stable identity from wherever its bytes happen to live today — a maintainer can migrate the backing registry without breaking anyone who already wrote ocx.sh/kitware/cmake:3.28. Resolution and offline behavior work the same way as a direct registry reference; publisher signals like deprecated or yanked are surfaced as warnings rather than silently acted on.
Learn more
Indices In Depth — one format, many copies; remote query path; index internals; fresh-machine fallback. Indices In Depth → index.ocx.sh — resolution pipeline, caching, status surfacing. Versioning In Depth → Locking — how ocx.lock pins independent of any index.
Route traffic through a corporate mirror
In many corporate and air-gapped networks, external registries — ghcr.io, docker.io, quay.io, ocx.sh — are firewall-blocked. The organization runs an artifact manager (JFrog Artifactory, Sonatype Nexus, Harbor) with proxy/remote repositories that cache those upstreams. OCX needs to route its registry traffic to the mirror without changing the canonical package identity or the content-addressed digest.
The [mirrors] config table maps each host to its mirror endpoint. OCX appends the upstream repository path after the mirror's repo-key prefix and contacts only the mirror. No origin fallback — in a firewall-controlled network, falling through to the internet is the opposite of intent.
# ~/.ocx/config.toml (or $XDG_CONFIG_HOME/ocx/config.toml)
[mirrors]
"ghcr.io" = "https://artifactory.example.com/ghcr-remote"
"docker.io" = "https://artifactory.example.com/dockerhub-remote"With this config, ocx package install ghcr.io/owner/tool:1.2 fetches the manifest and blobs from artifactory.example.com/ghcr-remote/owner/tool. The canonical identifier — ghcr.io/owner/tool:1.2 — is never changed.
A plain string, as above, redirects every kind of traffic OCX sends that host. A host that also serves an ocx-index can be split per role instead — see Route index traffic through a mirror.
Relation to the default registry
[registry] default and [mirrors] are independent and compose. Default injection expands a registry-less identifier (e.g. kitware/cmake:3.28 → ocx.sh/kitware/cmake:3.28) at parse time, before any mirror rewrite. If you also configure a [mirrors] entry for ocx.sh, that default-injected identifier is then mirrored. A fully air-gapped setup can mirror every registry the project uses, including the default one.
Pinning ocx.sh at a mirror's registry endpoint carries one deliberate side effect: it suppresses the compiled-in index for that namespace, which then resolves as a plain OCI registry through the mirror. That is the point — a site that routes ocx.sh to its own artifact manager should not start dialling index.ocx.sh, a host it never allow-listed. OCX logs a warning naming the namespace it dropped, because the index's digest verification and yank gate go with it. To keep the verified index path and pin the physical registry, name the index explicitly; a written index outranks the compiled-in one and is never suppressed.
[registries."ocx.sh"]
index = "https://index.ocx.sh" # explicit: survives the mirror entry
[mirrors]
"ocx.sh" = "https://artifactory.corp/ocx-remote"
"index.ocx.sh" = { index = "https://artifactory.corp/ocx-index" }Lockfile portability
OCX stores the canonical upstream host and digest in ocx.lock — never the mirror host. A lock file produced behind a corporate mirror is valid on a machine with direct internet egress, and vice versa. The mirror is a transport detail; the identity of the content is unchanged.
Why the mirror host never appears in the lock
OCX derives every on-disk path — blob store, package store, local index, symlinks — from the canonical identifier. The mirror only changes which server is contacted; the local object store is keyed the same way with or without a mirror configured. This is what makes the lock portable: sha256:abc123… identifies the same bytes regardless of which server served them.
Unpinned tags and the trust model
When you install a package with an unpinned tag (e.g. kitware/cmake:3.28), OCX trusts the mirror's tag→digest resolution the same way it would trust the origin registry's. The mirror could, in principle, map the tag to different content. After resolution, OCX verifies the blob digest against the manifest — a tampered blob is rejected. But the manifest itself came from the mirror's tag resolution.
For tamper-proof installs, pin with ocx lock: once a digest is recorded in ocx.lock, the tag is never re-resolved and the mirror cannot substitute a different manifest undetected.
Publisher-signature verification (e.g. cosign, Notation) adds an additional trust layer that validates the publisher's identity independent of the mirror. This is deferred for a post-v1 release.
Auth for the mirror
Authenticate against the mirror host, not the upstream. Set OCX_AUTH_<mirror_slug>_* (replacing non-alphanumeric characters with underscores) or run ocx login <mirror-host>. The upstream's credentials are never used on the read path.
export OCX_AUTH_artifactory_example_com_TYPE=bearer
export OCX_AUTH_artifactory_example_com_TOKEN="<artifactory-token>"Set mirrors in CI via OCX_MIRRORS
For CI or container setups where the command line is not controlled, set mirrors via OCX_MIRRORS instead of a config file. The value is a JSON object:
export OCX_MIRRORS='{"ghcr.io":"https://artifactory.example.com/ghcr-remote"}'OCX_MIRRORS wins over [mirrors] on a per-host, per-role basis and is forwarded to every subprocess ocx spawns, so nested invocations — generated launchers, ocx exec — see the same mirror map automatically.
Mirroring index.ocx.sh traffic is a role, not a separate table
A plain [mirrors] string redirects both OCI registry traffic (manifests, layers) and index traffic (root/index-object/catalog fetches) for that host — but the two usually live on different hosts. A project resolving packages through index.ocx.sh sets the index role on that entry: "index.ocx.sh" = { index = "https://artifactory.corp/ocx-index" }. See Route index traffic through a mirror for the full split.
Learn more
Configuration reference → [mirrors] — full schema, the registry/index role split, auth, interaction table, plain-HTTP note. Environment reference → OCX_MIRRORS — JSON encoding, per-host per-role precedence, subprocess forwarding.
Configure OCX defaults
OCX behavior is controlled at three layers: config files, environment variables, and CLI flags. Higher layers always win — CLI flags override env vars, which override config files.
Config files are in TOML format and live in three locations:
| Tier | Path |
|---|---|
| System | /etc/ocx/config.toml |
| User (Linux) | $XDG_CONFIG_HOME/ocx/config.toml or ~/.config/ocx/config.toml |
| User (macOS) | ~/Library/Application Support/ocx/config.toml (XDG_CONFIG_HOME is not consulted on macOS) |
| OCX home | $OCX_HOME/config.toml (default: ~/.ocx/config.toml) |
Files are loaded lowest-to-highest and merged. Missing files are silently skipped. No config file is required.
Explicit additions. --config FILE or OCX_CONFIG=/path/to/file.toml layers an extra file on top of the discovered chain — useful for refining ambient config without rewriting it. Both can be set together (--config sits at highest file-tier precedence). The specified file must exist. To disable an ambient OCX_CONFIG without unsetting it, set it to the empty string.
Kill switch. OCX_NO_CONFIG=1 skips the discovered chain (system, user, $OCX_HOME) but leaves explicit paths intact. Combine with --config for a fully hermetic CI load: OCX_NO_CONFIG=1 ocx --config ci.toml ....
Learn more
Configuration reference — every config key, type, default, error string. Configuration In Depth — discovery tier rationale, merge semantics, worked examples (Docker base image, hermetic CI, portable install).
Centrally managing ocx configuration
Corporate onboarding for a package manager usually means baking a config file into a base image, or dropping one via device management, then re-imaging every workstation and CI runner whenever the mirror map or patch registry needs to change. The [managed] tier gives you a third option: publish the corporate config itself as an ordinary OCX package (its content is one config.toml), and let every host converge to it on its own schedule. Your config file gets exactly what your packages already have — versioned tags, rolling cascades, digest pins, rollbacks — because it travels the same machinery.
On a fresh machine, adoption is this one line right after the binary download — a workstation that already has ocx on its PATH uses the same command (--no-modify-path here only skips the unrelated PATH wiring — the profile blocks and the session-level registration):
ocx self setup --managed-config internal.company.com/ocx-config:user --no-modify-pathThis resolves the reference, synchronously fetches and verifies the package, and only then writes the [managed] seed into $OCX_HOME/config.toml. A network failure during onboarding leaves no partial state — the seed is written only once the first snapshot is safely on disk. The reported digest is the adopted content's identity: record it once at onboarding (trust-on-first-use) and any later ocx config update --check can be compared against what the operator says they published.
A machine that needs only the configuration — an automation host or CI image where installing shims and wiring shell profiles is beside the point — adopts with ocx config setup instead. It runs the identical adoption sequence (same precedence, same fetch-first ordering, same seed fence) with no binary bootstrap and no profile writes:
ocx config setup --managed-config internal.company.com/ocx-config:userA bare re-run of either command — no --managed-config flag at all — re-resolves and re-syncs whatever source is already active (the env override if one is set, otherwise the existing seed). This is not just a repair path for a wiped or mismatched snapshot: against a healthy, already-adopted seed it re-checks the registry every time and pulls forward a newer published config without a separate ocx config update. That re-sync is best-effort once a snapshot already exists — a fetch failure warns and keeps what is on disk (exit 0) rather than failing the setup — while a wiped or mismatched snapshot still hard-fails on a fetch error, the same as first adoption.
CI runners skip the seed entirely — set the org-level environment variable and sync once at the top of the job:
export OCX_MANAGED_CONFIG=internal.company.com/ocx-config:ci
ocx config update
ocx package install "kitware/cmake:3.28"OCX_MANAGED_CONFIG is invocation-only: it overrides [managed] source for that process and everything it spawns, but is never written back to disk — the right shape for an ephemeral runner that starts from a clean $OCX_HOME every run.
From then on, every ordinary command merges the last-synced snapshot above the user config, with zero network access. See how managed config fits into the tier chain for the precedence details and the identity check that protects a shared $OCX_HOME from adopting a snapshot fetched for a different source.
Publishing an update
The operator publishes with ocx config push. It validates the payload first — it must parse as an ocx config (write it against the config schema), must not contain a [managed] section, and must stay within 64 KiB — then pushes it as an ordinary package:
ocx config push -i corp/ocx-config:user-1.4.2 ./config.toml --cascade--cascade gives your config the same rolling-tag algebra your packages already use: pushing user-1.4.2 also advances user-1.4, user-1, and user, so a fleet tracking :user picks up the new content on its next ocx config update or background tick (subject to refresh and interval — see [managed]), while a host that needs yesterday's exact content can track user-1.4.1. The reported digest is what every consumer's --check will show once converged.
Fleet operators relying on refresh = "apply" should plan for its scope: the background tick only runs on an interactive terminal outside CI, online, unpaused, and past the throttle window, so CI runners and other headless hosts never auto-converge — they need the explicit ocx config update step from the CI recipe above.
Testing a candidate before you publish
A candidate config.toml is just a file on disk until ocx config push turns it into a fleet-wide artifact. ocx config test answers the question you have right before that step: if I push this, what would every host that adopts it actually see?
The gap it closes is the one described in Unknown keys and sections — a typo like registry.defalt never fails the push. The payload publishes clean, and the mistake only shows up later, as a setting some host silently never got, not as an error at authoring time. ocx config test runs the exact validator ocx config push runs — same 64 KiB cap, same TOML parse, same [managed] rejection — against the file on disk, merges it onto your own machine's config, and reports what came out the other side, including everything the schema did not recognize:
The merge is onto your local tiers — system, user, $OCX_HOME — never onto whatever managed snapshot is already synced on this machine; the candidate stands in for that snapshot, not on top of it. A value the candidate does not set falls back to your own config, the same as it would on a host that adopted the payload. Nothing is published, adopted, or written, and no registry is contacted — there is nothing to verify against a candidate that has not been pushed anywhere yet.
Run it before every push: catch the typo locally, publish once you're sure, then use staged rollout below to widen the blast radius gradually.
Staged rollout, rollback, pause
Variant tags stage a rollout: publish to a canary-… version first, verify on a handful of machines tracking :canary, then publish the same payload under the user-… variant. Both are just cascade families in one repository — the same rolling-tag idiom OCX uses for package cascades.
The cascade algebra gives you a ring within a single family too, with no second variant to maintain: push user-1.4.2 without --cascade first — only that exact tag moves — and point a handful of canary hosts at user-1.4.2 directly. Once they check out, push the same content again with --cascade to advance user-1.4, user-1, and user together; the rest of the fleet, tracking the floating :user tag, picks it up on its next sync or ocx config update.
A host that needs to step off the fleet's floating tag temporarily has two levers, both local:
# Roll back to a known-good version (any tag, digest, or tag@digest):
ocx config update user-1.4.1
# Hold the background tick for up to 7 days while you debug:
ocx config update --pause 3d user-1.4.1
# Rejoin the fleet:
ocx config update --resumeA pause holds both the background tick and the setup-time re-sync — ocx self setup and ocx config setup also skip refreshing an already-adopted seed while a pause is in force. Required-gate enforcement and an explicit ocx config update keep working regardless, and any explicit update without --pause clears it. ocx --format json config update --check reports the full local state (source, digest, tag, drift, pause window, pin), which is the fleet-visibility story: OCX is deliberately pull-based and console-free, so "what is this host running?" is answered by that one command in your existing inventory tooling.
The rollback above is not durable by itself: the seed still tracks the fleet's floating tag, so the next setup re-sync or refresh = "apply" tick moves the host forward again once it runs. --pause holds that off temporarily; a digest-pinned source is the permanent version — content-addressed, so nothing can drift it forward at all.
No downgrade monotonicity
A managed-config snapshot accepts any digest change the registry reports, including a rollback to older content — the same as any tag-based OCI pull. There is no built-in check that a new digest is "newer" than the one already cached. For byte-exact reproducibility, or to rule out an accidental rollback entirely, pin source to a digest instead of a tag: internal.company.com/ocx-config@sha256:….
CI caches must not skip the sync
If your CI caches $OCX_HOME across jobs, the cached snapshot is whatever some earlier job synced — keep an explicit sync step in the job so a poisoned or stale cache entry is always reconciled against the registry before any tool resolution happens. ocx config update, and a re-run of ocx config setup or ocx self setup --managed-config against the seed already adopted into the cached $OCX_HOME, all reconcile it — pick whichever one the job already runs; nothing extra is required on top. The identity gate refuses a snapshot recorded for a different source outright, but only a sync brings a stale same-source snapshot forward, and that sync is best-effort (a registry blip keeps the stale entry rather than failing the job) — use ocx config update directly where a stale sync must fail the job instead of being tolerated.
Rolling out an incompatible change
A fleet is never on one ocx version. The payload you publish today is read by whatever binaries your hosts happen to be running, so the question that decides your rollout is: can an older ocx read this file and do something sensible with it?
Most of the time it can, because OCX ignores what it does not recognize. Add a key a newer ocx understands and older hosts apply the rest of the file and skip that one line. That covers additions, which is most changes — you publish once, and hosts converge whenever they converge.
It does not cover a key whose meaning changed, or whose value takes a new shape. An older binary reads that key with its old meaning and acts on it — a silent misconfiguration, which is worse than an error. Nothing in the file format can save you here, because the old binary is not wrong to parse it the way it does.
Use the tag for those. source is an ordinary OCI reference, so the payload has the same version algebra as any package:
# Hosts still on the old ocx keep reading the old payload.
[managed]
source = "internal.company.com/ocx-config:user"
# Hosts that have upgraded move to the new one.
[managed]
source = "internal.company.com/ocx-config:user-2"Publish the incompatible payload under a new tag family and leave the old one serving the old content. Roll the seed forward on a host once that host's ocx is new enough — via your provisioning tool, or ocx config setup --managed-config internal.company.com/ocx-config:user-2. The two fleets coexist for as long as they need to; nothing has to happen in lockstep, and no host is ever handed a payload it cannot read correctly.
Which lever
Adding a key or a section — publish to the existing tag, older hosts ignore what they do not know. Changing what a key means, or the shape of its value — new tag family, migrate hosts as they upgrade. When in doubt, take the tag: it costs one extra repository tag and rules out silent misreads.
Trust scope
The trust root for a managed-config package is the operator's own registry — the same trust boundary [mirrors] and [patches] already rely on. The payload is verified by content digest, so a tampered or truncated fetch is rejected, but v1 does not verify a publisher signature. Signature verification is deferred to the forthcoming trust-policy work (identity-pinned verify via [trust.policy] and policy-gated auto-verify, GitHub #98 / #99) — both [managed] and [patches] become consumers once that lands. Until then, treat write access to the managed-config registry the same way you would treat write access to your [mirrors]/[patches] registries: a compromise there can redirect any of the three tiers fleet-wide.
One redirection path is closed by construction rather than left to trust-policy work: the package fetch itself is routed only through mirrors configured in local tiers (system, user, $OCX_HOME, OCX_CONFIG, --config). A payload's own [mirrors] entry can never redirect the connection used to fetch its next refresh — a self-hijack that the one-hop [managed] strip already prevents for the config content itself, applied here to the transport too.
Learn more
Configuration in-depth → managed-configuration tier — where it sits in the precedence chain, why refresh never blocks a command, offline behavior. [managed] reference — every field, type, default. ocx config push reference — payload validation, cascade tags, exit codes. ocx config update reference — VERSION pins, --pause/--resume, --check, exit codes, JSON shape. ocx self setup --managed-config reference — onboarding flag, exit codes.
Update OCX
OCX is itself an OCX-managed package. The binary lives at $OCX_HOME/symlinks/ocx.sh/ocx/cli/current/content/bin/ocx. Unlike other packages, ocx self update only swaps the current symlink — no candidate symlink is created for the new version.
Run ocx self update to update OCX to the latest released version, or ocx self update --check to query for a newer version without installing it.
Both commands bypass the background update-check throttle — they always query the published index and registry live. If a new version is available, ocx self update installs it and updates the current symlink. The $OCX_HOME/symlinks/…/current/content/bin PATH entry that ocx self activate exports picks up the new binary automatically on the next shell invocation.
When ocx self update runs, OCX queries for the latest major.minor.patch release tag. Rolling tags (1, 1.2), pre-releases (1.2.3-rc1), and build-tagged versions (1.2.3+build) are filtered out — the command recommends only stable releases.
The background update-check runs automatically at most once per day (configurable via OCX_UPDATE_CHECK_INTERVAL). When a newer version is detected, a notice is printed to stderr at the end of the current command:
A new OCX version is available: ocx.sh/ocx/cli:1.1.0. Consider updating by running `ocx self update`.Set OCX_NO_UPDATE_CHECK=1 to disable the background check entirely. The check is also suppressed in CI environments and non-TTY stderr.
When reporting a bug, run ocx version --verbose to capture commit, build timestamp, target, and CI run URL. For dev-channel builds the output also shows channel: dev.
Learn more
Command-line reference → ocx self update — exit codes, install path, throttle bypass. Command-line reference → ocx version — verbose build provenance, JSON schema. Environment reference → OCX_UPDATE_CHECK_INTERVAL — adjust the background check frequency.
Supply-Chain Integrity
Knowing that a binary was downloaded from the right registry is not the same as knowing it was built by the right person at the right time. Anyone with push access to a registry — or the ability to intercept traffic to it — could substitute a different binary under the same tag. Signatures provide tamper evidence: they bind a specific binary digest to a specific signer identity via a publicly verifiable log entry.
OCX integrates Sigstore keyless signing. You do not manage signing keys. Fulcio issues a short-lived certificate binding an ephemeral key to your OIDC identity, and Rekor records the entry in a public, append-only transparency log.
How keyless signing compares to GPG
Traditional GPG signing requires generating, distributing, and revoking a long-lived key pair — a human process that organizations frequently skip. Sigstore keyless signing replaces the key management ceremony with short-lived OIDC credentials your CI system already provisions. The Rekor transparency log plays the role of a public key server, but with an immutable audit log rather than a mutable key ring.
Sign a release
ocx package sign publishes a Sigstore bundle as a referrer of the target manifest. In a GitHub Actions workflow with id-token: write permission, ambient OIDC detection works with no extra configuration:
ocx package sign -p linux/amd64 registry.example/pkg:1.0Verify what you install
ocx package verify checks a previously published signature against an expected certificate identity and OIDC issuer. Supply them as flags for a one-off check — there is no default, because verification is meaningless without specifying whose signature you trust. Against public Sigstore that is all you need: the trust root is fetched and verified over TUF. For a private or self-hosted Sigstore deployment you also have to say where the trust root comes from — pass --sigstore-trusted-root, or configure it once and never pass it again (Self-hosted Sigstore).
ocx package verify \
-p linux/amd64 \
--certificate-identity https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.example/pkg:1.0Want this to happen automatically?
Once you pin a signer identity below, ocx package install and ocx package pull run this same check on every install — no flags, no extra step. See Verify by default.
Air-gapped verification
Verifying a signature normally reaches out to Sigstore's public trust services to learn the Rekor public key. An air-gapped or hardened runner has no such egress — but it still needs to prove the binary it is about to run was signed by the right identity.
The key insight is that verification has two separate network surfaces. One is the registry the artifact and its signature live in; in an air-gapped setup that is a local mirror you already run. The other is the Sigstore trust services. Only the second is what --offline removes for verify — the registry is still read.
So you supply the trust material locally. A Sigstore trusted-root JSON carries the Fulcio CA, the certificate-transparency log keys and the pinned Rekor key together, so nothing is fetched:
ocx --offline package verify \
-p linux/amd64 \
--sigstore-trusted-root /etc/ocx/trusted_root.json \
--certificate-identity ci@example.com \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.internal/pkg:1.0Alternatively, a successful online verify caches the trust material it used, so a later --offline verify against the same Rekor instance reuses it — no --sigstore-trusted-root needed. And on a fleet, nobody passes the flag at all: the trust root is distributed once through configuration. See Self-hosted Sigstore.
Offline verify never skips
If you go offline with no trusted-root file and no cached material, verify fails with exit 78 and names the remedy. It never silently treats "cannot reach the trust service" as "verified" — an unverifiable package is an error, not a pass.
Pin the signer identity
Passing --certificate-identity and --certificate-oidc-issuer on every invocation works for a one-off check, but a CI pipeline verifies the same package against the same signer on every run. Repeating both flags in every workflow step is one more place a copy-paste error can silently widen who the pipeline trusts — and it gives you no way to accept a signer during a rotation window without a moment where the old or the new identity fails.
A [[trust.policy]] entry declares the accepted signer once, for every package under a scope, instead of on every command line:
[[trust.policy]]
scope = "ghcr.io/acme/*"
signers = [
{ kind = "keyless", identity = "https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main",
oidc_issuer = "https://token.actions.githubusercontent.com" },
]Declare it in a config.toml tier (system, user, or $OCX_HOME) or in the project's ocx.toml, and ocx package verify resolves the identity automatically — no identity flags needed for any package under ghcr.io/acme/ (the trust-root requirement from above still applies):
signers is ANY-of — adding an entry widens acceptance
A policy's signers array can hold more than one entry, keyless or key-mode, and any one of them is enough to verify a signature. Adding an entry never narrows a policy — it is one more way in, not a stricter check. See Signers in the configuration reference for the full mechanics, including kind = "key" signers.
ocx package verify -p linux/amd64 --sigstore-trusted-root /etc/ocx/trusted_root.json ghcr.io/acme/tool:1.0A scope can also name several patterns and carve some back out, which is what you want when one namespace under a governed registry is deliberately unsigned:
# Everything under ghcr.io/acme/ must be signed by CI — except the
# experimental namespace, which stays ungoverned.
[[trust.policy]]
scope = { include = ["ghcr.io/acme/*"], exclude = ["ghcr.io/acme/experimental/*"] }
signers = [
{ kind = "keyless", identity = "https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main",
oidc_issuer = "https://token.actions.githubusercontent.com" },
]An empty (or omitted) include is a catch-all, so exclude on its own governs the whole fleet minus the listed subtrees. An excluded package is not denied — it is simply not covered by this policy, so it installs unverified unless another policy covers it. See Scope matching for the full rule.
The two locations are not interchangeable: an operator's config.toml policy always wins over a project's ocx.toml policy for a package it covers, even if the project's scope is narrower. A project ocx.toml only adds trust for packages the operator hasn't already pinned — it can never override or narrow an operator's pin. See Tier precedence in the configuration reference for the full rule.
When the signing workflow moves (a renamed workflow file, a new repository, a rotated identity), add a second entry at the same scope in the same file instead of replacing the first. Both are accepted until you remove the old one, so there is no window where CI fails because the signer changed mid-rotation. (Rotation-by-addition only works within one tier — an old entry in config.toml and a new entry in ocx.toml do not combine; see the tip below.)
[[trust.policy]]
scope = "ghcr.io/acme/*"
signers = [
{ kind = "keyless", identity = "https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main",
oidc_issuer = "https://token.actions.githubusercontent.com" },
]
[[trust.policy]]
scope = "ghcr.io/acme/*"
signers = [
{ kind = "keyless", identity = "https://github.com/acme/tool/.github/workflows/release-v2.yml@refs/heads/main",
oidc_issuer = "https://token.actions.githubusercontent.com" },
]Learn more
Signing In Depth — trust root mechanics, how a referrer is published, Sigstore bundle storage, slice boundaries, and offline semantics. Configuration reference → [[trust.policy]] — full schema, scope matching (including include/exclude), most-specific-wins resolution, and operator-vs-project tier precedence. package sign reference and package verify reference — flags, exit codes, and CI examples.
Verify by default
A [[trust.policy]] entry is only useful if the check it describes actually runs. Left as a manual step, verification is the thing that gets skipped the first time a deploy is running late — the exact gap the tip earlier in this section called out.
Once a policy covers a package, every command that fetches it verifies its signature automatically — ocx package install and ocx package pull, and every command that auto-installs on demand: ocx package exec, ocx package env, ocx exec, ocx env, and patch discovery (ocx patch why / ocx patch test). No extra flag, no separate step before or after:
# with the ghcr.io/acme/* policy from the previous section in place
ocx package install ghcr.io/acme/tool:1.0The check runs before any layer is downloaded, at the point where the manifest digest has just resolved. A tampered digest or a signer outside the pinned identity aborts the install right there, with the same exit codes ocx package verify uses — 77 for a certificate identity or issuer mismatch, 78 for a trust-root or policy problem, 79 for a missing signature, 65 for a tampered bundle. Nothing has been written to the package store or a symlink yet, so a rejected artifact costs a manifest fetch, not a wasted download.
Trust stays opt-in, and it is opt-in per scope. A package outside every [[trust.policy]] scope installs exactly as it did before this feature existed — OCX logs an INFO line noting that no policy covered it, and nothing blocks. The same rule applies to a covered package's transitive dependencies: each is verified only if a policy also covers its scope. Pin a scope broad enough (ghcr.io/acme/*) to cover a dependency closure you want verified end to end, and use exclude to carve back out any subtree that must stay unsigned. Auto-verify also reads the operator config.toml tier only: unlike ocx package verify, a project ocx.toml policy never gates an install or pull.
Sometimes you need to skip the check anyway — a mirror with no referrers support, a package you're debugging. Pass --no-verify to skip it for one invocation, or set OCX_NO_VERIFY for a CI-wide opt-out; --verify re-enables the check for one invocation even with the environment variable set, since the flag always wins. Either bypass logs one WARN per invocation — a skipped check is visible in the logs, never silent.
Under --offline a policy-covered install needs its trust material locally: a Sigstore trusted-root JSON you supplied, or the cache a prior online verify wrote to $OCX_HOME/state/trust_root/ — offline never reaches the TUF fetch. With neither available, the install fails closed with exit 78 instead of installing something it couldn't check — the same rule Air-gapped verification above describes for the standalone command.
Learn more
Command-line reference → package install and package pull — the full auto-verify contract, options table, and exit codes. Environment reference → OCX_NO_VERIFY — CI-wide opt-out, truthy/falsy values, forwarding to subprocess children.
Remove and clean up
ocx package uninstall kitware/cmake:3.28 removes the candidate symlink for that tag. The binary stays in the package store in case other references hold it. Pass --purge to also drop the binary if no other reference remains.
ocx clean sweeps the entire store — packages with no live install symlink and no forward-ref from a dependent package are removed in a single pass, along with any layers and blobs that become unreachable.
When multiple projects share the same OCX_HOME, ocx clean retains every package referenced by any registered project's ocx.lock — not just the active one. A project is registered automatically whenever ocx lock, ocx add, or ocx remove writes its lockfile. Deleting a project's directory makes its packages collectable at the next clean (silently — no warning). Browse $OCX_HOME/projects/ to see which projects are currently registered. Pass --force to bypass the project registry; live install symlinks are always honoured.
ocx clean also garbage-collects consent: a project's stamp is removed once its directory is confirmed gone, the same way its packages become collectable. A project you move or delete loses both its held packages and its consent silently in the same pass — moving a checkout back re-consents it the same way a fresh clone would, via the next ocx add, ocx lock, ocx pull, or ocx exec.
Learn more
Storage In Depth → Garbage Collection — full reachability walk across refs/symlinks/, refs/deps/, refs/layers/, refs/blobs/. Dependencies In Depth → Garbage Collection — why dependencies are protected by dependents, not by back-references. Project Toolchain In Depth → Multi-project retention — symlink ledger, GC semantics, $OCX_HOME/projects/ browsability.
Lock-first by default: where are --locked and --frozen?
Users coming from uv, Cargo, or pnpm often look for --locked / --frozen flags on read-path commands. OCX folds the lock-freshness guarantee into the defaults — read paths refuse stale locks unconditionally, and the only commands that touch ocx.lock are explicit mutators — and exposes the no-new-versions guarantee as the global --frozen flag.
| You used to write… | OCX equivalent |
|---|---|
uv lock --check | ocx lock --check |
uv sync --locked | ocx pull / ocx exec (default; exit 65 on drift) |
uv sync --frozen | ocx --frozen pull / ocx --frozen run |
cargo build --locked | ocx exec / ocx pull (default) |
cargo build --frozen | --offline (subsumes --frozen: no unknown tags, no network) |
pnpm install --frozen-lockfile | ocx pull (default) |
--frozen and --offline sit on different axes. --frozen freezes version discovery: a tag already in the local index (or a digest-pinned reference) resolves, but an unknown tag errors instead of being fetched — known content still downloads over the network. --offline bans the network entirely, so even a digest-pinned blob that is not already cached fails. Use --frozen to guarantee no unfamiliar version slips in; use --offline for a fully air-gapped run; combine both for the strictest mode (offline wins where they overlap).
Why this asymmetry? OCX is backend-first: read paths refuse stale locks unconditionally so CI scripts cannot silently drift. The mutating commands (ocx add, ocx remove, ocx lock, ocx update) are the only commands that touch ocx.lock; if you do not run them, the lock cannot change.
For the "verify a subset would not change without writing" flow, use ocx update --check. It mirrors ocx lock --check but evaluates the partial-resolve candidate against the predecessor.
Migration
This section covers the changes introduced in the feat/project-toolchain release that affect existing workflows.
Shell integration removed — re-run the installer
ocx shell hook, ocx shell init, ocx shell env, and root ocx install / select / deselect / uninstall / exec have been removed — they exit 64 if invoked. ocx ci export is also removed.
Global toolchain activation is now handled by the installer. Re-run the OCX install script to write $OCX_HOME/env.sh and the block-marker source line in your login profile:
# Idempotent — safe to re-run; existing block marker is overwritten in-place.
curl -fsSL https://setup.ocx.sh/sh | shAfter installation, every new login shell sources $OCX_HOME/env.sh, which runs ocx self activate — putting the global toolchain's trampolines on PATH and, in the default env mode, evaluating ocx --global env --shell=sh on top. No ocx shell init call is needed — the installer owns profile wiring.
OCI-tier operations that moved under ocx package:
| Old command | New command |
|---|---|
ocx install <pkg> | ocx package install <pkg> |
ocx select <pkg> | ocx package select <pkg> |
ocx deselect <pkg> | ocx package deselect <pkg> |
ocx uninstall <pkg> | ocx package uninstall <pkg> |
ocx exec <pkg> -- cmd | ocx package exec <pkg> -- cmd |
For direnv-driven repos, use ocx direnv init to write .envrc — the project toolchain activation model is unchanged.
Linux + zsh — GUI terminals may not read .zprofile
Known limitation: On Linux, GUI terminal emulators (GNOME Terminal, Alacritty, Kitty, etc.) typically open non-login shells and do not read ~/.zprofile. If ocx is not found after re-running the installer, add source ~/.zprofile (or . ~/.zprofile) to your ~/.zshrc.
Project mutators are atomic
ocx add, ocx remove, and ocx lock now acquire an in-place exclusive flock on ocx.toml before reading or writing either file. Concurrent invocations from different terminals or parallel CI jobs are serialised. The old .ocx-lock sentinel file is gone — remove it from .gitignore and run git rm .ocx-lock if previously committed.
--project accepts custom filenames
The --project flag and the OCX_PROJECT environment variable now accept any path, not just files named ocx.toml. The CWD walk still only looks for files named exactly ocx.toml.