Skip to content
ocx
install

Environments

Every package declares a flat list of environment variables in its metadata.json. When you run ocx package exec or ocx package env against one or more root packages, the composer reads those declarations and builds a single runtime environment. This page explains how that environment is assembled. It also explains the single boolean, --self, that selects which surface of the environment is emitted.

Every package has two distinct environment surfaces:

  • Interface surface — what the outside world sees. The variables a consumer needs when depending on or running this package. Contributed to $PATH lookups, JAVA_HOME, library hints, and anything that crosses the package boundary outward.
  • Private surface — what the package’s own runtime needs. Internal compiler paths, runtime libraries, and shim infrastructure that a package’s own entry-point launchers use but should not leak to consumers.

The default ocx package exec PKG -- cmd selects the interface surface of PKG — the public contract. Passing --self selects the private surface — the full self-env including private-tagged entries.

Generated launchers always embed --self so the package’s own binary runs with its complete internal environment. You should never need to pass --self directly for interactive use.

A single boolean --self flag selects which environment surface is emitted by ocx package exec and its sibling commands. The default is off — the consumer view a human or script sees when invoking a package from the outside. Generated entry-point launchers embed --self so the launched binary sees the package’s full self env.

Selects the interface surface: only entries whose visibility.has_interface() is true are emitted — that is, public and interface entries. private entries are hidden.

Use it for:

  • ocx package exec PKG -- cmd direct invocations from a shell or CI script
  • ocx package env PKG to inspect what a package exposes to consumers
  • Any context where you are using a package’s output, not being the package

Selects the private surface: only entries whose visibility.has_private() is true are emitted — that is, private and public entries. interface entries are hidden, matching the publisher’s declared semantics (“interface = consumer-only, not for self runtime”).

private entries are visible in this mode. That is intentional. A package’s own launcher needs the internal paths (compilers, runtimes, shared libraries) that the publisher deliberately kept off the consumer surface.

Var visibility--self off (interface surface)--self on (private surface)
privateNoYes
publicYesYes
interfaceYesNo
sealed (rejected at parse)NoNo

sealed is not a valid value for env entries — it is rejected at metadata parse time. Only dependency-edge visibility uses all four levels.

CommandDefaultNotes
ocx package execoffOCI-tier entry point; launchers embed --self
ocx package envoffInspect the resolved env for one or more packages
ocx package testoffMaintainer preview of an unpublished package
ocx package depsoffShow dependency tree with visibility annotations

--self is package vocabulary and exists only on the OCI tier. The project-tier commands (ocx exec, ocx env) do not accept it. The self view leaves a package’s own entrypoints/ off PATH, and a project toolchain is a consumer of every package it declares. The flag would compose a strictly worse toolchain rather than a fuller one.

The composer iterates each root in the order supplied to the command. For each root, the composition sequence is:

  1. TC entries in topological order — transitive closure dependencies, deepest first (deps before dependents). The TC for each root is pre-computed at install time and stored in resolve.json. The composer reads it in one pass, with no recursive walk at exec time.
  2. Root’s own env-var declarations — emitted after all TC contributions so the root’s PATH entries prepend on top of its dependencies’ contributions.
  3. Root’s own entrypoints synth-PATH — if the package declares entrypoints, a synthetic PATH ⊳ <pkg-root>/entrypoints entry is appended last.

When multiple root packages are composed (e.g. ocx package exec pkg1 pkg2 -- cmd), a shared seen-set spans all roots. A package encountered as a transitive dep of both roots is emitted only once, the first time it is encountered.

The TC for a root assigns each transitive dep an effective visibility. It is the result of inductive composition along every path from root to that dep. Install time computes it via Visibility::through_edge and deduplicates it via Visibility::merge.

At exec time the composer applies a surface gate:

SurfacePredicatePasses
Interface (--self off, default)tc_entry.visibility.has_interface()public and interface
Private (--self on)tc_entry.visibility.has_private()public and private

