Development Preview build — APIs and content may change. Visit ocx.sh for the current release.
Skip to content

User Guide

File Structure

Most package managers store everything in a single mutable tree — installing a new version silently replaces the old one, breaking anything that referenced the old path. They also hit the network on every operation, making offline use and reproducible CI awkward.

Storage

ocx separates concerns into independent stores under ~/.ocx/ (configurable via OCX_HOME):

  • ~/.ocx/
    • packages/immutable, content-addressed assembled packages
    • layers/extracted OCI layers — shared across packages that reference the same layer
    • blobs/raw OCI blobs — manifests, image indexes, referrers
    • tags/local mirror of registry tag-to-digest mappings — no binaries
    • symlinks/stable symlinks safe to embed in shell profiles and configs
    • temp/download staging — cleaned on successful install

Each store has a single responsibility. Upgrading a package updates symlinks in symlinks/ and adds an entry to packages/, but never touches tags/. The stores can be understood — and reasoned about — one at a time.

Path component encoding

Registry names, repository names, and tags that appear as directory or file names in the stores are slugified before they become filesystem path components. Dots and hyphens are preserved so that domain names (e.g. ghcr.io) and semantic versions (e.g. 3.28.1) remain readable on disk.

Digest-addressed directories use a two-level shard (sha256/{2hex}/{30hex}/). Only 32 hex characters are encoded in the path; the full digest is written to a sibling digest file inside each entry and is the source of truth for identity.

Packages

When ocx installs a package, the actual files land in packages/. The critical design decision: the storage path is derived from the content's SHA-256 digest, not from the package name or tag.

  • ~/.ocx/packages/
    • {registry}/
      • sha256/ab/c123…/one directory per unique package build
        • content/package files — binaries, libraries, headers
        • entrypoints/generated launchers for declared entry points — one script per name
        • metadata.jsondeclared env vars and extraction options
        • refs/back-references to install symlinks — guards against deletion

This content-addressed layout has two important consequences:

  • Automatic deduplication. If cmake:3.28 and cmake:latest resolve to the same binary build, they share one directory on disk. Storage is proportional to the number of distinct builds, not the number of tags that reference them.
  • Immutability. A path under sha256/<shard>/ never changes its contents. This makes packages safe to reference directly from scripts or cache layers that require a known, stable binary.

Similar to the Nix store and Git objects

The Nix package manager stores every package at /nix/store/{hash}-name/ using the same principle: the path is a function of the content. This is what makes Nix derivations reproducible across machines — the same hash always means the same files. Git's internal object store (.git/objects/) works identically. ocx applies this model to OCI-distributed binaries.

Generated launchers in entrypoints/. When a package's metadata.json declares an entrypoints array, ocx materializes a sibling entrypoints/ directory at install time with one script per entry — a POSIX .sh launcher for Unix shells and a .cmd launcher for Windows. Each launcher bakes the digest-addressed content/ path and re-enters via ocx launcher exec, so every invocation runs under the same clean-environment guarantee as ocx exec <package>. Packages that declare no entrypoints never get an entrypoints/ directory. See the entry points guide for the publisher workflow.

Garbage collection via refs/. The refs/symlinks/ subdirectory inside each package tracks every install symlink that currently points to it. That directory is the GC root signal — ocx clean starts a reachability walk from every package with a live refs/symlinks/ entry and follows forward-refs through all three tiers.

How back-references work

When ocx install cmake:3.28 creates the symlink symlinks/…/cmake/candidates/3.28 → packages/…/sha256/ab/c123…/content, it simultaneously writes a back-reference entry inside the package's refs/symlinks/ directory. Removing the symlink via ocx uninstall removes that back-reference entry. ocx clean then builds a reachability graph across all three tiers: packages with live refs/symlinks/ entries (and any profile content-mode references) are roots, and a single BFS pass follows each package's forward-refs in refs/deps/, refs/layers/, and refs/blobs/. Packages, layers, and blobs that remain unreachable across all three tiers are deleted in one sweep.

Commands: ocx install adds packages; ocx uninstall --purge removes a specific one; ocx clean removes all unreferenced packages.

Layers

A package on disk looks monolithic — one content/ directory under one digest — but the bytes inside it can come from more than one upstream archive. Each archive is a layer, stored once in ~/.ocx/layers/ and shared across every package that references it.

  • ~/.ocx/layers/
    • {registry}/
      • sha256/ab/c123…/one directory per unique layer blob
        • content/extracted layer files — hardlink source for assembled packages

