Skip to content

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:

sh
ocx package install "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 cmake:3.30 next to cmake:3.28 adds a second candidate; nothing is overwritten. The content-addressed layout dedups identical builds automatically: if cmake:3.28 and 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:

sh
ocx package exec "cmake:4.2.0" -- cmake --version

ocx 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 three steps in strict order:

  1. Bootstraps itself — installs the latest published ocx.sh/ocx/cli into the content-addressed package store and wires the current symlink. The loose binary you downloaded is only needed to run this step; after it completes, the managed copy in ~/.ocx/ takes over.
  2. Writes the env shims — creates $OCX_HOME/env.sh, env.fish, env.ps1, env.nu, and env.elv. These files are byte-identical across users; no install-time substitution occurs.
  3. 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-running ocx self setup is safe.

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)

sh
# 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 zsh

After 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)

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.
. $PROFILE

Windows 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:

powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

ocx self setup never changes the execution policy automatically — that is a user security decision.

Options

FlagEffect
--no-modify-pathWrite shims only; do not touch any shell profile. Equivalent to setting OCX_NO_MODIFY_PATH=1 for that invocation.
--profile PATHTarget an explicit profile file instead of auto-detecting. Repeatable.
--dry-runShow what would be written without writing anything. Useful to preview which profiles are detected.
--forceOverwrite 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 touching any profile:

sh
/tmp/ocx self setup --no-modify-path
# Then add ~/.ocx/symlinks/ocx.sh/ocx/cli/current/content/bin to PATH yourself.

Note that --no-modify-path is not remembered between invocations. If you run ocx self setup again later without the flag, profiles will be modified. Set OCX_NO_MODIFY_PATH=1 persistently in your environment or pass the flag each time to prevent that. 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:

sh
# 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:

sh
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":

sh
ocx package install "cmake:4.2.0"
ocx package select "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:

TagMeaningResolves to after refresh
cmake:3.28.1_20260216120000Specific build, do not re-pushSame build (publisher convention)
cmake:3.28.1Rolling patchLatest 3.28.1 build
cmake:3.28Rolling minorLatest 3.28.x build
cmake:3Rolling majorLatest 3.x build
cmake:latestFloatingLatest release

For build flavor — debug, PGO, slim — use a variant prefix: python:debug-3.12. For exact reproducibility regardless of any tag, use a digest: cmake@sha256:abc123…. Platform is auto-detected; override with -p, --platform only for cross-arch installs.

ocx index list cmake --variants shows available variants without downloading anything.

Working with variants

ocx package deselect cmake clears current without uninstalling. ocx package uninstall 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.

Mirrored upstream tools sit at the registry root under their common name: cmake, shellcheck, uv. 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: a root name is an upstream tool OCX repackaged; an ocx/ name is OCX itself.

Slash-nested names are ordinary OCI repositories — ocx install ocx/mirror:1 resolves exactly like ocx install cmake:3.28. Anyone publishing to their own registry can group packages the same way; the convention is OCX's, the mechanism is the registry's.

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:

ModeFlagPathAuto-installUse case
Package store (default)(none)~/.ocx/packages/…/<digest>/yes (online)CI, scripts, one-shot queries
Candidate symlink--candidate~/.ocx/symlinks/…/candidates/<tag>noPin a specific tag in editor or IDE config
Current symlink--current~/.ocx/symlinks/…/currentno"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).

jsonc
// .vscode/settings.json — path survives every upgrade
{ "cmake.cmakePath": "~/.ocx/symlinks/ocx.sh/cmake/current/content/bin/cmake" }
sh
# ~/.bashrc — always resolves to the selected version
export PATH="$HOME/.ocx/symlinks/ocx.sh/cmake/current/content/bin:$PATH"

When ocx package install --select 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:

sh
ocx package install --select "cmake:4.2.0"
ocx package which --current "cmake:4.2.0"
ocx package which --candidate "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 install (and ocx select) generates two files per entrypoint in the package's entrypoints/ directory:

FileRole
<name>.exeNative launcher — the sole Windows entry point for all callers
<name>.shimOne-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.

sh
ocx init
ocx add "cmake:4.2.0"
ocx direnv init

This 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:

sh
ocx package pull "webapp:2.0.0"

If webapp:2.0 declares dependencies on nodejs:24 and bun:1.3, all three packages end up in the package store. Only 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:

sh
ocx package exec "webapp:2.0.0" -- serve --version

ocx 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:

Inspecting the dependency tree

--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:

Resolved dependency order

--why traces the path from a root package to a transitive dependency:

