- docs
- Reference
- Environment Composition
Environment Composition
How OCX assembles environment variables and selects the active toolchain tier. The reasoning behind each rule is in Environments and Toolchain activation.
Strict Isolation
Section titled “Strict Isolation”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.
| Command | Tier | Reads |
|---|---|---|
ocx exec | Project | ocx.toml and ocx.lock |
ocx package exec | OCI | OCI identifiers on the command line, never any ocx.toml |
See Strict isolation.
PATH precedence
Section titled “PATH precedence”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.
Idempotent re-application
Section titled “Idempotent re-application”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
ocxprocess, 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-listPATHconversion) cmd: rebuildsPATHwith%VAR:search=%substring deletion, with noFOR /Fand no delayed expansion, and matches segments case-insensitively- Also applies to: the in-process environment of
ocx execandocx package exec, and to CI exports (--ci=github,--ci=gitlab)
See Idempotent re-application.
Hermetic ocx exec
Section titled “Hermetic ocx exec”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_STATEledger. Your own variables andPATHentries are untouched. Without shell integration nothing is removed - Same revert:
ocx package exec,ocx package testandocx patch test
See Hermetic ocx exec.
Hermetic ocx package exec
Section titled “Hermetic ocx package 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.
Patch Opt-Out Scope
Section titled “Patch Opt-Out Scope”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.
| Invocation | Honors the opt-out | How |
|---|---|---|
ocx exec | Yes | Forwards the opted-out bases’ canonical registry/repository keys in OCX_PATCHES and the names it resolved in OCX_LAUNCH_IDENTITIES |
ocx env, ocx direnv export | Yes | Read the project config directly; they and the per-prompt reconciler mark the opted-out package in OCX_LAUNCH_IDENTITIES |
| Launcher re-entered from those chains | Yes | Matches the opt-out against the forwarded names |
Launcher run by absolute path or through ocx package exec | No | Decodes no forwarded opt-out and composes the companion overlay as if no-patches were never set |
See Per-package opt-out.
Tier Selection
Section titled “Tier Selection”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).
| Tier | How to activate | File |
|---|---|---|
| Project | CWD walk finds ocx.toml; or --project <path>; or OCX_PROJECT | nearest ocx.toml ancestor |
| Global | --global flag; or OCX_GLOBAL | $OCX_HOME/ocx.toml |
- CWD walk: stops at the first
ocx.tomlit 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.
Root --global commands
Section titled “Root --global commands”--global is a root flag and must appear before the subcommand, as in ocx --global add ripgrep:14. It affects these toolchain-tier commands:
| Command | With --global |
|---|---|
ocx add | Adds binding to the global file |
ocx remove | Removes binding from the global file |
ocx lock | Re-locks the global file |
ocx update | Advances a binding in the global file |
ocx pull | Pre-warms packages declared by the global file |
ocx exec | Composes env from the global file + its lock |
ocx env | Emits composed toolchain env for the global file |
Toolchain Activation
Section titled “Toolchain Activation”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.
The activate × pinned matrix
Section titled “The activate × pinned matrix”activate | pinned | What the project contributes to PATH | What a composing emitter composes | What ocx pull renders |
|---|---|---|---|---|
env | false | its composed entries — each package’s own PATH directories | through the links/<group>/<entry> links | the trampolines and the links |
env | true | its composed entries — each package’s own PATH directories | digest paths from ocx.lock, consulting no link | the trampolines only — the link pass writes and prunes nothing |
bin | false | <home>/toolchain/active/bin, and nothing else | through the links/<group>/<entry> links | the trampolines and the links |
bin | true | <home>/toolchain/active/bin, and nothing else | digest paths from ocx.lock, consulting no link | the trampolines only |
none | false | nothing | through the links/<group>/<entry> links | the trampolines and the links |
none | true | nothing | digest paths from ocx.lock, consulting no link | the trampolines only |
- Session directories:
$OCX_HOME/toolchain/active/binand OCX’s install directory behind it are onPATHin every row, includingnone - Composing emitters:
ocx env,ocx exec,ocx direnv export, theenv-mode reconciler and theenv-mode login exporter. Onlyocx envandocx execdeclare--pinned/--no-pinned env-mode reconciler and login exporter: read the global tier’sactivate, so underbinornoneneither 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
PATHcontributions 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-renderbingate:<home>/toolchain/active/binis 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` hereWithin one prompt the entries land in this order, front to back:
- The project’s composed entries
- The project’s
<home>/toolchain/active/bin $OCX_HOME/toolchain/active/bin- OCX’s own install directory
- 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.
Visibility Surfaces
Section titled “Visibility Surfaces”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.
--self | Surface emitted | Use case |
|---|---|---|
| off (default) | Interface surface — vars where has_interface() is true | Human or CI script using the package |
| on | Private surface — vars where has_private() is true | Generated launchers invoking ocx launcher exec internally |
- Launchers: generated launchers force
self_view = trueinternally and do not expose--selfto 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-Referencing Values
Section titled “Self-Referencing Values”${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:
KEYmust be declared strictly earlier in the sameenvarray. 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--selfis on - Failure: a malformed template, a
requiredpaththat 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 execandocx package execon either surface
See self.env and Interpolation Tokens.
Integrations
Section titled “Integrations”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.
Row Shape
Section titled “Row Shape”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 keypackage: the canonical resolved identifier of the declaring package. The tag may be absent, and a digest-pinned identifier is legalpayload: that package’s block, interpolated against that package’s own${installPath}, never the composing root’s- Filter:
.integrations[] | select(.namespace=="…")
Ordering
Section titled “Ordering”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
Interface Surface Only
Section titled “Interface Surface Only”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
privateedge contributes nothing on either surface
Patch Companions Contribute Too
Section titled “Patch Companions Contribute Too”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
envfollows the surfaces of the package it matched. Itsintegrationsstay interface-only, and the interface-surface rule is not evaluated per contributor - Declining:
no-patchesdrops an optional companion whole,envand integrations together. A required companion is required on the same terms as itsenv
Absent from --shell and --ci
Section titled “Absent from --shell and --ci”Neither --shell nor --ci output carries an integrations representation. Only --format json carries the array.
Composition Order
Section titled “Composition Order”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 execorder: first by group-selection order (the order of-gflags, afterallexpansion, deduplicated). Then alphabetical by binding name within each group
See In Depth: Project Toolchain → Composition order rule.
List Variables
Section titled “List Variables”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,
pathorlist, 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.envrcor a launcher re-entry therefore never grows alistvariable cmd.exe:ocx env --shell=cmdandocx package env --shell=cmdskip everylist-typed entry with a# ocx:note on stderr naming the key, and every other type still exportscmd.exescope: a text-export limitation only. The in-process environment thatocx execandocx package execbuild for a child process on Windows is unaffected
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.
Project Environment
Section titled “Project Environment”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
Precedence
Section titled “Precedence”| Stage | Source | Notes |
|---|---|---|
| 1 (lowest) | Ambient inherited env | Minus the shell reconciler’s own contribution (above); skipped entirely under --clean |
| 2 | Package-composed env | Composition order above — group-selection order, then alphabetical by binding name |
| 3 | Patch-companion overlay | [patches] — each companion var on the surfaces of the package it matched; applied after all package env, before stages 4-6 |
| 4 | Project [env] | Constants replace; path entries prepend; list entries append |
| 5 | Group [group.<name>.env] | In -g selection order — a group listed later wins |
| 6 (highest) | --env NAME or --env KEY[:TYPE[:SEP]]=VALUE | Repeatable; 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 |
pathacross stages: everypathapplication is idempotent with move-to-front semantics. So a stage 4, 5 or 6pathentry lands ahead of a stage 2 packagepathentry for the same keylistacross stages: a stage 4, 5 or 6listentry lands behind a stage 2 packagelistentry 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, neverwarn
Where --env lives
Section titled “Where --env lives”--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.
| Tier | Commands | Also available |
|---|---|---|
| Project toolchain | ocx 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 Xcomposes stages 1 to 6 exactly asocx exec --env Xdoes, andocx package envpairs withocx package execthe same way --clean: controls only stage 1, what the child inherits from the parent shell. Package env values come fromocx.lockand the resolved digests alone, with or without it- Forwarded under
--clean:OCX_HOMEandOCX_BINARY_PINalways, and every other resolution-affectingOCX_*setting whenever this invocation resolved one, includingOCX_DEFAULT_REGISTRY,OCX_INSECURE_REGISTRIES,OCX_NO_PROJECTandOCX_NO_CONFIG_REFRESH - Project
[env]: excluded from the lock’sdeclaration_hash, and it has no visibility axis because a project is never a dependency of anything --self: package vocabulary, absent from the project tier