The layer store enables a different kind of dedup than the package store. The package store dedups whole builds — two tags pointing at the exact same binary share one directory. The layer store dedups parts of builds: a 200 MB shared base layer used by ten packages is downloaded, extracted, and stored exactly once. Each package's content/ is then assembled by hardlinking files from one or more layer directories, so the package looks complete even though no bytes were copied.

Similar to Docker image layers and pnpm's content store

Docker image layers are the same concept at the registry level: an image is a stack of layers, each addressed by digest, downloaded only when missing from the local cache. ocx applies this to binary packages instead of containers. pnpm uses a comparable trick on the install side, storing every package version once in a content-addressed store and hardlinking them into individual node_modules/ trees.

Publishing multi-layer packages. When you call ocx package push, every positional argument after the identifier is a layer — zero or more. Each layer is either a path to a local archive file or a digest reference to a layer that already exists in the target registry. A push with zero layers is valid too: it produces a config-only OCI artifact (useful for referrer-only / description-only manifests) and requires --metadata since there is no file layer to sniff a sibling metadata path from.

shell
# Two-layer package: shared base + package-specific top
ocx package push -p linux/amd64 mytool:1.2.3 base.tar.gz tool.tar.gz

# Re-publish with the base layer reused by digest — no re-upload.
# The digest ref must spell out the original archive extension
# (`.tar.gz` / `.tgz` / `.tar.xz` / `.txz`) — OCI blob HEADs do not
# carry the media type, so ocx refuses to guess.
ocx package push -p linux/amd64 mytool:1.2.4 sha256:<hex>.tar.gz newtool.tar.gz

The order matters for the manifest descriptor list, but assembled content must not overlap — two layers cannot contain the same file path. Overlap is rejected at install time with a clear error.

Digest verification on pull

Every layer blob downloaded by ocx install or ocx package pull is streamed through SHA-256 on the way to disk and compared against the digest declared in the manifest before extraction. A mismatch — the registry serving different bytes for the same digest (CWE-345) — deletes the tampered file and fails the command. Zero-layer pulls are valid: a config-only package (produced by ocx package push with no file layers and --metadata) installs into an empty content/ directory, which is the expected shape for referrer-only or description-only artifacts.

Bring your own archives

ocx package push does not bundle a directory for you. Each layer must be a pre-built archive (.tar.gz / .tgz or .tar.xz / .txz). This is intentional: archive creation is non-deterministic (timestamps, compression entropy, file ordering), so re-bundling the same content yields a different digest and defeats layer reuse. Use ocx package create if you need to bundle a directory — that command produces a stable archive once, which you can then push and reference by digest from any number of subsequent packages.

If a file in your current directory is literally named like a digest reference (e.g. sha256:abc….tar.gz), prefix it with ./ to force file interpretation — bare sha256:… tokens are always parsed as digest refs.

Designing for reuse

The layer reuse workflow is most valuable when you have many packages that share a large common base — a runtime library, a vendored toolchain, or a fixed dataset. Push the base once, record its digest, and reference it from every dependent package's push command. The registry stores it once; ocx downloads it once; every package gets a fresh content/ view assembled from the same shared layer.

Commands: ocx package push publishes layered packages; ocx package create bundles a directory into a stable archive.

Tags

When you run ocx install cmake:3.28, how does ocx know which binary to fetch? It looks up the tag 3.28 in the local tag store and finds the corresponding digest. The tag store is a local copy of that mapping — no network required.

  • ~/.ocx/tags/
    • {registry}/
      • {repo}.json{ "3.28": "sha256:abc…", "3.30": "sha256:def…" }

The tag store is a snapshot: it reflects the state of the remote registry at the last time you refreshed it. That snapshot has two benefits:

  • Offline installs. If the tag store is populated and the package is already in packages/, ocx install cmake:3.28 works with no network access at all — tag resolution is a local file read.
  • Reproducibility. A CI runner that does not update the tag store gets the same binary on every run, regardless of what the registry serves today. Tags in OCI registries are mutable; your local snapshot is not.

Similar to APT's package lists

apt-get update downloads package metadata from configured sources and caches it in /var/lib/apt/lists/. All subsequent apt-get install calls resolve packages from that local snapshot — the network is only involved during an explicit refresh, not on every install. ocx index update <package> is the per-package equivalent: you control when the snapshot changes, and the rest of the time you work from the local cache.

ocx index update <package> refreshes the tag store for a specific package. The global flag --remote forces tag and catalog lookups to query the registry directly for a single command, without updating the persistent local tag snapshot. Blob data fetched under --remote still populates $OCX_HOME/blobs/ via write-through, so subsequent offline installs work for any package that was resolved while online.