Tracing why a dependency is pulled in

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: 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 snapshot. The next-strongest lock — and the one most users want — is to freeze the local tag-to-digest snapshot. Tags resolve 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 snapshot gets the same binary on every run, even if the registry re-pushes the tag.

Pin a bundled snapshot. The most ergonomic lock for tool authors. The local snapshot 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 snapshot, which pins the binary:

yaml
- 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 → Bundled Snapshots — full bundled-snapshot pattern, OCX_INDEX env var, Dependabot/Renovate flow. Versioning In Depth → Locking — digest pin rationale, OCI tag mutability.

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:

sh
ocx --global add "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, upgrade, and pull:

sh
ocx --global add "cmake:4.2.0"
ocx --global add "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

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.

ocx self activate emits PATH prepend, shell completions (unless OCX_NO_COMPLETIONS=1), and an eval "$(ocx --global env --shell=sh)" call for the global toolchain. Every new login shell runs this block, placing the currently-selected OCX and its installed global tools on PATH.

The installer appends a block-marker source line to the login profile so re-running it is idempotent:

sh
# BEGIN ocx
. "$HOME/.ocx/env.sh"
# END ocx

You can inspect what the global env exports:

sh
ocx --global add "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.

OCI-tier package operations

The individual package primitives that manage candidate and current symlinks are now grouped under ocx package:

sh
ocx package install "cmake:4.2.0"
ocx package select "cmake:4.2.0"
ocx package exec "cmake:4.2.0" -- cmake --version
ocx package env "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 run 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:

sh
ocx init
ocx add "cmake:4.2.0"
ocx run -- cmake --version
ocx package exec "cmake:4.2.0" -- cmake --version

A project's ocx run 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, upgrade, pull, run, 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.

toml
# ocx.toml
[tools]
cmake      = "ocx.sh/cmake:3.28"
shellcheck = "ocx.sh/shellcheck:0.11"

Each value is a fully-qualified OCI identifierregistry/repo[:tag][@digest]. Bare-tag forms like cmake = "3.28" are rejected so the file is unambiguous regardless of any default-registry config. The schema is published at https://ocx.sh/schemas/project/v1.json and wired through taplo for editor autocompletion.

Lifecycle commands

sh
ocx init
ocx add "cmake:4.2.0"
ocx lock
ocx pull
ocx run -- cmake --version

ocx 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 run 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 run) 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 upgrades your other tools — ocx add and ocx remove carry every untouched lock entry forward unchanged. Only ocx upgrade 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:

text
ocx.lock merge=union

Fresh clone

Just checked out a repo that already has an ocx.toml and ocx.lock? Warm the local object store with ocx pull:

sh
ocx pull

Then 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.

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:

toml
[tools]
cmake = "ocx.sh/cmake:3.28"

[group.ci]
shellcheck = "ocx.sh/shellcheck:0.11"

[group.release]
goreleaser = "ocx.sh/goreleaser:2.0"
sh
ocx init
ocx add "cmake:4.2.0"
ocx add -g ci "uv:0.10.0"
ocx pull -g ci
ocx lock

The same binding name may appear in [tools] and any [group.*] table — identity is (group, name). This lets a project pin one shfmt for daily use and a different one in ci without conflict.

Shell activation

Project tools should land on PATH whenever you cd into the project — without eval-ing on every shell startup. Two entry points, each suited to a different workflow:

  • ocx direnv export — stateless direnv backend; ocx direnv init drops a ready .envrc that re-evaluates on each directory entry.
  • ocx run — no hook at all, for CI and scripts: runs a command directly in the locked project env.

The direnv backend exports only — it never installs missing tools or contacts the registry. Run ocx pull first.

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.

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 run is for.

The simplest form runs a command in the default group ([tools]) environment:

sh
ocx init
ocx add "cmake:4.2.0"
ocx run -- cmake --version

-- is mandatory. Every token after -- is forwarded unchanged to the child. Pass -g to scope to a named group:

sh
ocx init
ocx add -g ci "uv:0.10.0"
ocx run -g ci -- uv --version

To compose the environment from every group at once, use the all keyword:

sh
ocx init
ocx add "cmake:4.2.0"
ocx add -g ci "uv:0.10.0"
ocx run -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:

sh
ocx init
ocx add "cmake:4.2.0"
ocx run "cmake" -- cmake --version

The name must resolve unambiguously in the selected scope; ocx run exits 64 if a name is unknown or matches entries in more than one selected group with conflicting identifiers.

ocx run vs ocx package exec

ocx run 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 run; otherwise use ocx package exec.

Learn more

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:

sh
ocx init
ocx add "cmake:4.2.0"
ocx pull
ocx run -- cmake --version

