Skip to content
ocx
install

Run binaries from your project

Run a binary with exactly the tools ocx.lock pins, or export that environment to a shell or a CI job. Start with an ocx.toml and a current lock, and call each tool by its binding name instead of its OCI identifier.

ocx exec runs a command in the default group ([tools]) environment:

Terminal window
ocx init
ocx add "kitware/cmake:4.2.0"
ocx exec -- cmake --version

The argument shape is:

Terminal window
ocx exec [-g GROUP[,GROUP,...]]... [NAME...] -- ARGV...

-- is mandatory, and at least one token must follow it. Every token after -- is forwarded unchanged to the child. ocx exec cmake without -- exits 64. ocx exec -- echo hi with no NAME composes every binding in the default scope. Pass -g to scope to a named group:

Terminal window
ocx init
ocx add -g ci "astral-sh/uv:0.10.0"
ocx exec -g ci -- uv --version

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

Terminal window
ocx init
ocx add "kitware/cmake:4.2.0"
ocx add -g ci "astral-sh/uv:0.10.0"
ocx exec -g all -- cmake --version

-g all expands to [tools] plus every declared [group.*] before env composition. Groups listed later win PATH lookup. See Project Toolchain In Depth → Running binaries for the composition order.

The -g forms select these scopes:

InvocationScope
ocx exec -- CMDDefault group ([tools]) only
ocx exec -g ci -- CMD[group.ci] only
ocx exec -g ci,release -- CMD[group.ci] and [group.release]
ocx exec -g all -- CMD[tools] plus every declared [group.*]
ocx exec cmake -- CMDDefault scope, filtered to the cmake binding

Omitting -g means the default group, not everything. The word all is reserved. [group.all] in ocx.toml is rejected at parse time (exit 78), and ocx add --group all is rejected with exit 64.

When you only need a specific binding from the composed set, name it:

Terminal window
ocx init
ocx add "kitware/cmake:4.2.0"
# `ocx add` names the binding after the repository basename, so a two-segment
# identifier still binds as `cmake` — the binding name is what `ocx exec` takes.
ocx exec cmake -- cmake --version

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

ocx exec forwards the child’s exit code byte-for-byte. It sets its own code only when it fails before or while spawning the child:

CodeCondition
0The child exited 0.
1The spawn failed after the binary resolved.
64-- is missing, argv is empty, a -g segment is empty or unknown, no ocx.toml was found, or a name is unknown or ambiguous.
65ocx.lock is stale (run ocx lock), or the command did not resolve in the composed environment.
69The registry was unreachable during auto-install.
78ocx.lock is missing (run ocx lock), or ocx.toml has a parse error.
79A package was not found in the registry during auto-install.
80Authentication failed during auto-install.

The exit codes reference lists every code the CLI uses. Clap usage errors exit 64 instead of clap’s default 2.

Short rule: if you have an ocx.toml, use ocx exec. If you do not, use ocx package exec.

ocx package exec is the OCI-tier command. Its first argument is an OCI identifier such as node:20 or ocx.sh/kitware/cmake:3.28@sha256:…. It never reads ocx.toml or ocx.lock, so it behaves identically in every directory and beside any project file. That makes it the right primitive for GitHub Actions, Bazel rules and CI scripts that manage their own tool pins.

ocx exec is the project-tier command. Its symbols are binding names declared in ocx.toml, such as cmake or shellcheck. It resolves those names through ocx.lock and installs missing packages. It then composes the declared environment and spawns the child. A missing ocx.toml is a usage error (exit 64), not a fallback to OCI-tier behavior.

Both commands share these behaviors:

  • Install missing packages from the registry.
  • Compose and forward the package-declared environment.
  • Accept --clean to strip inherited shell state.
  • Accept --env KEY[:TYPE[:SEP]]=VALUE for a per-invocation override, or a bare --env NAME to carry one of the caller’s own variables through.
  • Forward the child’s exit code byte-for-byte.

Only ocx package exec accepts --self. That flag selects a package’s own private surface, which leaves the package’s entrypoints/ off PATH. Launchers exist for a consumer to invoke the package, while the package’s own runtime calls bin/ directly. A project toolchain is a consumer of every package it declares, so the self view would compose a strictly worse toolchain there.

The two commands cover complementary cases: running a package by its binding name (exec) and running it by its registry identity (package exec). See Project Toolchain In Depth → Running binaries for binary resolution and composition order, and Read the exit code for the codes.

By default, ocx exec and ocx env resolve a binding through the toolchain’s rendered links/<group>/<entry> link and do not name its package directly. That is a convenience. Run ocx update later, and the same on-disk path keeps resolving. The link’s target moves, and nothing that already reads through it has to recompose.

That convenience is a risk for a build that has to reproduce byte for byte. Suppose a release step records the binary path it resolved. It might write that path to a provenance log, or the artifact might embed an absolute toolchain path in its debug info. That record has to keep naming the one package it was built with. A teammate who runs ocx update in the same checkout for an unrelated binding must not make the recorded path answer with a different binary.

--pinned (or the equivalent pinned = true in ocx.toml) is the escape from the link. It composes the exact digest roots ocx.lock names at that moment and consults no links/<group>/<entry> link. Nothing is left on disk for a later ocx update to move.

# ocx.toml: this project always composes digest paths, whoever runs it.
pinned = true
[tools]
cmake = "ocx.sh/kitware/cmake:3.28"

ocx env is the exporter counterpart of ocx exec. It reads ocx.toml and ocx.lock (or the global toolchain under --global) and prints the composed environment instead of running a child. The default is an aligned table, --format json is a document, and --shell[=NAME] prints export lines. Only --shell is safe to eval, and the table and the JSON are not sourceable.

--shell needs the equals form, --shell=bash and not --shell bash, so an unquoted positional token cannot inject shell. A package missing from the object store is installed as part of composition, and one covered by a [[trust.policy]] is signature-verified first. --no-pull skips that fallback and keeps the command strictly offline. Unmaterialised packages are warned about on stderr and omitted, and nothing downloads.

The global tier is lenient. ocx --global env never fails on an unconfigured or corrupt global toolchain and exports an empty environment instead. That rule does not depend on --shell. A corrupt global lock surfaces through the commands that rewrite it, and the project tier stays strict. The installer’s env.sh and env.ps1 call ocx self activate on every shell start. That evaluates ocx --global env --shell=… only when the global toolchain’s activate is env. The nushell hook reads ocx --format json --global env. Global toolchain state cannot break either.

Terminal window
eval "$(ocx env --shell=bash)" # the current project toolchain
eval "$(ocx --global env --shell=sh)" # the global toolchain
ocx env --ci=github # persist to $GITHUB_ENV and $GITHUB_PATH
ocx env --ci=gitlab >> "${{ export_file }}"

--ci=gitlab writes JSON lines for the GitLab step runner’s ${{ export_file }}. That is an experimental path for run: keyword jobs and does not work with traditional script: jobs. CI Integration has a full step-runner example.