On a fresh machine, you do not need to run ocx index update before the first ocx install cmake:3.28. When the local tag store has no entry for a requested tag, ocx install transparently resolves that single tag against the configured remote, persists it to the tag store, and proceeds with the install. Subsequent commands — including --offline — then work from the cached entry without touching the network. Refreshing a cached tag or discovering every tag for a repository is still the job of ocx index update; the fallback only covers the specific tag being installed.

Commands: ocx index catalog, ocx index update; flag: --remote

Package paths embed the digest: ~/.ocx/packages/ocx.sh/sha256/ab/c123…/content. That path changes on every upgrade. You cannot put it in a shell profile, an IDE config, or a build file and expect it to still work next month.

symlinks/ solves this with stable symlinks whose paths never change — only their targets are re-pointed when you install or select a new version.

  • ~/.ocx/symlinks/
    • {registry}/
      • {repo}/
        • currentactive package root — set by ocx select
          • content/package files — binaries, libraries, headers
          • entrypoints/generated launchers — added to $PATH by ocx shell profile load
          • metadata.jsondeclared env vars and extraction options
        • candidates/
          • 3.28pinned package root — created by ocx install cmake:3.28
          • 3.30pinned package root — created by ocx install cmake:3.30

Two symlink entries cover every use case. Both target the package root (packages/{registry}/{algorithm}/{2hex}/{30hex}/) rather than the content/ subdirectory; consumers traverse into …/content/ for files, …/entrypoints/ for launcher scripts, or read …/metadata.json directly:

candidates/{tag} — pinned to a specific version. Created by ocx install and pointed at the exact digest that tag resolved to at install time. You can have cmake 3.28 and 3.30 installed side by side; both candidates coexist until you explicitly uninstall one. Even if the registry later re-pushes the 3.28 tag with a different binary, your candidate still points to the build you originally installed.

current — a floating pointer to whichever candidate you last declared active. Set by ocx select (or ocx install --select in one step). It is never updated automatically — not when you install a newer version, not when you update the tag store. This is intentional: tools referencing current should only change behavior when you decide they should. When the selected package declares entrypoints, ocx shell profile load adds {repo}/current/entrypoints to $PATH so every declared launcher becomes a top-level command. See the entry points guide for how launchers, PATH, and clean-env execution compose.

Inspired by SDKMAN and Homebrew

SDKMAN (the Java SDK manager) uses the same two-level pattern: ~/.sdkman/candidates/{tool}/{version}/ for pinned installs and a current symlink updated by sdk default {version}. Homebrew does the same with its Cellar/{formula}/{version}/ store and a stable opt/{formula} symlink pointing at the active version. Linux's update-alternatives is the system-level equivalent, managing tools like java and python3 via a layer of stable symlinks in /etc/alternatives/.

Stable paths in IDE settings and shell profiles

Because current never changes its own path, you reference it once and forget it:

shell
# Install cmake and make it the active version in one step
ocx install --select cmake:3.30
jsonc
// .vscode/settings.json — path survives every future 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 you later run ocx install --select cmake:3.32, current is re-pointed. Your IDE and shell pick up the new version automatically — no config changes needed.

Commands: ocx install, ocx select; ocx deselect, ocx uninstall

Path Resolution

Several commands resolve a package to a filesystem path that is embedded in their output. The path you receive determines how stable that output is across future package updates.

Every mode names the package root — the directory that contains the package's content/ and entrypoints/ subdirectories alongside metadata.json, manifest.json, and the other per-package files. Consumers traverse one level in: <root>/content/ for installed files, <root>/entrypoints/ for generated launchers.

ModeFlagPath usedAuto-installUse case
Package store (default)(none)~/.ocx/packages/…/<digest>/yes (online)CI, scripts, one-shot queries
Candidate symlink--candidate~/.ocx/symlinks/…/candidates/<tag>noPinning to a specific tag in editor or IDE config
Current symlink--current~/.ocx/symlinks/…/currentno"Always selected" path in shell profiles or IDE settings

Package store paths are content-addressed and change whenever the package digest changes (i.e. on every update). They are precise and self-verifying but unsuitable for static configuration.

Candidate and Current paths go through symlinks managed by ocx that target the package root directly. Because the symlink path itself does not change, any tool that hardcodes the path continues to work after the package is updated — only the symlink target is re-pointed.

  • --candidate requires the package to be installed (the candidate symlink must exist). A digest component in the identifier is rejected; use a tag-only identifier.
  • --current requires a version of the package to be selected (the current symlink must exist), mirroring the update-alternatives model on Linux. current is not automatically the latest installed version — it only moves when explicitly selected. A digest component in the identifier is rejected.