A dep whose effective visibility from the root is sealed contributes nothing to either surface. A dep with effective visibility public contributes to both surfaces.

The gate applies only to whether a dep contributes. When a dep does contribute, only its own interface-tagged env entries (public and interface on the Var.visibility field) cross the dep edge into the consumer’s surface. A private entry of a dep never crosses an edge on its own. Its resolved value can cross, if one of the dep’s own interface-tagged vars references it via ${self.env.KEY}. That token reads a resolved value regardless of the referenced key’s own visibility. To keep a private var’s value genuinely internal, a dep publisher must not let any crossing var of the same package reference it.

Why does the dep contribute only its interface vars, not its full private surface?

The edge visibility (Visibility on the Dependency entry) controls whether a dep’s env is accessible at all from the root’s perspective. What arrives is always the dep’s outward-facing interface, its public and interface entries, and never its internal runtime environment. A private entry can still reach a consumer through a crossing entry’s ${self.env.KEY} value (see above). This matches the CMake model: a PUBLIC dependency forwards its INTERFACE headers to consumers, not its PRIVATE build flags.

A template fault anywhere in a package’s env can fail the whole composition, because every declared var is resolved before the surface gate runs. Self-Referencing Values has the resolve-then-gate rule.

Three composition types:

  • path entries (e.g. PATH, LD_LIBRARY_PATH) — prepended. Each dep’s bin/ directory is inserted before the existing value. Because root contributions come after TC entries, the root’s bin/ wins lookup over any transitive dep’s bin/.
  • constant entries (e.g. JAVA_HOME) — last-writer-wins. The last package in topological order that sets a constant variable determines the final value. Root is always last for its own env-var declarations. When two unrelated TC entries both set the same constant, the later one wins silently (see conflicting scalars).
  • list entries (e.g. JDK_JAVA_OPTIONS, GODEBUG) — appended, joined by the key’s agreed separator, with any earlier occurrence of the same contribution removed first. The iteration order is the same as for path (TC entries first, root’s own declarations last). The fold direction is the opposite: a dep’s contribution lands after the existing value instead of before it. The root’s own list declarations, applied last, therefore end up at the very end rather than the very front. Most option-list variables resolve duplicate options last-wins, so the root’s flags take effect over a dependency’s (Appending Option Lists documents the exceptions).

path and list entries fold in opposite directions because their consumers resolve duplicates differently. A PATH lookup takes the first match, so the latest contribution goes in front. An option-list consumer resolves duplicates last-wins: GODEBUG scans its setting list backward, and a repeated NODE_OPTIONS scalar flag takes the last occurrence. Ordering a list value like a path value would put the wrong contribution first for consumers like these.

Every fold, path or list, is a render and not accumulated state. The composer recomputes a variable’s value from the ambient value plus every applicable contribution, in application order:

[prepend-zone: vector reversed] [ambient value] [append-zone: vector order]

path contributions land ahead of the ambient value, most recently applied first (move-to-front). list contributions land after it, in the order applied (move-to-back). The last package or project stage to contribute a list value lands at the very end. Re-running the fold with a value already present removes it from its old position and re-appends it at the back. A repeated ocx exec, a re-evaluated .envrc or a launcher re-entry therefore never grows a list variable. path entries have the same move-to-front idempotence, mirrored for the opposite end.

A list entry’s separator is settled per key, not per entry, because a consumer parses the whole variable with one delimiter. The first entry that declares an explicit separator establishes it for that key. A later entry that omits separator inherits it. A key nobody gives an explicit separator falls back to a single space.

A package declaring GODEBUG with , plus a project [env] entry that omits the separator therefore inherits the comma, and GODEBUG stays parseable. The same package plus a project entry that explicitly writes ; is a conflict, not a silent second delimiter. The composition fails closed with exit 65, naming the key and both separators, Rust debug-quoted ("," and ";").