For OCI-tier CI (no ocx.toml, direct identifier pinning):

sh
ocx package pull "cmake:4.2.0"
ocx package env "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][env-composition-ref] 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 → Bundled Snapshots — pair ocx pull with a bundled snapshot for end-to-end determinism. Storage In Depth → Packages — why concurrent CI writes are safe.

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:

  1. Environment variables
  2. Docker credentials

Environment variables

Configure auth for a registry via OCX_AUTH_* variables:

sh
export OCX_AUTH_docker_io_TYPE=bearer
export OCX_AUTH_docker_io_TOKEN="<token>"
sh
export OCX_AUTH_docker_io_TYPE=basic
export OCX_AUTH_docker_io_USER="<user>"
export OCX_AUTH_docker_io_TOKEN="<token>"

The variables are:

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:

sh
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):

  1. credHelpers[REGISTRY] in ~/.docker/config.json (per-registry helper)
  2. credsStore (global default helper)
  3. 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.

sh
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 snapshot 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:

ModeFlagSourceNetwork?
Default(none)Local snapshotNo (unless fetching a new binary)
Remote--remoteOCI registryYes
Offline--offlineLocal snapshotNever

--offline prevents any network access for that command. If the local snapshot does not have a requested package, the command fails immediately rather than attempting a registry query — useful to verify the current snapshot and object 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 snapshot. Use it for one-off checks of currently available tags.

--index / OCX_INDEX only change where the snapshot is read from — useful when consuming a bundled snapshot from a GitHub Action or Bazel rule.

Refreshing the snapshot

ocx index update <package> syncs the local snapshot for a specific package:

  • Bare identifier (e.g., cmake) — downloads all tag-to-digest mappings.
  • Tagged identifier (e.g., cmake:3.28) — fetches only that single tag, ideal for lockfile workflows.

On a fresh machine, ocx package install cmake:3.28 does not need an explicit index update first — when the local snapshot has no entry for the requested tag, OCX resolves it transparently against the registry, persists it, and proceeds with the install.

Learn more

Indices In Depth — remote query path, snapshot internals, blob write-through caching, fresh-machine fallback. Versioning In Depth → Locking — why offline snapshots are also a lock.

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."<upstream-host>"] config table maps each upstream hostname 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.

toml
# ~/.ocx/config.toml  (or $XDG_CONFIG_HOME/ocx/config.toml)
[mirrors."ghcr.io"]
url = "https://company.jfrog.io/ghcr-remote"

[mirrors."docker.io"]
url = "https://company.jfrog.io/dockerhub-remote"

With this config, ocx package install ghcr.io/owner/tool:1.2 fetches the manifest and blobs from company.jfrog.io/ghcr-remote/owner/tool. The canonical identifier — ghcr.io/owner/tool:1.2 — is never changed.

Relation to the default registry

[registry] default and [mirrors] are independent and compose. Default injection expands a bare identifier (e.g. cmake:3.28ocx.sh/cmake:3.28) at parse time, before any mirror rewrite. If you also configure [mirrors."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.

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, tag store, 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. 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.

sh
export OCX_AUTH_company_jfrog_io_TYPE=bearer
export OCX_AUTH_company_jfrog_io_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:

sh
export OCX_MIRRORS='{"ghcr.io":"https://company.jfrog.io/ghcr-remote"}'

OCX_MIRRORS wins over [mirrors] on a per-host basis and is forwarded to every subprocess ocx spawns, so nested invocations — generated launchers, ocx run — see the same mirror map automatically.

Learn more

Configuration reference → [mirrors."<host>"] — full schema, auth, interaction table, plain-HTTP note. Environment reference → OCX_MIRRORS — JSON encoding, per-host 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:

TierPath
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).

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 registry. 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.

Remove and clean up

ocx package uninstall 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.

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 --checkocx lock --check
uv sync --lockedocx pull / ocx run (default; exit 65 on drift)
uv sync --frozenocx --frozen pull / ocx --frozen run
cargo build --lockedocx run / ocx pull (default)
cargo build --frozen--offline (subsumes --frozen: no unknown tags, no network)
pnpm install --frozen-lockfileocx 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 upgrade) 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 upgrade --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:

sh
# Idempotent — safe to re-run; existing block marker is overwritten in-place.
curl -fsSL https://ocx.sh/install.sh | sh

After installation, every new login shell sources $OCX_HOME/env.sh, which runs eval "$(ocx --global env --shell=sh)". No ocx shell init call is needed — the installer owns profile wiring.

OCI-tier operations that moved under ocx package:

Old commandNew 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> -- cmdocx 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.