Both symlink modes fail immediately if the required symlink is absent. They never attempt to install or select a package as a side effect.

Example — stable path for VSCode settings.json

jsonc
{
  // This path remains valid across package updates — only the symlink target changes.
  // The `current` symlink targets the package root; clangd lives under `content/bin/`.
  "clangd.path": "/home/user/.ocx/symlinks/ocx.sh/clangd/current/content/bin/clangd"
}

Set it up once by installing and selecting a version, then inspect the stable path:

shell
ocx env --current clangd

Use ocx find to print the resolved package root directly, without setting environment variables. Supports the same --candidate and --current modes and is useful for scripting.

Versioning

Every binary package has three dimensions: what it is, which version, and which platform build. Most tools handle these separately — different flags, different naming conventions, different configuration layers — and leave the coordination to you.

Each ecosystem encodes platform differently: apt uses arch suffixes and ports mirrors, pip bakes the platform into the wheel filename, and Bazel rules tend to maintain a hand-curated URL matrix. Version and platform are always managed as separate concerns.

ocx collapses all three dimensions into a single identifier. Platform is auto-detected and applied silently; version semantics are expressed through the tag convention. ocx install cmake:3.28 resolves the right binary for the current machine, verifies it cryptographically, and installs it — without flags, name suffixes, or mirror configuration.

Tags

An OCI tag is a human-readable label pointing to a specific manifest. The OCI specification offers no notion of tag stability — tags are mutable pointers. Structure and stability emerge from publishing conventions.

Rolling tags are a widespread pattern in container ecosystems — Docker official images use them extensively (ubuntu:24.04, node:22, nginx:latest). Semantic Versioning formalizes the same hierarchy for software releases. ocx adopts both conventions for binary packages.

ocx follows a semver-inspired tag hierarchy where specificity signals intent:

{major}[.{minor}[.{patch}[-{prerelease}][_{build}]]]

The _build suffix — typically a UTC timestamp like _20260216120000 — signals that the publisher considers the tag final and does not intend to re-push it. It is a convention, not a guarantee enforced by the registry.

Why _ instead of semver's +?

The OCI Distribution Specification restricts tags to [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127} — the + character is not allowed. ocx uses _ as the build separator so that every version string is a valid OCI tag by construction. This follows the same convention adopted by Helm for OCI-stored charts.

If you type + out of habit (e.g., cmake:3.28.1+20260216), ocx accepts it and normalizes to _ automatically. See Build Separator in the FAQ for the full rationale.

TagPublisher's intentAfter index refresh
cmake:3.28.1_20260216120000Do not re-pushSame build (by publisher convention)
cmake:3.28.1Rolling patchLatest build of 3.28.1
cmake:3.28Rolling minorLatest 3.28.x build
cmake:3Rolling majorLatest stable 3.x build
cmake:latestFloatingLatest release

OCI tags are not immutable

Any tag — including build-tagged ones — can be overwritten by the publisher. Two mechanisms protect your installs regardless of what the registry does later:

  • Local tag store snapshot. The tag → digest mapping only changes when you run ocx index update. The snapshot is yours until you decide to refresh it.
  • Content-addressed package store. Once a binary is installed, it lives at a path derived from its SHA-256 digest. The tag used to install it is irrelevant — the bytes are permanent.

For absolute reproducibility without relying on any index, reference the digest directly: cmake@sha256:abc123…

Variants

A binary tool can be built multiple ways — with different optimization profiles (pgo, pgo.lto), feature toggles (freethreaded), or size trade-offs (slim). Variants describe how a binary was built, not where it runs. All variants for a given platform execute on the same host; the user chooses the variant, it is not auto-detected.

Without variant support, publishers create separate packages (python-pgo, python-debug) to represent these build differences, which loses the semantic connection between builds of the same tool.

Why not Docker's tag-suffix convention?

Docker Hub encodes variants as tag suffixes (python:3.12-slim), but this creates a parsing ambiguity with semver prereleases — 3.12-alpha and 3.12-slim are syntactically indistinguishable. Concourse CI's registry image resource documents this explicitly, resorting to a heuristic: only alpha, beta, and rc are treated as prereleases, everything else is a variant suffix. ocx avoids this ambiguity entirely with a variant-prefix format.

ocx uses a variant-prefix format convention instead. The default variant's tags carry no prefix — python:3.12 resolves the same build as python:pgo.lto-3.12 when pgo.lto is the default.

TagMeaning
python:3.12Default variant at version 3.12
python:debug-3.12Debug variant at version 3.12
python:debug-3Latest version of the debug variant in the 3.x series
python:debugLatest version of the debug variant
python:latestLatest version of the default variant