The agreement runs once every contributing entry is known (package-composed, patch-companion, project, group and --env alike). It sees the whole picture before any fold happens. Settling a separator also re-checks every entry’s value against it. A parse-time check can only compare a value to the separator that entry itself declared. A value edged by an inherited separator fails the composition with exit 65 even though its own parse-time check passed. The separator rule covers package metadata and the [env] value grammar covers ocx.toml.

A package’s env values can reference each other, not just ${installPath}. ${self.env.KEY} resolves to the resolved value of this package’s own earlier-declared KEY var. A computed path or value is named once and reused, instead of repeated in every value template that needs it.

{
"env": [
{ "key": "TOOL_HOME", "type": "constant", "value": "${self.installPath}/sdk" },
{ "key": "TOOL_CFG", "type": "constant", "value": "${self.env.TOOL_HOME}/etc" },
{ "key": "TOOL_BIN", "type": "path", "value": "${self.env.TOOL_HOME}/bin" }
]
}

TOOL_CFG and TOOL_BIN both build on TOOL_HOME without repeating ${self.installPath}/sdk. If the SDK subdirectory ever moves, one edit to TOOL_HOME propagates to every var built from it.

${self.env.KEY} may only reference a KEY declared strictly earlier in the same package’s env array. A forward or self reference, or a reference to a key declared twice, is refused at publish time. The self.env reference has the full resolution rules and the generator-order hazard. The token resolves to KEY’s resolved value, not its unexpanded template. It is legal only in env values and never in entry-point args. Interpolation Tokens has the grammar shared by both.

Resolution is surface-independent: it reads the same bytes regardless of KEY’s own visibility or which surface is being composed. That is why a template fault anywhere in a package’s env can fail the whole composition. A public var’s ${self.env.KEY} might name a private KEY. OCX therefore cannot resolve only the vars a surface is about to emit. Every declared var is resolved before the composer decides what crosses.

Three faults fail the whole composition with exit 65: a malformed template, a required path that does not exist on disk, and an unresolvable ${self.env.KEY} reference. The fault can sit on any var, including one that the surface in play will not emit. This holds for ocx env, ocx package env, ocx exec and ocx package exec alike, on either surface. A package’s own metadata either resolves in full or the composition refuses, independent of who is asking or which surface they asked for.

A project build must produce the same environment on every machine. If a project’s environment were filled in from the global toolchain, a collaborator without the same $OCX_HOME/ocx.toml would resolve different packages. OCX therefore draws a hard boundary: global packages never compose into, supplement, or fall back into a project’s resolved environment. The user guide gives the reasoning and the comparison with other tools.

The boundary applies without exception to two commands. ocx exec is the project-tier env-composition command: it reads ocx.toml and ocx.lock and never consults, merges or falls back to $OCX_HOME/ocx.toml. ocx package exec is the OCI-tier command: it never reads any ocx.toml and takes OCI identifiers directly. Both are hermetic with respect to the global set, so an undeclared package is absent and never filled in.

OCX enforces the boundary by PATH precedence, not by PATH stripping. The global toolchain’s binaries are on PATH from shell start. When a project toolchain is activated, through ocx exec or ocx direnv, the project’s binaries are prepended and shadow any global binary of the same name. Names the project does not declare stay reachable.

Isolation is a static consequence of PATH ordering. For ocx direnv, the .envrc evaluates ocx direnv export on every directory entry, which emits only the project’s PATH entries for direnv to prepend before the ambient PATH.

Every PATH prepend OCX emits is idempotent with move-to-front semantics. Re-applying the same output never grows PATH. Examples are direnv re-evaluating .envrc on each directory change, a captured snippet re-read from a profile, and a script re-running ocx env --shell. A directory already present is removed from its old position and placed at the front. The most recent activation therefore wins lookup, and the variable stays a fixed length.

The emitted shell statements are self-contained: they depend on no ocx process, no guard variable and no helper function. That makes them safe to capture into a profile.

