Skip to content
ocx
install

Environment Composition

How OCX assembles environment variables and selects the active toolchain tier. The reasoning behind each rule is in Environments and Toolchain activation.

Global packages never compose into, supplement, or fall back into a project’s resolved environment. Both env-composition commands are hermetic with respect to the global toolchain: an undeclared package is absent and never filled in.

CommandTierReads
ocx execProjectocx.toml and ocx.lock
ocx package execOCIOCI identifiers on the command line, never any ocx.toml

See Strict isolation.

Isolation is PATH order and not PATH stripping: a project’s binaries are prepended ahead of the global toolchain’s and shadow same-named global binaries. ocx direnv export emits only the project’s PATH entries, which direnv prepends before the ambient PATH.

See PATH precedence.

Every PATH prepend OCX emits is idempotent with move-to-front semantics: a directory already present moves from its old position to the front. Re-applying the same output therefore never grows PATH.

  • Output: self-contained shell statements, with no dependency on an ocx process, a guard variable or a helper function
  • Shells: bash, zsh, ash, ksh, dash, fish, PowerShell, elvish, nushell, Windows cmd
  • Version floors: elvish 0.16+ (str: module), nushell 0.101+ (auto-list PATH conversion)
  • cmd: rebuilds PATH with %VAR:search=% substring deletion, with no FOR /F and no delayed expansion, and matches segments case-insensitively
  • Also applies to: the in-process environment of ocx exec and ocx package exec, and to CI exports (--ci=github, --ci=gitlab)

See Idempotent re-application.

ocx exec reads exactly two files, ocx.toml and its sibling ocx.lock, and composes only the packages they declare. By default it inherits the spawning shell’s environment, prepends the composed PATH entries and sets the other composed variables on top.

  • --clean: drops the inherited environment
  • Named bindings: ocx exec cmake -- … resolves only the named bindings to a host leaf. Omit the names and the whole scope must resolve
  • -g GROUP: selects the namespace for name resolution, not a mandate that every binding in it be available
  • Shell integration: whatever the per-prompt reconciler applied to the spawning shell is reverted first, using the __OCX_ENV_STATE ledger. Your own variables and PATH entries are untouched. Without shell integration nothing is removed
  • Same revert: ocx package exec, ocx package test and ocx patch test

See Hermetic ocx exec.

ocx package exec takes one or more OCI identifiers, resolves each, 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.

A project’s no-patches opt-out is read from ocx.toml, so it applies only where the project file is directly in scope. A system-required tier is never subject to it.

InvocationHonors the opt-outHow
ocx execYesForwards the opted-out bases’ canonical registry/repository keys in OCX_PATCHES and the names it resolved in OCX_LAUNCH_IDENTITIES
ocx env, ocx direnv exportYesRead the project config directly; they and the per-prompt reconciler mark the opted-out package in OCX_LAUNCH_IDENTITIES
Launcher re-entered from those chainsYesMatches the opt-out against the forwarded names
Launcher run by absolute path or through ocx package execNoDecodes no forwarded opt-out and composes the companion overlay as if no-patches were never set

See Per-package opt-out.

OCX has two toolchain tiers, and selection is always explicit: there is no implicit fallback from project to global. --global and --project are mutually exclusive, and combining them exits with code 64 (UsageError).

TierHow to activateFile
ProjectCWD walk finds ocx.toml; or --project <path>; or OCX_PROJECTnearest ocx.toml ancestor
Global--global flag; or OCX_GLOBAL$OCX_HOME/ocx.toml
  • CWD walk: stops at the first ocx.toml it meets. A nested project replaces the enclosing one, and two project files never layer
  • No home-tier discovery: a walk that finds nothing means no project tier is active, and the global toolchain is active only when requested

See Nested projects switch, they do not stack.

--global is a root flag and must appear before the subcommand, as in ocx --global add ripgrep:14. It affects these toolchain-tier commands:

CommandWith --global
ocx addAdds binding to the global file
ocx removeRemoves binding from the global file
ocx lockRe-locks the global file
ocx updateAdvances a binding in the global file
ocx pullPre-warms packages declared by the global file
ocx execComposes env from the global file + its lock
ocx envEmits composed toolchain env for the global file

Two ocx.toml keys decide how a selected toolchain reaches a shell, and both are read per tier from that tier’s own file. activate picks the shape, and pinned picks whether composed paths follow the rendered links/<group>/<entry> links or name digest paths from ocx.lock.

See Toolchain activation.