Rolling tags cascade within their variant track: debug-3.12.5 cascades to debug-3.12debug-3debug. Variants never cross — publishing a new debug build never updates pgo.lto or the default variant's tags. See Cascades for the full cascade model.

For the default variant, cascading also produces unadorned alias tags: publishing a new default-variant build cascades both the prefixed track (if any) and the bare 3.12.53.123latest track.

Discovery

ocx index list python --variants lists the available variant names for a package without downloading any binaries.

Working with variants

Platform vs. variant

OCI platform.variant is a CPU sub-architecture field (ARM v6, v7, v8) — it determines where a binary can run and is selected automatically. OCX software variants determine how a binary was built and are selected by the user. The two concepts are orthogonal.

Cascades

Publishers are expected to maintain the full tag hierarchy. When cmake:3.28.1_20260216120000 is released, all rolling ancestors are re-pointed — but only if this is genuinely the latest at each specificity level:

cmake:latest                  ← rolling — updated

cmake:3                       ← rolling — updated

cmake:3.28                    ← rolling — updated

cmake:3.28.1                  ← rolling — updated

cmake:3.28.1_20260216120000   ← source of truth

Publishing cmake:3.27.5_20260217 would update cmake:3.27 but not cmake:3 or cmake:latest — the 3.28.x series is still ahead. Rolling tags only advance, never regress. Note this is a convention, not a guarantee enforced by the registry — publishers must maintain the cascade manually.

Cascades operate within a single variant track. Publishing debug-3.28.1 updates debug-3.28, debug-3, and debug — but never touches the default variant's tags (3.28, 3, latest) or any other variant's tags. For the default variant, cascading also produces unadorned alias tags that mirror the variant-prefixed chain. See Variants for details.

Cascading a new release

ocx package push --cascade handles the full cascade automatically: publish one build and let ocx re-point all rolling ancestors in a single command.

Choosing a tag
  • Auditable, specificcmake:3.28.1_20260216120000
  • Stable with patch fixescmake:3.28.1 or cmake:3.28
  • Follow active developmentcmake:3 or cmake:latest

Platforms

The OCI Image Index specification defines a multi-platform manifest format that allows multiple platform builds to live under a single tag. When you install a package, ocx reads your running system and selects the matching build automatically — no flags, no configuration, no separate repository per architecture.

SystemDetected as
Linux on x86-64linux/amd64
Linux on ARM (Graviton, Ampere, RPi 4+)linux/arm64
macOS on Inteldarwin/amd64
macOS on Apple Silicondarwin/arm64
Windows on x86-64windows/amd64

If a package publishes a universal build with no platform field in the manifest, ocx uses it as a fallback — common for interpreted runtimes and truly portable binaries.

When you need a specific variant for cross-compilation, a Docker image build for a different architecture, or a CI matrix test, pass -p, --platform:

shell
ocx install -p linux/arm64 libssl:3
shell
# Register the foreign arch, add a ports mirror, then install
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y libssl-dev:arm64
Richer than OS and architecture

The OCI platform model supports finer-grained descriptors that publishers can use for precise compatibility targeting:

  • variant — CPU sub-architecture. For ARM: v6, v7 (32-bit), v8 (64-bit / arm64). ocx selects the most specific match.
  • os.version — OS version string, primarily used for Windows Server editions (e.g. 10.0.14393.1066).
  • os.features — OS capability flags, e.g. win32k for Windows container isolation modes.
  • features — Reserved for future platform extensions in the OCI spec.

ocx matches all declared fields when selecting among manifest entries. See the OCI Image Index specification for the complete field reference.

Note: OCI platform.variant is a CPU sub-architecture field, distinct from OCX software variants which describe build-time characteristics like optimization profiles or feature sets.

Locking

The most direct lock is a digest: cmake@sha256:abc123… bypasses the tag store entirely and identifies an exact binary regardless of what any tag points to. Because all installed bytes are content-addressed, every package can be pinned this way — no lockfiles, no registry queries, just the hash.

For most use cases, the local tag store snapshot already provides the lock. Tags resolve to the digest recorded at last update, and that mapping does not change until you run ocx index update. A CI runner that never updates its tag store gets the same binary on every run.

For tool authors distributing ocx-powered workflows, there is a more ergonomic option. The local tag store holds only metadata — small JSON files, no binaries — so it can be shipped inside a GitHub Action, Bazel Rule, or DevContainer Feature. The result is a two-level lock: the tool version locks the tag store snapshot, which locks the resolved binary. Users pin the tool and get deterministic builds without managing digests, platform conditionals, or separate lockfiles.