Terminal window
ocx package env kitware/cmake --shell bash >> ~/.bashrc

Every later shell re-sources the block with ocx possibly absent, and the directory still lands exactly once, at the front. The same move-to-front dedup applies to the in-process environment of ocx exec and ocx package exec and to CI exports (--ci=github, --ci=gitlab).

All ten supported shells (bash, zsh, ash, ksh, dash, fish, PowerShell, elvish, nushell and Windows cmd) emit idempotent move-to-front output. The cmd form rebuilds PATH with %VAR:search=% substring deletion. It uses no FOR /F and no delayed expansion, so !-bearing paths stay intact. It matches segments case-insensitively, the way Windows PATH lookup does. Elvish needs the str: module (0.16+) and nushell the auto-list PATH conversion (0.101+).

ocx exec reads exactly two files, ocx.toml and its sibling ocx.lock. The resolved environment consists of the packages those files declare and no more. A package that is not in ocx.toml is not in the child environment, whatever is installed globally or on the parent shell’s PATH.

Naming a binding subset (ocx exec cmake -- …) narrows composition further. Only the named bindings are resolved to a host leaf and installed. A -g group selects the namespace for name resolution. It is not a mandate that every binding in it be available. An unrelated binding in scope with no leaf for the current host therefore does not block a narrowly-named run. Omit the names and the whole scope must resolve.

By default ocx exec inherits the spawning shell’s environment. It prepends the composed PATH entries and sets the other composed variables on top, so ambient parent-shell entries remain reachable after the project’s. The default is not hermetic; pass --clean for an environment that drops the inherited one and exposes only the composed package set.

One part of the spawning shell’s environment is not inherited: whatever shell integration applied to it. An explicit ocx exec names the environment it wants. The toolchain the per-prompt reconciler folded into your shell is therefore taken back out first. The __OCX_ENV_STATE ledger is the revert set, the same set the reconciler itself would revert on leaving the directory. Your own variables and PATH entries are untouched; only what OCX put there is removed, and only where the value is still the one OCX wrote. Without shell integration there is no ledger and nothing is removed. The same applies to ocx package exec, ocx package test and ocx patch test.

ocx package exec takes one or more OCI identifiers on the command line. It resolves each identifier, composes the declared environment variables of the resolved packages, and spawns the command with that environment. No ocx.toml is read, neither the project file nor the global file, so the operation is stateless with respect to project configuration.

Three packages: R (root), A (public dep of R), B (interface dep of A).

  • Rroot package
    • Avisibility: public
      • Bvisibility: interface

At install time, R’s resolve.json contains the TC:

{
"dependencies": [
{ "identifier": "ocx.sh/b:1@sha256:bbb...", "visibility": "public" },
{ "identifier": "ocx.sh/a:1@sha256:aaa...", "visibility": "public" }
]
}

B is listed first (dep before dependent, topological order). A’s edge is public, and B’s edge from A is interface — public.through_edge(interface) = public — so B’s effective visibility from R is public.

When you run ocx package exec R -- my-cmd (default, interface surface):

  1. B — visibility.has_interface() → true. Emit B’s public and interface env entries. B declares PATH += ${installPath}/bin (public). Entry emitted: PATH ⊳ ~/.ocx/packages/ocx.sh/b:1/.../content/bin.
  2. A — visibility.has_interface() → true. Emit A’s public and interface env entries. A declares PATH += ${installPath}/bin (public) and MY_TOOL_HOME = ${installPath} (public). Entries emitted.
  3. R’s own env — R declares PATH += ${installPath}/bin (public). Emitted last, so R’s bin/ prepends on top of A and B.

Final PATH (shown left-to-right in prepend order, last-prepended wins):

~/.ocx/.../R/content/bin : ~/.ocx/.../A/content/bin : ~/.ocx/.../B/content/bin : $original_PATH

R’s bin/ is found first in PATH lookup.

When two packages in the composition both declare a constant variable with the same key, the last-writer-wins rule determines the final value. The root package is always last for its own declarations, so it wins over any transitive dep.