activatepinnedWhat the project contributes to PATHWhat a composing emitter composesWhat ocx pull renders
envfalseits composed entries — each package’s own PATH directoriesthrough the links/<group>/<entry> linksthe trampolines and the links
envtrueits composed entries — each package’s own PATH directoriesdigest paths from ocx.lock, consulting no linkthe trampolines only — the link pass writes and prunes nothing
binfalse<home>/toolchain/active/bin, and nothing elsethrough the links/<group>/<entry> linksthe trampolines and the links
bintrue<home>/toolchain/active/bin, and nothing elsedigest paths from ocx.lock, consulting no linkthe trampolines only
nonefalsenothingthrough the links/<group>/<entry> linksthe trampolines and the links
nonetruenothingdigest paths from ocx.lock, consulting no linkthe trampolines only
  • Session directories: $OCX_HOME/toolchain/active/bin and OCX’s install directory behind it are on PATH in every row, including none
  • Composing emitters: ocx env, ocx exec, ocx direnv export, the env-mode reconciler and the env-mode login exporter. Only ocx env and ocx exec declare --pinned / --no-pinned
  • env-mode reconciler and login exporter: read the global tier’s activate, so under bin or none neither composes at all
  • Link cells: a lock entry whose link is absent, stale or not a link composes on its digest path. Only roots have links, so a dependency’s PATH contributions and every ${deps.<name>.installPath} are digest paths in both lanes. See --pinned
  • pinned = true: suppresses the link pass without deleting a tree, so switching it on and off needs no re-render
  • bin gate: <home>/toolchain/active/bin is added only when the tree’s render stamp matches this project and this lock. Otherwise nothing is added and the prompt prints the line below
ocx: /home/dana/work/api: its toolchain has not been rendered for this lock; run `ocx pull` here

Within one prompt the entries land in this order, front to back:

  1. The project’s composed entries
  2. The project’s <home>/toolchain/active/bin
  3. $OCX_HOME/toolchain/active/bin
  4. OCX’s own install directory
  5. The global tier’s composed entries

See Order on PATH.

The trampoline body, its Windows sidecar and the name-collision rule are in A trampoline composes at call time and Name collisions.

Each package declares an interface surface (what consumers see) and a private surface (what its own launchers see). The --self flag on package env, package exec, package test and package deps selects the surface, and it is OCI-tier only.

--selfSurface emittedUse case
off (default)Interface surface — vars where has_interface() is trueHuman or CI script using the package
onPrivate surface — vars where has_private() is trueGenerated launchers invoking ocx launcher exec internally
  • Launchers: generated launchers force self_view = true internally and do not expose --self to callers
  • Patch companions: a companion’s env var is composed on the surfaces of the package it matches, read as if that package had declared it

See Companions are part of their target.

${self.env.KEY} resolves to the resolved value of this package’s own earlier-declared KEY var. It is legal only in env values, never in entry-point args.

  • Declaration order: KEY must be declared strictly earlier in the same env array. A forward or self reference, or a reference to a key declared twice, is refused at publish time
  • Resolves to: KEY’s resolved value, not its unexpanded template
  • Surface: independent of KEY’s own visibility and of whether --self is on
  • Failure: a malformed template, a required path that does not exist on disk, or an unresolvable ${self.env.KEY} fails the whole composition with exit 65. This applies to any var of the package, including one not emitted on the surface in play
  • Commands: the failure applies to ocx env, ocx package env, ocx exec and ocx package exec on either surface

See self.env and Interpolation Tokens.

Package metadata can declare vendor-namespaced integrations blocks, and composition carries them as attributed rows without interpreting them. Two packages that declare the same namespace produce two independent rows, and OCX never merges them.

See What composition does.

integrations is a fourth top-level array of the --format json envelope, beside items, binaries and entrypoints, and never nested inside items. The array is present, with attribution, even when composing a single package.

{
"items": [ … ],
"binaries": [ … ],
"entrypoints": [ … ],
"integrations": [
{
"namespace": "com.microsoft.vscode",
"package": "kitware/cmake:3.28@sha256:aaaa…",
"payload": { "extensions": ["rust-lang.rust-analyzer"] }
}
]
}
  • namespace: the declared key
  • package: the canonical resolved identifier of the declaring package. The tag may be absent, and a digest-pinned identifier is legal
  • payload: that package’s block, interpolated against that package’s own ${installPath}, never the composing root’s
  • Filter: .integrations[] | select(.namespace=="…")

Rows follow the admitted-set walk order of Composition Order: for each root, its admitted dependencies first in topological order, then the root itself. Roots keep the order the package set produced them in, and namespaces within one package’s contribution are ordered lexicographically.

  • Diamond: a dependency reached by two roots contributes once, at its first-seen position