The contrast with maintaining a hand-curated URL matrix — one filename → checksum entry per version × os × arch — is clear: a version bump means editing one rule version, not a dictionary.

GitHub Action with bundled index

yaml
- uses: ocx-actions/setup-cmake@v2.1.0   # pins action → pins index → pins binary
  with:
    version: "3.28"                       # human-readable tag, no platform conditions

@v2.1.0 locks everything end-to-end. @v2 follows minor releases — as the maintainer ships updated index snapshots, cmake:3.28 may resolve to a newer build when the action version changes. No SHA256 lists, no if: runner.os == 'Linux' conditionals.

The same pattern applies to Bazel Rules and DevContainer Features — index updates travel as part of the tool release, not as a separate step for end users.

Indices

OCI tags are mutable — cmake:3 today may point to a different digest after the next patch release. The versioning section covers this in detail. For a tool that installs binaries reproducibly, this is a problem: ocx install cmake:3 run twice on different days could silently resolve different builds.

ocx solves this by keeping a local snapshot of tag-to-digest mappings. That snapshot only changes when you explicitly refresh it. The same snapshot always resolves cmake:3 to the same digest — regardless of what the registry serves today.

Remote Index

The remote index is the live OCI registry. It answers metadata queries — which tags exist for a package, which digest a tag currently points to, which platforms a manifest declares — directly and authoritatively. Use ocx index catalog to browse available packages or ocx index list to see the tags for a specific package against the live registry with --remote.

The remote index is the data source for ocx index update. Refreshing the local snapshot means querying the remote index and writing the results to disk.

Local Index

The local index reads from ~/.ocx/tags/ — a snapshot of OCI tag-to-digest mappings. Resolving cmake:3 is a file read; no network request is made.

The local index is never updated automatically. You decide when your snapshot changes. Until you explicitly refresh it, the same identifier always resolves to the same digest — on your laptop, on CI, and on every team member's machine. Rolling tags like cmake:3 map to the digest current at last update, not whatever the registry serves today.

The snapshot is small enough — JSON metadata only, no binaries — to ship inside a Bazel Rule or GitHub Action. Those tools bundle a frozen snapshot at release time and set OCX_INDEX (or pass --index) to point ocx at it; consumers write cmake:3 and the bundled snapshot resolves it deterministically, with the package store and install symlinks remaining in OCX_HOME as usual.

Out-of-the-Box Support for Dependabot and Renovate

A single version bump to the action or rule — proposed automatically by Dependabot or Renovate — advances the bundled index. Users get the updated binary with no config changes. No per-platform URL matrix to hand-edit, no separate PR to bump the tool itself.

ocx index update <package> syncs the local index for a specific package from the remote registry. A bare identifier (e.g., cmake) downloads all tag-to-digest mappings; a tagged identifier (e.g., cmake:3.28) fetches only that single tag's digest and manifest, merging it into the existing local tags file. This tag-scoped mode is ideal for lockfile workflows where the index should contain only explicitly requested tags. Packages not listed are not touched.

Commands: ocx index update, ocx index catalog, ocx index list

Active Index

Every command that resolves a package identifier — ocx install, ocx find, ocx exec, ocx index list — uses one working index for that invocation. By default, this is the local index. Two flags change which index is used:

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

--remote forces tag and catalog lookups to query the registry directly for a single command. The persistent local tag store ($OCX_HOME/tags/) is not updated. Blob data fetched under --remote still writes through to $OCX_HOME/blobs/, so the command populates the blob cache while bypassing the tag snapshot. Use it for a one-off check — seeing current available tags, or resolving the latest digest — without committing the tag resolution result to the local snapshot.

--offline prevents all 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 that your current index and object store are self-sufficient before a build in a restricted or air-gapped environment.

--index / OCX_INDEX do not change the active index mode — the local snapshot remains active. They only change where that snapshot is read from. See Local Index.

The active index controls tag and manifest resolution only. The package store is independent — installed binaries are accessible in all three modes regardless of which index is active.

Authentication

ocx utilizes a layered approach of different authentication methods. Most of these are specific to the registry being accessed, allowing to configure different authentication methods for different registries. The following authentication methods are supported and queried in the the order they are listed. If one of the methods is successful, the rest will be ignored.

Environment Variables

To configure authentication for a registry you can use environment variables. The following examples show how to configure authentication for the registry docker.io using a bearer token or basic authentication.

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 above examples configures the following environment variables:

INFO