A concrete case: a package P declares JAVA_HOME = ${installPath} as public, and its dependency J also declares JAVA_HOME = ${installPath} as public. In the standard TC walk, J is emitted before P (dep before dependent). The composition proceeds:

  1. J — emits JAVA_HOME = ~/.ocx/.../J/content (constant, last-write so far)
  2. P — emits JAVA_HOME = ~/.ocx/.../P/content (constant, overwrites J’s value)

Final value: ~/.ocx/.../P/content — P wins, because it appears later in topological order.

A generated launcher re-enters ocx with the package root baked in at install time. A running ocx also forwards its resolution-affecting settings to every ocx it spawns. Launcher Forwarding covers both.

Visibility decides what a consumer inherits, and --self decides what a package sees when it runs itself. Both answers depend on the whole dependency graph, so reading them off one metadata.json is not enough. ocx package inspect --closure and ocx inspect --closure compute them without installing anything: they walk the declared dependencies from cached or fetched metadata alone and project the result as two surfaces. A closure key in the JSON output is how a script tells that the closure ran. The resolution key reflects only whether the reference needed platform selection.

ocx package inspect adapts to the reference. A multi-platform tag lists the platform candidates with their child digests and no metadata. A single manifest, such as a flat tag or an @digest, emits the declared metadata and the layers. --resolve platform-selects through the index and adds the OCI resolution chain (index, manifest, config). --closure platform-selects the same way, so on an image index it never returns the candidates listing. It fails closed: if any dependency’s manifest or metadata cannot be loaded the whole request fails instead of rendering a partial closure.

interface is the consumer view, what a package depending on the reference inherits and what ocx package env composes for a downstream consumer. It admits the reference itself plus every dependency whose effective visibility reaches the interface axis. private is the self-execution view, what the reference runs with when it runs itself: its own bin/ plus every dependency reaching the private axis, as ocx package env --self composes.

The two surfaces overlap by design:

  • A public dependency reaches both axes and appears in both surfaces. A dependency reached only through a private edge appears in private only, and an interface-only dependency in interface only.
  • Entry points carry an implicit interface visibility. A launcher exists so a consumer can invoke the package, while the package’s own runtime bypasses its launchers and calls bin/ directly. The reference’s own entry points therefore appear under interface only, and ocx package env --self never puts its entrypoints/ on PATH. A dependency’s entry points cross the edge like any interface-side carrier. They appear on whichever surfaces admit the dependency, because they are how this package invokes that dependency.
  • Binaries carry an implicit public visibility, since raw executables serve consumers and the package’s own shims alike. The reference’s own binaries, like its public env vars, appear in both surfaces.
  • Env crossing is asymmetric, matching the composer. The reference’s own vars cross on the surface’s axis: interface and public vars on interface, private and public vars on private. A dependency contributes only its interface-side vars on either surface. A dependency’s own private var is that dependency’s internal detail and never crosses the edge.

Each surface carries four attributed arrays and a completeness flag. binaries and entrypoints list what lands on PATH on that axis, each as { name, package }. env lists the exposed keys as { key, kind, package }, with a separator only when kind is list, and omits the value. integrations lists the namespaces each admitted package declares. binaries_complete is false exactly when some admitted node on that axis left binaries undeclared. A declared-empty claim ("binaries": []) asserts zero binaries and keeps the aggregate complete, so an unknown claim never silently counts as zero.

closure.conflicts names the conditions detected over the interface projection. entrypoints means two or more packages claim the same entry-point name. repositories means one repository resolves to two or more distinct digests. Both arrays are always present, and empty means the surface is realizable. A non-empty conflicts exits 65 (DataError) while still reporting the condition in full. The payload is what a caller reads to act, and the exit code is what a pipeline branches on. 65 is the code install and compose return for the identical condition, so inspect --closure exits exactly where the corresponding ocx exec would.