Integrations reach only the interface surface, at every depth: --self always composes "integrations": [], including a generated launcher’s forced self view. An integrations block has no visibility field, unlike binaries and entrypoints, whose membership follows the two-axis visibility of each declared entry.

  • Private edge: a dependency reached only through a private edge contributes nothing on either surface

A patch companion declaring integrations contributes them like any package. The row’s package field names the companion’s own identifier and never the base it was admitted for.

  • Several bases: a companion matched against several bases contributes once
  • Surfaces: a companion’s env follows the surfaces of the package it matched. Its integrations stay interface-only, and the interface-surface rule is not evaluated per contributor
  • Declining: no-patches drops an optional companion whole, env and integrations together. A required companion is required on the same terms as its env

Neither --shell nor --ci output carries an integrations representation. Only --format json carries the array.

When several packages contribute to an environment, via ocx exec -g GROUP1,GROUP2 or ocx package exec PKG1 PKG2, path entries are prepended: the last package walked has its PATH entries placed first. In -g argument order, groups listed later win PATH lookup.

  • ocx exec order: first by group-selection order (the order of -g flags, after all expansion, deduplicated). Then alphabetical by binding name within each group

See In Depth: Project Toolchain → Composition order rule.

A list entry is appended and not prepended. Each contribution lands after the ambient value, so the last package or project stage to contribute lands at the very end.

[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 the ambient value, in the order applied (move-to-back), which is what a last-wins consumer resolves to
  • Render: every fold, path or list, is a render and not accumulated state
  • Idempotence: 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
  • cmd.exe: ocx env --shell=cmd and ocx package env --shell=cmd skip every list-typed entry with a # ocx: note on stderr naming the key, and every other type still exports
  • cmd.exe scope: a text-export limitation only. The in-process environment that ocx exec and ocx package exec build for a child process on Windows is unaffected

See Rendering list variables.

Two entries for one key with different explicit separators fail the composition with exit 65. See Separator agreement, separator is required and the [env] value grammar.

ocx.toml can declare its own environment on top of what packages provide. [env] sets project-wide constants, [group.<name>.env] sets group-scoped ones, and the --env flag sets a one-off override.

  • Materialization: project and group [env] entries materialize as ordinary env entries and are appended to the vector Composition Order produces

See Project and group [env].

StageSourceNotes
1 (lowest)Ambient inherited envMinus the shell reconciler’s own contribution (above); skipped entirely under --clean
2Package-composed envComposition order above — group-selection order, then alphabetical by binding name
3Patch-companion overlay[patches] — each companion var on the surfaces of the package it matched; applied after all package env, before stages 4-6
4Project [env]Constants replace; path entries prepend; list entries append
5Group [group.<name>.env]In -g selection order — a group listed later wins
6 (highest)--env NAME or --env KEY[:TYPE[:SEP]]=VALUERepeatable; constant (default) replaces, path prepends, list appends; a relative path value anchors to the current directory, not the project root stages 4-5 use. The bare NAME form carries the invoking process’s own value of NAME in at this stage, and contributes nothing when NAME is unset there
  • path across stages: every path application is idempotent with move-to-front semantics. So a stage 4, 5 or 6 path entry lands ahead of a stage 2 package path entry for the same key
  • list across stages: a stage 4, 5 or 6 list entry lands behind a stage 2 package list entry for the same key, in the same append zone. See List Variables
  • Shadowing: a project constant that shadows a package-declared constant of the same key logs at debug, never warn

--env is a per-invocation override and not project configuration, so it is available on every command that composes an environment, on both tiers. The package tier reads no ocx.toml, so stages 4 and 5 do not exist there.

TierCommandsAlso available
Project toolchainocx exec, ocx env, ocx direnv export-g/--group selects which groups’ [env] composes
Package (OCI)ocx package exec, ocx package env, ocx package test, ocx patch test--self selects the visibility surface
  • Export and execution agree: ocx env --env X composes stages 1 to 6 exactly as ocx exec --env X does, and ocx package env pairs with ocx package exec the same way
  • --clean: controls only stage 1, what the child inherits from the parent shell. Package env values come from ocx.lock and the resolved digests alone, with or without it
  • Forwarded under --clean: OCX_HOME and OCX_BINARY_PIN always, and every other resolution-affecting OCX_* setting whenever this invocation resolved one, including OCX_DEFAULT_REGISTRY, OCX_INSECURE_REGISTRIES, OCX_NO_PROJECT and OCX_NO_CONFIG_REFRESH
  • Project [env]: excluded from the lock’s declaration_hash, and it has no visibility axis because a project is never a dependency of anything
  • --self: package vocabulary, absent from the project tier