The registry name in the variable is normalized by replacing all non-alphanumeric characters with underscores. For example, for the registry docker.io, ocx will look for OCX_AUTH_docker_io_TYPE. This is stricter than the path component encoding used for filesystem paths, which preserves dots and hyphens.

The user and token are used according to the authentication type specified. If no authentication type is specified but a user or token is configured, ocx will infer the authentication type. For example, if a user and token are configured, ocx will assume basic authentication. If only a token is configured, ocx will assume bearer authentication. For more information see the documentation of the environment variables.

Docker Credentials

If a docker configuration is found, ocx will attempt to use the stored credentials for authentication. The configuration is typically stored in the file ~/.docker/config.json and can be configured using the docker login command.

sh
docker login "<registry>"

The docker configuration file location can be overridden by setting the DOCKER_CONFIG environment variable.

Dependencies

Package publishers can declare that their package requires other packages to function. A web application might need a JavaScript runtime; a build tool might need a compiler. When a publisher builds their package, they pin each dependency to an exact OCI digest, recording the precise build they tested against.

As a user, you do not need to manage these dependencies yourself. When you install or run a package, ocx handles them automatically.

Resolution

When you pull or install a package that declares dependencies, ocx fetches every required package transitively — dependencies of dependencies, and so on — and stores them in the package store. The process is fully automatic and requires no extra flags:

shell
ocx package pull webapp:2.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 package you explicitly requested — the dependencies are implementation details, fetched and stored but not surfaced as top-level installs.

To actually run the package with its dependency environments configured, use ocx exec:

shell
ocx exec webapp:2.0 -- serve --port 8080

ocx exec composes the environments of all dependencies in the correct order before launching the command. Running a package binary directly — without ocx exec — does not set up dependency environments. There are no launcher scripts yet; environment composition always goes through ocx exec or ocx env.

install + select does not set up dependency environments

ocx install --select creates a current symlink that points to 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 accessible. For packages with dependencies, always use ocx exec to run commands, or ocx env / ocx shell env to export the full environment into your shell first.

You can still install dependencies explicitly

If you want nodejs:24 available as a standalone tool alongside its role as a dependency, install it separately:

shell
ocx install --select nodejs:24

Both references — the dependency relationship and your explicit install — coexist. The binary is stored once in the package store (content-addressed), and both references protect it from garbage collection.

Environment

When you run ocx exec or ocx env with a package that has dependencies, ocx builds the environment by applying each package's declared variables in topological order. Dependencies come first, then the package you requested.

This means a package can override a dependency's scalar variables (like JAVA_HOME) while accumulator variables (like PATH) are merged naturally — each dependency's bin/ directory is prepended in order.

Conflicting scalar variables

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. This is the same behavior as listing multiple packages manually in ocx exec. If you see a conflict warning, inspect the dependency tree with ocx deps --flat to understand the evaluation order.

This is especially common with the shell profile. If you add a package with dependencies to your profile (e.g., webapp:2.0 which depends on nodejs:24) and also have nodejs:24 in your profile as a standalone tool, both will contribute environment variables. The profile loads all packages via ocx shell env, which includes dependency environments — so NODE_HOME may be set twice from different content paths. To avoid this, either remove the standalone entry from the profile (the dependency provides it), or accept that the profile entry's value wins (it appears later in the load order).

Visibility

Every package owns two environment surfaces: an interface surface (what consumers see by default) and a private surface (what the package's own launchers see at runtime). A build tool that wraps a compiler might need CC and LD_LIBRARY_PATH internally — those belong on the private surface. A shared Java runtime that every consumer needs goes on the interface surface.

The visibility field on each dependency entry controls which surface that dep's env reaches:

ValuePrivate surface (--self)Interface surface (default)Example
sealed (default)NoNoStructural dep — accessed by path only.
privateYesNoInternal tool for own shims/launchers.
publicYesYesShared runtime both sides need (e.g. Java).
interfaceNoYesMeta-package that composes env for consumers.
json
{
  "dependencies": [
    { "identifier": "ocx.sh/java:21@sha256:a1b2…", "visibility": "private" },
    { "identifier": "ocx.sh/maven:3@sha256:c3d4…",  "visibility": "public" }
  ]
}

In this example, java is private — the package needs it internally but consumers don't get JAVA_HOME. maven is public — both the package and its consumers see Maven's environment.

When dependencies form chains, visibility propagates inductively: if the child's effective visibility exports to consumers, the result equals the parent's edge; otherwise sealed. When two paths reach the same dependency through a diamond, the most open visibility wins (OR per axis).

The tree view annotates non-public dependencies so you can see visibility at a glance — (private) deps are used internally, (sealed) deps are structural only, and public deps have no annotation:

Dependency tree with visibility

The flat view shows the effective visibility in its own column — this is the primary tool for debugging which dependencies actually contribute to the environment:

Flat view with visibility column
Surface selection: --self flag

The --self flag selects which surface ocx exec emits:

  • Off (default) — interface surface: only public and interface deps contribute env vars. This is what ocx exec, ocx env, ocx shell env, ocx shell profile load, ocx ci export, and ocx deps use when you invoke them directly.
  • --self — private surface: public and private deps contribute. Generated launchers embed --self automatically so a package's own binary runs with its full internal environment.

Pass --self to any env-consuming command. See Visibility Views for the full truth table and Env Composition for the complete algorithm.

Inspection

ocx deps shows the dependency tree for installed packages. The default view is a logical tree showing the declared relationships:

Inspecting the dependency tree

Use --flat to see the resolved evaluation order — the exact sequence ocx uses when composing environments. This is the primary debugging tool when environment variables are not what you expect:

Resolved dependency order

When a transitive dependency causes an unexpected conflict, --why traces the path from a root package to the dependency in question:

Tracing why a dependency is pulled in

Cleanup

Dependencies are protected from garbage collection as long as any package that depends on them is still referenced. When you uninstall a package, its dependencies do not disappear immediately — they remain in the package store until no other installed package depends on them. ocx clean removes only packages that have no references at all: no install symlinks and no dependent packages.

How dependency protection works

When ocx installs a package with dependencies, it records each dependency as a forward-ref inside the dependent package's refs/deps/ directory, pointing at the dependency's content/. Nothing is written into the dependency's own refs/symlinks/. ocx clean keeps a dependency alive as long as any reachable package has a matching refs/deps/ entry — the dependency is protected by the liveness of its dependents, not by a back-reference inside itself.

ocx clean processes the full dependency graph in a single pass: it walks refs/deps/, refs/layers/, and refs/blobs/ forward-refs from each root, marks everything reachable, and deletes what remains. Any dependency that loses its last dependent in the same sweep becomes unreachable and is collected alongside it. No manual intervention is needed.

Scope

ocx dependencies are deliberately simple — they solve a specific problem without introducing the complexity of a full-featured dependency resolver.

No version ranges. A dependency is pinned to an exact digest. There is no "find me any Java >= 21" logic. The publisher chose a specific build, tested against it, and recorded it. This is what makes the dependency graph fully reproducible: the metadata is the complete truth, regardless of what the registry contains today.

No automatic updates. When a dependency gets a security patch, the publisher must release a new version of their package with the updated digest. This is a deliberate tradeoff — reproducibility over convenience. Future tooling will help publishers detect when their pinned dependencies have newer builds available.

Not a project-level lockfile. Dependencies live in package metadata. They describe what a single package needs, not what a project needs. A project-level configuration file with version resolution and lock semantics is a separate concept for a future release.

How other tools compare

Nix takes the same approach: every derivation pins its inputs by hash, and the store is content-addressed. There are no version ranges — the exact input is determined at build time. Go modules use Minimum Version Selection with a go.sum integrity file — deterministic, but with a resolution algorithm. Homebrew uses floating name-only dependencies with no pinning at all.

ocx sits closest to Nix in philosophy — exact pins, no resolution — but without the functional language and the build system. The dependency declaration is a flat list of digests in a JSON file.

Configuration

OCX behavior is controlled at three layers: config files, environment variables, and CLI flags. Higher layers always win — CLI flags override environment variables, 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 or OCX_CONFIG for a fully hermetic CI load: OCX_NO_CONFIG=1 ocx --config ci.toml ....

See the Configuration reference for the full list of config keys, merge rules, examples, and error messages.

CI Integration

CI environments need tool binaries to be available and their environment variables exported — but they don't need version switching, candidate symlinks, or any of the install-store machinery that supports interactive use. OCX provides two commands tailored for this:

package pull downloads packages into the content-addressed package store without creating any symlinks. ci export then writes the package-declared environment variables directly into the CI system's runtime files.

shell
ocx package pull cmake:3.28
ocx ci export cmake:3.28

On GitHub Actions, ci export auto-detects the environment and appends PATH entries to $GITHUB_PATH and other variables to $GITHUB_ENV, making them available in all subsequent steps.

TIP

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, since content-addressed writes are inherently idempotent.

Relationship to install

install is package pull plus candidate-symlink creation (and optionally --select for the current symlink). In CI you typically don't need symlinks — the content-addressed package-store path that package pull reports is fully reproducible and digest-derived.