Skip to content

Command Line

General Options

The following options are available for all commands and must be specified before the command name.

--log-level

The log level for OCX, which can be set to one of the following values:

  • off: No logs will be emitted.
  • error: Only error messages will be emitted.
  • warn: Warning messages and error messages will be emitted. This is the default log level.
  • info: Informational messages, warning messages, and error messages will be emitted.
  • debug: Debug messages, informational messages, warning messages, and error messages will be emitted.
  • trace: All messages will be emitted, including trace messages.

--format

When set, ocx will output information in the specified format instead of plain text. Supported formats are:

  • plain (default): Human-readable plain text.
  • json: Machine-readable JSON format.

The available data depends on the command being executed.

Under --format json, a command that fails writes a single error envelope to stdout — schema_version, command, exit_code, and an error object — so a failure is parseable the same way a success is. This applies to every command, not only the ones documenting a detail discriminant table below. A command that already wrote its result document before failing keeps that document and emits no envelope, so stdout is never two concatenated JSON values. Under --quiet, success prints nothing and a failure still prints the envelope.

--json

Shorthand for --format json. Combining it with --format is not an error — the last one on the command line wins, in both directions. So ocx --json --format plain prints plain text, which keeps plain reachable when --json comes from a shell alias or wrapper script rather than from you.

--offline

Disables all network access for this invocation. Tag→digest resolution must be satisfied by the local index or by a digest-pinned identifier; unpinned tags missing from the local index error immediately rather than triggering a registry query. Useful for hermetic CI runs and air-gapped environments.

An unpinned tag that is absent from the local index exits 81 (PolicyBlocked) — the same code --frozen produces for the same class of miss. To recover, either run ocx index update online first, or switch to a digest-pinned identifier.

WARNING

ocx index update <pkg> writes the tag's dispatch object and root document into the local index, so a subsequent ocx --offline package install <pkg> can resolve the tool's per-platform digest with no network. The install itself still needs the actual manifest and layer archives, which the index does not carry — those are fetched into the package store only by an online ocx package install or ocx package pull. Run one of those online first if you need the binary itself available offline.

--remote, -r

Routes mutable lookups (tag list, catalog, tag→manifest resolution) to the remote registry instead of the local index. Pure-query commands (ocx index list, ocx index catalog, ocx package description pull) do not persist the result to the local index — to refresh it, run ocx index update explicitly. Implies network access.

Digest-addressed reads (manifests and layers already identified by a content digest) still consult the local index first and write newly fetched blobs through to $OCX_HOME/blobs/ — content-addressed data is immutable, so caching is safe regardless of mode.

Combining this flag with --offline is accepted as "pinned-only mode" — see Pinned-only mode below.

Pinned-only mode

Setting both --offline and --remote together produces a deliberately strict mode: no source contact, no local writes, and any tag-addressed resolution that cannot be satisfied locally errors instead of silently falling back. The CLI emits an info log to confirm the mode is active.

Use it in CI to assert every project dependency is digest-pinned:

sh
ocx --offline --remote exec -- my-build-script

If any tool resolution falls back to a floating tag, the command fails — a hermetic-build sanity check without round-tripping to the registry.

--frozen

Freezes tag→digest resolution to the local index. A tag already in the local index resolves from cache; a digest-pinned reference (repo@sha256:…, or a tag pinned by ocx.lock) still fetches its content over the network. But an unpinned tag that is missing from the local index errors with exit 81 instead of being fetched and recorded — --frozen guarantees that no unknown (un-pinned) version slips in. To resolve a new tag under --frozen, populate the local index first with ocx index update — run without the flag, because recording a new mapping is itself the discovery a freeze exists to refuse, so a frozen index update is rejected with exit 81.

The flag scopes to the package tier — the local index is the pin it freezes. Patches float by design, so a patch companion resolves live under --frozen exactly as it does without it, and pins in the patch tier's own state ($OCX_HOME/state/patch-companions/) rather than the index; freeze the patch tier deliberately with ocx patch freeze plus OCX_PATCH_SNAPSHOT. The managed-configuration tier is likewise unaffected: ocx config setup and ocx config update behave identically with and without the flag.

Unlike --offline, --frozen is not a network ban: it still reaches the registry for known and digest-pinned content. It only refuses to discover a new tag→digest mapping. Use it in CI to assert a project never installs a version that was not already locked or indexed.

sh
ocx --frozen pull                 # succeeds when every tool is already locked
ocx --frozen add some/tool:tag    # exit 81 if that tag is not in the index

--frozen conflicts with --remote (exit 64); the two are contradictory. Combining --frozen with --offline is accepted — offline is the stricter constraint and takes effect. The same policy can be set persistently via the OCX_FROZEN environment variable.

Cargo divergence

Cargo's --frozen implies --offline; OCX's --frozen does not disable the network — known and digest-pinned content still downloads. For cargo build --frozen semantics use --offline alone: offline is the stronger constraint and already refuses unpinned tags (adding --frozen is accepted but has no further effect).

--index

Override the path to the local index collection directory for this invocation. By default, ocx reads the local index from $OCX_HOME/index/ (typically ~/.ocx/index/) — a directory holding one subtree per source (ocx.sh/, ghcr.io/, …).

shell
ocx --index /path/to/bundled/index install cmake:3.28

This flag swaps the whole collection for a shipped one — never a partial overlay of the two. It is intended for environments where an index copy is bundled alongside a tool rather than living inside OCX_HOME — for example inside a GitHub Action, Bazel Rule, or DevContainer Feature that ships a frozen index copy as part of its release.

Under --remote, tag- and catalog-addressed lookups bypass the local index entirely and query the registry directly, so --index has no effect on those. Digest-addressed lookups — including the digest a resolved tag carries once pinned — still consult the redirected collection first in every mode, --remote included, so --index keeps mattering for anything already pinned by digest.

The same override can be set persistently via the OCX_INDEX environment variable. The --index flag takes precedence when both are set.

--quiet

Alias: -q.

Suppresses the structured stdout report that every command emits — tables in plain mode, the JSON document in --format json mode. Errors, warnings, and progress spinners continue to surface on stderr.

Quiet is opt-in and orthogonal to --format. Use it when calling ocx as a step in a larger pipeline that only cares about the exit code, or when chaining commands where intermediate output would clutter logs.

shell
# Pre-warm the project store in CI without dumping a table per package.
ocx --quiet pull

The same toggle is available via the OCX_QUIET environment variable; the flag wins when both are set.

--jobs

Caps the number of root packages pulled in parallel. Applies to every command that fans out through pull_allpackage install, pull, package pull, package exec (when it auto-installs missing tools), and the env-composition path of env.

The cap acts on the outer dispatch only: transitive dependencies and OCI layer extraction stay unbounded so a child pull never deadlocks waiting for a permit held by its own ancestor. Singleflight dedup and per-package file locks already protect the registry against duplicate work.

ValueMeaning
(unset)Unbounded. Every root package spawns immediately — legacy behavior.
0Use all logical cores (matches GNU parallel -j 0).
N > 0Cap at N concurrent root pulls.
NegativeRejected at parse time.

OCX intentionally diverges from Cargo on --jobs 0: GNU Parallel's "saturate this machine" convention is more useful in CI matrices where the runner has a variable CPU count and the user wants the cap computed for them.

The same value can be set persistently via OCX_JOBS. The CLI flag wins when both are set.

shell
# Cap parallelism on a constrained runner.
ocx --jobs 2 install cmake:3.28 ripgrep:14

--color

Controls when to use ANSI colors in output.

  • auto (default): Enable colors when stdout is a terminal and NO_COLOR is not set.
  • always: Always emit color codes, even when piped.
  • never: Disable all color output.

The --color flag takes the highest precedence over all color-related environment variables (NO_COLOR, CLICOLOR, CLICOLOR_FORCE).

--project

Path to the project-level ocx.toml (project-tier toolchain config) — or to the directory that holds it.

When set, OCX reads this as the project tier and skips the CWD walk entirely. A file is read directly, and any filename is accepted (not just ocx.toml), which is useful for fixtures and integration tests. A directory resolves to <dir>/ocx.toml — the spelling --project . uses, and the one every rendered toolchain trampoline bakes in when it re-enters as ocx --project '<project root>' exec.

The same override can be set persistently via the OCX_PROJECT environment variable. To disable project-file discovery entirely — including the OCX_PROJECT variable but not an explicit --project flag — set OCX_NO_PROJECT=1.

Symlink policy: Paths supplied via --project or OCX_PROJECT are trusted and followed through symlinks. Paths discovered by the CWD walk reject symlinks to prevent directory-traversal redirection.

Error cases: A path that does not exist at all exits with code 79 (NotFound). A directory that exists but holds no ocx.toml is not a missing file — it is no project — and exits with code 64 (UsageError), the same code ocx exec and its siblings use whenever they have no project to act on. A path that exists but cannot be read as configured — permission denied, or a candidate that is not a regular file (a device, a FIFO) — exits with code 74 (IoError).

--global, -g

Selects $OCX_HOME/ocx.toml (default ~/.ocx/ocx.toml) as the project file. This is a root flag — it must appear before the subcommand name (like --project or --offline), not after it.

sh
ocx --global add ripgrep:14      # correct
ocx -g add ripgrep:14            # same thing
ocx add --global ripgrep/ripgrep:14      # error: unknown flag

The short form is position-sensitive, and deliberately so: -g before the subcommand is --global, while -g after it is the --group selector of the toolchain-tier commands. ocx -g update -g ci updates the ci group of the global toolchain.

When --global is set, the following toolchain-tier commands target $OCX_HOME/ocx.toml instead of a discovered project file: add, remove, lock, update, pull, exec, and env. shell allow and shell revoke honour the same selector, and shell allow then refuses: the ocx home toolchain is always consented and never carries a consent stamp.

--global is mutually exclusive with --project. Passing both — whether as flags or via the OCX_GLOBAL / OCX_PROJECT environment variables — exits with code 64 (UsageError). The global toolchain never composes into project resolution; see strict isolation for the full hermetic contract.

No implicit home discovery

There is no implicit fallback to $OCX_HOME/ocx.toml when no project is found in the CWD walk. You must pass --global explicitly to target the global file. The prior automatic home-tier discovery has been removed.

Strict isolation

The global toolchain is a shell-convenience tier only. ocx exec and ocx package exec are always hermetic:

  • ocx exec without --global reads only the in-effect project file. The global file is never consulted.
  • ocx package exec reads no project file at all.

Neither command performs gap-fill from the global toolchain.

Environment variable

OCX_GLOBAL is the environment-variable equivalent. It is forwarded to child ocx processes the same way as other resolution-affecting flags.

--config

Path to an extra configuration file to load for this invocation.

shell
ocx --config /path/to/config.toml install cmake:3.28

The file layers on top of the discovered tier chain — it does not replace it. Settings in the specified file win over system, user, and $OCX_HOME/config.toml values, but the discovered tiers still load first. To suppress the discovered chain entirely, combine with OCX_NO_CONFIG=1.

The specified file must exist — a missing path is an error (exit code 79 / NotFound). This is different from the three discovered tiers, which silently skip missing files.

The same override can be set persistently via OCX_CONFIG. When both are set, the --config file sits at highest file-tier precedence and wins on conflicting scalars.

See the Configuration reference for the full precedence table, merge rules, and error messages.

Exit codes

OCX exposes a stable, typed exit-code taxonomy so scripts can discriminate failures without parsing stderr.

Most package tools return 0 on success and 1 on any failure. That forces downstream scripts to either ignore the error category or grep stderr — both are fragile. A CI wrapper cannot distinguish "registry unreachable, retry in 30 seconds" from "package not found, fail the build" without parsing error text that can change.

OCX aligns with BSD sysexits.h (codes 64–78) for the standard failure categories, and reserves 79–86 for OCX-specific cases. The numeric values are stable across releases — case $? works.

INFO

The sysexits.h convention originates in BSD Unix and is documented at man.freebsd.org. It assigns semantic meaning to exit codes 64–78, leaving 79–127 free for tool-specific use. OCX occupies 79–86.

CodeNameMnemonicWhen usedRecovery
0SuccessSuccessful completion
1FailureGeneric failure — only when no specific code appliesInspect stderr
64UsageErrorEX_USAGEBad CLI invocation: unknown flag, wrong argument count, invalid syntax; package verify given only one of --certificate-identity / --certificate-oidc-issuer, or given neither with no matching [[trust.policy]] scope; also a --fulcio-url/--rekor-url the SSRF guard refuses because it points at a forbidden address (loopback, private, link-local), whether spelled as an IP literal or resolved from a nameCheck the command syntax
65DataErrorEX_DATAERRInput data malformed: bad identifier, invalid digest, corrupted manifest, tampered Sigstore bundle; also a manifest fetch that got back something other than a manifest — an HTML page from a misconfigured mirror, for instance — refused by its content type before digest verification ever runs; also registry-served content whose digest does not match the descriptor; also a platform feature mismatch — the package ships for the host os/arch but no candidate's os.features are a subset of the host's (e.g. glibc vs musl), see --platform; also an ambiguous selection — a dual-libc host matched two equally-specific candidates (see libc differentiation); also a registry-controlled redirect or auth realm that OCX refuses to follow during push or pull — a session URL or redirect naming a different registry host, a plaintext credential realm, a redirect that would drop TLS, or a redirect on an upload request at all (see Redirect Refusals)Validate identifiers and file contents; for a mirror serving a non-manifest response, check the mirror's own health and its [mirrors] routing; for a feature mismatch or ambiguous selection, override with --platform; for a refused redirect or realm, see Redirect Refusals — it is a registry-side problem or a missing insecure entry, never one a rerun fixes
69UnavailableEX_UNAVAILABLEThe registry answered, but not usefully — and a rerun will not change that. Also a local resource that cannot be reached; also a guarded registry or Sigstore endpoint host that fails to resolve at all — a proxied destination is unaffected, since the configured proxy resolves it instead of OCX (see Proxies)Inspect stderr; fix the registry or the URL before retrying
74IoErrorEX_IOERRI/O error: filesystem permission denied, disk full, read/write failureCheck filesystem permissions and free space
75TempFailEX_TEMPFAILTemporary failure that may succeed on retry: registry connect failure or timeout, 429, 502, 503, 504, rate limit, transient network, or a layer blob that arrived short of its manifest-declared sizeRetry with backoff
77PermissionDeniedEX_NOPERMInsufficient permissions: filesystem EPERM, offline sign refused, OIDC pre-check failedAdjust filesystem permissions, or drop --offline to sign
78ConfigErrorEX_CONFIGConfiguration error: bad config file, missing required field, parse failure, trust root unavailable, a registry host the SSRF guard refuses outright (see trusted_hosts), a matched [[trust.policy]] entry is malformed. Three carve-outs from that last one, each keyed on what was actually unusable: an unreadable or non-regular key path is 74, a key file whose bytes are not a key is 65, and an unimplemented key backend is 85. An inline key_pem that is not a key stays here — config text is what is wrongInspect the config file at the printed path
79NotFoundOCXResource not found: package 404, explicit config path absent, no signatures found for targetPin a different version or correct the path
80AuthErrorOCXAuthentication failure: registry 401 or 403, missing credentials, Fulcio OIDC token rejectedRefresh or set registry credentials
81PolicyBlockedOCXA deliberate local policy (--offline or --frozen) refused a network or resolution operation — not a fault. Includes an unpinned-tag resolve that the policy forbadeLoosen the flag, or populate the local index first with ocx index update — itself run without the flag
82DirtyRcBlockOCXA managed shell-integration block carried user edits and ocx self setup ran without --force; the block was left untouched. Distinct from ConfigError (78): the content is valid but intentionally user-modifiedRe-run with --force, or edit the block manually and re-run
83TransparencyLogUnavailableOCXRekor transparency log unreachable during sign or verify (5xx/timeout, or SET absent with only TSA present)Retry later; check Rekor endpoint
84ReferrersUnsupportedOCXRegistry cannot hold a referrer — sign and attest also try the tag-schema fallback index first; push and copy --referrers require the API itself. Write path only; the read path lands on 79 insteadUse a registry with OCI 1.1 referrers support
85UnsupportedKeyBackendOCXA key reference named a KMS backend OCX recognises but has not implemented (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Reachable from --key, from a key = "…" signer in a matched [[trust.policy]], and from a managed-config payload carrying one. Distinct from 79 and 74: the reference is well-formed and the backend is real, it simply has no implementation hereUse a file key, or wait for the backend
86ForgeCapabilityUnavailableOCXA forge answered and refused a write because the instance or the target project lacks a capability the selected --transport needs — job-token pushes disabled on the index project, or the publishing project missing from that project's job-token allowlist. Distinct from 69 (the forge was reached), from 80 (the credential is valid and is not what was refused) and from 81 (the remedy is never in the caller's own hands). Sibling of 84: reachable, but a needed capability is absent with no fallbackAsk an administrator of the index project to enable job-token pushes and allowlist the publishing project, or announce over --transport api with an access token

75 means the same command may succeed if run again; 69 does not. That distinction is what makes automated retry safe: a wrapper loops on 75 and stops on 69, without parsing a single line of stderr. The per-command tables below still name 69 as "registry unreachable" — those rows exit 75 instead whenever the failure is transient (the connect never completed, the request timed out, or the registry answered 429/502/503/504).

Scripts can case $? on these stable values:

shell
ocx package install kitware/cmake:3.28
case $? in
    0)  echo "installed" ;;
    64) echo "usage error; check flags" ;;
    69) echo "registry answered badly; a rerun will not help" ;;
    75) echo "transient failure; retry with backoff" ;;
    78) echo "bad config; inspect the config file" ;;
    79) echo "not found; pin a different version" ;;
    80) echo "auth failed; refresh credentials" ;;
    81) echo "policy blocked (offline/frozen); loosen the flag or update the index" ;;
    82) echo "managed shell rc block left dirty; rerun with --force" ;;
    83) echo "Rekor unavailable; retry signing or verification later" ;;
    84) echo "publish couldn't write a referrer; registry serves no referrers store" ;;
    85) echo "key backend recognised but not implemented; use a file key" ;;
    86) echo "forge lacks a capability this transport needs; an admin must act" ;;
    *)  echo "unexpected failure (exit $?)"; exit 1 ;;
esac

--candidate / --current

The --candidate and --current flags are available on commands that resolve a package's location on disk, for example package env, package which, or package exec.

Every mode returns a package root — the directory that contains the package's content/ and entrypoints/ subdirectories alongside metadata.json, manifest.json, and the other per-package files. The mode controls only the shape of the path that names that root.

By default these commands return the content-addressed path in the object store — a hash-derived directory that changes whenever the package is reinstalled at a different version. Use --candidate or --current to resolve via a stable install symlink instead, whose path never changes regardless of the underlying object. This is useful for paths embedded in editor configs, Makefiles, or shell profiles that should survive package updates.

ModeFlagPath returned
Object store (default)(none)~/.ocx/packages/…/{digest}/
Candidate symlink--candidate~/.ocx/symlinks/…/candidates/{tag}
Current symlink--current~/.ocx/symlinks/…/current

All three paths name the same package root: the install symlinks target the object-store package directory directly. Consumers that need installed files traverse into <root>/content/, launcher consumers traverse into <root>/entrypoints/, and metadata readers open <root>/metadata.json.

Constraints

  • --candidate: the package must already be installed. Digest identifiers are rejected — use a tag identifier.
  • --current: a version must be selected first (via select or install --select). Digest identifiers are rejected. The tag portion of the identifier is ignored — only registry and repository are used to locate the symlink.
  • --candidate and --current are mutually exclusive.

--lazy-mode

Available on the seven commands that compose or pre-warm an environment: env, exec, pull, direnv export, package env, package exec, and package which. Not available on package install or package select — those are the only two commands that write the candidate/current symlink namespace, and a symlink must never point at a shim directory.

Controls when a declared tool's content downloads: now, or on first use.

ValueBehavior
never (default)Compose eagerly — content is materialized before the tool reaches PATH.
alwaysCompose a shim — the tool's declared names are on PATH immediately; content downloads the first time one of those names runs.

--lazy-mode is the top tier of a five-level resolution ladder, most specific first:

TierSource
1--lazy-mode on the invoked command
2[package."<id>"] in ocx.toml
3[group.<name>] in ocx.toml
4The toolchain-level lazy-mode key in ocx.toml
5OCX_LAZY_MODE
Floor: never

An omitted flag leaves the CLI tier absent, letting the more general tiers speak — it never means never. See Deferred Tools for the full lifecycle, and ocx package which below for how a deferred tool reports its on-disk kind.

Windows defers like every other platform

lazy-mode carries no platform floor. A tool the ladder resolves to always is deferred on Windows exactly as it is on Linux and macOS. The only difference is the shape of the slot: a deferred name occupies two files there — <name>.exe, a hardlink to the shared launcher shim, plus a <name>.shimref sidecar carrying the pinned identifier the shim reads back. See Deferred Tools.

--lazy-report

Controls whether a deferred tool's first-invocation download renders progress. Declared on exactly one subcommand in the whole CLI — the hidden ocx launcher shim verb that a generated shim launcher execs into, never one a user types directly.

ValueBehavior
silent (default)No progress channel is opened.
progressRender progress on the controlling terminal; falls back to silent where none is reachable (a Docker build, a CI runner, anything under setsid).

It cannot be a flag on any of the seven composing commands above: the process that renders it is a separate one, spawned by the generated launcher long after the composing command exec'd away, so a value given at compose time has no route to the process that would use it. It resolves instead through its own four-tier ladder — one tier shorter than --lazy-mode's, since there is no group to consult once composition is over:

TierSource
1--lazy-report (on ocx launcher shim only)
2[package."<id>"] in ocx.toml
3The toolchain-level lazy-report key in ocx.toml
4OCX_LAZY_REPORT
Floor: silent

See Deferred Tools for why lazy-report has no [group.<name>] tier.

--pinned, --no-pinned

Declared on ocx env and ocx exec, and on no other command. The pair picks which of two lanes the composed paths take: the digest roots ocx.lock pins, or the rendered links/<group>/<entry> links that point at them.

Declaring the flag and composing a toolchain are two different lists. Five emitters compose a toolchain, and each one takes a lane: ocx env, ocx exec, ocx direnv export, the env-mode shell hook, and the global login exporter ocx self setup writes into your shell profile. The three that declare no flag resolve the lane from the lower tiers of the ladder alone.

The two lanes agree until the lock moves. An environment composed from links keeps a root's paths working across the next ocx update: the same path resolves to the new package root, with nothing to re-compose. One composed from digest paths names the exact packages the lock pinned at compose time, so an update does not reach it. Following the links is the default.

FlagComposed paths
--pinnedThe digest roots ocx.lock pins right now. No links/<group>/<entry> link is consulted, and the rendered tree is never read — the lane is decided before any filesystem call.
--no-pinnedThe rendered links/<group>/<entry> links, so a later ocx update reaches a root with no re-render. This is the default.

The difference is visible in the composed values themselves — the same toolchain, once through its rendered links and once through the digest roots the lock pins:

Toolchain links versus digest paths
sh
# Follow the links (the default) — a later `ocx update` is picked up.
ocx exec -- cmake --version

# Name the locked digests instead, for a build that must not move underneath it.
ocx exec --pinned -- cmake --version

Neither flag takes a value: --pinned=true is a usage error (exit 64), not a parsed true. Passing both is not an error — the pair is last-wins, the git --[no-]verify idiom — so ocx exec --pinned --no-pinned follows the links and ocx exec --no-pinned --pinned pins.

A link that is not there degrades that one entry

The link lane is decided per entry, and it never fails. An entry composes on its digest path instead whenever its links/<group>/<entry> link is absent, is not a link at all, or points somewhere other than the digest root ocx.lock derives for this host — and the other entries in the same composition still compose through their links. Two conditions degrade the whole composition the same way: a toolchain home the symlink guards refuse, and a link probe that cannot complete. Nothing is printed, and no exit code changes.

A digest path is correct when it appears. It names the same package directory the link would have named — the two spellings are one directory, not a good result and a degraded one. The single property it does not carry is the one the link lane exists for: it does not follow a later ocx update.

One consequence is worth naming, because it is the one that surprises: two machines on the same ocx.lock can emit different spellings for the same entry — one the link, one the digest root — and that is expected. Compare what the paths resolve to, never the strings.

Only roots take the link lane

A links/<group>/<entry> link points at a lock entry — a tool named in [tools] or a [group.<name>] table — and at nothing else. So a lock entry is the only thing the link lane can move. Everything a dependency contributes is a digest path in both lanes: the PATH directories a dependency adds to the composition, and every ${deps.<name>.installPath} a package's [env] dereferences.

The "no re-render" property is therefore a property of roots. After an ocx update, an already-composed environment picks the new root up through the root's link, while its dependency-contributed paths still name the packages the previous lock pinned, until something composes the environment again.

Resolution order

The flag is the top tier of a three-level ladder, most specific first:

TierSource
1--pinned / --no-pinned on the invoked command
2The toolchain-level pinned key in ocx.toml
3OCX_TOOLCHAIN_PINNED
Floor: follow the links

OCX_TOOLCHAIN_PINNED is the weakest tier, not an override. It sits below ocx.toml, so an exported value loses to a project that states the key; the variable decides only for a project that states none. Omitting both flags leaves the CLI tier absent so the lower tiers can speak — it never means --no-pinned, and an explicit --no-pinned is how you override an ocx.toml that asked for digest paths.

toml
# ocx.toml — this project composes digest paths whatever the shell exports.
pinned = true

[tools]
cmake = "ocx.sh/kitware/cmake:3.28"

Not on the package tier

ocx package env and ocx package exec reject both flags as an unknown argument (exit 64). A package composition has no toolchain tree, so it has no link lane to choose between; accepting the flag as a silent no-op there would read as though it had chosen one.

Not on ocx pull

ocx pull declares neither flag, deliberately. pull renders the links/<group>/<entry> links rather than composing from them, and this setting selects the lane a composition takes — a flag on pull would be inert on the tree the command writes. The lower tiers still resolve: pull reads the ocx.toml key, then OCX_TOOLCHAIN_PINNED, and hands the result to the render.

Commands

add

Appends a tool binding to the nearest ocx.toml, resolves its digest into ocx.lock, and installs the package in one step.

The command locates the project ocx.toml by walking the directory tree from the current working directory upward (same discovery as ocx lock and ocx pull). It fails with exit code 64 if no ocx.toml is found — it does not scaffold one implicitly. To create a project file first, run ocx init.

After mutating ocx.toml, ocx add resolves only the new bindings and carries every existing lock entry forward unchanged, then installs the newly added tools.

Multiple identifiers may be given in one invocation. They are staged together and committed atomically — if any identifier is invalid or its binding name already exists, nothing is written. --group applies to every identifier in the batch.

The same binding name may coexist in the default [tools] table and in any named [group.*] table — binding identity is (group, name). This lets a project carry different versions of the same tool in different contexts:

shell
ocx add shfmt/shfmt:3.13              # adds to default [tools]
ocx add --group ci shfmt/shfmt:3.13   # also legal — coexists in [group.ci]

Usage

shell
ocx add [OPTIONS] <[NAME=]IDENTIFIER>...

Arguments

  • <[NAME=]IDENTIFIER>...: One or more fully-qualified tool identifiers to add (e.g. ocx.sh/kitware/cmake:3.28 or ghcr.io/acme/mytool:1.0). Bare identifiers without a tag (e.g. ocx.sh/kitware/cmake) default to :latest — the written ocx.toml entry is always explicit (cmake = "ocx.sh/kitware/cmake:latest"), following the same convention as docker pull. See the bare-identifier default for the design rationale. Prefix an identifier with NAME= to bind it under an explicit key instead of the derived repository basename — see Binding names below.

Options

FlagShortDescription
--group <NAME>-gAdd the binding to a named group instead of the default [tools] table. Must be non-empty and contain only alphanumeric characters, -, or _.
--pullAfter writing the lock, materialise the newly added tool into the object store and create its candidate symlink. Default when --no-pull is absent.
--no-pullWrite the lock only; skip materialisation. Defer the install to a later ocx pull or first ocx exec.
--platform <PLATFORM>-pMaterialise the leaf for the named platform instead of the host — see Platforms for the grammar. Single-valued: passing more than one exits 64. The lock already pins every shipped platform's leaf, so this only selects which to fetch — the lock stays host-agnostic (an amd64 host can pre-warm an arm64 leaf). Defaults to the current host. A platform the publisher does not ship exits 78.
--help-hPrint help information.

Target the global toolchain

Pass --global before the subcommand to target $OCX_HOME/ocx.toml: ocx --global add ripgrep:14. See --global for the full root-flag reference.

Exit codes

CodeMeaning
0Binding added, lock updated, tool installed.
1The in-place ocx.toml edit could not be expressed safely (rare); the command aborts rather than falling back to a lossy rewrite.
64No ocx.toml found, binding already exists, invalid --group name, invalid binding NAME, --global combined with --project, or more than one --platform value (single-valued flag).
65ocx.toml drifted from ocx.lock before this add — run ocx lock to reconcile.
69Registry unreachable while resolving the new tag.
74I/O error reading or writing ocx.toml or ocx.lock.
75Another ocx process holds the project lock on ocx.toml, or a transient registry failure (connect failure, timeout, 429/502/503/504) survived the resolve retries. Retry with backoff.
78ocx.lock uses an unsupported version — V1 and V2 locks are rejected; regenerate with ocx lock. Also: ocx.toml schema invalid or TOML parse error, or a requested --platform is not shipped by a tool.
79Tag not found in the registry.
80Authentication failure against the registry.

Binding names

Without NAME=, the binding key is the repository basename — ocx add ocx.sh/kitware/cmake:3.28 binds under cmake. Two tools that share a basename in different namespaces collide under that default: ocx.sh/gitlab/cli and ocx.sh/github/cli both derive to cli, so adding the second fails with "binding already exists".

Prefix either identifier with an explicit NAME= to bind it under a distinct key instead:

shell
ocx add gh=ocx.sh/github/cli:2.40
ocx add glab=ocx.sh/gitlab/cli:1.30

Both tools now coexist under their own keys — ocx exec gh, ocx exec glab, ocx remove glab, and the ocx.lock entry all key on the name you gave, not the repository path. NAME must be non-empty and contain only [A-Za-z0-9._-]; an invalid name exits 64.

clean

Removes unreferenced objects from the local object store.

An object is unreferenced when nothing points to it — no candidate or current symlink, no other installed package depends on it, and no registered project's ocx.lock pins it. Projects are registered in the $OCX_HOME/projects/ ledger (a flat directory of symlinks, one per project; created automatically when ocx lock or ocx add writes a lockfile). This happens after uninstall (without --purge) or when symlinks are removed manually. When a package with dependencies is removed, its dependencies may become unreferenced and are cleaned up in the same pass.

DANGER

Do not run clean concurrently with other OCX commands. A concurrent install may reference an object that clean is about to remove, causing the install to fail.

The rendered toolchain tree is outside this graph entirely. <home>/toolchain/ holds shells/default/bin trampolines and links/<group>/<entry> links — pure derived state that ocx pull rewrites from ocx.lock — so clean never walks it and never collects from it. The packages those links point at are ordinary object-store entries, held live by the project's own ocx.lock through the $OCX_HOME/projects/ ledger like every other pinned tool.

Usage

shell
ocx clean [OPTIONS]

Options

NameShortDescriptionDefault
--dry-runShow what would be removed without making any changes.false
--forceBypass the $OCX_HOME/projects/ ledger and collect packages held only by other projects' ocx.lock files. Live install symlinks are still honoured.false
--help-hPrint help information.

JSON output schema (--format json)

ocx --format json clean emits an array of objects, one per candidate entry:

FieldTypeDescription
kind"object" | "temp"Storage tier of the entry.
dry_runbooleantrue when --dry-run was passed; false on a live run.
pathstringAbsolute path to the package or temp directory.
held_byarray of stringsAbsolute paths to project directories whose ocx.lock pins this package. Populated only in dry-run mode, only for entries the ledger retained (never collected). Empty array when nothing holds the entry.
json
[
  {
    "kind": "object",
    "dry_run": true,
    "path": "/home/alice/.ocx/packages/.../sha256/ab/cdef.../",
    "held_by": ["/home/alice/dev/proj-a"]
  },
  {
    "kind": "object",
    "dry_run": true,
    "path": "/home/alice/.ocx/packages/.../sha256/12/3456.../",
    "held_by": []
  }
]

Plain output

Dry-run output is a table. When any entry has a non-empty held_by, the table gains a Held By column:

Type    Held By                     Path
object  /home/alice/dev/proj-a      /home/alice/.ocx/packages/.../
object                              /home/alice/.ocx/packages/.../
temp                                /home/alice/.ocx/temp/abc.../

A blank Held By cell means the entry is unreferenced and will be collected. A populated cell lists the project directory (or directories) holding the package. The Held By column is omitted when no entries are held. temp entries are never governed by the ledger and never show a Held By value.

Non-dry-run output is always 2-column (Type | Path): held entries are never collected and therefore never appear.

Consent-stamp sweep. Every ocx clean run also removes the per-project consent stamp for any project whose directory no longer exists on disk — the one exception to state/ otherwise being outside clean's reach. A stamp is swept only when its own recorded project_dir is confirmed absent (a dangling symlink where the directory used to be still counts as present, and is retained); an unreadable, malformed, or unrecognised-version stamp is retained too. This runs on every invocation, --dry-run and --force included, and is independent of --force's effect on the object-store scan — the sweep never consults the $OCX_HOME/projects/ ledger --force bypasses. The one case that skips it is a run whose object-store liveness picture is already untrustworthy, which retains everything including consent stamps and defers to the next healthy run.

Swept stamps are not currently reported: neither the JSON array (which carries no consent-kind entry) nor the plain-table output nor any diagnostic line names what was removed. A moved or temporarily unreachable project directory can therefore lose its consent silently — re-running one of the consent-writing commands against it re-stamps.

deps (package-tier — ocx package deps)

Shows the dependency tree for one or more installed packages. Operates on locally-present packages only — no auto-install. This is an OCI-tier command under the ocx package group — it operates on OCI identifiers and never consults ocx.toml. See Dependencies in the user guide for background.

deps never resolves any package's env values — it walks structural metadata only, so a package whose env references an undeclared dependency, or that carries any other unresolvable interpolation token, still appears in the tree unchanged. Only two things drop a package from the output, each logged at warn naming the package: metadata.json that fails to parse or fails schema validation, and a declared env-var modifier type this ocx does not recognize (a newer publisher, not a broken install).

Usage

shell
ocx package deps [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to inspect. Accepts multiple packages — when given more than one, the command builds the combined dependency graph (the same graph package exec uses for environment composition).

Options

  • --flat: Show the resolved evaluation order instead of the tree. This is the exact order package exec and env use for environment composition — useful for debugging unexpected variable values.
  • --why <DEP>: Explain why a dependency is pulled in. Shows all paths from the given root packages to <DEP>. Mutually exclusive with --flat.
  • --depth <N>: Limit tree depth. --depth 1 shows direct dependencies only.
  • -p, --platform: Target platform to consider when resolving packages.
  • --self: Use the self view (mask Visibility::PRIVATE) — emits private and public entries (everything publisher marked for own runtime). Default off = consumer view (mask Visibility::INTERFACE) emits public and interface. See Visibility Views.
  • -h, --help: Print help information.

Default output is a logical tree showing declared dependencies. Diamond dependencies (the same package reached via multiple paths) are marked with (*) and their subtree is not expanded again:

myapp:1.0 (sha256:aaa1b2c3…)
├── ocx.sh/java:21 (sha256:bbb4e5f6…)
└── ocx.sh/kitware/cmake:3.28 (sha256:ccc7d8e9…)
    └── ocx.sh/gcc:13 (sha256:ddd0a1b2…)

--flat shows the combined evaluation order after topological sort and deduplication:

Package            Digest
ocx.sh/gcc:13      sha256:ddd0a1b2…
ocx.sh/kitware/cmake:3.28  sha256:ccc7d8e9…
ocx.sh/java:21     sha256:bbb4e5f6…
myapp:1.0          sha256:aaa1b2c3…

--why traces all paths from roots to a specific dependency:

myapp:1.0 → ocx.sh/kitware/cmake:3.28 → ocx.sh/gcc:13

deselect

Moved to ocx package deselect — exits 64 if invoked as bare ocx deselect. See package deselect for the current form.

Removes the current-version symlink for one or more packages.

The package is deselected but not uninstalled: its candidate symlink and object-store content remain intact. To also remove the installed files, use package uninstall.

When the deselected package declares entry points, the launchers stop being reachable through current/entrypoints/ as soon as the current symlink is removed. The symlink removal is idempotent — an already-absent link is not an error.

Usage

shell
ocx package deselect <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to deselect.

Options

  • -h, --help: Print help information.

env (root — toolchain-tier)

Export the composed toolchain environment for the active project or global toolchain.

This is the toolchain-tier env exporter. It reads ocx.toml + ocx.lock and emits the combined environment for the resolved tool set. Output format is controlled by the root --format flag (default: plain table). Use --shell to get eval-safe shell export lines — that is the only form safe to pass to eval.

With --format json, the document carries binaries/entrypoints/integrations sibling arrays alongside entries, plus an advisories array for any deferred tool in the composition — see package env's JSON shape for the full field reference; both commands report through the same envelope.

A tool missing from the local object store is auto-installed as part of composition. Because it auto-installs, a tool covered by a [[trust.policy]] is signature-verified first — the same gate as package install (see its auto-verify contract). No --verify/--no-verify flag here; opt out via OCX_NO_VERIFY.

--shell requires the equals-form (--shell=bash, not --shell bash) to prevent shell injection through unquoted positional tokens.

Usage

shell
ocx env [OPTIONS]

Options

FlagShortDescriptionDefault
--group <NAME>-gScope env composition to the named group(s). Repeatable and comma-separated (-g ci,lint -g release). default selects [tools]; all expands to default + every declared [group.*]. An unknown group exits 64 in the project tier; the global tier is lenient (matches nothing, empty env).[tools] only
--shell[=NAME]Emit eval-safe shell export lines for the named shell dialect. NAME is one of bash, zsh, fish, sh (POSIX/Dash), powershell, nushell, elvish. The equals-form is required — passing --shell NAME as two tokens is rejected with exit 64. --shell bare (no =NAME) autodetects from $SHELL. Mutually exclusive with --ci.(unset — uses --format)
--ci[=PROVIDER]Write the composed environment into the CI system's persistence channel so the exported variables and paths are available to later pipeline steps. PROVIDER is one of github (alias github-actions) or gitlab (alias gitlab-ci). The equals-form is required (--ci=github, not --ci github). Bare --ci (no =PROVIDER) auto-detects from GITHUB_ACTIONS and GITLAB_CI; no provider detected exits 64. Mutually exclusive with --shell.(unset)
--export-file=PATHWrite GitLab CI/CD JSON-lines output to PATH instead of stdout. Requires --ci=gitlab. Rejected with exit 64 when combined with --ci=github (GitHub infers its sink from GITHUB_ENV and GITHUB_PATH) or when given without --ci.(unset — stdout for gitlab)
--platform <PLATFORM>-pCompose the environment for a single target platform instead of the host (cross-build export). Single-valued: passing more than one exits 64. A tool that ships no leaf for the target exits 78 (project tier) or is skipped (global tier, lenient). Defaults to the current host.(current host)
--lazy-mode <MODE>Top tier of the lazy-mode resolution ladder. always composes a shim for every tool the ladder resolves to always, instead of downloading its content up front.(inherit from ocx.toml / OCX_LAZY_MODE)
--pinned, --no-pinnedTop tier of the pinned resolution ladder. --pinned composes the digest roots ocx.lock names; --no-pinned composes the rendered links/<group>/<entry> links. Neither takes a value, and passing both is last-wins.(inherit from ocx.toml / OCX_TOOLCHAIN_PINNED)
--pullMaterialise missing tools into the object store before composing (single batched install, like ocx exec). A tool already present resolves locally with no network — only a genuine miss pulls. Last-wins with --no-pull. Ignored under --global — the global tier never installs.default
--no-pullSkip the install fallback: resolve against local state only. A lock-pinned tool that is not materialised is reported on stderr with an ocx pull hint and omitted from the composed env; the command never contacts the registry and the exit code stays 0.
--show-patchesAnnotate each entry with its origin. When [patches] is configured, companion overlay entries are appended after the toolchain's own entries; this flag adds a Source column to the plain table (a "source" object in JSON) naming the descriptor rule and companion that produced each overlay entry. No effect when [patches] is not configured. Mutually exclusive with --shell and --ci.false
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOOa=b. Only the segment before that first = is checked for a :TYPE[:SEP] qualifier — an environment variable name can never contain :, so a Windows-style value with its own colon (--env PATH:path=C:\tools\bin) is read correctly, and --env FOO:constant=a=b sets FOO to a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends) — the same three kinds [env] uses. SEP qualifies list only: the string a list contribution is joined to the existing value with (--env GODEBUG:list:,=gctrace=1); omitted, the key inherits whatever separator another contributor already declared, or a single space if none did — see Env Composition. A relative path value resolves against the current directory the flag was invoked from, not the project root [env] resolves against: a checked-in file must mean the same thing from any subdirectory, while a flag is composed by whatever script invokes ocx, and the current directory is the one base that script can compute. Highest-precedence stage: wins over ambient, package, patch, and project/group [env] (see Project Environment). A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64). See the PATH override warning under ocx exec.
-h, --helpPrint help information.

Reserved group keywords

  • default — always valid; selects the top-level [tools] table.
  • all — always valid as a -g argument; expands to [default, *named_groups_alphabetical] before composition (identical to exec). Not declarable: [group.all] in ocx.toml exits 78 at parse time; ocx add --group all exits 64 at mutate time.

Target the global toolchain

Pass --global before the subcommand to target $OCX_HOME/ocx.toml: ocx --global env --shell=bash. See --global for the full root-flag reference.

--ci=gitlab requires GitLab Functions / step runner

--ci=gitlab writes JSON-lines ({"name":"…","value":"…"}), which is the format consumed by the GitLab step runner via $. This is an experimental feature for run: keyword jobs. It does not work with traditional script: jobs, which use artifacts: reports: dotenv (KEY=VALUE format) for cross-job variable passing. See CI Integration for a full step-runner example.

Examples

shell
# Plain table output (default):
ocx env

# Machine-readable JSON via the root --format flag:
ocx --format json env

# Eval-safe export for the current project toolchain (bash):
eval "$(ocx env --shell=bash)"

# Eval-safe export for the global toolchain (POSIX sh):
eval "$(ocx --global env --shell=sh)"

# Sourced from $OCX_HOME/env.sh (written by the installer):
eval "$(ocx --global env --shell=sh)"

# Persist toolchain env to GitHub Actions (reads $GITHUB_ENV / $GITHUB_PATH):
ocx env --ci=github

# Persist toolchain env to GitLab step runner (experimental; run: keyword jobs only):
ocx env --ci=gitlab --export-file="${{ export_file }}"

# Or redirect stdout when --export-file is omitted:
ocx env --ci=gitlab >> "${{ export_file }}"

Plain and JSON output are not sourceable

ocx env and ocx --format json env print an aligned table or JSON document — neither form is eval-safe. The only eval-safe channel is --shell[=NAME].

ocx env installs missing tools by default

The exporter resolves each lock-pinned tool locally first — a tool already in the object store needs no network (its digest is content-addressed, nothing to look up). Only a genuine miss falls through to install it inline, like ocx exec. Pass --no-pull to skip that fallback and stay strictly offline: unmaterialised tools are warned about on stderr and omitted (the deterministic-CI shape), and the command never downloads.

Exit codes

CodeMeaning
0Success. Under --global, any unusable global toolchain — not configured, or a corrupt/stale $OCX_HOME/ocx.lock — is a valid empty environment, not an error (report path and --shell path alike). The global tier is lenient.
64Unknown --group name (project tier only — the global tier is lenient and yields an empty env); empty --group comma segment; --shell NAME passed as two tokens (use --shell=NAME); --ci and --shell used together; --export-file given without --ci or combined with --ci=github; bare --ci (auto-detect) used outside a recognized CI environment; more than one --platform (env composes a single environment); --global combined with --project; or no ocx.toml in scope (project tier).
65ocx.lock is stale — run ocx lock (project tier); or two contributors to one env key declared conflicting list separators (see Separator agreement).
78ocx.toml or ocx.lock parse error (project tier); or --ci=github used outside GitHub Actions where GITHUB_ENV and GITHUB_PATH are unset.

The global tier is lenient: ocx --global env never fails on an unconfigured or corrupt global toolchain — it exports an empty environment. This is one predictable rule that does not depend on --shell (which only selects the output format, never whether the command errors). A corrupt global lock surfaces instead via the commands that rewrite it — ocx --global lock, ocx --global add, ocx --global update. The project tier stays strict (a missing/stale/corrupt ocx.lock errors). A useful consequence of the lenient global rule: the installer's env.sh/env.ps1, which source ocx --global env --shell=… on every shell start, can never be broken by global toolchain state.


env (package-tier — ocx package env)

Print the resolved environment variables for one or more OCI-tier packages.

With the root --format plain (default), outputs an aligned table with Key, Type and Value columns. With --format json, outputs {"entries": [...], "binaries": [...], "entrypoints": [...], "integrations": [...]} — see package env for the full shape. Use --shell[=NAME] for eval-safe shell export lines — the only sourceable form.

If a package declares dependencies, their environment variables are included in the output in topological order — dependencies before dependents.

In the default mode, packages are auto-installed if not already available locally (including transitive dependencies). Because it auto-installs, a package covered by a [[trust.policy]] is signature-verified before its environment is composed — the same gate as package install (see its auto-verify contract). See Path Resolution for the --candidate and --current modes.

For the full ocx package env entry, see package env.

Usage

shell
ocx package env [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to resolve the environment for.

Options

  • -p, --platform: Target platform to consider when resolving packages.
  • --candidate, --current: Path resolution mode — see Path Resolution.
  • --self: Use the self view (mask Visibility::PRIVATE) — emits private and public entries (everything publisher marked for own runtime). Default off = consumer view (mask Visibility::INTERFACE) emits public and interface. See Visibility Views. integrations is always [] under --self — integrations reach only the interface surface, regardless of view.
  • --shell[=NAME]: Emit eval-safe shell export lines for the named dialect. Same conventions as root ocx env --shell. Mutually exclusive with --ci.
  • --ci[=PROVIDER]: Write the resolved environment into the CI system's persistence channel so later pipeline steps see the exported paths and variables. PROVIDERgithub / github-actions, gitlab / gitlab-ci. Bare --ci auto-detects from GITHUB_ACTIONS / GITLAB_CI (exits 64 if neither detected). Equals-form required. Mutually exclusive with --shell. See CI Integration for full walkthrough.
  • --export-file=PATH: Write GitLab CI/CD JSON-lines output to PATH. Requires --ci=gitlab; rejected with exit 64 for --ci=github or when given without --ci.
  • --show-patches: Annotate each entry with its origin. Adds a Source column (plain) or a "source" object (JSON) naming the descriptor rule and companion behind each companion-overlay entry appended after the package's own entries. No effect when [patches] is not configured. Mutually exclusive with --shell and --ci.
  • -h, --help: Print help information.

exec

Alias: x.

Spawns a child process whose environment is composed from the project's ocx.lock. This is the project-tier env-composition command — symbols are binding names from ocx.toml, not OCI identifiers. For OCI-identifier-based invocations, use package exec.

A binding missing from the local object store is auto-installed as part of composition. Because it auto-installs, a binding covered by a [[trust.policy]] is signature-verified first — the same gate as package install (see its auto-verify contract). No --verify/--no-verify flag here; opt out via OCX_NO_VERIFY.

-- is mandatory and at least one token after it is required. A missing -- or empty argv produces exit 64.

Usage

shell
ocx exec [OPTIONS] [NAME...] -- ARGV...

Arguments

  • [NAME...]: Zero or more binding names to include in the composed environment. Each name must exist and be unambiguous in the selected scope. When omitted, every binding in the selected scope is composed. The -g scope only selects the namespace for name resolution — when you name a subset, only those tools must resolve and install; an unrelated tool in scope that ships no leaf for the current host (exit 78) does not block the run. When you omit NAME, the whole scope is the set and every tool must resolve.
  • ARGV...: Command to execute with arguments. The first token is the binary name; the rest are passed unchanged to the child. -- is mandatory before ARGV.

Options

FlagShortDescriptionDefault
--group <NAME>-gScope env composition to the named group(s). Repeatable and comma-separated (-g ci,lint -g release). default selects [tools]; all expands to default + every declared [group.*].[tools] only
--cleanStart with a clean environment containing only the composed package variables, instead of inheriting the current shell environment.off
--lazy-mode <MODE>Top tier of the lazy-mode resolution ladder. always composes a shim for every tool the ladder resolves to always; its content downloads the first time the child process invokes it.(inherit from ocx.toml / OCX_LAZY_MODE)
--pinned, --no-pinnedTop tier of the pinned resolution ladder. --pinned composes the digest roots ocx.lock names; --no-pinned composes the rendered links/<group>/<entry> links. Neither takes a value, and passing both is last-wins.(inherit from ocx.toml / OCX_TOOLCHAIN_PINNED)
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOOa=b. Only the segment before that first = is checked for a :TYPE[:SEP] qualifier — an environment variable name can never contain :, so a Windows-style value with its own colon (--env PATH:path=C:\tools\bin) is read correctly, and --env FOO:constant=a=b sets FOO to a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends) — the same three kinds [env] uses. SEP qualifies list only: the string a list contribution is joined to the existing value with (--env GODEBUG:list:,=gctrace=1); omitted, the key inherits whatever separator another contributor already declared, or a single space if none did — see Env Composition. A relative path value resolves against the current directory the flag was invoked from, not the project root [env] resolves against: a checked-in file must mean the same thing from any subdirectory, while a flag is composed by whatever script invokes ocx, and the current directory is the one base that script can compute. Highest-precedence stage: wins over ambient, package, patch, and project/group [env] (see Project Environment). A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64).
--records-dir <DIR>Sink directory for the exec-time resolution record — one JSON file written immediately before the child starts, naming every package digest that composed the environment plus the resolved executable. Overrides the [records] dir config key and OCX_RECORDS_DIR. Unset at every tier means no record is written.(unset — recording off)
--records-name <TEMPLATE>Filename template for the sink, over the closed placeholder set in Filename grammar. Has no effect unless a sink directory is also active. Overrides the [records] name config key and OCX_RECORDS_NAME.{time}-{pid}-{rand}.json
--consent / --no-consentWhether to record a consent stamp for the project. Running exec in a directory is normally consent, which is wrong when a generated launcher re-enters as ocx --project <baked home> exec on a machine whose operator never chose that checkout. Outranks OCX_NO_CONSENT, which sets the default for a whole pipeline. --no-consent also sets that variable on the child environment, so a nested ocx the child launches declines too; --consent does not clear one it inherited. Suppressing the stamp never suppresses the child.--consent
--help-hPrint help information.

Target the global toolchain

Pass --global before the subcommand: ocx --global exec -- cmake --version. The global file must exist (no auto-init for read commands). See --global.

--env PATH=... replaces the composed PATH, it does not extend it

--env with no :TYPE is a constant — it replaces the key outright, the same as a bare-string [env] entry. --env PATH=/opt/tools/bin therefore overwrites the composed PATH, silently dropping every package's bin/ and entrypoints/ directory. There is no name-based special case for PATH; write --env PATH:path=/opt/tools/bin to prepend instead of replace.

Composition order

First by group-selection order (the order of -g flags after all expansion, deduplicated); then alphabetical by binding name within each group.

The composer prepends env entries in iteration order, so the last group listed has its bin/ directories searched first in the child's PATH. Example: -g default,ci puts [group.ci]'s entries ahead of [tools]' on PATH; flip to -g ci,default to invert. Same rule applies within a group — alphabetically-later bindings land ahead of alphabetically-earlier ones.

Reserved group keywords

  • default — always valid; selects the top-level [tools] table.
  • all — always valid as a -g argument; expands to [default, *named_groups_alphabetical] before composition. Not declarable: [group.all] in ocx.toml exits 78 at parse time; ocx add --group all exits 64 at mutate time.

The first token after -- is resolved once, before the child starts: exec searches only the composed environment's PATH contributions (the resolved packages' own directories), never the ambient PATH and never a working-directory fallback, and refuses a match that is itself an ocx-generated launcher trampoline rather than let the child re-enter ocx exec through it.

Exit codes

CodeMeaning
(child)Child ran; its exit code is forwarded byte-for-byte.
1Child spawn failed after resolution succeeded — a TOCTOU race (the resolved binary vanished or lost its executable bit before execvp) or another spawn errno not covered by a more specific code below. An unresolvable command is never this code; see 65.
64-- missing; empty argv; empty -g segment; no ocx.toml found; unknown -g group; unknown binding NAME; ambiguous NAME across groups with conflicting identifiers; --global combined with --project; a bare --env FOO with no =; an --env TYPE that names no modifier or is empty (--env X:bogus=v, --env X:=v); or --env sets an OCX_*/__OCX_* key. (OCX remaps clap's default exit 2 to 64.)
65ocx.lock is stale — run ocx lock; or two contributors to one env key declared conflicting list separators (see Separator agreement); or a policy-covered binding's Sigstore bundle is tampered (auto-verify); or the command does not resolve within the composed environment (no ambient-PATH or working-directory fallback); or the command resolves to an ocx-generated launcher trampoline, refused to prevent an unbounded self-invocation loop.
69Registry unreachable during auto-install of a missing package.
74The exec-time resolution record could not be written and [records] required is true — the child never starts.
75Transient registry failure during auto-install (connect failure, timeout, 429/502/503/504) — rerunning may succeed.
77A policy-covered binding's certificate identity or OIDC issuer does not match (auto-verify).
78ocx.lock absent — run ocx lock; or ocx.toml parse error — including a tool binding declared directly under [group.<name>] instead of [group.<name>.tools], or an [env]/[group.<name>.env] entry with an OCX_*/__OCX_* key (e.g. [group.all] declared); or no leaf digest for the host platform at the locked version (no "any" fallback key in [tool.platforms]) — run ocx update <tool> to re-resolve; or a policy-covered binding's trust root/policy is misconfigured (auto-verify). The host-leaf check fires only for tools actually composed: the named subset when NAME is given, or every tool in scope when it is omitted; or the --records-name/OCX_RECORDS_NAME/[records] name template names an unrecognized placeholder, carries no varying component ({time}, {pid}, or {rand}), or renders to something other than a single plain filename; or the --records-dir/OCX_RECORDS_DIR/[records] dir sink resolves through a symlink to a different directory (see Execution Records).
79Package not found in registry during auto-install; or no signature found for a policy-covered binding (auto-verify).
80Authentication failure during auto-install.

Examples

shell
# Run task in the default [tools] environment
ocx exec -- task build

# Run shellcheck from [group.ci] only
ocx exec -g ci -- shellcheck ./script.sh

# Compose all groups and print the resulting environment
ocx exec -g all -- env

# Use only the cmake binding from the default scope
ocx exec cmake -- cmake --version

# Pass flags to the child (-- separates ocx args from child argv)
ocx exec -g ci -- shellcheck --format=gcc ./script.sh

# Clean environment — only package-declared vars, no shell inheritance
ocx exec --clean -- env

# One-off override — wins over ambient, package, and project/group [env]
ocx exec --env CI=1 --env SOURCE_DATE_EPOCH=0 -- task build

# Prepend a project-local directory to PATH for this invocation only
ocx exec --env PATH:path=node_modules/.bin -- eslint .

Project-tier vs OCI-tier

ocx exec requires ocx.toml and ocx.lock. If you do not have a project file, use ocx package exec with an OCI identifier instead.

See Project Toolchain In Depth → Running tools for composition order, PATH precedence, the all keyword, and worked examples.

which (package-tier — ocx package which)

Resolves one or more packages and prints their package root paths. This is an OCI-tier command under the ocx package group — it operates on OCI identifiers and never consults ocx.toml.

The package root is the directory containing the package's content/ and entrypoints/ subdirectories alongside metadata.json, manifest.json, and the other per-package files. Consumers traverse into <root>/content/ for installed files or <root>/entrypoints/ for generated launchers — both stay one path join away.

By default the content-addressed object-store package root is returned. The --candidate and --current modes return the stable install symlink path; those symlinks themselves target the package root, so traversal works the same through them. See Path Resolution for the trade-off between modes.

Never downloads anything, whether or not --lazy-mode is passed — this command only reports what already exists on disk. Every entry also names which kind of directory it found: package for a materialized package root, or shim for a tool composed with --lazy-mode always whose content has not downloaded yet. Once such a tool has been used once, its content is on disk and the entry reports package again. --candidate and --current always report package, because the install symlinks they resolve are only ever written for materialized content. See Deferred Tools for the full lifecycle.

Usage

shell
ocx package which [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to resolve.

Options

  • -p, --platform: Platform to consider when resolving. Defaults to the current platform. Ignored when --candidate or --current is set.
  • --candidate, --current: Path resolution mode — see Path Resolution.
  • --lazy-mode: Report a deferred tool's shim directory instead of refusing it — see below. Has no effect together with --candidate/--current, which always report a materialized package.
  • -h, --help: Print help information.

JSON shape (breaking, pre-1.0): the value under each requested identifier is now an object, {"path": "...", "kind": "package"|"shim"}, rather than a bare path string. Plain output gains a matching Kind column.

TIP

Use --format json with jq to embed the path in a script:

shell
cmake_root=$(ocx --format json package which --candidate kitware/cmake:3.28 | jq -r '.["kitware/cmake:3.28"].path')

direnv

direnv integration for the project toolchain. Bare ocx direnv is shorthand for ocx direnv init — the once-per-project setup that writes a .envrc. The generated .envrc evaluates ocx direnv export on every directory entry.

Usage

shell
ocx direnv [SUBCOMMAND] [OPTIONS]

Options

  • -h, --help: Print help information.

init

Writes a .envrc file in the current directory that wires ocx direnv export into direnv. After running ocx direnv init (or bare ocx direnv), run direnv allow in the same directory to activate the hook. The generated .envrc watches ocx.toml and ocx.lock, so direnv re-runs the hook whenever either file changes.

Usage

shell
ocx direnv init [OPTIONS]

Options

  • --force: Overwrite an existing .envrc in the current directory. Without this flag, an existing file causes the command to exit with a ConfigError (78) and leave the file untouched.
  • -h, --help: Print help information.

Exit codes

CodeMeaning
0.envrc written successfully.
74I/O error writing .envrc.
78.envrc already exists and --force was not given.

export

Stateless export generator for the project toolchain. Reads the nearest project ocx.toml, loads the matching ocx.lock, resolves every default-group tool, and prints bash export lines for the resolved environment. It emits a fresh export block on every invocation, leaving the diffing/caching to the caller (typically direnv). It is what the generated .envrc evaluates; you do not normally type it by hand.

Output is always bash. direnv sources .envrc files in a bash sub-shell regardless of the user's interactive shell, then translates the resulting environment to the interactive shell internally via direnv export <shell>. Programs invoked via eval from .envrc therefore have to emit bash — there is no shell-dialect option on this command.

By default a tool missing from the object store is materialised on miss (like ocx env): a tool already present resolves locally with no network — its digest is content-addressed, nothing to look up — so only a genuine miss falls through to install it. The pull is best-effort and is skipped whenever no registry is reachable (--offline / no configured remote), so a missing tool never fails or blocks the prompt. Pass --no-pull to keep the hook strictly offline: missing tools then produce a one-line stderr note and are skipped. Either way a stale lock produces a stderr warning but the stale digests are still used, and when no project ocx.toml is found in scope the command exits 0 with no output.

Usage

shell
ocx direnv export [OPTIONS]

Options

  • --group <NAME> / -g: Scope composition to the named group(s), same grammar as ocx exec -g. Omitted, the scope is the top-level [tools] table and its [env] — a group's [env] is otherwise unreachable from an .envrc.
  • --env <KEY[:TYPE[:SEP]]=VALUE>: Set an environment variable for this invocation only, same grammar as ocx exec --env. A relative path value resolves against the directory ocx runs in, which under direnv is the directory holding .envrc.
  • --lazy-mode <MODE>: Top tier of the lazy-mode resolution ladder. Without it, a project declaring lazy-mode = "always" in ocx.toml would still compose eagerly here even though ocx env defers it — ocx direnv export composes through the same ladder as every other env-composing command, so the environment does not depend on which command opened the shell.
  • --pull / --no-pull: --pull (default) installs a missing tool on the object-store miss before exporting; --no-pull keeps the hook strictly offline and omits it. POSIX last-wins.
  • -h, --help: Print help information.

Widening the scope

ocx direnv init writes an .envrc that calls this command with no arguments. The line is yours to edit afterwards — eval "$(ocx direnv export -g ci --env FORCE_COLOR=1)" — and direnv picks it up on the next reload.

Exit codes

CodeMeaning
0Success (no project, or exports emitted).
64Malformed --env argument, empty -g comma segment, or a -g naming no declared group. Unlike a missing lock or an unmaterialised tool, these are argv faults in a file you edited — they fail loudly rather than exporting nothing.
65ocx.lock is stale (declaration_hash mismatch — run ocx lock); or two contributors to one env key declared conflicting list separators (see Separator agreement).
74I/O error during resolution.
78Parse error reading ocx.toml or ocx.lock.

index

catalog

bash
ocx index catalog [OPTIONS] [REGISTRY...]

Lists all packages available in the index. Uses the local index by default; pass --remote to query the registry directly without writing through to the local index. Repository names are always prefixed with their registry in the output (e.g., ocx.sh/kitware/cmake).

Arguments

  • [REGISTRY...]: Registries to query. Accepts zero or more registry hostnames. Defaults to OCX_DEFAULT_REGISTRY (or ocx.sh) when omitted.

Options

  • --tags: Include available tags for each package. Slower — requires fetching additional information for each package.

list

Alias: ls.

bash
ocx index list [OPTIONS] <PACKAGE>...

Lists available tags for one or more packages.

Identifiers carrying a digest (@sha256:...) are rejected with a usage error — index list enumerates tags, and a digest narrows nothing. Use ocx package description pull <pkg>@<digest> for a single artifact, or drop the @digest suffix. Tag-only identifiers (<pkg>:<tag>) still work as a tag filter on the returned list. With --platforms, a digest-bearing identifier (<pkg>@<digest>) is accepted and resolves directly to that artifact's platform set, offline when already cached locally.

Arguments

  • <PACKAGE>: Package identifiers to list tags for. Must not include a digest suffix.

Options

  • --platforms: Show the platforms (os/arch) the package publishes, read from its image index manifest. Uses the tag from the identifier, or latest if none specified. Resolved live from the registry under --remote; otherwise read from the local index.
  • --variants: Lists unique variant names found in the tags.
  • -h, --help: Print help information.

TIP

index list is a pure-query command — under --remote it contacts the registry without writing the local index. To refresh the persistent snapshot, run ocx index update explicitly.

update

bash
ocx index update <PACKAGE>...

Explicitly refresh the local index for one or more packages from the remote source. Per tag, it writes the tag's dispatch object plus its root document into the local index collection — never a leaf platform manifest — in a fixed, crash-safe order, then upserts the package's catalog entry. This is what keeps a committed .ocx/index/ self-contained for version choice: resolving a locked tool's platform-manifest digest from the index afterward needs no other store or network access. The manifest bytes and layers themselves are content, still fetched on demand from the registry when actually installed.

<PACKAGE>... names what gets refreshed, and at least one is required. A tagged identifier (e.g., kitware/cmake:3.28) records only that tag — the remote tag listing is skipped entirely, which is ideal for lockfile workflows where the local index should contain only explicitly requested tags. A bare identifier (e.g., kitware/cmake) records every tag the source currently lists.

Only the packages you name are touched, and nothing else is fetched. A package left out keeps every tag pin and its repository pointer exactly as committed, even when the source has moved on.

Nothing here syncs a whole index as a side effect, because a remote index floats (packages appear, platforms get added, tags move) while the local copy is the set of snapshots you deliberately asked for. See Indices for why that is the shape. The whole-registry form is a verb of its own — ocx index sync <REGISTRY> — and is equally explicit. To see what a source has without refreshing anything: ocx index sync --dry-run, or ask the source directly with ocx index catalog --remote / ocx index list --remote.

A bare ocx index update with no <PACKAGE> is a usage error (exit 64) — with no "everything" to fall back on, there is nothing for it to mean.

ocx index update never writes to $OCX_HOME/blobs/ or $OCX_HOME/layers/ — those are populated only by an online ocx package install or ocx package pull that actually materializes a package. After running ocx index update <pkg>, an ocx --offline package install <pkg> resolves the tool's per-platform digest from the index but still fails to install, since the manifest and layer archives themselves are not part of the index.

On the first successful update for a given published source, ocx index update also writes that source's config.json if one is not already present. See Serving a local index snapshot for what that unlocks.

A tag-refresh failure never discards a package's other, successfully-refreshed tags. Within one package's root, only the failing tag — and any other tag that shares its content digest — is withheld from the commit; every other tag in that package is still pinned at its newly fetched digest. The invocation as a whole still reports failure: the exit code corresponds to the first failure in request order (deterministic across repeated runs). A package with no failing tag keeps every requested tag.

Arguments

  • <PACKAGE>...: Package identifiers to update in the local index for. Include a tag to update only that tag; omit the tag to update all tags. At least one is required.

Options

  • -h, --help: Print help information.

Exit codes

CodeMeaning
0Every named package refreshed.
64No <PACKAGE> given.
81--offline or --frozen refused the command. Recording a new tag→digest mapping is the discovery a freeze forbids, and this is the package tier --frozen scopes to — so index update refuses in its own right. Re-run it without the flag to move the pin.
otherThe first failure in request order decides — see Exit codes (e.g. 79 not found, 80 authentication failure).

sync

bash
ocx index sync <REGISTRY>... [--dry-run]

The whole-registry form of update: name registries instead of packages, and each registry's own catalog names the packages. This is how a whole mirror is snapshotted in one command — see Serving a local index snapshot.

The package set is read live from the source — a published source's own c/index.json, a plain-OCI registry's repository listing — never from the local copy, which is the set you already have. Each package is then refreshed exactly as if named bare to update, so it adopts every tag the source lists and keeps any tag only the local copy holds.

The derived branch's repository listing depends on the registry implementing OCI's _catalog endpoint: Docker Hub disables it outright, and GHCR supports it only with an authenticated, correctly-scoped token — a registry that refuses _catalog fails that registry's whole snapshot.

It is one explicit, operator-invoked read of each source's catalog at that instant, not a standing subscription and not a replica. A merge never deletes, so repeated runs accumulate a union of snapshots: a package that disappeared upstream keeps the tags this machine already recorded. regenerate is the only command that removes anything, and only a catalog entry whose root document is already gone — it cannot retract a package, for which the answer is a fresh index home.

Several registries are one run, not several. Every <REGISTRY> is enumerated before any of them is refused, so one unreachable source does not cost the others their snapshot; the command still fails afterwards and reports each failure separately on stderr. The per-package refresh is bounded at the same in-flight ceiling update uses, across all registries together rather than per registry, so naming ten registries does not multiply the load on any of them.

On the first successful sync for a given published source, ocx index sync also writes that source's config.json if one is not already present, exactly as update does.

A registry whose catalog cannot be read at all — a missing endpoint, an auth refusal (exit 80), or a reachable, correctly-configured published source that simply serves no c/index.json (exit 69) — fails the command. It is never read as an empty catalog. A served catalog listing zero packages is a different fact: the source answered, so that enumerates cleanly and exits 0. That case still prints a warning on stderr naming the registry, since a pull token without catalog scope commonly answers with an empty listing rather than an authentication refusal.

A published source whose catalog carries a yanked tag is refused fail-closed: the mirror operator must set OCX_ALLOW_YANKED before running ocx index sync, or the run exits 65 and snapshots nothing for that registry. This is the operator's own opt-in, separate from and prior to the client-side refusal a resolve against the snapshot hits later.

Arguments

  • <REGISTRY>...: Registries whose catalogs name the packages to refresh. At least one is required; repeat for several. A registry named twice is enumerated once, in first-mention order.

Options

  • --dry-run: Print the packages this would refresh, one per line and sorted within each registry, and refresh none of them. Enumeration still runs, which contacts the source, so --offline and --frozen still refuse it. Returns before both the per-package refresh and the patch-descriptor sync that otherwise follows a successful run: nothing under the index home is written — not even config.json — and no [patches] sync runs either. Exits 0 even when the enumerated set is empty; a registry that fails to enumerate still fails the dry run, with no partial listing printed.
  • -h, --help: Print help information.

Exit codes

CodeMeaning
0Every enumerated package refreshed — or, under --dry-run, printed.
64No <REGISTRY> given.
65A registry's catalog served a key that is not a well-formed repository path. That registry is refused whole, before any of its packages is touched, and --dry-run fails the same way.
81--offline or --frozen refused the command — including a --dry-run, since printing the set still means contacting the source.
otherA registry that could not be enumerated decides, ahead of any per-package failure — including 69 for a reachable source that serves no c/index.json. Otherwise the first per-package failure in the order --dry-run prints them decides; see Exit codes (e.g. 79 not found, 80 authentication failure).

regenerate

bash
ocx index regenerate <REGISTRY>...

Re-derives a published index source's c/index.json catalog from the root documents actually present under its p/ tree, and writes it back. c/index.json is derived data — every entry restates a digest the root document beside it already carries — and every other writer only ever adds to it, which leaves one drift nothing else repairs: an entry naming a package whose root document is gone. regenerate is the one operation that clears such an entry, by replacing the whole map with what the walk actually finds.

It makes no assumption the tree was written by this ocx: root documents possibly written by another implementation, and a tree with no prior c/index.json at all, are both valid input. It consults no source and moves no pin — it never contacts a registry or an index endpoint — so --frozen and --offline both permit it: update and sync are the commands both flags refuse (theirs is the write both flags exist to gate); regenerate is the only one whose purpose is to write, rewriting the catalog deliberately; catalog and list are permitted because, without --remote, they read the local copy — though list can still trigger a read-path self-heal on an already-drifted tree, a write neither flag gates.

It writes exactly one file per registry, that registry's c/index.json — clearing a stale c/index.json.etag beside it too, if the tree carries one left by an older ocx — and never a root document, never a dispatch object, and never config.json: name_segments is an operator declaration no tree can be read for, so fabricating one while repairing a foreign tree would be wrong. A tree whose catalog already matches its p/ tree is left byte- and mtime-identical, once any stale c/index.json.etag has been cleared — that clearing happens even on an otherwise-clean first run against a tree written by an older ocx.

Symlinked layouts lose packages silently

regenerate does not follow symlinks under p/. A symlinked root document is skipped, and a symlinked directory is never queued at all — taking every root beneath it with it in one step. Because the catalog is replaced wholesale, that is not a missing entry but silent bulk removal from c/index.json: the packages still resolve by tag (resolution reads roots directly and never through the catalog), but they vanish from ocx index catalog, from index sync enumeration, and from anything else that reads the catalog. regenerate is specified for trees whose roots and intermediate directories are real files and directories — every tree ocx itself produces. A symlink-deduplicated layout (a package staged once and linked into several locations) needs the real files underneath, not links to them — hardlinks are unaffected, since a hard-linked file is a regular file to the walk.

The removal is not always permanent: on a tree this machine also resolves, not merely serves, a resolve's own catalog self-heal re-adds a dropped entry the next time the package is resolved, since the root read behind it follows symlinks directly. It sticks for a tree that is served and never locally resolved.

Each <REGISTRY> must be a published index source — one configured with [registries."<ns>"] index. A derived, plain-OCI namespace has no catalog document by grammar (its catalog is the p/ enumeration itself) and is refused with exit 78, naming the registry.

Arguments

  • <REGISTRY>...: One or more published index sources to regenerate. At least one is required.

Report

Per registry: the number of root documents the walk found, and every package added (a root on disk with no catalog entry), corrected (a catalog entry whose digest disagreed with the root on disk), or removed (a catalog entry naming a root no longer on disk). A run that changed nothing for every named registry prints a single line instead of an empty table. Honours the root --format json.

Exit codes

CodeMeaning
0Every named registry's catalog matches its p/ tree, and the report was printed.
64No <REGISTRY> given.
78A named registry — or a configured namespace resolving to the same on-disk subtree under a different name — is not a published index source.
other65 — a root document under p/ failed to parse. 74 — the named registry's subtree does not exist (a mistyped <REGISTRY>, or a checkout not yet materialized): regenerate never creates one, it refuses instead; 74 also on a p/ walk that found no root document while the catalog still names packages (refusing to replace a non-empty catalog with an empty one), and on a source lock that timed out. Per-registry failures aggregate in argument order; the lowest-index failure is the process error.

about

Prints environment information: the ocx version, default registry, the detected host platform, detected libc family (on Linux), detected shell, and home directory. When build provenance was baked in at compile time, two optional rows appear: Commit (short SHA and clean/dirty status) and Channel (e.g. dev). These rows are absent on local builds and on stable releases without a channel override.

The Libc row appears only when libc was detected — it is absent on non-Linux hosts and on hosts with no readable dynamic loader (a truly minimal or static-only container). Non-FHS layouts such as Gentoo Prefix and Homebrew-on-Linux are detected: OCX reads the loader path from a present system binary rather than guessing fixed paths. NixOS is detected when nix-ld is active (nix-ld installs an FHS shim the probe finds); without nix-ld the probe binaries are statically linked and detection yields an empty set, so the Libc row is absent. The Platform row shows the bare os/arch of the detected host — see Platforms for how that value and the Libc row combine into the platform OCX resolves against.

In a terminal, ocx about renders an isometric logo alongside the info table. In non-interactive contexts (piped output without --color always), the plain key-value fallback is used instead.

Usage

shell
ocx about

Plain output — terminal

              ++++++               ++++++
          ...                              (logo)

Version    0.3.2-dev+20260528143045
Commit     a1b2c3d4 (clean)
Channel    dev
Registry   ocx.sh
Platform   linux/amd64
Libc       libc.glibc
Shell      bash
Home       /home/user/.ocx

JSON output

ocx --format json about emits a flat object. The commit, build, and ci blocks are merged from the build provenance payload and follow the same schema and suppression rules as ocx --format json version. The libc field is an array of detected libc os.feature tags (e.g. ["libc.glibc"], ["libc.glibc","libc.musl"]); empty array [] when no libc was detected:

json
{
  "version": "0.3.2",
  "registry": "ocx.sh",
  "platforms": ["linux/amd64"],
  "libc": ["libc.glibc"],
  "shell": "bash",
  "home": "/home/user/.ocx",
  "commit": { "sha": "...", "short": "a1b2c3d4", "describe": "...", "dirty": false },
  "build":  { "timestamp": "...", "profile": "release", "target": "...", "rustc": "..." },
  "ci":     { "provider": "github-actions", "run_url": "...", "workflow": "...", "ref": "...", "sha": "..." }
}

channel is present only when baked in (dev-deploy builds). commit, build, and ci blocks are absent on local builds without git or CI context. Use ocx about as the first diagnostic when troubleshooting feature mismatch errors — the Libc row shows exactly what the platform detector found.

status

Reports what ocx.toml and ocx.lock say, without resolving anything. Offline, read-only, and never writes either file: no registry is contacted, no platform is selected, no package metadata is read, and no relative path value is anchored to the project root.

Status answers on projects that other commands refuse. A missing ocx.lock exits 78 for pull and exec; a drifted one exits 65; an unparseable one fails outright. All three are states status reports as payload and still exits 0 for — it is the command you reach for when the project is broken.

For what those declarations resolve to on this host — the pinned digest per binding, the composed environment, what would land on PATH — use inspect.

Usage

shell
ocx status

Options

  • -h, --help: Print help information.

There is no -g/--group and no NAME argument. The report is a keyed object a caller narrows itself, and a filter here would only hide rows rather than change any answer — unlike in inspect, where the selection decides what gets composed.

Honors the global --format and --project / --global flags. --offline is accepted and inert: this command never reaches the network.

JSON shape

json
{
  "project": "/home/you/code/app/ocx.toml",
  "lock": {
    "present": true,
    "current": false,
    "lock_version": 3,
    "declaration_hash": "sha256:67d0ab…",
    "declaration_hash_expected": "sha256:91ffcc…",
    "generated_by": "ocx 0.4.3",
    "generated_at": "2026-06-14T23:29:57Z"
  },
  "groups": {
    "default": {
      "tools": {
        "go-task": {
          "declared": "ocx.sh/go-task/task:3",
          "platforms": {
            "linux/amd64": "sha256:fcfad8…",
            "darwin/arm64": "sha256:7ab019…"
          }
        },
        "newtool": { "declared": "ocx.sh/newtool:1" },
        "oldtool": { "platforms": { "linux/amd64": "sha256:11c0d6…" } }
      },
      "env": { "CI": { "type": "constant", "value": "1" } }
    },
    "ci": {
      "tools": {},
      "env": { "PATH": { "type": "path", "value": "node_modules/.bin" } }
    }
  },
  "package_settings": { "ocx.sh/foo:1": { "no_patches": true } }
}

default is a group like any other. The top-level [tools] and [env] tables in ocx.toml are its tools and env — which is why default is a reserved group name — so the report has no separate top-level env.

Each binding under groups.<name>.tools reports its state by which keys are present, the same convention binaries uses:

declaredplatformsMeaning
presentpresentDeclared in ocx.toml and locked.
presentabsentDeclared but not yet locked — added since the last ocx lock.
absentpresentLocked but no longer declared — orphaned in a stale lock.

platforms carries every leaf the lock records, not the host's. Picking the host leaf is resolution, which is inspect's job.

env values are verbatim: a relative type = "path" value stays relative here, because anchoring it to the project root is composition. inspect and env show the anchored form.

lock describes the lock file itself:

  • present — whether ocx.lock exists.
  • error — present only when the file exists but could not be parsed (an unsupported lock_version, a corrupt file). The header fields and every platforms map are then absent; the declaration half of the report is unaffected.
  • current — whether the lock's stored declaration_hash still matches the config's. Absent when nothing was parsed.
  • declaration_hash (stored) and declaration_hash_expected (recomputed) are both reported so a consumer sees why current is false without recomputing the project's canonicalization itself. Both cover [tools] and [group.*] only — [env] and [package.*] are excluded by design, so editing either leaves current true.

package_settings reports [package."<id>"] precisely because it is excluded from the declaration hash: nothing lock-derived can surface it.

Exit codes

  • 0 — success, including "no lock", "stale lock", and "unreadable lock".
  • 64 (UsageError) — no ocx.toml in scope, or a selector was passed.

inspect

Inspects what the project toolchain resolves to, without installing. The toolchain-tier counterpart to ocx package inspect: the same report and the same flags, keyed by ocx.toml binding instead of raw identifier, and carrying the project's composed environment alongside.

Selects the bindings in the requested groups, narrows them to any NAMEs given, and reports each one. Read-only — nothing is installed, no symlink is created, neither project file is written.

--resolve is what selects a platform, here exactly as on the OCI-tier command. By default each binding lists the platform candidates ocx.lock pins for it, so the default report is a pure projection of the two project files: no registry is contacted, no host leaf is chosen, and -p stays inert.

Needs a current ocx.lock: exit 78 when absent, 65 when it no longer matches ocx.toml. Without a pin there is no stable answer — re-resolving declared tags live would make the report depend on where a moving tag points at that moment. Use status for the declaration itself, including those two states.

Usage

shell
ocx inspect [OPTIONS] [NAME]...

Arguments

  • [NAME]...: Binding names to inspect. Each is an ocx.toml binding key. Defaults to every binding in the selected groups. Only the named bindings are reported, so under --resolve an unrelated sibling that ships no leaf for this host does not block the report.

Options

  • -g, --group <GROUP>: Restrict the selection to the named group(s). Repeatable and comma-separated. default selects the top-level [tools] table; all expands to default plus every declared [group.*]. Omitted means the default group, not everything — matching exec and env.
  • -p, --platform <PLATFORM>: Platform to resolve each binding's leaf against. Defaults to the host. Applies with --resolve and --closure; ignored in default mode, where the candidate list always shows every locked platform.
  • --resolve: Select this host's leaf and emit its metadata plus the OCI resolution chain. The lock already pins a platform manifest, so the chain starts there and carries no index entry.
  • --closure: Compute each binding's dependency closure from metadata alone, plus the interface / private surface projections. Because the walk sees the whole selection at once, it also reports collisions between two different tools before either is installed.
  • --env <KEY[:TYPE[:SEP]]=VALUE>: Set an environment variable for this invocation. Repeatable; appended last in the report's env array, matching where it lands in composition.
  • -h, --help: Print help information.

Honors the global --offline, --remote, --format, and --project / --global flags. Default mode reads no registry at all. A cold --closure costs one config-blob fetch per selected binding; a warm one is served from the local cache and works under --offline.

JSON shape

The same envelope ocx package inspect emits. Default — the locked candidates for each binding:

json
{
  "packages": [
    {
      "name": "shellcheck",
      "identifier": "ocx.sh/shellcheck/shellcheck:0.11",
      "candidates": [
        {
          "digest": "sha256:5238fe…",
          "pinned": "ocx.sh/shellcheck/shellcheck:0.11@sha256:5238fe…",
          "platform": "linux/amd64"
        },
        {
          "digest": "sha256:7ab019…",
          "pinned": "ocx.sh/shellcheck/shellcheck:0.11@sha256:7ab019…",
          "platform": "darwin/arm64"
        }
      ]
    }
  ],
  "env": [
    { "key": "CI", "type": "constant", "value": "1" },
    { "key": "PATH", "type": "path", "value": "/home/you/code/app/node_modules/.bin" },
    { "key": "CI", "type": "constant", "value": "0" }
  ]
}

--resolve replaces the candidate list with the selected leaf, and adds pinned_identifier / pinned_digest to the entry plus platform to the envelope:

json
{
  "platform": "linux/amd64",
  "packages": [
    {
      "name": "shellcheck",
      "identifier": "ocx.sh/shellcheck/shellcheck:0.11",
      "pinned_identifier": "ocx.sh/shellcheck/shellcheck:0.11@sha256:5238fe…",
      "pinned_digest": "sha256:5238fe…",
      "metadata": { "…": "…" },
      "layers": [ { "digest": "sha256:…", "media_type": "…", "size": 4051232 } ],
      "resolution": { "…": "…" }
    }
  ],
  "env": []
}

name is the binding, so an entry is addressable by the same key ocx.toml uses. identifier is the declaration verbatim, tag included. packages is in selection order: group order, then lock order within each group.

A candidate carries no media_type or size, and the entry no pinned_identifier or pinned_digest: the lock records one leaf digest per platform, never the descriptors that pointed at them nor the index that carried them. Absence is the signal — each candidate's own pinned is the pullable reference. Use status if you want the same map keyed by platform.

env is an ordered array, in application order: [env] first, then each selected group's [group.<name>.env] in -g order, then --env last. Every contributing declaration is kept rather than merged, so one key can legitimately appear more than once — which is why this is an array and status's per-scope view is an object. The array shows what was declared and in what order; ocx env is what answers what the final value is, and it materializes packages to do so because their values are ${installPath}-templated.

Package-declared environment is not in this array. It lives inside each entry's closure.surface.env under --closure, attributed per package and without values, exactly as in ocx package inspect.

Examples

shell
# What does the default group pin, per platform? (offline)
ocx --format json inspect | jq -r '.packages[] | .name as $n | .candidates[] | "\($n) \(.platform) \(.pinned)"'

# What does it pin on this host?
ocx --format json inspect --resolve | jq -r '.packages[] | "\(.name) \(.pinned_identifier)"'

# One binding, from a named group.
ocx --format json inspect -g ci shellcheck

# What would the whole toolchain put on PATH, without installing it?
ocx --format json inspect -g all --closure | jq '.packages[].closure.surface.interface.binaries'

# Does any pair of tools collide before I install them? (exits 65 if so)
ocx --format json inspect -g all --closure | jq '.packages[] | select(.closure.conflicts.entrypoints != [])'

Exit codes

  • 0 — success.
  • 64 (UsageError) — unknown group, unknown binding name, or a binding two selected groups resolve differently and that binding is in the narrowed set.
  • 65 (DataError) — ocx.lock is stale, or --closure found a conflict that makes the surface unrealizable (the conflict is still reported in full).
  • 78 (ConfigError) — ocx.lock is absent; run ocx lock.
  • 81 (PolicyBlocked) — --offline and a needed manifest or config blob is not cached. Default mode reads no registry, so this needs --resolve or --closure.

init

Creates a minimal ocx.toml in the current directory.

The generated file contains a #:schema directive and an empty [tools] table — a non-interactive skeleton following the "backend-first, minimal output" design. Once the file exists, use ocx add to append tool bindings or edit it directly; comments and declaration order in the file survive every mutation.

The command is an idempotent failure: if ocx.toml already exists (or a symlink at that path exists), it exits with code 64 without overwriting the existing file.

It also records a consent stamp for the project it creates, so the next shell prompt in that directory applies it — creating an ocx.toml is at least as deliberate a gesture as the ocx add that already writes one. The stamp records an empty source set, because the project has no lock yet; the first ocx add re-records it. Pass --no-consent to skip it and consent later with ocx shell allow, or set OCX_NO_CONSENT to make that the default for every command in a pipeline. The flag outranks the variable, so --consent stamps even where the variable is set.

Usage

shell
ocx init [OPTIONS]

Options

  • --consent: Record a consent stamp. This is the default unless OCX_NO_CONSENT is set.
  • --no-consent: Run without consenting to this project's shell activation.
  • -h, --help: Print help information.

Exit codes

CodeMeaning
0ocx.toml created successfully.
64ocx.toml already exists at the target path.
74I/O error writing the new file.

install

Moved to ocx package install — exits 64 if invoked as bare ocx install. See package install for the current form.

Downloads and installs one or more OCI-tier packages into the local object store.

Installs packages into the object store and creates a candidate symlink for each package, making them available for use by other commands. If a package declares dependencies, all transitive dependencies are downloaded to the object store automatically — only the explicitly requested packages receive install symlinks.

Usage

shell
ocx package install [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to install.

Options

  • -p, --platform: Target platform to consider.
  • -s, --select: After installing, update the current symlink for each package to point to the newly installed version. Required before using ocx env --current.
  • -h, --help: Print help information.

login

Authenticate to a registry and persist the credentials for use by subsequent ocx package install, ocx pull, and other registry-accessing commands.

Credentials are stored in the same ~/.docker/config.json that docker login and oras login write. The three tools interoperate: a credential written by any one of them is readable by the others.

Usage

shell
ocx login [OPTIONS] [REGISTRY]

Arguments

  • [REGISTRY]: Registry hostname (e.g. ghcr.io, registry.example.com). Optional — falls back to OCX_DEFAULT_REGISTRY (default ocx.sh) when omitted.

Options

FlagShortDescriptionDefault
--username <USER>-uUsername for the registry. Prompted interactively when omitted on a TTY.(prompt)
--password-stdinRead the password or token from stdin. Required in non-interactive contexts. No -p/--password VALUE flag — argv-visible secrets leak via ps and shell history (CWE-214).off
--allow-insecure-storePermit storing credentials as base64 in auths[registry] when no native credential helper is configured. Default: refuse, exit 78.off
--verify / --no-verifyVerify the credentials against the registry (GET /v2/) before storing them. On rejection, nothing is written (exit 80). --no-verify stores without a round-trip.verify
-h, --helpPrint help information.
Reserved flags

--auth-type <TYPE> is reserved for a future v2 OIDC / browser-OAuth flow. Passing it today emits a usage error (exit 64). Plain HTTP is not a login flag: ocx login probes the registry over whatever scheme the rest of the binary would use, which is HTTPS unless the host is named by [registries.<name>] insecure or OCX_INSECURE_REGISTRIES.

Credential storage tiers (highest priority first):

  1. credHelpers[REGISTRY] in ~/.docker/config.json — per-registry helper
  2. credsStore — global default helper
  3. Plaintext auths[REGISTRY].auth — base64 fallback (requires --allow-insecure-store)

Examples

Interactive login on a developer workstation with a configured credential helper:

shell
ocx login ghcr.io
# Username: myuser
# Password: ****
# Login succeeded

Non-interactive CI login piping a token on stdin:

shell
echo "$GHCR_TOKEN" | ocx login -u "$GHCR_USER" --password-stdin ghcr.io

Headless environment without a native keychain daemon:

shell
echo "$TOKEN" | ocx login -u ci --password-stdin --allow-insecure-store internal.registry.example.com

Exit codes

CodeMeaning
0Credentials persisted successfully.
64Usage error — missing required flag, empty password, or --password VALUE attempted.
74I/O error writing ~/.docker/config.json.
75Credential helper timed out (transient — retry).
78No credential store available (no helper configured and --allow-insecure-store not passed), or helper not on PATH.
80Credential helper failed (non-sentinel exit), helper output too large, or credentials rejected.

Docker interop

ocx login writes to the same ~/.docker/config.json as docker login and oras login. Override the location with DOCKER_CONFIG.

JSON output

shell
ocx --format json login ghcr.io
# {"registry":"ghcr.io","username":"ocx-bot"}

logout

Remove stored credentials for a registry.

Always exits 0 — including when the registry was never logged in. This matches the convention of docker logout, oras logout, and helm registry logout. CI cleanup scripts must not fail when a previous step already removed the credentials.

Usage

shell
ocx logout [REGISTRY]

Arguments

  • [REGISTRY]: Registry hostname. Optional — falls back to OCX_DEFAULT_REGISTRY when omitted.

Examples

shell
ocx logout ghcr.io
# Logged out of ghcr.io

# CI cleanup — safe even if no login occurred
ocx logout internal.registry.example.com || true  # redundant: already exits 0

Exit codes

CodeMeaning
0Credentials removed, or registry was not logged in (noop).
74I/O error writing ~/.docker/config.json.

JSON output

shell
ocx --format json logout ghcr.io
# {"registry":"ghcr.io"}

lock

Resolves every tool tag in the nearest ocx.toml to per-platform leaf digests and writes the result to ocx.lock next to it. The command is a whole-file reconcile: when the lock is already current (its declaration_hash matches the config), every pin is carried forward verbatim — a byte-identical, idempotent no-op that never advances a moving tag, even if it has moved upstream. When the config drifted, every declared tag is re-resolved and a moving tag may advance to wherever it points today. To force-advance pins on a current lock, use ocx update.

For each tool, the lock records the bare registry/repository coordinates plus a [tool.platforms] table mapping every platform the publisher ships to its leaf manifest digest. The command records all shipped platforms regardless of which OS it runs on, so a lock committed on Linux is complete for macOS and Windows CI runners. The command is fully transactional — either every tool resolves successfully and the file is rewritten atomically, or nothing is written and the previous ocx.lock survives unchanged.

The lock carries a declaration_hash over the canonicalized RFC 8785 JCS of ocx.toml. Downstream commands (ocx pull, ocx exec) consult this hash to detect when the lock is stale relative to the source declaration. When the resolved content of every tool is unchanged between two ocx lock runs, the file's generated_at timestamp is preserved verbatim — the byte-stable output keeps version-control diffs minimal.

After a successful write, the command checks whether the project's .gitattributes declares ocx.lock merge=union and emits a one-line stderr advisory when it does not, helping prevent merge conflicts on team projects.

ocx lock vs ocx update

ocx lock is an idempotent reconcile — it re-resolves only when ocx.toml changed. Use ocx update to force a re-resolve of every tag regardless of drift.

Usage

shell
ocx lock [OPTIONS]

Options

FlagShortDescriptionDefault
--pullAfter writing the lock, materialise all resolved tools into the object store and create their candidate symlinks. Default when --no-pull is absent.on
--no-pullWrite the lock only; skip materialisation. Defer the install to a later ocx pull or first ocx exec.
--checkVerify ocx.lock is current relative to ocx.toml and exit. No re-resolution, no writes, no network calls. Exit 0 if the lock matches; 65 if stale; 78 if the lock file is absent. CI primitive for "is the lock committed and current?" verification.off
--platform <PLATFORM>-pMaterialise the leaf for the named platform instead of the host — see Platforms for the grammar. Single-valued: passing more than one exits 64. Selects which already-locked leaf to fetch (the lock stays host-agnostic); a target the publisher does not ship exits 78. Defaults to the current host.(current host)
--help-hPrint help information.

Target the global toolchain

Pass --global before the subcommand: ocx --global lock. See --global.

Exit codes

CodeMeaning
0ocx.lock written (or preserved if content was unchanged).
64Missing ocx.toml, --global combined with --project, or more than one --platform value (single-valued flag).
65--check reported drift.
69Registry unreachable while resolving advisory tags.
74I/O error writing ocx.lock.
75Transient registry failure (connect failure, timeout, 429/502/503/504) survived the resolve retries — rerunning may succeed.
78Existing ocx.lock is malformed (parse error) or uses an unsupported version (V1/V2 are rejected; regenerate with ocx lock), ocx.toml schema-invalid, --check reported the lock is absent, or a requested --platform is not shipped by a tool.
79Tag unresolvable during resolution (package not found in registry after retries).
80Authentication failure against the registry.
81--offline or --frozen and a tag is not cached locally (policy blocked).

JSON output (--format json)

ocx --format json lock emits an array of objects, one per resolved tool:

FieldTypeDescription
bindingstringThe binding name from ocx.toml (the left-hand key).
groupstringGroup the binding belongs to ("default" for the top-level [tools] table).
digeststringHost-platform leaf digest in sha256:<hex> form.
platformsobjectFull available-only map: platform key string to leaf digest. Keys follow the lossless platform encoding (e.g. "linux/amd64", "darwin/arm64", "any").

Concurrent invocations of ocx lock and ocx update are serialised via an in-place exclusive flock on ocx.toml. Readers (ocx pull, git, IDE tooling) never acquire any lock and are never blocked by a running ocx lock.

update

Re-resolves advisory tags in ocx.toml against the live registry and rewrites ocx.lock. Unlike ocx lock or ocx add, which resolve through the local index by default, ocx update talks to the registry every time — the same default ocx self update uses, since the whole point is to see where a moving tag (:latest, :3) points today. With no arguments this is the whole-file forced-bump verb: every declared tag is re-resolved, even when the lock is already current. An unchanged result rewrites the lock byte-identically. The operation is fully transactional — on any resolution failure nothing is written. Resolution only ever writes ocx.lock; it never rewrites the local index at --indexOCX_INDEX$OCX_HOME/index/.

Pass binding names, -g/--group, or both to advance only part of the toolchain instead: every other pin in ocx.lock stays frozen. A scoped update advances each named binding's declared tag to today's resolution and carries every other entry forward unchanged. This only moves the resolution the declared tag already points to — it never changes the declaration itself. To pin a new explicit version, edit ocx.toml directly; that is a declaration change, not an update.

ocx update vs ocx lock

Whole-file ocx update always re-resolves every tag against the registry; scoped to -g/NAME arguments, it re-resolves only those. ocx lock only re-resolves when ocx.toml drifted (idempotent when clean), and prefers the local index like other project-tier commands. To advance versions, use ocx update. To reconcile a changed config, use ocx lock.

The update family

Four verbs share the name; each refreshes exactly one record. ocx index update refreshes the local index at --indexOCX_INDEX$OCX_HOME/index/ocx index sync is ocx index update over a whole registry's catalog rather than a named list. ocx self update refreshes the managed ocx installation. ocx config update refreshes the managed-config snapshot. ocx update refreshes ocx.lock. Under --frozen, ocx update caps discovery at that local index — a declared tag it doesn't already know exits 81. Under --offline, no network call is made at all, which also exits 81 when resolution is required.

Usage

shell
ocx update [OPTIONS] [NAME...]

Options

FlagShortDescriptionDefault
--pullAfter writing the lock, materialise all resolved tools into the object store and create their candidate symlinks. Default when --no-pull is absent.on
--no-pullWrite the lock only; skip materialisation. Defer the install to a later ocx pull or first ocx exec.
--group <NAME>-gAdvance every binding in one or more named groups; freeze the rest. Repeatable and comma-separated (-g ci,lint -g release). The reserved name default selects the top-level [tools] table; all expands to default plus every declared [group.*]. Combine with NAME arguments to advance only those bindings within the named groups.(whole file)
NAME...Binding names to advance; every other pin is frozen. Each name is advanced in every group it appears in (narrow with -g).(whole file)
--checkRe-resolves the selected scope (every declared tag, or only the bindings named by -g/positional names), compares the candidate to the predecessor, and exits 0 (matches) or 65 (DataError, a pin would change). No writes, no commit. When the predecessor lock is absent, exits 78.off
--platform <PLATFORM>-pMaterialise the leaf for the named platform instead of the host — see Platforms for the grammar. Single-valued: passing more than one exits 64. Selects which already-locked leaf to fetch (the lock stays host-agnostic); a target the publisher does not ship exits 78. Defaults to the current host.(current host)
--remoteRedundant — resolution already talks to the registry by default. Still accepted.false
-h, --helpPrint help information.

Target the global toolchain

Pass --global before the subcommand: ocx --global update. See --global.

Exit codes

CodeMeaning
0ocx.lock written, or --check confirmed the candidate matches.
64Missing ocx.toml, --global combined with --project, more than one --platform value (single-valued flag), or an unknown -g group or unknown binding NAME in a scoped update.
65--check reported the candidate would change pinned content (an advisory tag moved upstream), or a scoped update whose ocx.toml has drifted from ocx.lock (hand-edited since the last ocx lock) — run ocx lock to reconcile.
69Registry unreachable while resolving advisory tags.
74I/O error writing ocx.lock.
75Transient failure (rate limit, temporary network error) — retry.
78ocx.toml or existing ocx.lock malformed (parse error), an existing ocx.lock uses an unsupported version (V1/V2 are rejected; regenerate with ocx lock), --check invoked when the lock is absent, a requested --platform is not shipped by a tool, or a scoped update with no existing ocx.lock (there is no predecessor to carry untouched pins forward from) — run ocx lock first.
80Authentication failure against the registry.
81--offline or --frozen and a tag is not cached locally (policy blocked).

Examples

shell
# Re-resolve every declared tag against the registry:
ocx update

# Advance just ripgrep to where its declared tag points today:
ocx update ripgrep

# Advance every tool in the ci group, freezing the rest:
ocx update -g ci

Concurrent invocations of ocx update and ocx lock are serialised via an in-place exclusive flock on ocx.toml.

pull

Pre-warms the object store from the project ocx.lock without creating install symlinks. Distinct from package pull: this is the project-tier entry point — every tool comes from the digest-pinned lock, never from the index — making it the recommended primitive for reproducible CI setups.

ocx pull is read-only on ocx.lock. Re-resolution lives in ocx update; rewriting from the config lives in ocx lock.

Usage

shell
ocx pull [OPTIONS]

Options

FlagShortDescriptionDefault
--group <NAME>-gRestrict the pull to one or more named groups. Repeatable and comma-separated (-g ci,lint -g release). The reserved name default selects the top-level [tools] table; the reserved name all expands to default + every declared [group.*]. When omitted, every entry from the lock is pulled.(all groups)
--dry-runPrint which locked tools are already cached vs. would be fetched, then exit without writing to the store.off
--platform <PLATFORM>-pPre-warm the leaf for the named platform instead of the host — see Platforms for the grammar. Single-valued: passing more than one exits 64. Selects which already-locked leaf to fetch (the lock stays host-agnostic — an amd64 host can pre-warm an arm64 leaf); a target the publisher does not ship exits 78. Defaults to the current host.(current host)
--lazy-mode <MODE>Top tier of the lazy-mode resolution ladder. pull composes nothing, so always changes what is pre-warmed instead of what reaches PATH: a tool the ladder resolves to always gets its metadata, its dependency closure's config blobs, and its generated shim launchers — no content. The content downloads the first time one of those launchers runs, in whatever environment a later ocx exec or ocx env composes.(inherit from ocx.toml / OCX_LAZY_MODE)
--consent / --no-consentWhether to record a consent stamp for the project. Running pull in a directory is normally consent, which is wrong when a build system drives it against a checkout nobody chose. Outranks OCX_NO_CONSENT, which sets the default for a whole pipeline. Suppressing the stamp never suppresses the pull.--consent
--help-hPrint help information.

Target the global toolchain

Pass --global before the subcommand: ocx --global pull. See --global.

Exit codes

CodeMeaning
0Success (or empty group filter — nothing to pull).
64Missing ocx.toml, unknown --group name, empty comma segment, --global combined with --project, or more than one --platform value (single-valued flag).
65ocx.lock is stale (declaration_hash mismatch — run ocx lock).
78ocx.toml present but ocx.lock is missing — run ocx lock first. Also: an existing ocx.lock uses an unsupported version (V1/V2 are rejected; regenerate with ocx lock).
78No leaf digest for the host (or requested --platform) at the locked version (and no "any" fallback key in [tool.platforms]) — the publisher does not ship that platform.

Lock mtime touch

After a successful pull, ocx pull re-saves ocx.lock with byte-identical content so the file's mtime advances. This re-fires ocx direnv watch_file ocx.lock, ensuring direnv refreshes the shell environment once the object store is warmed. The save is skipped under --dry-run.

Outside --dry-run, plain output is a three-column Package / Kind / Path table, one row per pulled tool; --format json is a matching object keyed by pinned identifier, {"path": "...", "kind": "package"|"shim"}. A tool the lazy-mode ladder resolved to never reports its materialized package root and kind: "package"; a tool resolved to always reports the generated shim directory this run created and kind: "shim" — no package root exists for it yet. See Deferred Tools.

One reserved key sits beside the identifier keys: advisories, the same array ocx env and ocx package env publish — {"kind": "...", "package": "...", "key": "...", "message": "..."} objects, one per deferred tool whose declared metadata could not be fully validated. Always present, empty unless a tool composed with --lazy-mode always raised one; warning-only, and written to stderr as well so the plain channel carries it too. No pinned identifier can collide with the key, since every other key is a registry/repository@sha256:... string.

Toolchain render

A warm object store is not yet a usable toolchain: something has to point at those packages under names a shell can resolve. ocx pull does both in one run. After the roots this invocation selected have resolved, it renders the toolchain home, so the tree the shell integration and ocx exec read is current when the command returns — no second step.

Four things are written under <home>/toolchain/, and those four names are the whole of what the tree owns at depth 1:

WrittenWhat it is
shells/default/bin/<name>One launcher trampoline per exposed name. On Windows this is two entries per name — <name>.exe plus its <name>.exec sidecar.
activeA link to shells/default/. It is the PATH-facing spelling: the directory to export is <home>/toolchain/active/bin, and ocx shell state reports it as toolchain_bin.
links/<group>/<entry>/A directory link to a package root, one per selected group. Every name you declared is a component here, one level below the four tree-owned names.
.gitignoreA single * line, written on every render so the tree lands in a directory git already ignores — see Storage.

The render stamp that records this run's entry set — written last, after the final entry and the final prune — is not part of this tree: it lives under $OCX_HOME/state/ — per-project at state/projects/<key>/render_stamp.json, or directly at state/render_stamp.json for the global toolchain — precisely so it survives a toolchain-dir relocation and a checkout wipe of <home>/toolchain/ alike.

The render is a reconcile, not an append: the computed name set is written and every name no longer in it is pruned. A group that leaves the lock is pruned at links/<group>, not at the tree root — so a depth-1 directory named after a locked group is an orphan of the tree's own namespace and is pruned as one.

-g narrows it. A bare ocx pull covers every group the lock declares plus the default group unconditionally; a -g ci,lint run covers exactly those. The trampoline directory belongs to the default group and to nothing else, so a run that narrows the default group away leaves shells/default/bin/ entirely untouched rather than emptied — reconciling it would force the default group's metadata to resolve, growing a network dependency you did not ask for.

The heal is not this command's alone. Every composing emitter repairs the groups it is about to emit before it probes a single link: it creates an absent link and repoints a stale one, then reads. That is a write on ocx env's per-prompt path, and it is what delivers the link lane's "no re-render" behaviour after an ocx update without a pull in between. It is best-effort and silent — a read-only tree, a home the symlink guards refuse, or a link lock it cannot acquire leaves the entry unrepaired, and the composition emits that entry's digest path instead.

--dry-run reports the delta and writes nothing. It also performs no heal: a poisoned or stale link is still there afterwards, because every write step the heal needs is a step a dry run does not take.

A render failure never fails the pull. The command's product is a warmed object store, and that work is finished before the render starts; a read-only checkout, a foreign-owned .ocx/, a lock timeout, or a metadata closure an offline invocation cannot walk are all states where the warming still succeeded. Whatever the render could not do is reported as a warning on stderr and ocx pull still exits 0. The render outcome is never the exit code — script against the warmed store, not against the tree.

What a skipped entry looks like

Each thing the render could not write is one warning: line on stderr naming the artifact, the path, and the reason. The reason is what you act on; the exit code says nothing, by the paragraph above.

The reason most people meet is a directory sitting where a link belongs. cp -rL, rsync without -l, Docker COPY and most zip extractors dereference symlinks, so a project copied by one of them arrives with links/<group>/<entry> as a full directory copy of the package instead of a link:

$ ocx pull
warning: Toolchain render skipped link "cmake" in group "default" at "/work/copy/.ocx/toolchain/links/default/cmake": "a directory occupies this link's name — the mark of a symlink-dereferencing copy (`cp -rL`, `unzip`, `rsync` without `-l`, Docker `COPY`); it is left in place rather than deleted recursively. Remove '/work/copy/.ocx/toolchain/links/default/cmake' and run `ocx pull` again"

The remedy is the message's last clause: delete that one path, then pull again. The directory is left byte-for-byte intact rather than removed recursively, because both of its name components come from ocx.lock and a recursive delete driven by a file's contents is not a thing this command does. Until it is deleted the entry is skipped on every render, and that entry composes on its digest path — correct, just not moved by a later ocx update.

The active link needs no such action. A copy that turned it into a real directory is repaired silently on the next render: no name in that path comes from a lock or a config file, so ocx can replace it without deciding anything about your data.

Dry-run preview

ocx pull --dry-run resolves each locked tool through the local index (cache-first, like the real pull does) and reports whether it is already in the store. The store is never modified. Combine with --offline to forbid the cache-miss network probe entirely.

shell
$ ocx pull --dry-run
Package                                     Status
localhost:5000/cmake@sha256:1f4a9c2e7b03    cached
localhost:5000/ripgrep@sha256:8d2b60fa1c95  would-fetch

Plain output shortens each locked leaf to a 12-hex digest; the full pin rides out under --format json, which also carries a path field. That path matches the contract of ocx package which: it is the package root (parent of content/ and entrypoints/), not the content/ subdirectory, and it is populated only for cached rows. Consumers traverse into <path>/content/ for files or prefer ocx env to compose PATH and friends.

The staleness gate fires ahead of the dry-run branch, so a stale lock still exits 65 — the preview is not a way to bypass declaration_hash validation. The output respects --format json and --quiet.

remove

Alias: rm.

Removes one or more tool bindings from ocx.toml, rewrites ocx.lock, and uninstalls the tools.

Each argument accepts either a bare binding name (cmake), a name with a tag (kitware/cmake:3.28), or a fully-qualified identifier (ocx.sh/kitware/cmake:3.28). An identifier form is reduced to the repository basename — the tag and registry are used only to locate the correct entry and the installed package; the key match is against the TOML map key. A binding added under an explicit name (ocx add glab=ocx.sh/gitlab/cli) is matched only by that name — remove it with ocx remove glab, not its identifier. Fails with exit code 79 if any argument matches no binding; the removals are staged together, so a fail-fast leaves ocx.toml unchanged.

When the same binding name appears in more than one group (e.g. in both [tools] and [group.ci]), ocx remove cannot determine which entry to drop and exits with code 64. Pass --group to make the target group explicit:

shell
ocx remove cmake                  # ok — unambiguous
ocx remove --group ci shellcheck  # removes from [group.ci] only
ocx remove shellcheck             # error 64 — ambiguous; use --group

Usage

shell
ocx remove [OPTIONS] <IDENTIFIER>...

Arguments

  • <IDENTIFIER>...: One or more binding names or fully-qualified identifiers to remove (e.g. cmake, kitware/cmake:3.28, or ocx.sh/kitware/cmake:3.28). A binding added under an explicit NAME= is addressed by that name only.

Options

FlagShortDescription
--group <NAME>-gRemove the binding from this named group only. Use when the same name exists in multiple groups.
--help-hPrint help information.

Target the global toolchain

Pass --global before the subcommand: ocx --global remove ripgrep. See --global.

Exit codes

CodeMeaning
0Binding removed, lock rewritten.
1The in-place ocx.toml edit could not be expressed safely (rare); the command aborts rather than falling back to a lossy rewrite.
64No ocx.toml found in scope, binding name is ambiguous across groups (use --group), or --global combined with --project.
65ocx.toml drifted from ocx.lock before this remove — run ocx lock to reconcile.
74I/O error reading or writing ocx.toml or ocx.lock.
75Another ocx process holds the project lock on ocx.toml. Retry with backoff.
78A survivor's legacy lock entry can no longer be migrated exactly — run ocx update to re-resolve. Also: ocx.toml schema invalid or TOML parse error.
79Binding not found in the specified group (or any group when --group is omitted).

select

Moved to ocx package select — exits 64 if invoked as bare ocx select. See package select for the current form.

Selects one or more packages as the current version by updating the current symlink.

Each package is resolved using the selected index. No downloading is performed — the package must already be installed.

Usage

shell
ocx package select [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to select.

Options

  • -p, --platform: Target platform to consider when resolving packages.
  • -h, --help: Print help information.

TIP

ocx package install --select installs and selects in one step.

See path resolution modes for how the current symlink is used downstream.

Entry-point name collisions

Entry point name collisions are checked at two distinct points; select itself performs no collision check, since flipping current does not compose environments.

The first gate is at install time, scoped to a single package's interface closure. When install (with or without --select) downloads a package whose own bundle plus its interface-visible transitive deps declare the same entry-point name twice, the install aborts before the temp→object-store atomic move and exits with EntrypointCollision (exit code 65, DataError). All N owning packages are listed so the publisher can deselect the right one.

The second gate is at consumption time, invoked whenever ocx env or ocx package exec is given two or more roots. The compose-time check projects each root's interface surface (bundle entry points plus interface-visible TC entries) and reports the same EntrypointCollision error if two roots claim the same name. This catches conflicts that the per-package install gate cannot see — two roots installed independently without --select and combined only at exec time.

select is symlink-only: it flips current for a single package and never composes the environment, so it has no entry-point collision check. See Exit codes for the full taxonomy.

shell

hook

REMOVED — exits 64. The ocx shell hook command itself is gone, along with the stateful _OCX_APPLIED fingerprint variable it kept.

The per-prompt hook it managed is not gone — it moved. ocx self setup wires it into your shell profile, ocx self activate emits it on every prompt, and ocx shell state reports whether it is active. See Shell Integration for the full mechanism.

env

REMOVED — exits 64. The ocx shell env command has been removed.

For eval-safe shell export of package env, use the root ocx env --shell=<SHELL> command (toolchain-tier) or ocx package env for OCI-tier packages.

completion

Generate shell completion scripts for ocx.

Usage

shell
ocx shell completion [OPTIONS]

Options

  • --shell <SHELL>: Shell to generate completions for. One of bash, zsh, fish, elvish, powershell. Auto-detected from the parent shell when omitted; ocx fails with an error if the detected shell is unsupported. nushell is not supported for completions (clap has no Nushell completion backend); this does not affect ocx env --shell=nushell activation, which works independently.

Install examples

shell
# add to ~/.bashrc
source <(ocx shell completion --shell bash)
shell
# write into the first fpath entry, then `compinit`
ocx shell completion --shell zsh > "${fpath[1]}/_ocx"
shell
# load for the current session, or save under ~/.config/fish/completions/ocx.fish
ocx shell completion --shell fish | source
powershell
# add to $PROFILE
ocx shell completion --shell powershell | Out-String | Invoke-Expression

state

Report why the per-prompt shell integration hook is — or is not — active in the current shell.

self activate --reconcile runs at every prompt, but it never explains itself: an inert shell and an active one produce the same silence. state is the read-only diagnostic that fills that gap — it decodes the same __OCX_ENV_STATE carrier the hook writes, re-derives the same fingerprint, resolves the same project, and prints the one enumerated reason the shell is inert (or confirms it is active), without writing anything.

Usage

shell
ocx shell state                 # the answer
ocx shell state --verbose       # the answer, plus the evidence behind it
ocx --format json shell state   # the complete structured report

Options

FlagMeaning
-v, --verboseAdd the diagnostics behind the answer: the decoded ledger with its carrier accounting, the fingerprint watch set with each member's size and mtime, the project's state key and stamp, and the hook ladder. Plain text only.

The default text output is the answer and nothing else — where $OCX_HOME is, which project is in effect, whether the integration is active, and, when it is not, the enumerated reason and the one line that says what to do about it. Everything --verbose adds is diagnostic detail for a support conversation, not an answer to "is it working".

--verbose is a rendering tier, not a payload. The root --format flag set to json emits the complete structured report — ocx_home, ocx_home_present, shell_integration_installed, toolchain_home, toolchain_bin, activate, pinned, lock_refusal, carrier_present, carrier_bytes, ledger, fingerprint_current, watch_set, project_dir, project_key, project_stamped, grant, stamp_written_at, priors, hook, yielded_to, inert_reason, notes — and that document is identical with and without --verbose. A machine consumer never sees less because a human flag was absent.

Output is coloured when stdout is a terminal and colour is enabled (see --color): the verdict, the reason, and the fix are highlighted so they can be found at a glance. Redirected to a file or a pipe, the text is byte-identical minus the escapes.

Never eval-able. Every line of the text form starts with a fixed label this command owns — a heading in the first column, or an indented label, or an indented - list marker — and every interpolated value (a ledger key, a project directory, a source name) is quoted with Rust's own {:?} escaping, never shell-escaped. No line of ocx shell state output is valid export/set/$env. syntax in any of the ten supported shell dialects, for every enumerated inertness reason, at either detail tier, coloured or not, so pasting a diagnostic into a live shell can never execute it — the deliberate opposite of ocx self activate, whose entire output is meant to be eval'd.

Which grant activated this project. When a project is active, state names the clause that granted it — grant in the structured report, a granted by: line in the text form. There are three, and they are not interchangeable:

grantText formWhere it comes from
stampa consent stamp, present (written …)A consent stamp under $OCX_HOME/state/projects/<key>/, written by ocx shell allow or as a side effect of one of the other consent-writing commands. stamp_written_at carries the instant the stamp itself records. Remove it with ocx shell revoke.
path[shell.consent] pathsA [shell.consent] paths entry in config.toml naming this directory. Revoked by editing that file.
namespace[shell.consent] namespacesA [shell.consent] namespaces grant covering every source the store corroborates for this lock. Authorizes the project's packages only, never its own [env]. Revoked by editing config.toml.

A stamp outranks the [shell.consent] table, so a project can be active with nothing in config.toml to explain it. That is what this row is for.

Exit codes

ConditionExit code
Any reportable state — active, inert (for any of the enumerated reasons below), a corrupt or over-cap ledger, yielded to another tool0
$OCX_HOME exists but cannot be read74

An inert shell is a finding, not a failure: the reason is the payload. The only failure path is an unreadable $OCX_HOME — a genuinely absent one is the ordinary state of a fresh install and still reports normally.

Inertness reasons

state enumerates why the hook has not activated the current project, in the same precedence the hook itself applies:

ReasonMeaning
Hook disabled--hook/--no-hook, OCX_NO_HOOK, or [shell] hook decided against running the hook at all; the report names the deciding rung and tier
Yielded to another toolA live direnv or mise sentinel was observed and ocx stepped back rather than compete for the prompt
ocx.lock unavailableocx.lock is absent, unreadable, or unparseable, so the source-set predicate driving the consent stamp check has nothing to quantify over
Uncorroborated namespaceThe project's sources match a [shell.consent] namespaces grant by the lock's claim, but the package store's own record of where those digests came from does not corroborate it; the report names both the claimed and the verified source sets
Source-set driftThe lock's source set is no longer a subset of what the stamp last saw; the report names the new sources
No consent stamp, no matching grantThe project has neither a stamp — from ocx shell allow, ocx init, or one of the consent-writing commands — nor a path grant covering it
Ledger over capA scope's applied-variable count exceeds the carrier's size budget
Ledger absent or corruptA carrier present but undecodable is the corrupt case. An unset carrier splits on whether ocx self setup has ever wired this shell (probed as $OCX_HOME/env.sh): wired reports "nothing has been applied in this shell yet" (fix: none needed, the next prompt applies it); never-wired reports "the shell integration has never been installed here" (fix: run ocx self setup, then start a new shell)

When none of these apply and a project is consented but not yet reflected in the carrier, state reports one of two outcomes, distinct from both active: yes and every inert reason above. If the lock still composes, it reports active: not yet — the scope is consented and will apply on the shell's next prompt. If consent allows the project but its ocx.lock refuses composition (absent, or stale against ocx.toml), every prompt would otherwise exit 65 (its stderr discarded by the hook) before the scope ever reaches the ledger, silently promising a convergence that never arrives — state instead reports active: no, names the refusal, and points at ocx lock.

allow

Consent to a project's shell integration: record the consent stamp that lets a shell prompt apply that project's tools and environment.

Six commands — add, remove, lock, update, pull, exec — already write this stamp as a side effect: running a mutating ocx command in a directory is consent. allow is the way to write one on purpose, without mutating anything.

The stamp records the source set the project's ocx.lock resolves from at the moment it is written. Adding a tool from a new registry or organisation invalidates it (state reports source-set drift); run allow again to consent to the wider set.

Usage

shell
ocx shell allow            # consent to the project governing the current directory
ocx shell allow ../other   # consent to the project governing another directory

Arguments

ArgumentDescription
PATHThe directory whose project to consent to. Defaults to the current directory. The upward walk from PATH is the one a shell prompt makes, so this consents to exactly the project a prompt would activate. A --project or --global selector still takes precedence.

Exit codes

ConditionExit code
The stamp was written0
No ocx.toml governs PATH64
PATH names the ocx home — the global toolchain is always consented and never carries a stamp64

Grants that live in configuration instead — a directory under [shell.consent] paths, an organisation under [shell.consent] namespaces — are edited in config.toml, not here. ocx shell state reports which of the three is in effect.

revoke

Withdraw a project's consent stamp. The next shell prompt stops applying that project's tools and environment.

Immediately effective: the activation predicate reads the stamp file on every prompt. It does not touch [shell.consent] grants — if a paths or namespaces entry still covers the directory, the project stays active and ocx shell state says which one.

Revoking a project that carries no stamp is not an error: the result is the state you asked for either way.

Usage

shell
ocx shell revoke            # revoke the project governing the current directory
ocx shell revoke ../other   # revoke the project governing another directory

Arguments

ArgumentDescription
PATHThe directory whose project to revoke. Defaults to the current directory, resolved by the same walk ocx shell allow uses.

Exit codes

ConditionExit code
The stamp was removed, or there was none to remove0
No ocx.toml governs PATH64

A later ocx add, ocx lock, ocx pull, ocx exec, ocx remove or ocx update in that directory writes the stamp again.

init

REMOVED — exits 64. The ocx shell init command has been removed.

Global toolchain activation at shell start is handled by $OCX_HOME/env.sh, written by the in-repo installer with a block-marker idempotent .-source line in the login profile; the file runs eval "$(ocx --global env --shell=sh)". In bash, zsh, fish, PowerShell, and elvish (whose guard checks only the carrier and the working directory, not a watch-set stat), the same install also wires a per-prompt hook that keeps both the global toolchain and a consented project's tools converged after that — see Shell Integration. nushell's directory-change hook keeps the global toolchain live the same way, without a full per-prompt reconcile. For the strict-POSIX shells (ash, dash, ksh) and Windows Batch, which have no append-safe hook point at all, use ocx direnv for project toolchain activation.

self

The ocx self group manages the OCX installation itself: PATH activation, shell-completion injection, and binary self-update.

self setup

Complete a bare-binary install: bootstrap OCX into the content store, write the per-shell env shims ($OCX_HOME/env.*), and add a managed activation block to the detected shell profiles.

This is the answer to "I won't pipe curl into a shell": download the standalone ocx binary from GitHub Releases, run ocx self setup, and reach the same state the install script produces — no shell script involved. The loose binary bootstraps the managed copy, writes the shims, and wires shell profiles in one command.

Setup runs phases in a hard order: bootstrap first (install the specified or latest published ocx.sh/ocx/cli so the shims have a current to point at — a no-op when the same version is already installed), then managed-config adoption (resolve the ref from --managed-config, else OCX_MANAGED_CONFIG, else the existing seed; whichever one resolves is synchronously fetched and persisted, then the [managed] seed fence is written only on success — a fetch failure leaves no partial state; no source at any of the three levels reports not_configured and the phase is a no-op), then the five env.* shims, then the profile activation blocks. A failed bootstrap stops the run before any shim, profile, or managed-config write is touched.

Re-running is safe. The shims and the managed block are diff-gated: an unchanged setup is a no-op. A stale ocx-authored block is rewritten in place (format upgrade); a legacy # BEGIN ocx block is migrated to the versioned fence. A block the user edited by hand is reported dirty and left untouched (exit 82) unless --force is passed.

Usage

shell
ocx self setup [VERSION] [--toolchain-activate MODE] [--no-modify-path] [--profile PATH]... [--dry-run] [--force] [--managed-config REF]

Arguments

ArgumentDescription
VERSIONOptional version to install. Three forms are accepted:
1.2.3 — install the release with that tag.
sha256:<64hex> — install the exact content identified by that digest (no tag resolution; written bare, without @).
1.2.3@sha256:<64hex> — install that tag and verify it resolves to the given digest (immutability assertion). If the tag resolves to a different digest, the command fails with exit 65 and names both digests. Under --frozen, comparison uses the local index; a mismatch message hints ocx index update.
Omit VERSION to install the latest published release. The literal value latest is treated as an ordinary tag lookup and resolves only if the registry publishes such a tag — omitting VERSION is the recommended way to request the latest release. Malformed input exits 64.

Options

FlagShortDescriptionDefault
--no-modify-pathWrite the env shims but touch neither a shell profile nor the session PATH. Suppresses both surfaces; the run reports each location it did not touch, as skipped_opt_out. Equivalent env var: OCX_NO_MODIFY_PATH (truthy). The opt-out is not remembered between runs.off
--toolchain-activate MODEWrite activate = "MODE" into $OCX_HOME/ocx.toml, the tier that decides how the global toolchain reaches a shell. MODE is env, bin or none; the three meanings are in the activate reference, where bin and none compose the same PATH for this tier. A project's own ocx.toml decides for that project, and OCX_TOOLCHAIN_ACTIVATE is the weakest tier of both. Omit to leave ocx.toml untouched; the file is created carrying only this key if absent. An unknown mode exits 64.(untouched)
--profile PATHOverride the auto-detected profiles; repeatable. Explicit targets use POSIX-fence semantics regardless of file name.(autodetect)
--dry-runReport what would change and write nothing. Resolves the version and reports WouldPull with the resolved digest, but writes nothing. Never returns exit 82.off
--forceOverwrite a managed block that carries user edits (the dirty state).off
--managed-config REFAdopt (or clear) the corporate managed-config tier. REF is resolved as an OCI reference, synchronously fetched and persisted, then the [managed] seed fence in config.toml is written only on success — a fetch failure leaves no partial state. Pass --managed-config "" to clear an existing seed and delete the snapshot. Omitting the flag does not skip resolution: it falls back to OCX_MANAGED_CONFIG, then the existing seed. Every run reconciles whichever source resolves — a wiped or mismatched snapshot self-heals (hard-fail on a fetch error, same as first adoption), and an already-adopted seed is re-synced to whatever the registry serves now, so a newer published config is picked up without a separate ocx config update. That re-sync is best-effort once a matching snapshot already exists on disk: a fetch failure warns on stderr and keeps the existing snapshot (exit 0) instead of failing the run.(resolved: env, then existing seed)
--hookPersist the per-prompt shell integration hook: writes [shell] hook = true to config.toml. Last of --hook/--no-hook wins.(untouched)
--no-hookPersist the per-prompt hook off: writes [shell] hook = false.(untouched)
--completionPersist shell completions on: writes [shell] completions = true.(untouched)
--no-completionPersist shell completions off: writes [shell] completions = false.(untouched)
-h, --helpPrint help information.

Omitting a flag from either pair leaves the corresponding config.toml key untouched — self setup only ever writes the keys you name. This is the persistent counterpart to self activate --hook/--no-hook, which decides the same ladder rung for one session only and never touches disk.

Session PATH

A shell profile reaches login shells. It does not reach an IDE, a desktop launcher, or a background service started outside any shell — those inherit their environment from the session, and a PATH written into ~/.zshrc never gets there. So ocx self setup writes a session-level registration too, co-primary with the profile block rather than a fallback for it.

Two directories are registered, in this order, so a global toolchain that pins ocx is what a session resolves and the installed binary is the floor beneath it:

  1. $OCX_HOME/toolchain/active/bin — the global toolchain's trampolines.
  2. $OCX_HOME/symlinks/<ocx cli id>/current/content/bin — the directory the installed ocx itself resolves from.

Each platform has exactly one store:

PlatformStoreMechanism
WindowsHKCU\Environment, value PathWritten as REG_EXPAND_SZ, then a WM_SETTINGCHANGE broadcast so running applications pick the change up.
Linux$XDG_CONFIG_HOME/environment.d/ocx.conf (~/.config/environment.d/ocx.conf when XDG_CONFIG_HOME is unset)One environment.d line, PATH=…:$PATH — a prepend, so nothing another tool put on PATH is displaced.
macOS~/Library/LaunchAgents/sh.ocx.path.plistA RunAtLoad LaunchAgent labelled sh.ocx.path, published at mode 0644 (launchd refuses an agent with "dubious permissions") and bootstrapped into the current GUI domain.

The registration is idempotent by presence test, never by append: a second ocx self setup reports unchanged and the stored value does not grow.

A write failure is a warning, not an exit code. A store that cannot be written is reported and warned about, and setup still exits 0; re-running retries it. The one refusal that is fatal is an $OCX_HOME this platform's format cannot spell — a % or ; on Windows, a character environment.d cannot carry, a " the plist quoting cannot carry. That is checked before anything is written, so a refused run leaves the machine byte-identical, and it exits 78.

Each store's reach has limits worth knowing. environment.d is read only by processes started under systemd --user — confirmed for GNOME and KDE Plasma Wayland, and read by neither LightDM (by default), SDDM, nor a non-systemd desktop; a Flatpak- or Snap-sandboxed application takes its PATH from the sandbox instead. ~/.pam_environment is deliberately not written — it has been deprecated since pam_env 1.5.0. On every platform, terminals and applications already open see nothing until they are restarted.

Opting out

--no-modify-path (or a truthy OCX_NO_MODIFY_PATH) suppresses this arm along with the profile blocks — the writers are never called. Each store still appears in the run summary, as skipped_opt_out, so you can see what was not touched.

Removing the session PATH by hand

There is no ocx self uninstall yet (#413), so a session-PATH registration is reversed by hand. One location per platform:

Edit, never delete

On Windows the registry value is shared: HKCU\Environment\Path holds every other user PATH entry too. Remove ocx's two segments from the value; do not delete the value. The same rule is why launchctl unsetenv PATH must never be used on macOS — it deletes the whole session-wide value rather than ocx's contribution to it, stripping every other tool's segment from every GUI application launched afterwards. ocx has no wrapper for it, deliberately.

powershell
# Open the built-in editor, remove the two ocx segments from the user Path, and
# save — saving preserves the value's type and broadcasts the settings change
# for you, which is why this is the recommended route.
rundll32 sysdm.cpl,EditEnvironmentVariables

# Or from PowerShell, through the registry API rather than [Environment]:
# [Environment]::SetEnvironmentVariable(..., 'User') always writes REG_SZ,
# no matter what type the value had before. That is a permanent downgrade —
# even if today's Path holds no %VAR% reference, no %VAR% any tool adds to
# Path later will ever expand again. The registry API below reads the value
# unexpanded and writes back the same type it read, matching what ocx's own
# removal does; only ocx's own *registration* writes REG_EXPAND_SZ
# unconditionally (see the table above). Unlike the built-in editor and
# [Environment], it does not broadcast WM_SETTINGCHANGE — already-open
# terminals need a restart, or send the broadcast yourself.
# Read $root from a guarded expression, never bare $env:OCX_HOME: an empty
# prefix would match every segment and strip the whole PATH.
$root = if ($env:OCX_HOME) { $env:OCX_HOME } else { Join-Path $HOME '.ocx' }
$key  = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$path = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames')
$kept = ($path -split ';' |
  Where-Object { $_ -and -not $_.StartsWith($root, 'OrdinalIgnoreCase') }) -join ';'
$key.SetValue('Path', $kept, 'ExpandString')
$key.Close()
sh
rm ~/.config/environment.d/ocx.conf
# Or $XDG_CONFIG_HOME/environment.d/ocx.conf when that variable is set.
# Takes effect at the next login; the current session keeps its PATH.
sh
# Boot out the *service*, then delete its plist.
launchctl bootout "gui/$(id -u)/sh.ocx.path"
rm ~/Library/LaunchAgents/sh.ocx.path.plist

gui/<uid> is not gui/<uid>/sh.ocx.path

launchctl bootout gui/$(id -u) names the domain, not the service: it tears down your entire GUI login session. The service target — the one above, with /sh.ocx.path on the end — is what removes this one agent. Booting out an agent that is not loaded is not an error, so the command is safe to run twice.

ocx's own writer does not treat the two directions the same way, and the difference matters for anyone editing this by hand. Registering ocx's entries (ocx self setup) always writes REG_EXPAND_SZ, even over an existing REG_SZ value — deliberately, because rustup once shipped REG_SZ here and broke %VAR% expansion for every other entry already on Path. Deregistering them (the reversal above) preserves whatever type it read instead: removal never promises to upgrade or downgrade a value it is not otherwise touching. Both directions read the value unexpanded, so a foreign %…% reference already on Path is never flattened into whatever it resolves to at that moment. Prefer the built-in editor above when hand-editing: it is the one form here that both preserves the type and broadcasts the change for you.

This section only covers the session PATH

Reversing the session-PATH registration above does not remove ocx. Also still in place: the managed shell-profile block (the # >>> ocx v1 … >>> fence, or the dedicated fish/Nushell file), the $OCX_HOME/env.* shims, and $OCX_HOME itself — see Uninstalling for that recipe. Neither recipe touches a project's own rendered toolchain, either: delete .ocx/toolchain/ (or the toolchain-dir root, if configured) in every project you have run ocx pull or another render-performing command in. There is no ocx self uninstall to do any of this in one step yet (#413).

Version grammar

The VERSION positional applies to the ocx.sh/ocx/cli identifier as a suffix. It never accepts a registry or repository — only the tag, digest, or tag-plus-digest portion.

FormExampleBehavior
Tag only0.9.2Resolves the tag, installs, points current at it.
Digest onlysha256:ab12…Fetches by content digest; version field omitted from JSON output.
Tag + digest0.9.2@sha256:ab12…Resolves tag, cross-checks digest (immutability assertion), fails closed on mismatch (exit 65).

sha256 (64 hex chars) is the standard OCI digest algorithm; sha384 (96 hex chars) and sha512 (128 hex chars) are also accepted — except by package sign and package attest, which refuse a non-sha256 subject with exit 65 (subject_digest_unsupported) because cosign artifacts address their subject by sha256 alone. Hex digits must be lowercase for all three algorithms — uppercase letters are rejected with exit 64.

Tag characters are restricted: the first character must match [a-zA-Z0-9_]; subsequent characters must match [a-zA-Z0-9._-]; maximum 128 characters. The + character is accepted in tag strings and normalized to _ internally (the adr_version_build_separator.md convention).

A sha256: digest pin selects a platform-specific package digest — the same tag yields a different digest per OS and architecture. For CI matrices, pin by tag (each runner resolves its own platform digest) or supply a per-platform digest map; never share one digest across platforms.

When a pinned version is already installed and already pointed to by current, the command exits 0 with status already_present — no re-download.

When a pinned tag is semver-older than the currently installed version, a warning is emitted to stderr and the downgrade proceeds. This is an informational signal for CI logs, not a block.

The [--frozen] global flag affects pin resolution: a tag-only pin not present in the local index exits 81. A digest-only pin works under --frozen when the blobs are already cached locally.

JSON output (--format json)

A typical pinned run that pulled a new version:

json
{
  "status": "completed",
  "bootstrap": {
    "status": "pulled",
    "version": "0.9.2",
    "digest": "sha256:ab12cd34..."
  },
  "shims": [
    "/home/alice/.ocx/env.sh",
    "/home/alice/.ocx/env.fish"
  ],
  "profiles": [
    {"path": "/home/alice/.bashrc", "outcome": "completed"},
    {"path": "/home/alice/.zshrc", "outcome": "no_op"}
  ],
  "session_path": [
    {"location": "/home/alice/.config/environment.d/ocx.conf", "outcome": "written"}
  ],
  "reload_hint": true,
  "managed_config": {"status": "not_configured"}
}

The root object is discriminated by status:

FieldTypeDescription
statusstringTop-level run outcome — one of completed, no_op, skipped, migrated (see table below).
bootstrapobjectNested sub-object describing the ocx binary install step (see below).
shimsarray of stringsAbsolute paths to the env shim files written during this run. Empty when no shims changed.
profilesarray of objectsPer-profile outcome: `{"path": "…", "outcome": "completed"
session_patharray of objectsPer-store session-PATH outcome: {"location": "…", "outcome": "written"|"unchanged"|"removed"|"skipped_opt_out"|"skipped_unsupported"|"failed"}. Always present, one entry per store this host owns — including the skipped states. Empty only on a platform with no session-PATH facility at all.
dirty_profilesarray of stringsPaths of profiles that carried user edits and were skipped. Present only when status is skipped.
exec_policy_warningstringWindows-only advisory when the execution policy is Restricted. Omitted when absent.
conflicting_ocxstringAbsolute path to a shadowing ocx binary found ahead of the shim directory on PATH. Omitted when absent.
reload_hintbooleantrue when this run changed a PATH surface — a shim, a managed profile block, or a session-PATH store. A shim or profile change is applied by re-sourcing the shell; a session-PATH store reaches programs started outside a shell only after the next login. Omitted when false.
managed_configobjectResult of adopting or clearing the --managed-config tier (see below). Always present, even when the flag was not passed.

Root-level status values:

ValueMeaning
completedAt least one shim or profile was written or upgraded.
no_opEverything was already current; nothing changed.
skippedAt least one profile, or the [managed] config fence, carried user edits and was left untouched (no --force). Exit 82.
migratedA legacy activation block was migrated to the versioned fence; no dirty profiles or fence.

managed_config object — result of the managed-config adoption phase, discriminated by managed_config.status:

ValueCarries digest?Meaning
not_configuredNoNo source resolved from --managed-config, OCX_MANAGED_CONFIG, or an existing seed.
already_adoptedYesThe resolved ref matches the existing seed AND a matching snapshot is on disk. Either the registry was checked and still serves the same content (verified, not assumed), or the check was skipped outright — a digest-pinned seed (content-addressed, cannot drift), --offline, or an in-force config update --pause. The digest is the existing snapshot's digest. A wiped or mismatched snapshot self-heals instead: the run re-fetches and reports adopted.
adoptedYesA new or changed ref was fetched, persisted, and the seed fence written. Also covers self-heal of a wiped or mismatched snapshot behind a fence that was already current.
refreshedYes (+ previous_digest)The resolved ref matched the existing seed, but the registry now serves newer content than the on-disk snapshot: the snapshot was replaced in place — the fence itself is untouched, only rewritten on an adopted transition. digest is the new content; previous_digest is what the snapshot carried going in.
refresh_unavailableYes (+ reason)The re-sync of an already-adopted seed could not reach the registry. The existing snapshot is kept and the run still exits 0 — reason carries the fetch error, and the same message is written to stderr as a warning. Re-run, or run ocx config update directly, to retry.
clearedNo--managed-config "" removed the seed fence and deleted the snapshot.
dirtyNoThe [managed] fence carries user edits; left untouched without --force — drives root status: skipped (exit 82).
would_adoptNo--dry-run: a first adopt, a self-heal of a wiped or mismatched snapshot, or a clear would run, but nothing was fetched or written.
would_refreshYes--dry-run against an already-adopted seed: a re-sync would run, but nothing was fetched or written — dry-run never touches the network, so this does not confirm the registry actually has newer content.

jq .status returns the root discriminant, not the bootstrap status

jq .status on a self setup --format json result returns completed, no_op, skipped, or migrated — the overall run outcome. The bootstrap-specific values (pulled, already_present, would_pull) are nested one level deeper under bootstrap.status. Use jq .bootstrap.status to inspect the binary install step.

The bootstrap sub-object:

FieldTypeDescription
bootstrap.statusstringBinary install outcome: already_present, pulled, or would_pull (dry-run).
bootstrap.versionstringVersion string of the installed or would-install release. Omitted for digest-only pins.
bootstrap.digeststringPlatform-selected content digest in sha256:<hex> form. Present only on pinned runs; omitted on unpinned latest-release runs so JSON consumers stay byte-identical to prior behaviour.

bootstrap.status values:

ValueMeaning
already_presentThe requested version was already installed: on a pinned run, current already pointed at the pinned digest; on an unpinned run, the latest published release was already current.
pulledThe version was downloaded and current updated.
would_pullDry-run: this version would be downloaded.

The version field is omitted for digest-only pins. The digest field round-trips as a pin: use jq -r .bootstrap.digest to extract it and pass it back as ocx self setup "0.9.2@$digest".

To script against the bootstrap outcome:

shell
result=$(ocx --format json self setup 0.9.2)
root_status=$(echo "$result" | jq -r .status)          # completed / no_op / skipped / migrated
bootstrap_status=$(echo "$result" | jq -r .bootstrap.status)  # pulled / already_present / would_pull
digest=$(echo "$result" | jq -r '.bootstrap.digest // empty')  # sha256:<hex>, or empty when unpinned

Exit codes

CodeMeaning
0Setup completed, no-op, or migrated; or a dry-run (including over a dirty profile or fence).
64Malformed VERSION syntax (empty, short or uppercase hex, unknown algorithm, double @, trailing @).
65tag@digest immutability assertion failed — the tag resolved to a different digest than the one specified. Also returned when a --managed-config sync fetch succeeds but the package is malformed (no any/any entry, no config.toml, digest mismatch, over the 64 KiB cap, or not valid TOML).
69Registry unreachable while bootstrapping, or while syncing a --managed-config snapshot.
74I/O error writing a shim, shell profile, or --managed-config snapshot.
78The --managed-config value is not a valid OCI identifier. Also: $OCX_HOME cannot be spelled in this platform's session-PATH format (a % or ; on Windows, a character environment.d cannot carry, a " the plist quoting cannot carry). Checked before anything is written, so a refused run leaves the machine byte-identical. A session-PATH write failure is not here — it warns and exits 0.
79The pinned tag or digest was not found in the registry.
80Authentication failed while syncing a --managed-config snapshot.
81A policy (--offline or --frozen) blocked resolution and the version was not cached locally.
82A managed activation block — a shell profile fence or the [managed] config fence — carried user edits and --force was not passed. Scripts can case $? in 82) to detect this and re-run with --force.

Where codes 65, 69, 74, 79, and 80 above concern the [managed] tier, they apply to the fetch that establishes the seed — first adoption, self-heal of a wiped or mismatched snapshot, or an explicit clear; the same codes also arise from the bootstrap phase (installing the pinned binary), independent of managed config. The re-sync of an already-adopted seed is best-effort instead: a failed re-sync fetch (or a published payload that fails validation) reports managed_config.status: "refresh_unavailable" and still exits 0, rather than failing the whole run — a failure while writing the refreshed snapshot to disk still errors (74), since the on-disk state may no longer be the retained one.

Examples

shell
# Install the latest published release (default behavior):
ocx self setup

# Install a specific release by tag:
ocx self setup 0.9.2

# Install a specific release and assert the exact content:
ocx self setup 0.9.2@sha256:ab12cd34ef56...

# Install by digest alone (useful when a prior JSON run produced the digest):
ocx self setup sha256:ab12cd34ef56...

# Repeat a prior run's pin using the JSON output's digest field:
digest=$(ocx --format json self setup 0.9.2 | jq -r .bootstrap.digest)
ocx self setup "0.9.2@$digest"   # round-trip: asserts the same content

# Adopt the corporate managed-config tier (sync fetch, then seed the fence):
ocx self setup --managed-config internal.company.com/ocx-config:user

# Clear a previously adopted managed-config tier:
ocx self setup --managed-config ""

self activate

Emit eval-safe shell activation lines for the current OCX installation.

Running ocx self activate prints three blocks of shell code to stdout:

  1. Two PATH prepends: the resolved absolute path to <OCX_HOME>/symlinks/ocx.sh/ocx/cli/current/content/bin, and then <OCX_HOME>/toolchain/active/bin — the global toolchain's trampolines — which lands in front of it, so a pinned ocx wins over the installed one. Both paths are resolved at runtime from the binary's own OCX_HOME — no shell variable reference is emitted. Both are emitted in every activate mode: they are session-level directories, so a shell that opened before ocx self setup registered them with the OS, or on a host where that registration does not apply, still reaches the global toolchain.
  2. A completion script for the detected shell — emitted inline into the activation stream, only when completions are enabled (skipped silently when OCX_NO_COMPLETIONS=1 is set, when --no-completion is passed, when the session is non-interactive, or when the shell has no clap_complete backend). The completion block is emitted first so that, for PowerShell, its using namespace directives lead the stream — Invoke-Expression accepts them only as the first statement. Every shim states its own interactivity explicitly through a hidden --interactive/--no-interactive flag pair, using the test its own shell language provides ($- on POSIX, status is-interactive on fish, [Console]::IsInputRedirected on pwsh, a test -t 0 probe on elvish), and that answer feeds the completion gate; a direct ocx self activate with neither flag falls back to whether stdin or stderr is a terminal.
  3. A global env eval line — only when the global activate mode is env, which is the ladder's floor and therefore the usual case. Under bin or none the line is absent and the global toolchain reaches the shell through the trampolines prepended in step 1 instead, exactly as it does at every later prompt. It is guarded by a path test against the resolved absolute binary — never a $PATH name lookup, which a shell function or an earlier $PATH entry could shadow. POSIX form shown, with <ocx> standing for that absolute path: if [ -x '<ocx>' ]; then eval "$('<ocx>' --global env --shell=bash)"; fi. Per-shell variants use the target shell's native idiom — fish uses if test -x '<ocx>'; '<ocx>' --global env --shell=fish | source; end; powershell/pwsh use Test-Path -LiteralPath … -PathType Leaf and Invoke-Expression; elvish uses if ?(test -x '<ocx>') { eval ('<ocx>' --global env --shell=elvish | slurp) }. nushell is the one arm that still probes by name (which ocx), because it applies the global env as JSON data rather than evaluating a string; that gap is pinned as a strict xfail.

The OCX_HOME assignment-with-fallback lives in env.sh itself — written once by the installer, not emitted by ocx self activate. See the environment reference for details.

The output is designed to be sourced from $OCX_HOME/env.sh at login:

sh
: "${OCX_HOME:=$HOME/.ocx}"
export OCX_HOME
if command -v ocx >/dev/null 2>&1; then
    eval "$(ocx self activate --shell=sh)"
fi

Simplified illustration; the installer writes a byte-identical env.sh shim — OCX_HOME is resolved at runtime via ${OCX_HOME:=$HOME/.ocx}, not substituted at install time. Re-running the shim is safe because the emitted PATH updates are idempotent (move-to-front).

Usage

shell
ocx self activate [--shell[=NAME]] [--completion | --no-completion]

Options

FlagShortDescriptionDefault
--shell[=NAME]Target shell dialect. Must use the = form (--shell=bash). Bare --shell (no value) and absent --shell both trigger autodetect from $SHELL or the parent process. Exit 64 if undetectable. --shell=sh--shell=dash (POSIX strict alias).(autodetect)
--completionForce completion injection on, regardless of session interactivity.(auto)
--no-completionForce completion injection off. Last of --completion/--no-completion wins.(auto)
--hookForce the per-prompt shell integration hook on for this session, regardless of interactivity or [shell] hook.(auto)
--no-hookForce the per-prompt hook off for this session. Last of --hook/--no-hook wins (POSIX last-wins, same idiom as --completion/--no-completion).(auto)
-h, --helpPrint help information.

--hook/--no-hook decide this session only — the flag pair, the shell it launches, and nothing written to disk. Neither flag is remembered for the next shell: that's --no-hook at rung 1/2 of the five-rung ladder, below OCX_NO_HOOK (rung 3) and [shell] hook (rung 4), above the interactivity auto-probe (rung 5). See Shell Integration → Commands for the full ladder and how it interacts with self setup --hook/--no-hook writing the persistent [shell] hook key.

A hidden --interactive/--no-interactive pair also exists, carrying no row in the table above: it is machine surface emitted only by OCX's own env.* shims (see above) to state a session's interactivity explicitly, and is never meant to be typed by hand. It feeds rung 5 of both the hook and completion ladders — it is not itself a rung, and is not spelled as --hook, since a shim declaring interactivity at rung 2 would outrank OCX_NO_HOOK and [shell] hook for every session it starts.

Supported shells

NameDialect
shPOSIX strict (alias for dash)
dashDash
bashBash
zshZsh
ashAlmquist shell
kshKorn shell
fishFish
powershell / pwshPowerShell
elvishElvish
nushell / nuNushell
batch / cmdWindows CMD (Command Prompt)

Shell completion coverage

Completion injection wraps clap_complete. Not every shell supported by ocx self activate has a clap_complete backend. Unsupported shells silently skip the completion block — the PATH prepends still run, and so does the global env eval where the mode calls for it. Set OCX_NO_COMPLETIONS=1 to suppress completion injection entirely.

Completions load only for interactive sessions. Every shim states its own interactivity explicitly through a hidden --interactive/--no-interactive flag pair ($- on POSIX, status is-interactive on fish, [Console]::IsInputRedirected on pwsh, a test -t 0 probe on elvish), rather than leaving the binary to guess: every shim runs the activation with stderr redirected, so a stderr probe would read false in every real shell, and ssh -t host 'bash -lc …' hands a terminal to stdin for a shell that never renders a prompt — neither descriptor answers the question alone. Non-interactive sources — scripts, ssh host cmd — get the PATH prepends, and the global env eval where the mode calls for it, but skip the completion block entirely. A direct ocx self activate with neither flag falls back to whether stdin or stderr is a terminal.

Environment variables

VariableEffect
OCX_NO_COMPLETIONSSet to a truthy value to skip the completion injection block.

Exit codes

CodeMeaning
0Activation lines emitted successfully.
64Shell undetectable (bare or absent --shell and $SHELL unset or unrecognised).

self update

Check for a newer version of OCX and, if found, install it.

Both forms bypass the auto-check throttle — explicit user intent always runs the lookup regardless of when the last automatic check ran.

Version discovery queries the published index and registry live for the newest release — self update exists to reach the freshest upstream ocx, so it does not read the (possibly stale) local index. This matches ocx self setup and the background update notice ocx prints on other commands. Under --offline the check is skipped and the running binary is left unchanged; --remote is redundant (already the default) but still accepted.

Usage

shell
ocx self update [--check]

Options

FlagShortDescriptionDefault
--checkReport whether an update is available, without installing anything.off
-h, --helpPrint help information.

Behavior without --check

Queries the registry for the latest major.minor.patch release tag (rolling tags like 1, 1.2, build-tagged versions like 1.2.3+build, and pre-releases like 1.2.3-rc1 are filtered out). If the resolved version is greater than the running binary, installs via the same path as ocx package install --select. Reports one of three outcomes:

  • Already up to date — the running version is the latest.
  • Installed — a newer version was downloaded and selected.
  • Skipped — a soft failure (lookup unreachable, version unparseable) prevented the check; the running binary is unchanged.

After a successful install, ocx self update also refreshes the shell integration that ocx self setup owns: it regenerates the $OCX_HOME/env.* shims and re-applies the managed activation block in your shell profiles when its body has drifted from the current form. This refresh only heals an existing block — it never adds one where you have none (so a --no-modify-path install stays untouched) and never overwrites a block you have edited (it advises ocx self setup --force instead). When a block or shim is updated, it prints a one-line hint to re-source your profile. The session-PATH registration is outside this refresh: self update never writes it, so a store that failed or was opted out of stays that way until you run ocx self setup again.

Behavior with --check

Same lookup, no installation. Exits 0 when the lookup completes (including "already up to date" and "update available") — the result is printed to stdout. Exits 75 when the check is skipped (source unreachable, version unparseable, throttled). Use ocx --format json self update --check for machine-readable output.

Exit codes

CodeMeaning
0Check or install succeeded (including "already up to date" and "update available").
69Registry unreachable.
74I/O error writing the installed binary.
75Skipped — soft failure (registry probe failed, version unparseable, throttled, bootstrap, etc.); the running binary is unchanged.
79No release version found in the registry.
80Authentication failure against the registry.

JSON output shape

ocx --format json self update [--check] emits a single document with a status field:

json
{"status": "up_to_date"}
{"status": "update_available", "identifier": "ocx.sh/ocx/cli:1.2.3"}
{"status": "installed", "from": "0.0.1", "to": "0.0.2"}
{"status": "skipped", "skipped_reason": {"reason": "offline"}}
{"status": "skipped", "skipped_reason": {"reason": "registry_probe_failed", "detail": "503 Service Unavailable"}}

from is omitted on installed when the previous version could not be determined (subprocess version query failed — bootstrap mode). skipped_reason.reason is one of:

reasonMeaningCarries detail?
bootstrapSubprocess version query failed — binary absent, non-zero exit, or malformed JSON.No
offlineOCX is in offline mode; no probe attempted.No
throttledAuto-check window has not elapsed (only emitted on the auto-check path; self update [--check] always bypasses).No
registry_probe_failedRemote tag listing returned an error.Yes — error text
not_foundThe canonical ocx.sh/ocx/cli repository was not found in the registry.No
unparseable_currentThe installed binary returned a version string that does not parse as a release version.Yes — the offending string
unparseable_latestThe newest tag in the registry does not parse as a release version.No
no_release_tagNo clean major.minor.patch release tag exists in the registry tag list.No

Dogfood install

ocx self update installs the new version into the package store and updates the $OCX_HOME/symlinks/ocx.sh/ocx/cli/current symlink to point at it. No candidate symlink is created — only current is swapped. The same $OCX_HOME/symlinks/ocx.sh/ocx/cli/current/content/bin PATH entry that ocx self activate sets up resolves to the new binary automatically.

uninstall

Moved to ocx package uninstall — exits 64 if invoked as bare ocx uninstall. See package uninstall for the current form.

Removes the installed candidate for one or more packages.

Removes the candidate symlink and its back-reference. Object-store content is preserved unless --purge is given. To also remove the current symlink, pass --deselect or run package deselect separately. To remove all unreferenced objects at once, use clean.

Usage

shell
ocx package uninstall [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to uninstall.

Options

  • -d, --deselect: Also remove the current symlink. Equivalent to running ocx package deselect after uninstall — see package deselect for the full cleanup behavior.
  • --purge: Delete the object from the store when no other references remain after uninstall.
  • -h, --help: Print help information.

version

Prints the ocx version. Without flags, prints a bare major.minor.patch string suitable for script consumption. With --verbose, prints a multi-line build provenance summary. JSON output always includes the populated subset of provenance fields regardless of --verbose.

Usage

shell
ocx version [--verbose]
ocx --format json version

Options

FlagShortDescriptionDefault
--verbose-vEmit multi-line build provenance: host platform (OS/arch + detected libc), commit SHA, dirty flag, build timestamp, profile, target triple, rustc version, and CI run URL. Absent fields are suppressed — a local build without git shows no commit row; a build outside CI shows no CI row; the host: row is suppressed when OCX's supported platform set does not include the host OS/arch.off
-h, --helpPrint help information.

Plain output — default (no flag)

0.3.2

The bare semver string is the stable contract for script consumers. No trailing newline formatting varies by shell — pipe safely to grep, cut, or similar.

Plain output — --verbose

ocx 0.3.2-dev+20260528143045 (cargo: 0.3.1, channel: dev)
host:     linux/amd64 (libc.glibc)
commit:   a1b2c3d4 (clean) — 2026-05-28T12:00:00Z
built:    2026-05-28T14:30:45Z (release)
target:   x86_64-unknown-linux-gnu
rustc:    1.79.0
ci:       https://github.com/ocx-sh/ocx/actions/runs/1234567890

The host: row shows the detected OS/arch and, when detected, the libc family in parentheses (e.g. (libc.glibc) or (libc.musl)). It is suppressed when the host OS/arch is not in OCX's supported set. Rows for commit, built/target/rustc, and ci appear only when the corresponding data was baked in at build time. Local cargo build without git shows no commit row; builds outside GitHub Actions show no ci row.

The host: row is plain-output only — it does not add a field to the version JSON wire shape, so the self-update parser contract is unaffected. To inspect libc detection programmatically, use ocx --format json about which includes a libc field.

JSON output

ocx --format json version emits a single object. Only version is required; all other fields are optional and absent when their source data was unavailable at build time:

json
{
  "version": "0.3.2-dev+20260528143045",
  "cargo_pkg_version": "0.3.1",
  "channel": "dev",
  "commit": {
    "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
    "short": "a1b2c3d4",
    "describe": "v0.3.1-5-ga1b2c3d4",
    "dirty": false,
    "timestamp": "2026-05-28T12:00:00Z"
  },
  "build": {
    "timestamp": "2026-05-28T14:30:45Z",
    "profile": "release",
    "target": "x86_64-unknown-linux-gnu",
    "rustc": "1.79.0"
  },
  "ci": {
    "provider": "github-actions",
    "run_url": "https://github.com/ocx-sh/ocx/actions/runs/1234567890",
    "workflow": "release",
    "ref": "refs/tags/v0.3.2-dev",
    "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
  }
}

cargo_pkg_version is present only when it differs from version — this occurs on dev-deploy builds where the effective version is overridden via __OCX_BUILD_VERSION. A stable release always omits this field.

The version key is the only field the self update parser reads when comparing versions. Additional provenance fields are additive and ignored by the self-update parser — the JSON schema is open for extension without breaking wire compatibility.

ci

REMOVED — exits 64. The ocx ci command group has been removed.

CI environment export is available as the --ci[=PROVIDER] flag on ocx env (toolchain-tier) and ocx package env (OCI-tier). See CI Integration for full examples.

export

REMOVED — exits 64. See the ci section above.

package

announce

Observes an owner-curated set of registry tags for one package and publishes the rebuilt entry into the index: written to a local directory (--out), or opened as a pull request against the index repository.

The pull request comes from a fork with --fork. Omit --fork and the announce branch is pushed to --index-repo itself — for a publisher whose credential can already push there, which is the only working path when the publishing repository and the index share an owner, since a repository cannot be forked into the namespace that already owns it. Either way the change arrives as a pull request; announce never commits to the index's default branch.

GitHub and GitLab are both supported, on their public hosts and on self-hosted instances. On GitLab the change arrives as a merge request; everything else in this section reads the same. Which forge a run talks to comes from --index-repo:

--index-repoForgeAPI base
ocx-sh/index (no host)GitHubhttps://api.github.com
github.com/acme/indexGitHubhttps://api.github.com
gitlab.com/acme/indexGitLabhttps://gitlab.com/api/v4
github.example.com/acme/index + --forge githubGitHub Enterprise Serverhttps://github.example.com/api/v3
gitlab.example.com/acme/index + --forge gitlabSelf-managed GitLabhttps://gitlab.example.com/api/v4

A self-hosted host must name its forge with --forge; a run without it exits 64 and says so. A hostname carries no reliable signal of which forge runs behind it, and a wrong guess would send your credential to the wrong API — so ocx asks instead of guessing. --forge also overrides the inferred forge for github.com and gitlab.com, for an instance sitting behind a proxy under one of those names.

GitLab groups nest, and so does the coordinate: --index-repo gitlab.example.com/acme/platform/tooling/index names the project index inside the group path acme/platform/tooling. --fork takes the same form. On GitHub, where organizations do not nest, a nested namespace is rejected before any request is made.

Because a host and a group are both just a leading segment, a dotted top-level group is ambiguous: acme.team/platform/index reads as the host acme.team, and the run stops asking for --forge rather than guessing where to send your credential. Write the host out to say what you meant — gitlab.com/acme.team/platform/index names the group acme.team/platform on gitlab.com.

One self-hosted shape is not supported: an instance mounted under a path prefix, where the API lives at https://example.com/gitlab/api/v4 rather than at the host root. The [HOST/]NAMESPACE/PROJECT grammar has nowhere to put the prefix, and GitLab's own glab has the same open gap. Instances served at the host root — the default for every standard install — work.

Both forges use the same credential variable, OCX_ANNOUNCE_TOKEN — a GitHub personal access token or a GitLab personal access token, depending on which forge the run targets. Under --transport git that token still carries the REST reads, while the push can be split onto OCX_ANNOUNCE_GIT_TOKEN; inside a GitLab job with neither set, the job's own CI_JOB_TOKEN carries both.

A run that produces no change — the rebuilt entry is byte-identical to the one already committed — makes no commit, and the report's status reads unchanged instead of updated. Running announce again for a package already announced from the same branch updates the existing pull request in place rather than opening a second one. When the index repository's default branch has moved since that pull request opened, the run instead rebuilds the announce branch on the current base as a single commit that carries every tag already announced into the pull request, so the pull request stays mergeable and nothing already announced is lost — the branch's commit history is rewritten, but the pull request itself is reused.

An unchanged run normally opens no pull request either. Two exceptions exist. The first: a run whose announce branch still carries commits the index repository does not have — an earlier run's update reached the branch but never reached a pull request, so the unchanged run opens (or reuses) one and reports it, rather than leaving that work stranded. The second: an unchanged run — nothing new to carry forward — whose open pull request can no longer merge. That is a failure, not a no-op: the run exits 65 and names the branch. Close the pull request or delete the branch, and the next announce rebuilds it.

--out is unaffected by all of that: it writes the whole entry every run, unchanged included, so announce --out dir followed by a step that consumes dir never sees an empty directory. Only status reports that nothing moved.

Every run also observes the package description published by ocx package description push. When its artifact has moved since the last announce, the entry's description block is rebuilt — title, summary, keywords, and content-addressed copies of the README and logo — and the report's desc_status reads updated. An unmoved description costs one request and writes nothing. A description recorded in the index that the registry no longer serves stops the run rather than clearing it silently.

Publishing tags for a package that has no entry in the index yet is out of scope for announce — a package with no committed entry exits 79, and the first-time claim that creates one is ocx package claim.

A tag that is not a version — the OCX-internal __ocx namespace, which carries the keep tag from --keep-tag, or the frozen legacy sha256.<hex> keep tag — is dropped from the curated set rather than failing the run, and reported in the JSON report's reserved_tags_dropped. The one exception: --tags-from-registry filters a reserved tag out of its listing silently, before it reaches that report, since keep tags are pushed by default and reporting one per published version would drown a real drop. A reserved tag already committed in the index root is still reported, from any mode. A curated set that resolves to nothing but reserved tags exits 64.

Usage

shell
ocx package announce (--tags <TAGS> | --tags-file <PATH> | --tags-from-registry | --refresh) [--out <DIRECTORY> | --fork <REPOSITORY>] [OPTIONS] <NAMESPACE>/<NAME>

Options

NameDescriptionDefault
<NAMESPACE>/<NAME>Package to announce, e.g. acme/widget (required, positional). Flags come before it.
--tags <TAGS>Comma-separated tag list that replaces the currently-committed curated set. A committed tag not named here is dropped. Mutually exclusive with --tags-file/--tags-from-registry/--refresh; exactly one is required.
--tags-file <PATH>Add the tags in this file (comma- or newline-separated) to the already-committed curated set. Never removes a committed tag.
--tags-from-registryAdd every tag the package's registry repository currently holds to the already-committed curated set. Never removes a committed tag; a yanked tag stays yanked. Reserved tags are filtered out of the listing before the union.
--refreshRe-observe every already-committed tag, picking up a digest that moved (e.g. latest) without changing which tags are curated.
--out <DIRECTORY>Write the rebuilt index entry under this directory instead of opening a pull request. Written on every run, including one that changes nothing. Mutually exclusive with --fork, and the one mode that needs no credential.
--fork <REPOSITORY>Open (or update) the pull or merge request from this fork, as [HOST/]NAMESPACE/PROJECT. Omit it to push the announce branch straight to --index-repo, which needs push access on that repository. Requires OCX_ANNOUNCE_TOKEN.
--index-repo <REPOSITORY>Index repository the pull or merge request targets, as [HOST/]NAMESPACE/PROJECT. Give the host for a self-hosted instance; the namespace may be a nested GitLab group path.ocx-sh/index
--forge <FORGE>Which forge hosts the index: github or gitlab. Inferred for github.com and gitlab.com; required for a self-hosted host.inferred
--transport <TRANSPORT>How the request is written: api opens it through the forge's REST API, git clones the index repository and creates it from a single authenticated push. git is GitLab-only, and is the only way a GitLab CI job token can open a merge request — see claim for the same flag's full contract.api
--yank <TAG>Mark a tag as yanked — a publisher signal that content should no longer be installed, not a delete. Repeatable. Requires --yank-reason; only applies to a tag already in the curated set.
--unyank <TAG>Clear the yanked marker from a tag. Repeatable.
--yank-reason <TEXT>Reason recorded on every tag named by --yank in this run.
-h, --helpPrint help information.

The package used to be named by a --package flag. That spelling is deprecated: it still runs, warns once on stderr, and is removed in 0.7. Naming the package both ways in one invocation is a usage error (exit 64).

Exit codes

ConditionExit code
An I/O error reading --tags-file — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one74
A curated tag's physical host resolves to a private, loopback, link-local, or metadata address — add it to that namespace's trusted_hosts to allow78
Any mode other than --out run without OCX_ANNOUNCE_TOKEN set, the token was rejected (401/403), or — without --fork — the token cannot push to --index-repo. The last is checked before anything is written and names the repository and the missing permission80
The physical registry could not be resolved (DNS failure), or the forge is unreachable or returned a 5xx69
A curated tag does not resolve on the physical registry — check for a typo79
The package is unclaimed — no committed root exists for it yet. Claiming one is a human-lane action, never something announce performs79
The forge rate-limited the run (429), or a concurrent announce kept winning the branch — retry75
The curated set resolved to nothing but reserved tags — nothing left to announce64
--index-repo names a self-hosted host and no --forge was given, or --fork names the namespace that already owns the index (fork it into itself — omit --fork instead)64
--fork names a different host than --index-repo, or either coordinate's host is malformed. A fork lives on the same instance as its upstream, and a host that is not a hostname is refused rather than interpreted64
--index-repo or --fork names a nested namespace on GitHub, which has no nested organizations. Checked before any request64
The description recorded in the index no longer exists on the registry — republish it, or ask for it to be cleared in the index65
An unchanged run's open pull request can no longer merge against the index base — close the pull request or delete the branch; the next announce rebuilds it65
--transport git was selected and the forge answered, but the instance or the target project lacks a capability that transport needs — job-token pushes disabled on the index project, or neither of its allowlists (the publishing project by name, or one of its groups) admits it. A split credential pair checks this before anything is written and names the missing one; a bare job token cannot read either setting, so it pushes and GitLab's own rejection decides instead — same exit code, a generic message. An administrator of the index project has to act either way86

JSON report

json
{
  "package": "acme/widget",
  "status": "updated",
  "desc_status": "updated",
  "forge": "github",
  "transport": "api",
  "credential_kind": "token",
  "push_credential_kind": null,
  "branch": "indexbot-announce-acme-widget",
  "pull_request_url": "https://github.com/ocx-sh/index/pull/42",
  "pull_request_number": 42,
  "fork": "forkuser/index",
  "written_paths": [],
  "capability_checks": [
    { "name": "git-version", "status": "skipped", "detail": null },
    { "name": "push-access", "status": "passed", "detail": null },
    { "name": "job-token-push", "status": "skipped", "detail": null },
    { "name": "job-token-allowlist", "status": "skipped", "detail": null }
  ],
  "reserved_tags_dropped": []
}

status and desc_status are each unchanged or updated; desc_status reports the package description separately from the tags. pull_request_url/pull_request_number/fork are always null for --out; otherwise pull_request_url/pull_request_number are null only when the run made no pull request, so an unchanged run that ensured one still reports it, and fork is null whenever --fork was not given. written_paths lists the files written under --out — the whole entry on every run, unchanged included — and stays empty in every mode that opens a pull request. reserved_tags_dropped names the tags this run dropped for not being a version — always an array, empty rather than absent — except a reserved tag --tags-from-registry observed straight from the registry listing, which never enters it (see above).

forge, transport, credential_kind, push_credential_kind, branch and capability_checks carry the same vocabularies and the same guarantees as they do on claim — including the one that matters most for a pipeline: capability_checks is non-empty on every run, so a wrapper can assert the preflight ran instead of trusting a bare exit 0. branch is null under --out, which opens no request and so has no branch.

TIP

ocx package push --tags-file appends the tag it just pushed (and any cascade tags) to a file in the same comma/newline format --tags-file reads, so a publish pipeline can feed one straight into the other:

shell
ocx package push -i acme/widget:1.2.3 -c --tags-file tags.txt widget.tar.xz
ocx package announce --tags-file tags.txt --fork myuser/index acme/widget

Announce to a GitLab index, opening a merge request from a fork:

shell
ocx package announce --tags 1.0.0 \
  --index-repo gitlab.com/acme/index --fork gitlab.com/myuser/index acme/widget

Announce to a self-managed GitLab in a nested group, from a fork in another group:

shell
ocx package announce --tags 1.0.0 --forge gitlab \
  --index-repo gitlab.example.com/acme/platform/tooling/index \
  --fork gitlab.example.com/contrib/team/index acme/widget

Announce to a GitHub Enterprise Server index, pushing the branch to the index itself:

shell
ocx package announce --tags 1.0.0 --forge github \
  --index-repo github.example.com/acme/index acme/widget

Announce from a GitLab CI job, where the job token can push but cannot open a merge request through the API:

shell
ocx package announce --tags 1.0.0 --transport git \
  --index-repo gitlab.example.com/acme/index acme/widget

claim

Claims a package in the index so its tags can be announced. Renders the package's index entry — its logical name, the physical OCI repository its bytes live in, and the accounts that own it — and opens a pull request (GitHub) or merge request (GitLab) against the index repository, or writes the entry to a local directory with --out.

The claimed unit is the package, not the namespace prefix: the entry is p/<namespace>/<package>.json and the already-claimed refusal reads that exact path, so a second package under an already-claimed namespace still needs its own claim run. This is the first-time counterpart to announce, and the two never overlap: claim creates the entry, announce publishes tags into one that already exists. A package whose entry is already committed is refused at exit 65 with a message naming ocx package announce; that refusal holds in every mode, --out included, because it reads the index's base branch rather than the claim branch — so re-running an unmerged claim reports unchanged rather than failing.

Everything about forge selection, coordinates, nested GitLab groups and self-hosted instances reads exactly as it does for announce--index-repo, --forge, --fork and --out are one shared grammar across both commands, so a pipeline that already announces needs no new vocabulary to claim.

Who the entry records as owners. An owner is a login:id pair, and the numeric id is the part that matters: logins get renamed, ids do not. Without --owner, ocx takes the CI environment's user variables (GITLAB_USER_LOGIN + GITLAB_USER_ID, or GITHUB_ACTOR + GITHUB_ACTOR_ID, both halves required), and failing that the identity behind the credential. Passing --owner replaces that list rather than adding to it — the invoking identity is not appended. A bot account is refused at exit 64, both when the forge says so and when the login matches a documented bot shape.

The report says which rule produced the list, in owner_identity_source. resolved means the forge's users API answered and its canonical spelling, id and bot flag were taken from it. ci-environment means the CI variables named the list and the users API confirmed it. asserted means the users API was out of reach and a LOGIN:ID pair was taken on your word — reachable on GitLab only, and only under a job token, since that is the one credential whose users API is closed; a GitHub run never produces it. An unreachable users API with a bare LOGIN is a usage error naming the LOGIN:ID form, because guessing an id would write the wrong account into a governance field.

--transport git writes over a clone instead of the API. The default api transport opens the request through the forge's REST API. git clones the index repository into a temporary directory, builds the commit there, and creates the request from a single authenticated push — which is the only way a GitLab CI job token can open a merge request, because that credential can push to a repository and read the API but cannot open a merge request through it. It is GitLab-only and refused (exit 64, naming both) against a resolved GitHub forge, against --fork, and against --out. A split credential pair checks that the index project allows job-token pushes and that one of its allowlists (the publishing project by name, or one of its groups) admits it before anything is written, and names the missing one; a bare job token cannot read either setting, so it pushes instead and lets GitLab's own rejection decide — same exit code, 86, but a generic message rather than a named one. Either way, only an administrator of the index project can grant it.

Claiming from GitLab is walked through end to end in Announcing a package.

Usage

shell
ocx package claim --repository oci://<HOST>/<PATH> [OPTIONS] <NAMESPACE>/<PACKAGE>

Options

NameDescriptionDefault
<NAMESPACE>/<PACKAGE>Namespace and package to claim, e.g. acme/widget (required, positional). Flags come before it.
--repository <REPOSITORY>The physical OCI repository this package's bytes live in, as oci://HOST/PATH (required). This is the pointer every later announce resolves tags against, so it names the registry repository, never the index.
--owner <OWNER>An owner of the package, as LOGIN or LOGIN:ID. Repeatable, recorded in the order given. Giving any --owner replaces the detected list. A bare LOGIN is resolved against the forge's users API and needs it reachable; LOGIN:ID is taken on your word.detected
--upstream-org <ORGANIZATION>The upstream organization this package mirrors or repackages, for a third-party package. Anchor for the two flags below — neither is accepted without it.
--upstream-repository-url <URL>The upstream project's repository URL, as an absolute http or https URL carrying no embedded credentials. Written verbatim into the entry, so a forwarded CI_REPOSITORY_URL — whose userinfo is a live job token — is refused. Requires --upstream-org.
--upstream-disclaimer <TEXT>A disclaimer recorded on the entry, for a package not operated by the upstream project. Reaches the entry only: never interpolated into the request, so it fires no mentions and renders no markdown where humans review it. Requires --upstream-org.
--index-repo <REPOSITORY>Index repository the request targets, as [HOST/]NAMESPACE/PROJECT. Give the host for a self-hosted instance; the namespace may be a nested GitLab group path.ocx-sh/index
--forge <FORGE>Which forge hosts the index: github or gitlab. Inferred for github.com and gitlab.com; required for a self-hosted host.inferred
--transport <TRANSPORT>How the request is written: api through the forge's REST API, git from one authenticated push over a temporary clone. git is GitLab-only.api
--fork <REPOSITORY>Open (or update) the request from this fork, as [HOST/]NAMESPACE/PROJECT. Omit it to push the claim branch straight to --index-repo, which needs push access there. Mutually exclusive with --out and with --transport git.
--out <DIRECTORY>Write the rendered entry under this directory instead of opening a request. The one mode that needs no credential. Mutually exclusive with --fork and with --transport git.
-h, --helpPrint help information.

Exit codes

ConditionExit code
--out with --fork; --transport git with --fork, with --out, or against a resolved GitHub forge; --fork on a different host than --index-repo; a self-hosted --index-repo host with no --forge; a nested namespace on GitHub; an --upstream-* flag without --upstream-org64
--repository is not oci://host/path; --owner is neither a LOGIN nor a LOGIN:ID pair; --upstream-repository-url is not an http/https URL, or carries embedded credentials (the message names the flag and the rule, never the value — it would be a live token)64
No acting identity at all — the credential has no account and the CI environment named none; an owner named twice; a supplied id that disagrees with the forge's; an owner login the forge reports as a bot, or whose shape is a documented bot form; a bare LOGIN while the users API is out of reach64
The package is already claimed — an entry is committed on the index's base branch. Holds in every mode, --out included; announce it with ocx package announce instead65
The forge is unreachable or returned a 5xx; or --transport git was selected and no git was found, or the one found is older than the floor the transport needs. Checked before the forge is constructed69
Writing under --out failed — permission denied, disk full, or a parent that is not a directory74
The forge rate-limited the run (429), or a concurrent claim kept winning the branch — retry75
The git push was refused by the forge's own policy — a protected branch, or a push rule the commit does not satisfy77
An owner login the forge has no account for — check the spelling, or pass LOGIN:ID79
Any mode other than --out run with no forge credential, the credential was rejected (401/403), or — without --fork — it cannot push to --index-repo. The last is checked before anything is written and names the repository and the missing permission80
--transport git was selected and the forge answered, but the instance or the target project lacks a capability that transport needs — job-token pushes disabled on the index project, or neither of its allowlists (the publishing project by name, or one of its groups) admits it. A split credential pair names the missing one; a bare job token cannot read either setting, so the message is generic instead. An administrator of the index project has to act either way86

JSON report

json
{
  "package": "acme/widget",
  "name": "ocx.sh/acme/widget",
  "status": "updated",
  "forge": "gitlab",
  "transport": "git",
  "credential_kind": "job-token",
  "push_credential_kind": "job-token",
  "author": { "login": "release-bot-operator", "id": 4711 },
  "author_identity_source": "resolved",
  "owners": [{ "login": "alice", "id": 1001 }],
  "owner_identity_source": "asserted",
  "branch": "indexbot-claim-acme-widget",
  "pull_request_url": "https://gitlab.example.com/acme/index/-/merge_requests/17",
  "pull_request_number": 17,
  "fork": null,
  "written_paths": [],
  "capability_checks": [
    { "name": "git-version", "status": "passed", "detail": "2.47.1" },
    { "name": "push-access", "status": "unknown", "detail": "projects/:id" },
    { "name": "job-token-push", "status": "unknown", "detail": "ci_push_repository_for_job_token_allowed" },
    { "name": "job-token-allowlist", "status": "unknown", "detail": "job_token_scope/allowlist" }
  ]
}

status is unchanged or updated, compared against the open claim branch — so an --out run always reports updated, unlike announce's, which compares against the committed entry. credential_kind is job-token, token or none; it says job-token only when the credential is this environment's own CI_JOB_TOKEN, because ocx cannot tell a personal from a project, group or OAuth token and reports no kind it cannot observe. push_credential_kind is job-token, token, git-helper or null, and is always null under api, which pushes nothing; git-helper means ocx injected nothing and git's own credential helpers authenticated the push. owner_identity_source is resolved, ci-environment or asserted (GitLab-only, see above). author_identity_source is resolved, ci-environment or null, and never asserted. branch is present on every run, --out included — it is derived from the package, not read from the forge. fork is null on the direct path and under --transport git. capability_checks carries one row per capability in a fixed order, skipped rows included, and is non-empty on every run — so a pipeline asserts the preflight ran rather than trusting a bare exit 0. There is no failed status: a check that fails raises the error instead — unknown is a bare job token's normal answer for a capability it cannot read, and the push it still allows decides the outcome instead.

author records who authored the request, which is deliberately not who owns the package. It is not an attestation. Only its first rung — the forge's own answer about the credential — is the forge speaking; the second rung is an ordinary read of GITLAB_USER_LOGIN/GITHUB_ACTOR, which an earlier pipeline step can set to anything. author_identity_source says which rung answered: resolved for the forge's answer about the credential, ci-environment for the environment read, null when there is no author at all. Branch on that key rather than on author alone — the two rungs produce the same {login, id} shape and are not equally trustworthy.

TIP

Claim a package with an explicit owner and write the entry locally first, to review it before anything opens a request:

shell
ocx package claim --repository oci://ghcr.io/acme/widget \
  --owner alice:1001 --out ./entry acme/widget

Claim from a GitLab CI job, where the job token can push but cannot open a merge request through the API:

shell
ocx package claim --repository oci://registry.example.com/acme/widget \
  --index-repo gitlab.example.com/acme/index --transport git acme/widget

Claim a package that repackages a third-party project, recording where the software comes from:

shell
ocx package claim --repository oci://ghcr.io/acme/widget \
  --upstream-org WidgetProject \
  --upstream-repository-url https://github.com/WidgetProject/widget \
  --upstream-disclaimer "Repackaged by ACME; not affiliated with the WidgetProject." \
  --fork myuser/index acme/widget

cascade check

Diffs a package's cascade tag graph — the rolling aliases (latest, 3, 3.28, …) ocx package push --cascade maintains — against the state a fold over every published concrete version says it should be. Nothing re-checks the cascade graph at publish time if a cascade push is interrupted partway through (see Cascades); check is how that drift gets found again after the fact, without republishing anything. It never writes to the registry or to any local index.

check accepts both logical and physical identifiers. A logical identifier (a namespace with an index configured, e.g. ocx.sh/kitware/cmake:3.28) resolves to its physical registry location the same way ocx package install does, and additionally fetches that namespace's live index root — a third finding layer, index staleness, on top of the registry-graph findings every identifier gets, comparing each observed alias digest against the digest actually committed in the index. A physical identifier (a bare registry path, e.g. ghcr.io/ocx-contrib/cmake:3.28) skips that layer: there is no logical name to look an index root up under. A digest-pinned identifier (pkg@sha256:…) is a usage error — a digest names one immutable artifact, not a tag with alias ancestors to diff.

Each identifier's own tag selects how much of the graph a run covers:

Identifier formScope
Tagless (acme/cmake)Every alias tag in every variant track — latest, 3, 3.28, and any debug/other variant's aliases too
:latest (or any bare default-variant alias)The default variant's track only — never debug or another variant's
A rolling tag (:3.28, :debug-3)That tag's subtree plus the path from it up to its own root (3.283latest)
A fully build-tagged leaf (:3.28.1_20260216120000)The path up to root only — the leaf itself is the published source of truth and is never a write target
A bare variant name (:debug)Usage error (exit 64) — whether debug names a track root depends on the package's other tags, which this call has not read; scope a tag under it instead (:debug-3)
A digest reference (@sha256:…)Usage error (exit 64) — a digest has no tag graph to diff

Multiple identifiers naming the same package union their scopes into a single report; different packages each get their own. check authenticates for pull only — it never probes push credentials.

Usage

shell
ocx package cascade check <IDENTIFIER>...

Arguments

  • <IDENTIFIER>...: One or more package identifiers, logical or physical, each optionally carrying a tag that narrows scope (see the table above). Required.

Options

  • -h, --help: Print help information.

Exit codes

ConditionExit code
Every alias in scope matches the fold-expected state — nothing to report0
At least one finding: a stale or missing alias entry, a duplicate entry shadowing another for the same platform, an orphaned tag, or (logical identifiers only) an index root behind the registry65
A digest-pinned identifier, or any tag that does not name a node in the version graph — junk, a reserved tag such as a keep tag, a bare variant name, or a version nothing published (:9.99)64

JSON report

json
{
  "reports": [
    {
      "identifier": "acme/cmake",
      "logical": "ocx.sh/acme/cmake",
      "aliases": {
        "latest": { "state": "present" },
        "3": { "state": "present" },
        "3.28": { "state": "present" }
      },
      "rows": [
        {
          "tag": "3.28",
          "platform": { "architecture": "arm64", "os": "linux" },
          "status": "stale",
          "observed": "sha256:aaaa…",
          "expected": "sha256:bbbb…",
          "source": "3.28.1_20260216120000",
          "observed_source": null
        },
        {
          "tag": "3.28",
          "platform": { "architecture": "amd64", "os": "linux" },
          "status": "duplicate",
          "observed": "sha256:dddd…",
          "expected": "sha256:bbbb…",
          "source": "3.28.1_20260216120000",
          "observed_source": "3.28.0_20260101000000"
        }
      ],
      "index_findings": [
        { "finding": "stale", "tag": "3.28", "committed": "sha256:cccc…", "live": "sha256:bbbb…" }
      ],
      "ignored_tags": ["__ocx.keep.sha256-aaaa1111"],
      "unrepairable": []
    }
  ]
}

Every report is nested under one top-level reports array — the whole JSON contract is that one wrapper key. Each row's status is one of ok, missing, stale, orphan, or duplicate; source/observed_source name the published version a digest was folded from or recognized as belonging to, null when there is none. duplicate marks a platform for which the alias's index carries two descriptors: only the last one resolves, so the earlier entry is published but invisible to every consumer — repair collapses the pair back to one entry the same way it rebuilds any other stale slot. index_findings carries two shapes: a committed tag whose registry digest has moved past what the index still records (stale, shown above), and an alias tag observed live on the registry that the index has never committed at all ({ "finding": "not-committed", "tag": "…" }). unrepairable names aliases that need new content published before anything can fix them — { "reason": "child-manifest-missing", "tag": "…", "digest": "…" } (a referenced manifest is gone), { "reason": "child-digest-unaddressable", "tag": "…", "digest": "…" } (a digest algorithm this build cannot check), or { "reason": "would-empty-index", "tag": "…" } (repairing it would leave the alias with no entries at all). Field names above are representative of the shipped report's shape, not a frozen wire contract — what a script branches on is rows[].status and the finding classes, not a specific key spelling.

cascade repair

Recomputes and writes the whole alias index for every tag cascade check would report as broken. repair writes by default — the same convention as every other --dry-run flag in this reference: check is already the read-only preview, so repair needs no separate opt-out to be safe to run. Pass --dry-run to compute and report the same plan without touching the registry.

Identifier forms and scope selection are identical to cascade check — see the table there. An index-staleness finding on a logical identifier is never something repair writes: fixing the registry graph and re-publishing the index are different hops (see below), and repair only ever authenticates for registry push.

For each broken alias, repair rebuilds the whole platform index entry from the same fold check diffs against, preserving every observed entry the fold does not itself supersede — an OCI annotation already on the index, a non-platform entry like an attestation, or an orphaned alias tag whose child manifest still exists on the registry. An orphan is preserved while its child is resolvable and dropped only once it provably is not: before writing, every referenced platform manifest is checked to still exist, and a missing one is dropped only if every entry naming that digest is an orphan slot. If the same digest also backs a slot the fold expects (a manifest shared across two platforms, a Rosetta-style alias) or an entry with no platform at all (an annotation or attestation), the whole alias is refused instead (reported, not silently skipped) rather than quietly losing content, while every other alias in the run still writes. Writes are batched — nothing reaches the registry until the whole run's plan is built — and proceed concurrently per tag. After each write, repair re-reads the tag it just wrote and warns (does not fail) if the digest disagrees with what was pushed, which is evidence of a concurrent publisher racing the same tag rather than something repair can safely resolve — the write itself still landed, so the outcome is reported raced only when nothing was written at all (the tag moved between this run's read and its write), never when the write landed but a read-back disagreed. There is no conditional-request guard on the write itself — avoid running repair against a repository with a publish in flight.

repair only ever touches the registry side of the tag graph — reaching the public index with the fix is a second, separate hop through ocx package announce. --announce-tags <PATH> writes one bare alias-tag name per line, in the same comma/newline format --tags-file reads, so a pipeline can chain the two directly:

shell
ocx package cascade repair --announce-tags tags.txt acme/cmake
ocx package announce --tags-file tags.txt --fork myuser/index acme/cmake

The flag accepts exactly one package per invocation (usage error, exit 64, nothing written) — the follow-up announce names a single package positionally, and a second package's tags landing in the same flat file would give it no way to tell whose they were. What a real run records is exactly the tags whose write landed: any alias a raced, refused, or failed outcome moved is left out, since announcing it would commit a digest this run never wrote. Landed tags are unioned with every tag an index-staleness finding names — the one class of drift a repair cannot close itself, announced even when the same run wrote nothing at all — so one file still covers both hops. --dry-run writes nothing to the registry, so its file records the whole computed plan instead — every tag it would repair — since that is the only content a preview has to report. Either way the file is written on every run, including one that changes nothing (an empty file) — a workflow can always feed it into --tags-file unconditionally, the same way ocx package push --tags-file chains into announce. --tags-file's union semantics matter here: it never drops an already-committed tag, and it adds a tag that was never committed at all — an alias repair had to create from scratch — so one follow-up command covers a re-pointed alias and a brand-new one alike. When a run found index staleness on a logical identifier but had nothing of its own to repair, warming a particular machine's local copy is ocx index update's job, not repair's or announce's — the report names that third hop when it applies.

Usage

shell
ocx package cascade repair [OPTIONS] <IDENTIFIER>...

Arguments

  • <IDENTIFIER>...: One or more package identifiers, logical or physical, each optionally carrying a tag that narrows scope (same rules as cascade check). Required.

Options

NameDescriptionDefault
--dry-runCompute and report the repair plan without writing to the registry.off
--announce-tags <PATH>Write this run's alias-tag handoff to ocx package announce --tags-file, one bare tag per line. One package per invocation only — a second package's tags in the same file has no owner to attribute them to (usage error, exit 64, nothing written). A real run records the tags whose write landed, unioned with any tag an index-staleness finding names; --dry-run records its whole computed plan instead. Written on every run; empty when there is nothing to hand off.
-h, --helpPrint help information.

Exit codes

ConditionExit code
Every registry write this run attempted succeeded (an index-staleness finding from a logical identifier may remain — that is announce's job, not a failure here)0
At least one finding remains after the run — a write failed, an alias raced by a concurrent publisher before it could write (rerun the repair), or an alias could not be repaired without new content (its only remaining reference to a needed platform manifest is gone, or repairing it would leave the index empty)65
--dry-run computed a non-empty plan — a preview that still names repairs is not a clean run, even though nothing was written65
A digest-pinned identifier, or any tag that does not name a node in the version graph — junk, a reserved tag such as a keep tag, a bare variant name, or a version nothing published (:9.99)64
An I/O error reading --announce-tags — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one74

JSON report

json
{
  "entries": [
    {
      "report": {
        "identifier": "acme/cmake",
        "logical": "ocx.sh/acme/cmake",
        "aliases": {
          "latest": { "state": "present" },
          "3": { "state": "present" },
          "3.28": { "state": "present" }
        },
        "rows": [
          {
            "tag": "3.28",
            "platform": { "architecture": "arm64", "os": "linux" },
            "status": "stale",
            "observed": "sha256:aaaa…",
            "expected": "sha256:bbbb…",
            "source": "3.28.1_20260216120000",
            "observed_source": null
          }
        ],
        "index_findings": [],
        "ignored_tags": [],
        "unrepairable": []
      },
      "planned": [
        {
          "tag": "3.28",
          "index": {
            "schemaVersion": 2,
            "mediaType": "application/vnd.oci.image.index.v1+json",
            "manifests": [
              {
                "mediaType": "application/vnd.oci.image.manifest.v1+json",
                "digest": "sha256:bbbb…",
                "size": 1234,
                "platform": { "architecture": "arm64", "os": "linux" }
              }
            ],
            "artifactType": "application/vnd.sh.ocx.package.v1"
          },
          "observed_digest": "sha256:aaaa…",
          "referenced_digests": ["sha256:bbbb…"],
          "reasons": [
            {
              "tag": "3.28",
              "platform": { "architecture": "arm64", "os": "linux" },
              "status": "stale",
              "observed": "sha256:aaaa…",
              "expected": "sha256:bbbb…",
              "source": "3.28.1_20260216120000",
              "observed_source": null
            }
          ]
        }
      ],
      "outcomes": [
        {
          "tag": "3.28",
          "outcome": {
            "outcome": "written",
            "digest": "sha256:bbbb…",
            "verified": true,
            "dropped": ["sha256:dead…"]
          }
        },
        {
          "tag": "3",
          "outcome": {
            "outcome": "raced",
            "expected": "sha256:eeee…",
            "live": "sha256:ffff…"
          }
        }
      ],
      "announce_tags": ["3.28"]
    }
  ],
  "dry_run": false,
  "announce_tags_path": "tags.txt"
}

entries[].report is the same cascade check report shape for this package — findings this run is repairing, not a separate schema. planned carries the whole replacement index computed for each broken alias, reasons echoing the exact rows that justified it; outcomes is empty for a preview (dry_run: true), since nothing was attempted. written's verified is false when this run's post-write read-back found a different digest than it just pushed — evidence of a concurrent publisher, not a failure: the write still landed, and the plain-text table shows it as written-unverified rather than a distinct JSON outcome. dropped names the dead orphan-only digests this write removed before it went on the wire — omitted, never an empty array, when nothing was dropped. raced means a concurrent publisher moved the tag between this run's read and its write, so nothing was written for it (expected/live are each null when that side of the race never held the tag); rerunning the repair re-reads the new state. A refused outcome's outcome object nests the same tagged shape as unrepairable above ({ "outcome": "refused", "reason": "child-manifest-missing", "tag": "…", "digest": "…" }, and so on for the other two reasons) — this alias was never attempted; a failed outcome's is { "outcome": "failed", "message": "…" }, a write the registry itself rejected. entries[].announce_tags is this package's contribution to the top-level --announce-tags file — 3.28 here, not 3, because the raced write never landed. announce_tags_path is the path --announce-tags was given, null when the flag was not passed. Representative shape, as with cascade check above — the shipped report's exact key spelling is not a frozen contract, only the finding classes a script branches on.

create

Bundles a local directory into a compressed package archive ready for publishing. If the package metadata includes dependencies, the declared dependency graph is validated for cycles at this stage — catching errors before the package reaches the registry.

When --metadata is given, create is also the compiler for dependency pins: it validates the sidecar and always rewrites it, in canonical form, next to the output bundle — never a byte copy of the input file.

--platform is required whenever --metadata is given (else usage error, exit 64). It declares the platform the packaged content runs on. That answer cannot come from the build host: the host describes what the build machine supplies, while --platform states what the artifact demands — a static musl binary cross-built on a glibc host demands neither the host's libc nor its architecture. Every dependency is pinned for the platform you name, and whatever you name is the label the package is published under.

The platform never enters the metadata sidecar itself — create writes it instead to a build receipt (<stem>-receipt.json) next to the output bundle, a build artifact with no schema that is never pushed to a registry. ocx package push and ocx package test read the receipt back for a --platform you did not give them — see Build receipt below.

  • --platform <PLATFORM> (a concrete platform): each dependency without a digest is resolved against the selected index to the one manifest compatible with <PLATFORM>. Zero compatible candidates fails with exit 65 (lists what is available); more than one is ambiguous (exit 65). The sidecar is rewritten with the resolved digest pinned directly on each dependency's identifier.
  • --platform any: every unpinned dependency must itself offer an any manifest — an any requirement is satisfied only by an any offer, so a dependency with no any build fails create (exit 65), naming it. The resolved digest is pinned bare on the identifier — the same single-pin shape a concrete platform gets. A leaf manifest carries no platform descriptor of its own, so ocx package push later re-verifies the pin against the dependency's own image index rather than trusting the sidecar's word for it (see Multi-Platform Packages). create also rejects a direct digest pin anywhere in an any-targeted bundle's dependency list, including one already present before create ran (exit 65) — it resolves against an index and has no registry evidence to verify a pin it did not resolve itself.

Resolution honors --remote, --offline, and --frozen exactly like every other tag resolution: the default checks the local index first and fetches on a miss; --offline/--frozen refuse to resolve a dependency tag not already cached (exit 81); a dependency tag absent from the selected index fails with exit 79. See Resolving Dependency Pins for the full workflow and the Dependencies reference for the sidecar field shapes.

create is also the compiler for the binaries claim: --bin-scan / --no-bin-scan control whether it scans the content tree for executables the package puts on PATH to fill or verify that field. --bin-scan and --no-bin-scan are a paired, last-wins flag (same shape as every other --X/--no-X pair in this reference) with a tri-state resolution — neither flag given is its own mode, not simply "default off":

Modebinaries absent in the sidecarbinaries declared (including [])
Auto (neither flag — default)Scans; writes the discovered names into the resolved sidecar.Scan not run; the declared list passes through verbatim.
--bin-scanSame as Auto — verification needs a declaration to check against.Scans; a discovered executable missing from the declared list fails with exit 65 (UndeclaredBinary); a declared name present on disk but not executable fails with exit 65 (DeclaredNotExecutable); a declared name simply absent from disk is legal. On success the declared list passes through verbatim.
--no-bin-scanNo scan; the field stays absent.No scan; the declared list passes through verbatim.

--bin-scan requires --metadata/-m — a usage error otherwise (exit 64): the flag exists to verify a declaration, and there is nothing to verify without a metadata sidecar. --no-bin-scan needs no sidecar either way, since it never scans.

Filling or verifying the field needs a scan, and a scan needs a host that can evaluate the target platform's executable-file convention. The Windows extension allowlist is pure string matching — any host can apply it — but the Unix exec-bit convention can only be read on a Unix host. In practice that means a Windows host targeting anything but Windows: linux/*, darwin/* and --platform any alike — any names no native OS convention of its own, so it is scanned by the Unix exec bit like the rest. There, every mode that would have scanned fails with exit 65 (UnsupportedHostScan) — --bin-scan, and the Auto default with binaries absent. A host that cannot check the claim says so rather than publish an unchecked one quietly. Hand-author binaries, or pass --no-bin-scan to declare the gap deliberately; the error names both. A Unix host is unaffected in every mode and for every target.

An unreadable scan-target directory (e.g. permission denied) fails with exit 74 (IoError) rather than silently producing an empty list; only a target directory that does not exist yields zero candidates.

The scan only ever fills the resolved sidecar written next to -o — the same rail dependency pins already ride from create to push; the authored -m input file is never rewritten. It only considers ${installPath}- or ${self.installPath}-rooted (the two are exact aliases) path variables carrying interface visibility with no render modifier — a path value combined with a ${deps.*} segment, or carrying a :native/:posix modifier, is out of scan scope entirely and is silently excluded from the auto-filled claim, with no diagnostic here; a foreign or reused layer added later at push time was never part of the content tree create scanned either. All three cases need the publisher to hand-author binaries instead. See Executables for the full field semantics, including why a modifier-bearing interface PATH value fails the libc lint on Linux and any — the one place this same exclusion does surface a diagnostic.

Build receipt

create writes a second sidecar next to the bundle: <stem>-receipt.json, holding {"version": 1, "platform": "...", "identifier": "..."} — both fields optional, each present only if you gave create the matching flag. It is a build artifact, not package metadata: it has no JSON Schema, is never uploaded to a registry, and exists only to carry what one local build was told to the two commands that consume its output.

create writes it whenever it has something to record — with or without --metadata. Give neither --platform nor --identifier and no receipt is written, because there is nothing to put in one.

The receipt is a fallback, never an authority. Per value:

Flag on push / testReceiptResult
givenanythingthe value you gave, in silence — the receipt is not consulted for it
omittedrecords the valuethe recorded value
omittedrecords nothing (or no receipt at all)usage error (exit 64) — nothing determines the value

push resolves both --platform and --identifier this way; test resolves --platform (its --identifier names the local test subject and stays required). One finer gap the receipt also fills: push --identifier repo without a tag takes the version the receipt recorded, when the receipt names the same repository — the flag picks where to publish, the recorded build says which version it was. A receipt about a different repository contributes nothing and the ordinary latest default applies. The file is opened only when something is missing, so an invocation that states everything never touches it.

Checking the declared libc

Whenever --metadata is given and the target is a Linux one or any, create also checks the os.features the --platform value declares against what the packaged binaries actually need. Under subset matching an empty feature list is a positive claim that the artifact demands nothing of the host — so a glibc-linked binary published without libc.glibc resolves on a musl-only host and then fails to start with a bare No such file or directory, the kernel reporting a missing ELF interpreter for a file that is plainly there.

The check reads the ELF PT_INTERP header of every file the package puts on an interface PATH directory — the same scan scope as --bin-scan, but every regular file rather than only the executable ones, since the libc a file needs is a fact about its bytes. It is not gated on --bin-scan: that flag governs the binaries claim, this governs the os.features claim.

ConditionResult
Statically linked (no PT_INTERP)Needs no declaration
Needs a libc the declared platform requiresPasses
Needs a libc the declared platform does not requireExit 65; the message names the file, the loader, and a paste-ready --platform value
Dynamically linked, but the platform is anyExit 65 — any claims every host can run it
Carries an ELF header but will not parse, or names an unrecognised loaderExit 65 — an undeterminable requirement is never treated as "needs nothing"
Not an ELF object (scripts, data, docs)Not a subject of the check

The check runs before the archive is written, so a refusal leaves no bundle on disk.

It reads only the dynamic loader. A binary that needs libicu, libstdc++ or any other shared library still passes, as does one built against a newer glibc than the host provides — os.features carries libc family, not version. Targets other than Linux are not checked: macOS has a single C library, and OCX defines no libc.* feature for the Windows CRTs.

--no-libc-lint skips the check entirely, including its scan-scope refusal. It is an escape hatch rather than a convenience: the check reads bytes off disk, and a wrong answer from it would otherwise block every create for a Linux target with no way through. Skipping it leaves the declared os.features unverified — an artifact this section would refuse can then be published, and it will resolve on hosts that cannot execute it — so a warning naming the declared platform is printed to stderr. The warning follows the check's own scope — --metadata with a Linux target or --platform any. Anywhere the check never inspects (a bare create with no sidecar, or a non-Linux concrete target) the flag suppresses nothing, so nothing is said. Passing it on every leg of a per-platform matrix therefore stays quiet except where it actually skipped something. Nothing else changes: the same metadata and the same layers are written either way.

Usage

shell
ocx package create [OPTIONS] <PATH>

Arguments

  • <PATH>: Path to the directory to bundle.

Options

  • -i, --identifier <IDENTIFIER>: Package identifier the bundle will be published under. Used to infer the output filename when --output is a directory, and recorded in the build receipt for ocx package push to fall back to.
  • -p, --platform <PLATFORM>: Platform of the package content (e.g. linux/amd64, or any for platform-agnostic content) — see Platforms for the grammar. Required whenever --metadata is given, with no host default (see above); optional otherwise, where it only shapes the inferred output filename. With --metadata, a Linux target or any also has its os.features checked against the packaged binaries — see Checking the declared libc.
  • -o, --output <PATH>: Output file or directory. If a directory is given, the filename is inferred from the identifier and platform. The file extension controls the compression algorithm: .tar.xz (LZMA, default), .tar.gz (Gzip), or .tar.zst (Zstandard).
  • -f, --force: Overwrite the output file if it already exists.
  • -m, --metadata <PATH>: Path to a metadata.json sidecar to validate, resolve, and write alongside the output bundle. Requires --platform (see above); dependencies without a digest are pinned to that platform's manifest digests, and the resolved sidecar is written next to the output bundle in canonical form. If omitted, no metadata sidecar is written; the build receipt is written either way, since it records the invocation rather than the sidecar.
  • -l, --compression-level <LEVEL>: Compression level (fast, default, best). Default: default. Applies to whichever algorithm is selected.
  • -j, --threads <N>: Number of compression threads. 0 (default) auto-detects from available CPU cores (capped at 16). 1 forces single-threaded compression. Affects LZMA (.tar.xz) and Zstandard (.tar.zst) compression; Gzip is always single-threaded.
  • --bin-scan, --no-bin-scan: Scan the content tree for executables the package puts on PATH to fill or verify the binaries metadata claim — see the mode table above. Paired, last-wins flags; neither given (the default) fills an absent claim and passes a declared one through untouched.
  • --no-libc-lint: Skip the libc check on the packaged binaries — see Checking the declared libc. The escape hatch for a false refusal: the declared os.features then go unverified and a warning naming the platform is printed wherever the check would have run, but nothing about what gets written changes.
  • -h, --help: Print help information.

pull

Downloads packages into the local object store without creating install symlinks.

Unlike install, this command only populates the content-addressed object store — no candidate or current symlinks are created. If a package declares dependencies, all transitive dependencies are pulled into the object store as well. This is the recommended primitive for CI environments where reproducibility matters and symlink management is unnecessary.

Like package install, pull verifies a policy-covered package's Sigstore signature automatically before downloading, aborting fail-closed on a mismatch or a tampered artifact. See the auto-verify contract under install below for the seam, the operator-config-only policy scope, the --no-verify / OCX_NO_VERIFY opt-out, and offline behavior.

Usage

shell
ocx package pull [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to pull.

Options

  • -p, --platform: Target platform to consider. Defaults to the current platform.
  • --verify: Verify the package's signature when a [[trust.policy]] covers it (default); re-enables verification for this invocation even if OCX_NO_VERIFY is set.
  • --no-verify: Skip that verification for this invocation. Equivalent env var: OCX_NO_VERIFY (the flag wins over the env).
  • -h, --help: Print help information.

TIP

package pull reports the package root for each package — the same digest-derived directory that package which and package exec resolve to. The package root contains content/ and entrypoints/ as siblings; consumers traverse one level in. Two pulls of the same digest are safe to run concurrently.

For project-tier setups driven by ocx.lock, use pull instead — it consumes the lockfile directly and ignores the index.

push

Publishes a package to the registry as zero or more layers, all recorded in one image manifest for the single target platform this invocation publishes. Each layer is uploaded as an OCI blob, in the order given on the command line. A zero-layer push produces a config-only OCI artifact (referrer-only / description-only manifest) and requires --metadata. Publishing a package for more than one platform means running push once per platform under the same tag — see Multi-Platform Packages for the full pattern; OCX merges each push into the existing image index rather than replacing it.

push makes no dependency-resolution decisions — it is a gate. If the metadata sidecar declares dependencies, every one of them must already carry a manifest digest pin for the platform this invocation publishes (ocx package create is what resolves them; see Resolving Dependency Pins). push fails before uploading anything if:

ConditionExit code
No --platform, and no platform in the build receipt beside the bundle (see Build receipt)64
No --identifier, and no identifier in the build receipt64
A dependency is not digest-pinned65
A dependency's pin resolves to an OCI Image Index instead of a manifest65
A dependency of an any-targeted push pins a digest the dependency's own image index does not advertise as any65
A dependency's pinned manifest does not exist in its registry79
Authentication to a dependency's registry fails80

Usage

shell
ocx package push [OPTIONS] <LAYERS>...

Arguments

  • <LAYERS>...: Zero or more layers, in order (base layer first, top layer last). Each layer is either:
    • a path to a pre-built archive file (.tar.gz, .tgz, .tar.xz, .txz, .tar.zst, .tzst, or .tar.zstd), or
    • a digest reference of the form sha256:<hex>.<ext> (e.g. sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08.tar.gz) pointing at a layer that already exists in the target registry. The <ext> suffix is mandatory — OCI blob HEADs do not carry the original media type, so the publisher must declare it. Bare digests are rejected.
    • Extension aliases: .tgz is accepted as an alias for .tar.gz, .txz for .tar.xz, and .tzst / .tar.zstd for .tar.zst. The canonical forms tar.gz / tar.xz / tar.zst are what ocx emits internally — aliases are normalized on parse.
    • To force file interpretation of a pathological filename that happens to match the digest shape, prefix it with ./ (e.g. ./sha256:abc….tar.gz).
    • Omitting all layers produces a config-only OCI artifact with layers: [], valid for referrer-only / description-only manifests. --metadata is required in that case.

Options

  • -i, --identifier <IDENTIFIER>: Package identifier including the tag, e.g. kitware/cmake:3.28.1_20260216120000. Omit it to publish under the identifier the build receipt beside the bundle recorded; with neither, exit 64.
  • -p, --platform <PLATFORM>: Target platform to publish — see Platforms for the grammar. Single-valued: passing more than one exits 64. Omit it to publish for the platform the build receipt beside the bundle recorded; a value given here is used as given, and the receipt is not consulted for it. With neither, exit 64. Every dependency is projected for this platform (see the gate table above).
  • -c, --cascade: Cascade rolling releases. When set, pushing kitware/cmake:3.28.1_20260216120000 automatically re-points the rolling ancestors (kitware/cmake:3.28.1, kitware/cmake:3.28, kitware/cmake:3, and kitware/cmake:latest if applicable) to the new build — only if this is genuinely the latest at each specificity level. See tag cascades.
  • -m, --metadata <PATH>: Path to the metadata file. If omitted, ocx looks for a sidecar file next to the first file layer (e.g. pkg.tar.gzpkg-metadata.json). Required when no file layers are provided (all layers are digest references, or the layer list is empty).
  • --build-timestamp [<FORMAT>]: Append a UTC build-metadata segment to the published tag. datetime (default when flag passed bare) appends _YYYYMMDDhhmmss, date appends _YYYYMMDD, none is a no-op. The identifier's tag must already be X.Y.Z (optionally with a variant prefix or pre-release suffix) and must not already carry build metadata. Use this in continuous-deploy pipelines that publish rolling pre-release versions like dev.ocx.sh/ocx/cli:0.3.0-dev_20260514120000. The wire-format tag uses _ (OCI tags forbid +); semver + is accepted on input and normalized. When the flag is omitted entirely, no build-metadata segment is appended. Passing --build-timestamp=none is the explicit equivalent.
  • --keep-tag / --no-keep-tag: --keep-tag (default) also pushes a digest-named __ocx.keep.<algorithm>-<hex> tag for each platform manifest pushed in this invocation; --no-keep-tag skips it. This is a pure registry-side deletion safety net — a stray tag delete cannot orphan a digest still referenced by a lock, since the keep tag itself keeps the manifest reachable. A digest whose keep tag would exceed the OCI 128-character tag limit (sha512, at 146) gets none, rather than a truncated one two digests could collide on. It has no effect on index.ocx.sh resolution, which ignores keep tags entirely.
  • --tags-file <PATH>: After a successful push, append the pushed tag and any cascade tags to this file (creating it if absent), so ocx package announce --tags-file can pick them up. This is a scratch file for one pipeline run, not a persistent list — a stale file left over from an earlier run could re-add a tag that was deliberately dropped from a later announce.
  • --annotation <KEY=VALUE>: Record an OCI annotation on the published image index. Repeatable; see Annotations below.
  • --sbom <PATH>: Attach the file at PATH as a CycloneDX SBOM on the manifest this push just wrote — sugar for running ocx package attest --type cyclonedx against the pushed digest immediately afterward, including its polarity: a signing identity visible in the environment (an identity-token override, or an ambient CI platform) means a signed DSSE attestation; nothing visible means the SBOM is attached raw, typed by its own media type, with no signature at all. See Attestations for when each shape applies. The predicate is read and every offline/policy check runs before the push itself; a refusal there means nothing is uploaded. A failure after the push (Fulcio, Rekor, or the referrer write) does not roll the push back — the push report is printed first, and only then does the attest failure become the process's exit code. See attest for the predicate-type vocabulary, the identity-token precedence, and the size limit.
  • --sign: Sign each platform manifest this push writes, inline, immediately after each is pushed. Opt-in — a push without it signs nothing. The signature covers the platform manifest, whose digest is final the moment it is pushed, never the image index, whose digest is rewritten every time another platform merges into it — sign the index afterward with ocx package sign --tags-file, using the file --tags-file wrote. Keyless by default; --key selects a key pair instead. A push that lands and then fails to sign is not rolled back: the push report is still emitted, with the per-platform signing outcome recorded, and the signing failure decides the exit code.
  • --signature-format <FORMAT>: Signature wire format for --sign, and for the inline signing --sbom performs: bundle (default), simplesigning, or both — see sign for what each writes. A usage error (exit 64) without --sign or --sbom.
  • --key <REF>: Sign with a key pair instead of keyless Sigstore, for --sign and for the --sbom attach. Same key-reference grammar as sign. A usage error (exit 64) without --sign or --sbom.
  • --rekor-upload / --no-rekor-upload: Whether the signature --sign or --sbom produces is recorded in the Rekor transparency log. Keyless signatures are always recorded — --no-rekor-upload alongside keyless is a usage error (exit 64, rekor_upload_required_for_keyless). Under --key, recording is off by default; --rekor-upload opts in, or set rekor_upload = true under [trust.sigstore] in config.toml to opt a fleet in. A usage error (exit 64) without --sign or --sbom. See The Rekor rule for why the defaults differ from cosign's.
  • --fulcio-url <URL>: Fulcio CA endpoint (override for private deployments), for the keyless signing --sign or --sbom performs. Defaults to [trust.sigstore].fulcio_url, else https://fulcio.sigstore.dev. Keyless-only: alongside --key it is a usage error (exit 64) rather than a flag that quietly does nothing. A usage error (exit 64) without --sign or --sbom.
  • --rekor-url <URL>: Rekor transparency-log endpoint (override for private deployments), for the signature --sign or --sbom produces. Defaults to [trust.sigstore].rekor_url, else https://rekor.sigstore.dev. A usage error (exit 64) without --sign or --sbom.
  • -h, --help: Print help information.

Layer reuse

Digest-referenced layers are not re-uploaded — ocx only HEADs the registry to verify they exist. This is the foundation of the layer dedup model: a base layer pushed once can be referenced from any number of subsequent packages by digest.

shell
# Push a fresh base + tool combination
ocx package push -p linux/amd64 -i acme/mytool:1.0.0 base.tar.gz tool.tar.gz

# Reuse the same base by digest in a later release.
# The digest is the full 64-char sha256 hex written verbatim —
# the ellipsis is shown here only to keep the example short.
ocx package push -p linux/amd64 -i acme/mytool:1.0.1 sha256:<hex>.tar.gz newtool.tar.gz

Bring your own archives

ocx package push does not bundle a directory for you. Each file layer must be a pre-built archive. Re-bundling the same content yields a non-deterministic digest (timestamps, compression entropy) and defeats layer reuse — use ocx package create to produce a stable archive once, then push and reference it by digest from later commands.

Annotations

--annotation KEY=VALUE records an OCI annotation on the image index of every tag the push writes — the primary tag and, under --cascade, each rolling tag it re-points. The flag is repeatable, splits at the first = (so values may contain =), and keeps the last value for a repeated key. A key that is empty, or an argument with no = at all, is a usage error (exit 64).

Values are written verbatim: OCX never derives an annotation from the environment or from the repository path. Omitting the flag writes nothing and leaves whatever the index already carries untouched, so an earlier annotation survives a later plain push. Supplying a key overwrites that one key and leaves the rest of the index's annotations alone.

The annotation that matters in practice is org.opencontainers.image.source. It is the documented mechanism by which a registry links a package back to its source repository — on GHCR this is what produces the repository link on the package page and what makes the package inherit that repository's permissions. Registries derive nothing from the repository path, so a package published to ghcr.io/acme/tools/widget links to nothing until the annotation says otherwise:

shell
# In GitHub Actions, the runner already knows the answer.
ocx package push -c -p linux/amd64 -i ghcr.io/acme/tools/widget:1.2.3 \
  --annotation org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY \
  widget-1.2.3-linux-amd64.tar.xz

Any other annotation key works the same way — org.opencontainers.image.revision for the commit that produced the build, org.opencontainers.image.licenses for the SPDX expression. The OCI annotation spec lists the pre-defined keys and the reverse-domain convention for custom ones; OCX does not validate keys beyond rejecting an empty one.

Catalog display lives elsewhere

Title, description, and keywords shown in a catalog come from ocx package description push, which publishes them on the separate __ocx.desc tag. --annotation is for facts about a published build that a registry reads off the index itself.

Layer layout

Any layer argument to push or test accepts an optional :strip=N,prefix=P,from=REPO suffix that overrides strip_components for that one layer and/or requests cross-repository blob reuse:

<ref>:strip=<N>,prefix=<P>,from=<REPO>

All three fields are optional, input order is free, and each may appear on its own or combined — prefix=share,strip=1 parses the same as strip=1,prefix=share. <N> is a u8. <P> is a relative, non-escaping path — no leading /, no .., no Windows drive or UNC prefix, bounded to 32 components / 4096 bytes total — under which the layer's post-strip tree is placed in the assembled content/ directory instead of at the package root. <REPO> names a repository in the same registry (lowercase letters, digits, ., _, /, -; no leading or trailing /) that push attempts a registry-side blob mount from before falling back to a normal upload — useful for reusing a layer already pushed elsewhere without re-uploading its bytes. A registry may decline a mount for any reason, and most decline unless the credential carries pull scope on the source repository; a decline is not an error. For a file layer the fallback simply uploads the bytes, so from= is a pure optimization. For a sha256:<hex>.<ext> digest layer there are no local bytes to fall back on, so a declined mount fails the push unless the blob already exists in the target repository. The JSON push report's layers object ({mounted, uploaded, verified}) records how each layer was resolved; run with -l debug to see individual declines.

shell
# base.tar.gz ships wrapped in a `1.2.3/` directory; strip it and relocate the
# remainder under `share/`. tool.tar.gz keeps the default (root, no strip).
ocx package push -p linux/amd64 -i acme/mytool:1.0.0 \
  base.tar.gz:strip=1,prefix=share \
  tool.tar.gz

The resolved values are carried in the manifest layer descriptor's annotations as sh.ocx.layer.strip-components and sh.ocx.layer.prefix — never in metadata.json. Each field falls back independently at read time: an explicit annotation wins; a missing strip annotation falls back to the package-wide strip_components from metadata, then to 0; a missing prefix annotation falls back to the package root. A push with no :strip=/:prefix= on any layer writes no sh.ocx.layer.* annotations at all, so the manifest stays byte-identical to a pre-layout publish.

Constraints

  • Layers are a flat merge, not an overlay stack. OCI whiteout entries (.wh.*, .wh..wh..opq) — e.g. inside a foreign layer reused by digest from a Docker/BuildKit build — pass through as ordinary files; OCX never interprets them as deletions.
  • A deep prefix combined with a deep layer tree can approach the legacy Windows MAX_PATH limit. Keep prefix shallow for packages that install on Windows.
  • <P> cannot contain a comma — the layout suffix is a comma-separated key=value list with no escaping. This constrains the (typically short) prefix value itself, not the file paths inside the layer.
  • A layer filename that literally contains :strip=, :prefix=, or :from= cannot be pushed as a plain path — there is no escape hatch, unlike the ./ disambiguation used above for digest-shaped filenames. Avoid such filenames.
  • from=REPO is push-only — it is never carried into the manifest or its annotations, unlike strip/prefix.

Malformed layout syntax at publish (strip not a u8, an unknown key, a duplicate key, an empty value, an escaping/oversized prefix, or a from value outside [a-z0-9._/-] or with a leading/trailing /) is a CLI usage error (exit 64). The same prefix bound is re-validated when a layer is read back — manifests are third-party-writable — and a malformed annotation there is a data error (exit 65).

test

Materializes a package locally without a registry round-trip and runs a command or script in its composed env. Mirrors the argument shape of package push: identifier as -i/--identifier, then layers, then --platform. Either a trailing -- CMD [ARGS...] or a --script PATH is required; the two forms are mutually exclusive.

Commands resolve against the package first

A bare command name is looked up in the package's own directories before the host PATH, and the package's copy of a name is never skipped:

  • The package ships an executable of that name — it runs. This is the ordinary case.
  • The package ships the name but the file is not executable — the command fails with exit code 65, naming the path and its permission bits. It does not fall back to a same-named binary on the host, because that would test something the package does not contain and report a pass.
  • The package does not ship the name at all — it resolves on the host PATH as usual (sh, grep, and friends keep working), with a warning naming the directories that were searched.

A name carrying a path separator (./tool, an absolute path) addresses a file directly and is unaffected.

Catch a missing executable bit at publish time instead with ocx package create --bin-scan.

Usage

shell
# Trailing-command form
ocx package test [OPTIONS] --identifier <IDENTIFIER> [LAYERS]... -- <CMD> [ARGS]...

# Script form
ocx package test [OPTIONS] --identifier <IDENTIFIER> [LAYERS]... --script <PATH|->

Arguments

  • <LAYERS>...: Zero or more layers, in order (base first, top last). Same syntax as package push, including the optional :strip=N,prefix=P,from=REPO layout suffix: a path to a .tar.gz/.tar.xz/.tar.zst archive, or a sha256:<hex>.<ext> digest reference to a layer already in the registry. Digest refs are fetched on demand; missing digest blobs when a local policy (--offline or --frozen) is active produce exit code 81 (PolicyBlocked). from=REPO is meaningless for test (there is no registry push) and is simply ignored if present.
  • -- <CMD> [ARGS]...: Command to run inside the composed env. Required unless --script is given.

Options

NameShortDescriptionDefault
--identifier <IDENTIFIER>-iPackage identifier in tag form (repo:tag) — required. An explicit @digest suffix is rejected (the digest is computed locally from the supplied layers).
--platform <PLATFORM>-pTarget platform. Omit it to take the platform the build receipt beside the bundle recorded; a value given here is used as given, and the receipt is not consulted for it. With neither, exit 64.build receipt
--script <PATH|->Path to a Starlark test script, or - to read the script source from stdin. Mutually exclusive with the trailing -- CMD form.
--metadata <PATH>-mPath to the metadata JSON file. Defaults to a sibling of the first file layer (e.g. pkg.tar.gzpkg-metadata.json). Required when no file layers are provided.auto-detected
--keepPreserve the temp build directory after the command exits. Path is printed to stderr. Default temp root is $OCX_HOME/temp/test/. Mutually exclusive with --output.false
--output <DIR>-oMaterialize into DIR instead of an auto-managed temp dir. DIR must not exist or must be empty. Implies keep. Must reside on the same filesystem as $OCX_HOME/layers/. On Windows, must point under $OCX_HOME/. Mutually exclusive with --keep.
--selfCompose the package's private env surface (default: interface surface). Same semantics as ocx package exec --self.false
--cleanStrip ambient parent env before composing — only OCX_* config and composed package vars reach the child. Mirrors ocx package exec --clean.false
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOO -> a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends); SEP qualifies list only (--env GODEBUG:list:,=gctrace=1) and, if omitted, inherits whatever separator another contributor to the key already declared, or a single space if none did. A relative path value resolves against the current directory. Applied last, so it overrides every package-declared variable. This is a per-invocation override, not project configuration -- it does not make this command read ocx.toml. A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64). See the PATH override warning under ocx exec.
--help-hPrint help information.

Examples

shell
# Run the binary in its composed env (trailing-command form).
ocx package test -p linux/amd64 -i acme/mytool:1.0.0 mytool.tar.xz -- mytool --version

# Run a Starlark test script against the package.
ocx package test -p linux/amd64 -i acme/mytool:1.0.0 mytool.tar.xz --script smoke.star

# Read a Starlark script from stdin.
printf 'r = ocx.run("mytool", "--version")\nexpect.ok(r)\n' \
  | ocx package test -p linux/amd64 -i acme/mytool:1.0.0 mytool.tar.xz --script -

# Keep the temp dir for inspection on failure.
ocx package test -p linux/amd64 --keep -i acme/mytool:1.0.0 mytool.tar.xz -- mytool --version

# Materialize to a named directory.
ocx package test -p linux/amd64 --output ./build -i acme/mytool:1.0.0 mytool.tar.xz -- mytool --version

# Explicit metadata path + digest base layer.
ocx package test -p linux/amd64 -m metadata.json -i acme/mytool:1.0.1 \
  sha256:<hex>.tar.xz ./newtool.tar.xz -- mytool --version

Tempdir lifecycle

Without --keep or --output, the temp directory is deleted on any exit — success or failure. Use --keep to opt in to preservation on failure. Re-run with --keep to inspect.

Exit codes — trailing-command branch

The child's own exit code propagates verbatim, so any value can appear. Two codes are produced by ocx itself before the child starts: 64 for a usage error, and 65 either when the package ships the named command but the file is not executable (see the warning above) or when composing the environment finds two contributors to one key declaring conflicting list separators (see Separator agreement).

Exit codes — --script branch

CodeMeaning
0All expectations passed
1An expectation failed; expect.fail or fail() was called; or a host API returned a failure
64Usage error — both --script and -- CMD supplied; neither supplied; script file not found or unreadable
65Script syntax, type, or arity error
74I/O error — stdin read failure (--script -) or scratch directory creation failure

Exit code is the primary machine signal. When --format json is passed, a structured ScriptRunReport envelope is written to stdout alongside the exit code:

json
{
  "status": "passed|failed|usage|script_error|io|timeout",
  "assertion": { "kind": "ok|eq|ne|…|unknown", "message": "…" },
  "run":       { "exit_code": 0, "stdout": "…", "stderr": "…", "duration_ms": 12, "truncated": false }
}

assertion and run are null when not applicable. assertion.kind reflects the failing expect.* function and is the stable machine field; assertion.message prose is not stable. The three top-level keys and their sub-field shapes are stable v1 contract.

The --script command returns Ok(ExitCode) directly — it bypasses classify_error so exit codes always match this table regardless of upstream error state.

See the testing locally guide for a full pre-push workflow example including the scripted form.

inspect

Inspects what sits at a package reference — nothing is installed and no symlinks are created. It is not strictly free of local writes: default mode resolves the tag through the index, so a tag cache miss may populate the local index / blob cache (a Resolve-class read). The output adapts to the reference shape:

  • Default, image-index reference (the usual multi-platform tag): lists the platform candidates — for each child manifest the platform, child digest, media type, and size. No metadata is loaded and no platform is selected.
  • Default, single-manifest reference (a flat tag or an @digest pointing directly at an image manifest): emits the declared metadata (bundle version, strip_components, env vars, dependencies, entrypoints) plus the manifest's layers (digest, media type, size). No resolution chain.
  • --resolve: platform-selects through the index, then emits metadata and layers plus the OCI resolution chain (the walk-order indexmanifestconfig blobs).
  • --closure: walks the declared dependencies to compute the metadata-only dependency closure — every package reachable from the reference, read from cached or fetched metadata alone, without installing anything — plus two surface projections: interface (what would land on PATH for a consumer installing the reference) and private (what the reference's own runtime sees). On an image-index reference, --closure platform-selects first (honoring -p/--platform, the host platform otherwise) exactly like --resolve does, because the walk needs a concrete manifest to read declared dependencies from — --closure alone therefore never returns the metadata-less candidates listing.

Unlike package test, the identifier accepts an explicit @digest (a tag or digest both resolve).

--closure fails closed: if any dependency's manifest or metadata can't be loaded, the whole request fails rather than rendering a partial closure (see Exit codes below). A script deciding whether --closure ran should check for the closure key in JSON output, not for resolutionresolution reflects the shape of the reference (whether it needed platform selection), not whether --closure was requested.

Usage

shell
ocx package inspect [OPTIONS] <IDENTIFIER>...

Arguments

  • <IDENTIFIER>...: One or more package identifiers to inspect. Each is a tag (repo:tag) or @digest.

JSON output is one envelope — { platform?, packages, env } — whose packages array carries one entry per requested identifier, in request order, each naming itself in its name field. Plain output renders each package's tree in the same order.

Options

  • -p, --platform <PLATFORM>: Platform to select. Applies with --resolve and --closure; ignored in default mode (the candidate list always shows every platform).
  • --resolve: Platform-select through the index and emit the resolution chain — the pinned identifier and the walk-order chain blob descriptors (index → platform manifest → config blob, each with its role, media type, and size) — alongside the metadata and layers (the layers are shown for the selected manifest in both default and --resolve mode).
  • --closure: Compute the metadata-only dependency closure without installing. Adds one closure object to JSON output — deps (the transitive dependencies, in transitive-closure order) and surface (the interface and private projections) — and a matching closure branch to the plain-text tree. Combining --closure with --resolve on an image-index reference is redundant but harmless — the platform selection --closure already performs is the same one --resolve performs. A non-empty closure.conflicts exits 65 while still reporting the conflict.
  • --env <KEY[:TYPE[:SEP]]=VALUE>: Set an environment variable for this invocation, surfaced in the report's env array. Repeatable. Per-invocation only — this command still reads no ocx.toml; for a project's declared environment use ocx inspect.
  • -h, --help: Print help information.

Honors the global --offline, --remote, and --format flags. JSON is the primary consumer surface.

JSON shape

The top level is one envelope, shared verbatim with ocx inspect:

json
{
  "platform": "linux/amd64",
  "packages": [ { "name": "mytool:1.0.0", "…": "…" } ],
  "env": []
}
  • platform — the platform the run selected. Present only with --resolve / --closure: default mode selects none, so -p stays inert there and no platform is reported.
  • packages — one entry per requested identifier, in request order. An array rather than an object keyed by identifier, because order is part of the contract and JSON object key order is not. Each entry's name is the identifier exactly as it was requested.
  • env — the per-invocation --env overrides, in flag order. Always present, empty when none were passed. Package-declared environment is not here: it lives inside each entry's closure.surface.env, attributed per package and without values (those are ${installPath}-templated and only concrete after install).

Each packages entry carries name and identifier, plus pinned_identifier (the identifier with its digest already attached) and pinned_digest wherever one artifact was selected, plus one of the shapes below.

Default, image index — candidate listing:

json
{
  "identifier": "registry/repo:tag",
  "pinned_identifier": "registry/repo:tag@sha256:…",
  "pinned_digest": "sha256:…",
  "candidates": [
    {
      "digest": "sha256:…",
      "pinned": "registry/repo:tag@sha256:…",
      "platform": "linux/amd64",
      "media_type": "…",
      "size": 123
    }
  ]
}

A candidate's pinned is that child as a pullable reference — the entry's identifier with the child's digest attached — so naming one platform never means splicing a reference by hand. It is spelled pinned, not pinned_identifier: a candidate has one digest, so there is nothing to disambiguate against — the same reason resolution.pinned is spelled that way. The entry's own pinned_identifier names the index the candidates came from.

Default, single manifest (@digest or flat tag) — metadata plus layers:

json
{
  "identifier": "registry/repo@sha256:…",
  "pinned_digest": "sha256:…",
  "metadata": { "type": "bundle", "version": 1, "env": [], "dependencies": [], "entrypoints": {} },
  "layers": [{ "digest": "sha256:…", "media_type": "…", "size": 123 }]
}

--resolve — platform-selected metadata and layers + chain:

json
{
  "identifier": "registry/repo:tag",
  "pinned_digest": "sha256:…",
  "platform": { "os": "linux", "architecture": "amd64", "os.features": ["libc.glibc"] },
  "metadata": { "type": "bundle", "version": 1, "env": [], "dependencies": [], "entrypoints": {} },
  "layers": [{ "digest": "sha256:…", "media_type": "…", "size": 123 }],
  "resolution": {
    "pinned": "registry/repo:tag@sha256:…",
    "chain": [
      { "digest": "sha256:…", "role": "index", "media_type": "…", "size": 429 },
      { "digest": "sha256:…", "role": "manifest", "media_type": "…", "size": 448 },
      { "digest": "sha256:…", "role": "config", "media_type": "…", "size": 244 }
    ]
  }
}

--closure — adds one closure object on top of whichever body the reference already produces (the metadata body shown here for a single manifest, or the resolution body above when the reference needed platform selection):

json
{
  "identifier": "registry/cmake:3.28",
  "pinned_digest": "sha256:cccc…",
  "metadata": { "type": "bundle", "version": 1, "env": [], "dependencies": [], "entrypoints": {} },
  "layers": [{ "digest": "sha256:…", "media_type": "…", "size": 123 }],
  "closure": {
    "deps": [
      {
        "name": "zlib",
        "identifier": "registry/zlib@sha256:bbbb…",
        "effective_visibility": "public",
        "entrypoints": ["zfmt"],
        "integrations": ["com.jetbrains"],
        "dependencies": []
      },
      {
        "name": "gcc",
        "identifier": "registry/gcc@sha256:dddd…",
        "effective_visibility": "private",
        "binaries": ["gcc", "g++"],
        "entrypoints": [],
        "integrations": [],
        "dependencies": [
          { "identifier": "registry/zlib@sha256:bbbb…", "visibility": "public", "name": "zlib" }
        ]
      }
    ],
    "surface": {
      "interface": {
        "binaries": [],
        "entrypoints": [
          { "name": "cc", "package": "registry/cmake:3.28@sha256:cccc…" },
          { "name": "zfmt", "package": "registry/zlib@sha256:bbbb…" }
        ],
        "env": [
          { "key": "PATH", "type": "path", "package": "registry/cmake:3.28@sha256:cccc…" },
          { "key": "ZLIB_ROOT", "type": "constant", "package": "registry/zlib@sha256:bbbb…" }
        ],
        "integrations": [
          { "namespace": "com.microsoft.vscode", "package": "registry/cmake:3.28@sha256:cccc…" },
          { "namespace": "com.jetbrains", "package": "registry/zlib@sha256:bbbb…" }
        ],
        "binaries_complete": false
      },
      "private": {
        "binaries": [
          { "name": "gcc", "package": "registry/gcc@sha256:dddd…" },
          { "name": "g++", "package": "registry/gcc@sha256:dddd…" }
        ],
        "entrypoints": [
          { "name": "zfmt", "package": "registry/zlib@sha256:bbbb…" }
        ],
        "env": [
          { "key": "PATH", "type": "path", "package": "registry/cmake:3.28@sha256:cccc…" },
          { "key": "GCC_HOME", "type": "constant", "package": "registry/gcc@sha256:dddd…" },
          { "key": "ZLIB_ROOT", "type": "constant", "package": "registry/zlib@sha256:bbbb…" }
        ],
        "integrations": [],
        "binaries_complete": false
      }
    },
    "conflicts": { "entrypoints": [], "repositories": [] }
  }
}

closure.deps lists every package transitively reachable from the reference's declared dependencies, in transitive-closure order — dependencies before the packages that depend on them. The inspected reference itself is never listed here; it is named by the top-level identifier and contributes to both surface projections below. A dependency reached through two different paths (a diamond) still appears once, carrying the merge of every path that reaches it. Each entry carries:

  • name — the dependency repository's short display name (its final path segment).
  • identifier — the dependency's resolved identity, always digest-pinned. A closure node is a resolved artifact and never a bare tag, so there is no separate digest key repeating the tail of this one.
  • effective_visibility — the entry's visibility as composed from the root, down every path that reaches it.
  • binaries — the same tri-state as Executables: the key is absent when the publisher never declared the field, [] when the publisher declared zero, and a populated array when names are declared.
  • entrypoints — the entry's own declared entry-point launcher names. Independent of binaries: a package may declare either, both, or neither. A binary is a raw executable the package puts on PATH; an entry point is a named launcher that runs one with a fixed argument prefix — the two are separate axes, not a 1:1 pairing. Above, zlib declares an entry point but no binaries; gcc declares binaries but no entry point.
  • integrations — the entry's own declared integration namespace keys, lexicographically ordered, [] when it declares none (absent and empty are the same state here, unlike binaries's undeclared/declared-empty tri-state). Keys only, no payload — a closure node is not installed, the same reason env above carries no values.
  • dependencies — the entry's own declared edges (as authored, not composed), each carrying its visibility and dependency name — enough to reconstruct the DAG from the flat list.

closure.surface projects the same node set two ways — the two environments the package participates in, each equal to what the runtime composer emits:

  • interface is the consumer view: what a package depending on this reference inherits. It admits the reference itself plus every dependency whose effective_visibility reaches the interface axis. This is the surface ocx env composes for a downstream consumer.
  • 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. This is the surface ocx env --self composes.

The two surfaces overlap by design, and the overlap is deliberate, not redundant:

  • A public dependency reaches both axes and appears in both surfaces (zlib above). A dependency reached only through a private edge appears in private only (gcc above); 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 (cc above) — ocx env --self never puts its entrypoints/ on PATH — while a dependency's entry points cross the edge like any interface-side carrier and appear on whichever surfaces admit the dependency (zfmt above): they are how this package invokes that dependency.
  • Binaries carry an implicit public visibility — raw executables serve consumers and the package's own shims alike — so 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 (its interface/public vars on interface, its private/public vars on private). A dependency, however, 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 into this package, so it appears on neither surface.

Each surface carries four attributed arrays plus a completeness flag:

  • binaries / entrypoints — what lands on PATH on that axis, each entry { name, package } naming the declaring package.
  • env — the environment keys exposed on that axis, each entry { key, type, package } plus a separator field present only when type is list. type is path, constant, or list; the value is omitted because it is ${installPath}-templated and only concrete once the package is installed — the summary answers which keys would be set, not to what.
  • integrations — the integration namespaces each admitted package declares, each entry { name, package }name holds the namespace, never the payload (a closure node is not installed, so there is nothing concrete to interpolate a payload against). This reuses the same { name, package } shape binaries/entrypoints use above, not the { namespace, package, payload } shape the flat package env array carries — the two field names for the same concept belong to two different envelopes. One entry per (namespace, package) pair, never merged. Interface surface only: private.integrations is always [], and a dependency still needs an interface-reaching edge to contribute at all — gcc's private edge above contributes to neither surface, while zlib's public edge and the reference's own root position admit their namespaces to interface (cmake contributes com.microsoft.vscode the same way it contributes its own cc entrypoint).
  • binaries_completefalse iff some admitted node on that axis left binaries undeclared (the key absent from its metadata). A declared-empty claim ("binaries": []) is the opposite of a gap — the publisher asserts zero binaries — and keeps the aggregate complete; an unknown claim never silently counts as zero. Above, both surfaces admit zlib (undeclared) so both read false even though gcc declared its own claim.

closure.conflicts names install/compose conditions detected over the interface projection: entrypoints (two or more packages claiming the same entrypoint name) and repositories (one repository resolving to two or more distinct digests). Both arrays are always present; 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, the exit code is what a pipeline branches on. 65 is the same code install/compose already returns when it hard-rejects the identical condition, so inspect --closure exits exactly where the corresponding ocx exec would.

Examples

shell
# List the platforms a multi-platform tag offers.
ocx --format json package inspect mytool:1.0.0 | jq '.packages[0].candidates'

# Inspect several packages at once — one array entry each, in input order.
ocx --format json package inspect mytool:1.0.0 othertool:2.0.0 | jq '.packages[].name'

# Pick one entry by the name it was requested under.
ocx --format json package inspect mytool:1.0.0 othertool:2.0.0 \
  | jq '.packages[] | select(.name == "othertool:2.0.0")'

# Inspect one platform child by digest (same repo, online or cached).
ocx package inspect acme/mytool@sha256:abc…

# Platform-select and include the OCI resolution chain.
ocx --format json package inspect --resolve -p linux/arm64 mytool:1.0.0 | jq '.packages[0].resolution'

# What would land on PATH without installing it?
ocx --format json package inspect --closure mytool:1.0.0 | jq '.packages[0].closure.surface.interface'

# The exact artifact, without splicing identifier and digest together.
ocx --format json package inspect --resolve mytool:1.0.0 | jq -r '.packages[0].pinned_identifier'

Plain output

With --format plain (the default) the report renders as a tree rooted at the pinned identifier — the one place a full sha256: digest is spelled out, because it is what the command was asked for. The candidate listing shows one node per platform child; the single-manifest view shows the metadata branch (env, dependencies, entrypoints, and — when declared — binaries) followed by a layers branch listing each layer by index, annotated with the discriminating tail of its media type (tar+xz) and a human-readable size. Under entrypoints, an entry whose dispatch command diverges from its invocable name carries a → <command> annotation; entries whose command matches the name (the common case) are shown without annotation. binaries renders the same undeclared vs. declared-empty distinction the field carries on the wire: an undeclared claim produces no binaries node at all, a declared-but-empty claim renders a binaries (none declared) leaf, and a non-empty claim renders a binaries branch listing each name. JSON output is unaffected by this rendering split — the metadata field is the full metadata document, so the binaries key is present or absent exactly as declared:

text
registry/repo@sha256:…
├── metadata
│   ├── entrypoints
│   │   ├── fmt → cargo-fmt
│   │   └── build
│   └── binaries
│       ├── build
│       └── cargo-fmt
└── layers
    └── [0] · sha256:… · tar+xz · 192 B

Only the tail of the media type is shown because every layer of a package repeats the same application/vnd.oci.image.layer.v1 prefix — 30 characters that push the size past the right edge of a narrow terminal without telling two layers apart. --format json carries the full media type.

Here fmt dispatches to the cargo-fmt binary while build dispatches to a binary named build; binaries lists both underlying names the entry points wrap. A package that never declared the field renders no binaries node at all; one that declared it empty renders a single binaries (none declared) leaf instead of the branch shown above.

With --resolve, a resolution branch is added alongside metadata and layers. It opens with the platform the walk selected against, then a chain listing each blob by its role (index, manifest, config) with a human-readable size. The layers stay under the manifest — they are content the manifest references, not steps in the walk:

text
registry/repo:tag@sha256:…
├── metadata
│   └── …
├── layers
│   └── [0] · sha256:… · tar+xz · 192 B
└── resolution
    ├── platform linux/amd64+libc.glibc
    └── chain
        ├── index · sha256:… · 429 B
        ├── manifest · sha256:… · 448 B
        └── config · sha256:… · 244 B

The platform leaf is the answer whenever --platform drove the selection: a libc refinement like +libc.glibc is chosen during the walk and is visible nowhere else in the tree. The chain carries no media-type column — the role label already names it (an index role is application/vnd.oci.image.index.v1+json), and there is no pinned leaf because it would repeat the tree root byte for byte.

With --closure, a closure branch is added alongside metadata and layers (and resolution, on a multi-platform reference — --closure platform-selects the same way --resolve does). The branch holds a flat deps list — one leaf per transitive dependency, in transitive-closure order, labeled by its short name with the whole identifier annotated as a digest-inked span and its composed visibility tagged after — and a surface branch with interface and private sub-branches, each rendering its admitted binaries/entrypoints/env the same way the JSON surface object does. A dependency reached through two different paths already merges into one entry before rendering (see Visibility), so deps needs no repeat-visit marker:

text
registry/cmake:3.28@sha256:cccc…
├── metadata
│   └── …
├── layers
│   └── …
└── closure
    ├── deps
    │   ├── zlib · registry/zlib@sha256:bbbb… · public
    │   └── gcc · registry/gcc@sha256:dddd… · private
    └── surface
        ├── interface
        │   ├── entrypoints
        │   │   ├── cc · cmake
        │   │   └── zfmt · zlib
        │   ├── env
        │   │   ├── PATH · path · cmake
        │   │   └── ZLIB_ROOT · constant · zlib
        │   ├── integrations
        │   │   ├── com.microsoft.vscode · cmake
        │   │   └── com.jetbrains · zlib
        │   └── binaries incomplete: at least one admitted package leaves binaries undeclared
        └── private
            ├── binaries
            │   ├── gcc · gcc
            │   └── g++ · gcc
            ├── entrypoints
            │   └── zfmt · zlib
            ├── env
            │   ├── PATH · path · cmake
            │   ├── GCC_HOME · constant · gcc
            │   └── ZLIB_ROOT · constant · zlib
            └── binaries incomplete: at least one admitted package leaves binaries undeclared

Surface entries attribute each claim to its owning package by short name — the same name the deps branch above uses as its label, so deps reads as the legend for the whole surface. The full pinned identifier appears once per dependency there rather than once per claim; a three-dependency, five-binary closure would otherwise repeat an 89-character pin thirty times. --format json attributes by full identifier in both places.

Here gcc's direct edge is private — it never reaches the interface surface, so cc (the reference's own entrypoint) and zfmt (zlib, reachable by a separate public edge) are the only entries under interface, while private drops cc (the reference does not go through its own launcher) and gains gcc's two binaries. Both surfaces flag binaries incomplete: the reference itself and zlib never declare a binaries claim on either axis they're admitted to, and completeness requires every admitted node to have declared — gcc's own ["gcc"] claim does not offset it.

integrations renders only under interface here — cmake and zlib both contribute a namespace and both reach the interface axis, while private never gets a integrations branch at all (an empty array renders no branch, the same convention every other section here follows). gcc declares no integrations in this example, but the point holds even if it did: its private edge would keep them off both surfaces.

An entrypoint or repository conflict, when present, renders as its own branch directly under closure with one child per colliding party — the one place the view spends vertical space, because it fires exactly when there is a decision to make:

text
└── closure
    ├── deps
    │   └── …
    ├── surface
    │   └── …
    ├── entrypoint 'fmt' claimed by multiple packages
    │   ├── registry/zlib:1.3
    │   └── registry/gcc:13
    └── repository 'registry/zlib' resolves to multiple digests
        ├── sha256:bbbb11223344
        └── sha256:eeee55667788

Entrypoint conflicts name the colliding packages without their digests — which packages collide is the answer, and the digest is not. Repository conflicts are the inverse: one repository, several digests, each shortened to twelve hex characters.

Exit codes

  • 79 (NotFound) — the tag or digest does not resolve; with --closure, also a dependency in the closure that is genuinely absent from the registry (a source was consulted and it said no).
  • 81 (PolicyBlocked) — a local policy (--offline or --frozen) refused the resolution: the manifest or config blob is absent from the local cache, or an unpinned tag was not in the local index. With --closure, the same code covers a dependency's manifest or metadata blob missing from the local cache under --offline — run the same --closure inspection online once (or ocx package pull the dependency) to warm the cache, then retry offline.
  • 65 (DataError) — the resolved metadata is malformed, fails validation, or exceeds the metadata size cap; with --resolve -p <platform>, also a platform feature mismatch or an ambiguous dual-libc selection (see exit codes). With --closure, the same checks apply to every dependency in the closure — one bad dependency fails the whole request rather than a smaller closure.

copy

Promotes an already-published package to another registry or repository without rebuilding it.

The platform manifests and their blobs are copied verbatim, so every digest stays the same. That is the whole point: a Sigstore signature's subject is the platform manifest digest, and an ocx.lock entry pins it. Rebuilding the package for production would produce a different digest — orphaning the signature you verified in staging and invalidating every lock pinned against it — while looking like it worked. See Promoting packages for the dev → staging → prod walkthrough.

Three kinds of object, three different rules, because only one of them is content:

ObjectTreatment
Platform manifest + its blobsCopied byte for byte; the digest never changes.
The tag's image indexMerged one platform at a time. Copying linux/amd64 never removes a darwin/arm64 the target already offers.
Rolling tags (1.4, 1, latest)Recomputed against the target's tag list under --cascade, never carried over from the source's.

Usage

shell
ocx package copy [OPTIONS] <SOURCE>

Arguments

  • <SOURCE>: The published package to promote, as registry/repository:tag or registry/repository@sha256:<hex>. A tag names an image index (or, for a single-platform package, a bare manifest); a digest names one platform manifest and then --platform is required, because a platform manifest carries no platform of its own — OCX records the platform in the index entry, never in the manifest.

Options

  • --to <REGISTRY>: Rewrite only the registry host, keeping the repository path and the tag. dev.example.com/team/tool:1.4.2 --to prod.example.com lands at prod.example.com/team/tool:1.4.2. Mutually exclusive with --identifier.
  • -i, --identifier <IDENTIFIER>: The full target reference, for when the repository path or the tag changes too. Required when <SOURCE> names a digest — a digest carries no tag for --to to preserve.
  • -p, --platform <PLATFORM>: Repeatable. Against a tag it filters the source index; omit it to copy every platform the source offers. Against a digest it declares the platform, and exactly one is required. See Platforms for the grammar.
  • -c, --cascade: Also re-point the rolling ancestors (1.4, 1, latest) at the target. The blocker checks read the target's tag list, so promoting 1.4.1 into a production registry that already publishes 1.4.2 leaves 1.4 where it is.
  • --keep-tag / --no-keep-tag: --keep-tag (default) also writes a digest-named __ocx.keep.<algorithm>-<hex> tag for each copied platform manifest at the target — the same registry-side deletion safety net push writes.
  • --referrers / --no-referrers: --referrers (default) also copies everything anchored to each manifest — signatures, SBOMs, attestations — following referrer chains recursively. Requires the OCI Referrers API at the target; a registry without it exits 84 rather than accepting a referrer manifest it will never list. --no-referrers promotes the package alone, sidecar tags included. --referrers also carries cosign's sha256-<hex>.sig, .att and .sbom sidecar tags — ordinary tags rather than referrers, so nothing lists them and they are probed by name. Each is copied verbatim under the same tag name, with the blobs it references; the sweep runs whether or not the target serves the Referrers API, so sidecars land even on the registry:2 destinations that refuse a referrer. A sidecar tag the target already holds under a different manifest is never overwritten — a .sig accumulates signatures as layers within itself, so a verbatim PUT would destroy every signature the target has and the source does not. That tag is named in the report and left alone, the leaf and the other sidecars still land, and the command exits 65.
  • --description: Also copy the repository description (README, logo, catalog annotations) from the __ocx.desc tag. Off by default — a description is repository-level prose rather than part of the version being promoted, and environments legitimately carry different ones. ocx package description push --from copies it on its own.
  • --annotation <KEY=VALUE>: Record an OCI annotation on the target's image index. Repeatable, same semantics as push. Platform manifests are never annotated — that would change their digest, which is the one thing a copy must not do.
  • --dry-run: Report what would be copied and write nothing. The preview covers only the per-platform disposition below — a --cascade or --keep-tag promotion's rolling-tag and keep-tag moves are never computed under --dry-run, so those fields report empty regardless of what a real run would write. See Output below.
  • -h, --help: Print help information.

Output

One row per platform the target offers after the copy, each labelled with what happened to it — this is the result, on stdout:

ResultMeaning
addedThe target's index had no entry for this platform.
unchangedThe target already pointed at this exact digest.
replacedThe target pointed at a different digest for this platform.
kept (not in source)The target offers this platform and the source does not, so the merge left it alone.

The last row is why the report is per platform: a filtered promotion that leaves a mixed index behind is a legitimate outcome and a serious mistake, and only the row list tells them apart.

The Digest column means two things, and the Result column says which: on an added, replaced or unchanged row it is the digest this copy put there, and on a kept (not in source) row it is the digest the target already had and this copy never touched.

Under --dry-run the two write results read would add and would replace in the table. The JSON disposition keeps added / replaced either way — the top-level status (copied or planned) is what a script branches on.

The tags written, the blob traffic and the description outcome go to stderr as one status line, leaving stdout to the table. --format json carries all of it: cascade_tags_written, keep_tags_written, referrers_copied, blobs (present / mounted / uploaded), and descriptioncopied, absent when the source publishes none, skipped-dry-run, or null when --description was not passed.

Under --dry-run both cascade_tags_written and keep_tags_written are always empty, whatever --cascade and --keep-tag say: the tag phase is the part a dry run does not run.

Exit codes

ConditionExit code
<SOURCE> names a digest and --platform is absent, or given more than once64
<SOURCE> names a digest and --identifier is absent64
<SOURCE> names an image index by digest — name the tag instead64
--to and --identifier together64
No platform in the source matches --platform64
The source tag or digest does not resolve79
Authentication to either registry fails80
--referrers (the default) and the target has no Referrers API84
--offline is set — a copy always needs network access to both registries81

Promotion is safe to re-run

A second identical copy is idempotent in effect — no new content lands and no tag moves — but it is not a no-op on the wire. Every platform still re-verifies: the leaf manifest is re-fetched and re-PUT, and with --referrers (the default) its referrer set is re-copied too, because the target's index entry proves the manifest is there, not that every blob it names still is. Only blob bodies are skipped, via a HEAD against the target. The index is re-PUT too: each platform's entry is merged into every tag it lands on, and the merge is a read-modify-write that writes even when the entry it would set is already there. Pipelines can re-run a promotion step without special-casing it — the cost is a HEAD per blob, a manifest re-PUT per platform, and an index re-PUT per platform per tag, not a re-upload.

A copy is not a re-sign

The signature travels with the manifest, so it still names the identity that signed it in the source environment. If your policy requires a production-specific attestation, sign again at the target — promotion preserves provenance, it does not create it.

description push

Pushes or updates package description metadata (title, description, keywords, README, logo) on the registry. When updating an existing description, only the provided fields change — an omitted field is preserved from the current description, so a --title-only push does not blank the README.

Usage

shell
ocx package description push [OPTIONS] <IDENTIFIER>

Arguments

  • <IDENTIFIER>: Package identifier (repository only; tag is ignored).

Options

  • --readme <PATH>: Path to a README markdown file. Required on the first push to a repository with no existing description.
  • --logo <PATH>: Path to a logo image (PNG or SVG). The file's bytes must be the format its extension names; anything else exits 65 without touching the published description.
  • --title <TITLE>: Short display title for the package catalog.
  • --description <TEXT>: One-line summary.
  • --keywords <LIST>: Comma-separated search keywords.
  • --from <SOURCE>: Copy the whole description — README, logo and catalog annotations — from another package repository, replacing the target's. Mutually exclusive with the field options above: this is a copy, not a merge, so mixing the two would silently pick a winner. Use it to promote a catalog page reviewed in staging without re-authoring it, or after an ocx package copy that ran without --description. A source that publishes no description exits 79 and the target is left untouched; the same code covers a source repository that does not exist at all.
  • -h, --help: Print help information.

At least one of the above metadata options must be provided, or --from.

Exit codes

ConditionExit code
--from combined with --readme, --logo, --title, --description, or --keywords64
Neither --from nor any metadata option given1
--from <SOURCE> names a repository with no published description (or whose __ocx.desc tag does not resolve — the two are indistinguishable at this point)79
An I/O error reading --readme — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one74
A --logo file's bytes do not match the format its extension names65
--offline is set81
Authentication fails80

The "nothing to update" case exits 1 (Failure) rather than a more specific code: it raises a plain error with no ClassifyExitCode source, so classification falls through to the generic case. "No description to copy" carries the registry's own not-found cause, so it reaches 79 — a script can tell "there was nothing to promote" from "the command was wrong".

sign

Publishes a Sigstore signature for a package manifest — keyless by default, or under --key — as an OCI Referrers artifact. Keyless, the signing flow uses an ephemeral ECDSA P-256 keypair: Fulcio issues a short-lived certificate binding the key to your OIDC identity, the manifest digest is signed, and the entry is logged to Rekor. The resulting Sigstore bundle v0.3 is pushed to the registry as a referrer of the target manifest by default, discoverable and verifiable by ocx package verify. cosign verify discovers the default bundle publish directly — through the Referrers API where the registry has one, and through the fallback index where it does not. --signature-format simplesigning (or both) additionally writes the cosign sha256-<hex>.sig sidecar tag, for a consumer that reads only that shape; see cosign Parity in the signing guide.

Signing requires network access — --offline is rejected with exit 77.

Usage

shell
ocx package sign [OPTIONS] <IDENTIFIER>

Arguments

  • <IDENTIFIER>: Package identifier to sign (registry/repo:tag[@digest]).

Options

NameShortDefaultPurpose
--platform-p(the resolved object)Narrow into one platform of an image index. Omit it to act on whatever the reference resolves to — an index is then the subject itself, which is where cosign puts a multi-platform tag's signature. Given against a reference that resolves to a single manifest, there is nothing to narrow and the command fails
--fulcio-url([trust.sigstore].fulcio_url, else https://fulcio.sigstore.dev)Fulcio CA endpoint (override for private deployments)
--rekor-url([trust.sigstore].rekor_url, else https://rekor.sigstore.dev)Rekor transparency-log endpoint (override for private deployments)
--identity-token-fileRead the OIDC identity token from this file (highest precedence). File must be owner-readable only (chmod 600); world- or group-readable files are rejected with exit 77 (IdentityTokenFilePermissive). File must be owned by the effective user (uid match required); a foreign-owned file with mode 0600 is still rejected with exit 77 (CWE-732). Symlinks are not followed; a symlink at the supplied path is rejected with exit 77 (CWE-367 mitigation). Windows: permission validation is not implemented; use --identity-token-stdin or OCX_IDENTITY_TOKEN instead (the command exits 77 if --identity-token-file is used on Windows).
--identity-token-stdinRead the OIDC identity token from stdin (second precedence). Mutually exclusive with --identity-token-file
--no-ttyfalseSuppress the interactive browser OAuth fallback; ambient token detection must succeed or an override flag must supply a token
--no-cachefalseBypass the per-registry referrers-capability cache for this invocation
--signature-format <FORMAT>bundleSignature wire format: bundle writes an OCI 1.1 referrer carrying a Sigstore bundle; simplesigning writes the cosign sha256-<hex>.sig sidecar tag instead; both writes each of them
--key <REF>(keyless)Sign with a key pair instead of keyless Sigstore — see Keyless or a Key. Takes a key reference, [scheme://]<rest>: a bare path, or a file:// one, names a file holding the private key, encrypted-password read from OCX_KEY_PASSWORD; env://VAR holds that same PEM in the environment variable VAR (name it OCX_SIGNING_KEY — an unrecognised name is inherited by plugins). The awskms, gcpkms, azurekms, hashivault and k8s schemes are recognised and rejected by name (exit 85, unsupported_key_backend), never read as filenames. A reference that cannot be parsed at all is a usage error (exit 64, key_reference_invalid)
--rekor-upload(on for keyless)Record the signature in the Rekor transparency log. Keyless signatures are always recorded, so this only has an effect alongside --key, where uploading is off by default
--no-rekor-upload(off for --key)Skip the Rekor entry. Only valid alongside --key — a keyless signature must be recorded, since its Fulcio certificate is valid for about ten minutes and the log entry's timestamp is the only lasting proof the signature was made while it was; given without --key this is a usage error (exit 64, rekor_upload_required_for_keyless). See The Rekor rule
--tags <TAG,...>Sweep these tags instead of acting on the reference alone. Repeatable, and accepts a comma-separated list. Each tag is signed as the index it resolves to, in the repository the identifier names. Refused alongside --platform (exit 64)
--tags-file <PATH>Read the sweep's tags from a file, one per line or comma-separated — the same file ocx package push --tags-file writes and ocx package announce reads. Unioned with --tags when both are given. Refused alongside --platform (exit 64)

Sweeping the indices a publish left behind

push signs each platform manifest inline, and those digests are final the moment they land. An image index digest is not: it is rebuilt on every platform merge, so it is only final once the last platform is in. --tags and --tags-file exist to sweep those indices up afterwards — push records each tag it wrote, and a later ocx package sign --tags-file tags.txt <identifier> signs the index each of those tags now resolves to. The manifests underneath are already signed and are not revisited, which is why --platform is refused alongside either flag.

shell
ocx package push registry.example/widget:1.2.3 -c --tags-file tags.txt widget.tar.xz
ocx package sign --tags-file tags.txt registry.example/widget:1.2.3

A swept tag that resolves to a single manifest is skipped with a warning, not an error: push already signed it, and a tag list mixing single-platform and multi-platform packages is the normal case for a repository publishing both. The sweep continues past a per-tag failure and exits non-zero at the end with every failure listed — aborting at the first failure of twenty would leave you with no idea which of the remaining nineteen succeeded. When every failure shares one exit code the sweep returns that code; a mix returns 1.

One signature per index, not per tag. A signature is a referrer of the subject's digest, never of a tag, so the cascade aliases of one release — 3, 3.7, 3.7.0, latest — all name one index and one signature covers all of them. The sweep resolves every tag, collapses those sharing a digest, and signs each digest once; tags after the first to name a given index are reported covered, with the tag that carried the signature named in the detail column. Signing per tag would publish N identical referrers against one subject, and verify reads at most eight signature candidates — so a release swept twice would stop verifying. Re-running a sweep is therefore cheap and safe, though not a no-op: sign appends rather than replacing (a second identity's signature must be able to join the first), so each run adds one referrer per distinct index, never one per tag. A tag whose own run failed is retried by the next tag naming the same index — nothing was published, so nothing covers it.

Under --format json a swept run emits one document listing every tag:

json
{
  "schema_version": 1,
  "command": "package sign",
  "exit_code": 79,
  "data": {
    "tags": [
      { "tag": "1.2.3", "status": "completed", "report": { "subject_digest": "sha256:<64-hex>" } },
      { "tag": "1.2", "status": "covered", "message": "same index as tag '1.2.3'" },
      { "tag": "1.1", "status": "skipped" },
      { "tag": "latest", "status": "failed", "kind": "target_not_found", "message": "no manifest for platform any" }
    ]
  }
}

status is one of completed, covered, skipped or failed. Only failed makes the run exit non-zero: a covered tag is signed, by the referrer the row it names reports. Each report is the single-reference document described under JSON output below, verbatim — a consumer parses a swept run with the same code, one level down; covered and skipped rows carry no report of their own, because neither published one.

Token precedence

ocx package sign resolves an OIDC identity token from the following sources, in order:

  1. --identity-token-file <PATH> — read from file (highest precedence)
  2. --identity-token-stdin — read from stdin
  3. OCX_IDENTITY_TOKEN environment variable
  4. Ambient CI detection — GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL + ACTIONS_ID_TOKEN_REQUEST_TOKEN), GitLab CI (SIGSTORE_ID_TOKEN), CircleCI (CIRCLE_OIDC_TOKEN_V2)
  5. Interactive browser OAuth (suppressed when --no-tty is set)

Never pass a raw token on the command line — it would appear in shell history and process listings.

Signing targets Rekor v1

ocx package sign runs the full keyless pipeline end-to-end — keypair generation, Fulcio certificate, Rekor entry, Sigstore bundle v0.3 assembly, and the referrer push. It writes a Rekor v1 hashedrekord entry; a Rekor v2 (tiles) instance is not supported yet. See Deferred to future work.

Exit codes

CodeCondition
0Signature published successfully
64InvalidEndpointUrl — malformed --fulcio-url or --rekor-url (must be https://, or http:// on loopback only; no credentials, no unsupported schemes)
64KeyReferenceInvalid--key could not be parsed at all: an unrecognised scheme token, or nothing following the scheme
64RekorUploadRequiredForKeyless--no-rekor-upload was given without --key: a keyless signature must be recorded in Rekor, because a Fulcio certificate is valid for about ten minutes and the log entry's timestamp is the only lasting proof the signature was made while it was
69InvalidEndpointUrl — the --fulcio-url/--rekor-url host does not resolve at all; a rerun will not help until the host or the network is fixed
65RekorSetMalformed — Rekor returned the log entry but its Signed Entry Timestamp could not be extracted or parsed
65SubjectDigestUnsupported — the reference resolves to a subject addressed by sha384 or sha512. cosign artifacts address their subject by sha256 alone: the in-toto Statement binds on sha256, and the sidecar tag truncates the digest to 64 characters, so two subjects sharing a prefix would share one tag. Refused before anything is published or logged to Rekor, rather than at verify time after a permanent transparency-log entry has been burned
65KeyBackend — a --key <path> reference names a file that was read in full but whose bytes are not a key this backend accepts, or that exceeds the size cap. error.detail is key_backend
74An I/O error reading --identity-token-file, --tags-file, or a --key <path> reference — missing file, permission denied, or the symlink refusal. error.kind is io_error; error.detail is absent for --identity-token-file/--tags-file (a script must branch on error.kind for those two), but key_backend for the --key <path> door — the same door verify answers with key_unreadable
77OidcPreCheckFailed — OIDC pre-check rejected the token (missing scopes, audience mismatch, expired)
77OfflineSignRefused--offline is incompatible with package sign; Fulcio + Rekor are hard dependencies
77IdentityTokenFilePermissive--identity-token-file is readable by group/other (must be 0600 or tighter)
78Fulcio rejected the certificate signing request as malformed
79TargetNotFound — no manifest for the requested --platform under the target image index
79TargetNotAnIndex--platform was given but the reference resolved to a single manifest, not an index. A distinct error.detail (target_not_an_index) from target_not_found because the remedy differs: drop the flag, rather than go looking for a build that was never missing
80Fulcio rejected the OIDC token (issuer mismatch, expired, wrong audience)
83Rekor transparency log unavailable at time of signing, or it returned a log entry with no usable Merkle inclusion proof
84Registry serves neither the OCI Referrers API nor a writable fallback index
85UnsupportedKeyBackend--key named a key backend OCX recognises but has not implemented (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, before the reference is treated as a filename, so an unimplemented backend never surfaces as a missing file

JSON output (--format json)

On success, ocx package sign emits a C-S1-1 success envelope. The top-level shape is:

json
{
  "schema_version": 1,
  "command": "package sign",
  "exit_code": 0,
  "data": {
    "identifier": "registry.example/pkg:1.0",
    "subject_digest": "sha256:<64-hex>",
    "legs": [
      {
        "format": "bundle",
        "payload_digest": "sha256:<64-hex>",
        "manifest_digest": "sha256:<64-hex>"
      }
    ],
    "platform": "linux/amd64",
    "signer": "keyless-fulcio",
    "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
    "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
    "key_backend": "keyless",
    "transparency_log_index": 42
  }
}

data fields:

FieldTypeDescription
identifierstringIdentifier argument passed to the command
subject_digeststring (sha256:...)Digest of the manifest that was signed
legsarrayOne entry per wire shape written or attempted, in write order — two entries under --signature-format both
platformstringPlatform that was signed (e.g. "linux/amd64"), or "any" when --platform was omitted and the run signed whatever the reference resolved to
signerstringSigning mechanism: "keyless-fulcio", or the key backend's own slug under --key
certificate_identitystringSAN from the Fulcio-issued certificate
certificate_oidc_issuerstringOIDC issuer URL from the Fulcio-issued certificate
key_backendstringkeyless, file, or a key-backend scheme (awskms, gcpkms, azurekms, hashivault, k8s)
public_key_hintstringThe signing key's cosign hint. Present only under --key
transparency_log_indexnumber | nullRekor log index. Always present, null included — under --key with no --rekor-upload, a missing record is a legal outcome the operator must be able to see rather than infer from an absent field

data.legs[] rows:

FieldTypeDescription
formatstringbundle or simplesigning — which wire shape this row describes
payload_digeststring (sha256:...)Digest of the signed payload blob — the Sigstore bundle under bundle, the simplesigning claim under simplesigning. Absent when the leg failed
manifest_digeststring (sha256:...)Digest of the manifest the payload hangs from — the OCI referrer under bundle, the sha256-<hex>.sig sidecar under simplesigning. Absent when the leg failed
errorstringWhy the leg failed. Present exactly when the leg did not land — a --signature-format both run reports one failed leg alongside one that succeeded rather than hiding the success behind the failure

On error, ocx package sign emits a C-S1-1 error envelope. The error.detail field (when present) is a snake_case discriminant for programmatic matching:

json
{
  "schema_version": 1,
  "command": "package sign",
  "exit_code": 80,
  "error": {
    "kind": "auth_error",
    "detail": "oidc_token_rejected",
    "message": "Fulcio rejected OIDC token: issuer not in trust root",
    "context": {
      "identifier": "registry.example/pkg:1.0"
    }
  }
}

detail is omitted when no fine-grained discriminant is available. context is always present (may be {}). A remediation key is reserved in the envelope shape but not currently emitted. The kind values are the snake_case ErrorCategory variants: usage_error, auth_error, permission_denied, config_error, data_error, not_found, unavailable, temp_fail, transparency_log_unavailable, referrers_unsupported, io_error, internal.

detail discriminants for package sign (frozen contract C-S1-1):

detail valueExitMeaning
fulcio_bad_request78Fulcio rejected the CSR as malformed
oidc_token_rejected80Fulcio rejected the OIDC token (issuer mismatch, expired, wrong audience)
fulcio_unavailable75Fulcio could not be reached, or answered 429 or 5xx — a transient outage, safe to retry
transparency_log_unavailable83Rekor transparency log unavailable at time of signing, or it returned a log entry with no usable Merkle inclusion proof — publishing that bundle would produce a signature OCX itself refuses to verify
rekor_set_malformed65Rekor returned the entry but the SET could not be extracted or parsed
referrers_unsupported84Registry serves neither the OCI Referrers API nor a writable fallback index
target_not_found79No manifest for the requested --platform under the target image index
target_not_an_index79--platform was given but the reference resolved to a single manifest, not an index — drop the flag, rather than go looking for a build that was never missing
subject_digest_unsupported65The reference resolves to a subject addressed by sha384 or sha512; cosign artifacts address their subject by sha256 alone. Refused before anything is published or logged to Rekor, rather than at verify time after a permanent transparency-log entry has been burned
oidc_pre_check_failed77OIDC pre-check failed client-side before the token was sent to Fulcio
forbidden_registry_target78The target registry is refused by policy before any signing call is made
offline_sign_refused77--offline is incompatible with package sign
identity_token_file_permissive77Token file has permissive permissions, wrong owner, or is a symlink
invalid_endpoint_url64Malformed --fulcio-url or --rekor-url
invalid_endpoint_url69The endpoint host does not resolve at all
unsupported_key_backend85--key named a key backend OCX recognises but has not implemented (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, so it is never reported as a missing file
key_backend74A --key <path> reference names a file that could not be read — missing, permission denied, a directory or device node rather than a regular file, or another I/O failure. The single detail every KeyBackendError variant collapses to; unsupported_key_backend above is the separate parse-time refusal
key_backend65A --key <path> reference names a file that was read in full but whose bytes are not a key this backend accepts, or that exceeds the size cap
key_reference_invalid64--key could not be parsed at all: an unrecognised scheme token, or nothing following the scheme. Separate from unsupported_key_backend because the remedy is to fix the reference, not to wait for a backend
rekor_upload_required_for_keyless64--no-rekor-upload was given without --key; a keyless signature must be recorded in Rekor for the reason above
internal1Unexpected internal error

Example — CI keyless signing with GitHub Actions ambient OIDC

yaml
- name: Sign package
  run: |
    ocx package sign \
      -p linux/amd64 \
      registry.example/pkg:1.0

In GitHub Actions, the ACTIONS_ID_TOKEN_REQUEST_TOKEN variable is present automatically (requires id-token: write permission). No --identity-token-* flag is needed.

verify

Verifies a Sigstore keyless signature attached to a package manifest via OCI Referrers. The command fetches the Sigstore bundle v0.3 referrer for the target, verifies the Fulcio certificate chain against a supplied trust root (see --sigstore-trusted-root below), verifies the Rekor Signed Entry Timestamp (SET), verifies the signature over the subject manifest digest, and checks the certificate identity and OIDC issuer against the identity you either supply as flags or have pinned in a [[trust.policy]] entry. All five checks must pass for the command to exit 0.

--offline (or OCX_OFFLINE) scopes to the Sigstore trust services — the Rekor-key fetch and TUF — not the registry: verify still fetches the target and its signature referrer from the registry in every mode. Offline verify requires a pinned Rekor key from --sigstore-trusted-root (or one of the other trust-root rungs) or a fresh trust-root cache entry; see Offline and Air-Gapped Verification for the full model.

--certificate-identity and --certificate-oidc-issuer are optional — but only when a [[trust.policy]] scope covers the target (see Identity resolution below). Keyless verification is meaningless without an identity from one source or the other.

A keyless cosign sidecarsha256-<hex>.sig, or sha256-<hex>.att under --attestation — needs transparency-log evidence of its own: its layer must carry a dev.sigstore.cosign/bundle annotation, whose SET is verified against the log's public key and whose logged body must bind to that signature or envelope. Both shapes are held to the same gate. A Fulcio certificate is valid for about ten minutes, so without an entry nothing shows the signature was made while it was live and an expired certificate stays acceptable indefinitely — the sidecar is refused (65, signature_invalid). cosign attach signature writes no such annotation, so cosign refuses its own output here too and needs --insecure-ignore-tlog; ocx package sign --signature-format simplesigning does write one. --allow-unlogged-signature is the opt-out for air-gapped CI.

Usage

shell
ocx package verify [OPTIONS] \
  [--certificate-identity <IDENTITY> --certificate-oidc-issuer <URL>] \
  <IDENTIFIER>

Arguments

  • <IDENTIFIER>: Package identifier to verify (registry/repo:tag[@digest]).

Options

NameShortDefaultPurpose
--platform-p(the resolved object)Narrow into one platform of an image index. Omit it to act on whatever the reference resolves to — an index is then the subject itself, which is where cosign puts a multi-platform tag's signature. Given against a reference that resolves to a single manifest, there is nothing to narrow and the command fails
--certificate-identity(policy-resolved)Expected certificate SAN (Subject Alternative Name), exact match. Optional when a [[trust.policy]] scope covers the target; when given, overrides any policy and requires --certificate-oidc-issuer too. Examples: you@example.com, https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main
--certificate-oidc-issuer(policy-resolved)Expected OIDC issuer URL, exact match. Used together with --certificate-identity — passing one without the other is a usage error. Examples: https://github.com/login/oauth, https://token.actions.githubusercontent.com
--key <REF>(keyless)Verify against a pinned public key instead of a Fulcio certificate. Takes a key reference, [scheme://]<rest>: a bare path, or a file:// one, names a file holding a plain SPKI PEM, and env://VAR holds that PEM in the environment variable VAR — the public half only, so no password is read and OCX_KEY_PASSWORD belongs to signing. The awskms, gcpkms, azurekms, hashivault and k8s schemes are recognised and rejected by name (exit 85), never read as filenames. Conflicts with --certificate-identity / --certificate-oidc-issuer: a key signature carries no certificate, so there is no SAN to match
--signature-format <FORMAT>(bundle, then sidecar)Pin which cosign wire shape to accept: bundle (an OCI 1.1 referrer carrying a Sigstore bundle) or simplesigning (the cosign sidecar tag — sha256-<hex>.sig, or sha256-<hex>.att under --attestation). The pin decides discovery, not what is ignored afterwards — the shape it does not name is never looked for, so --signature-format simplesigning against a subject carrying only a bundle answers 79. Unset, verify prefers a bundle and falls back to a sidecar only when the bundle shape is absent; a bundle that was fetched and refused fails closed with its own exit code rather than promoting the sidecar. both is a write-side value: a verification result cannot say "either of these satisfied me", so it is a usage error here (exit 64)
--rekor-url([trust.sigstore].rekor_url, else https://rekor.sigstore.dev)Rekor transparency-log endpoint (override for private deployments)
--sigstore-trusted-root(public-good root over TUF)A bare path, or a file:// one, naming a Sigstore trusted-root JSON (or a directory holding trusted_root.json) — supplies the Fulcio CA, the certificate-transparency log keys and the pinned Rekor public key together, so no Rekor-key fetch is needed. Equivalent env var: OCX_SIGSTORE_TRUSTED_ROOT; the flag wins. Highest rung of the trust-root ladder — see Self-hosted Sigstore for the config-driven alternatives. Required for --offline verify unless another rung already supplies a pinned Rekor key
--allow-unlogged-signaturefalseAccept a keyless cosign sidecar — sha256-<hex>.sig or sha256-<hex>.att — that carries no dev.sigstore.cosign/bundle annotation. Without an entry there is no proof the signature was made while its short-lived Fulcio certificate was valid, so verify refuses that shape (65, signature_invalid) — this is the counterpart to cosign's --insecure-ignore-tlog and, like it, accepts a signature nothing timestamps, including one whose certificate has since expired. For air-gapped CI. Inert elsewhere: a bundle's transparency evidence stays mandatory under keyless and optional under --key
--no-cachefalseBypass the per-registry referrers-capability cache for this invocation
--attestationfalseVerify an in-toto attestation instead of a signature — same referrer discovery, trust-root and identity pipeline, a different referrer content type. See Verifying attestations below
--type(any type)Restrict attestation verification to one predicate type. Requires --attestation — used alone it is a usage error (exit 64)

Identity resolution

Two ways to tell ocx package verify whose signature to accept:

  • Flags — pass both --certificate-identity and --certificate-oidc-issuer. This is an exact-match pair that overrides any configured policy, matching the original flag-only behavior byte-for-byte.
  • [[trust.policy]] — omit both flags. Verify first checks the pooled config.toml-tier ("operator") policies against the target's canonical registry/repository; if any match, the project ocx.toml is not consulted at all. Only when no operator policy matches does verify fall back to the project ocx.toml's policies. See the configuration reference for scope matching, most-specific-wins resolution, regex identities, and the operator-authoritative precedence rule. Reading [[trust.policy]] from ocx.toml here is the one documented exception to "OCI-tier commands never consult ocx.toml" — trust policy is a security posture, not toolchain-binding resolution.

Supplying exactly one of the two flags is a usage error (exit 64) rejected by the argument parser (clap requires) before verification runs — a --certificate-identity without a matching --certificate-oidc-issuer, or vice versa, cannot express a valid match. Because it is caught at parse time it produces a bare usage error with no JSON envelope and no error.detail (it is not the no_identity_provided case). Supplying neither flag with no [[trust.policy]] scope covering the target is also exit 64, but that one is the NoIdentityProvided verify error (it does carry an envelope): there is no identity to check the signature against.

A bare Fulcio CA is not a trust root

ocx package verify runs the full pipeline end-to-end — referrer discovery, Fulcio chain, SCT, Rekor SET and inclusion proof, subject-digest signature, identity and issuer match. With no trust root supplied by any rung of the ladder and no cached trust material, it fetches the public-good trust root over TUF.

A Fulcio certificate embeds a Signed Certificate Timestamp that the verifier checks against the CT log's key, so trust material carrying CA anchors alone cannot verify anything — verify refuses it with exit 78 and the message trust root carries no CT log key. A Sigstore trusted-root JSON carries the anchors, the CT log keys and the pinned Rekor key together; that is the only shape --sigstore-trusted-root accepts. See Self-hosted Sigstore.

Exit codes

CodeCondition
0Signature verified — identity and issuer match, bundle cryptographically valid
64UsageError — malformed --rekor-url (must be https://, or http:// on loopback only; no credentials, no userinfo)
69UsageError — the --rekor-url host does not resolve at all; a rerun will not help until the host or the network is fixed
64NoIdentityProvided — neither --certificate-identity nor --certificate-oidc-issuer was given and no [[trust.policy]] scope covers the target (a lone flag is instead rejected at parse time as a bare usage error, with no envelope)
65Data integrity failure: signature invalid, subject digest mismatch, certificate chain invalid, Rekor SET invalid (bundle tampered), Rekor transparency-log body does not bind to the bundle (spliced SET), the signature candidate examination cap was reached before a valid signature was found, or bundle parse failed. In --attestation mode, also: predicate type mismatch, a missing or weak-digest subject, an unrecognized in-toto statement or DSSE payload type, a SLSA provenance builder mismatch, more than one matching attestation with no --type to disambiguate, or the attestation exceeded its size or byte-budget limit
74IoError — the key file a --key <path> reference (or a matched [[trust.policy]] signer's key) names could not be read. Also a path that is not a readable regular file — a directory, a device — the same 74 --config answers for one. The same code sign answers for the same reference, so the flag means one thing on both sides
74trust_root_unreadable — the trusted-root file named by --sigstore-trusted-root, OCX_SIGSTORE_TRUSTED_ROOT or [trust.sigstore] trusted_root could not be read: missing, permission denied, not a regular file, or larger than the 1 MiB ceiling every one of those reads is bounded at. A present-but-unreadable $OCX_HOME/sigstore/trusted-root.json lands here too; an absent one is not an error and the ladder continues. Same code, and same reason, as the --key <path> row above
65key_malformed — a key file was read in full and its bytes are not an SPKI public key. The path was fine, the material was not, which is why this is not the 74 above; an inline key_pem in a config document that is not a key is 78 instead, since there the config text itself is what is wrong
77Certificate identity or OIDC issuer mismatch
78Trust root unavailable or failed to load — malformed trusted-root JSON, no CT log key, a failed TUF fetch, or --offline verify with no pinned Rekor key available (no --sigstore-trusted-root, no configured trust root, and no fresh trust-root cache entry); the message names the remedy. A trusted-root path that cannot be read is the 74 above, not this
78TrustPolicyInvalid — the [[trust.policy]] entry matched for this target sets both identity and identity_regexp, sets neither, or its identity_regexp fails to compile. Applies the same whether the matched entry came from the operator config.toml tier or a project ocx.toml's fallback policies — the two tiers share this validation
78ForbiddenRegistryTarget — the target registry is refused by policy before any verification is attempted
79No signatures found for target, no usable Sigstore bundle among referrers, or no manifest for the requested --platform under the target image index. A registry serving neither the OCI Referrers API nor a fallback referrers tag lands here too — verify reads both, so "nothing found" is the verdict rather than a capability refusal. In --attestation mode: no attestation found for the target (attestation_not_found)
80Registry authentication failed while fetching referrers
83Rekor unavailable, or SET absent with only TSA timestamp present (Rekor v2 transition)
85UnsupportedKeyBackend — a key reference named a key backend OCX recognises but has not implemented — from --key, or from a key = "…" signer in a matched [[trust.policy]], which reach the same refusal through different doors and answer it with the same code — (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, before the reference is treated as a filename, so an unimplemented backend never surfaces as a missing file. A reference OCX cannot parse at all is exit 64 instead (key_reference_invalid)

Automatic verification on install and pull

When a [[trust.policy]] entry covers a package, ocx package install and ocx package pull verify it automatically before any layer downloads — see the auto-verify contract under install below and Verify by default in the user guide. Run ocx package verify directly to check a signature by hand, verify a package outside every policy's scope, or verify without installing.

JSON output (--format json)

On success, ocx package verify emits a success envelope wrapping the flat verification report:

json
{
  "schema_version": 1,
  "command": "package verify",
  "exit_code": 0,
  "data": {
    "subject_digest": "sha256:<64-hex>",
    "referrer_digest": "sha256:<64-hex>",
    "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
    "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
    "signed_at": "2026-04-19T12:00:00Z",
    "signatures": [
      {
        "signature_format": "bundle",
        "discovery_method": "referrers_api",
        "key_backend": "keyless",
        "referrer_digest": "sha256:<64-hex>",
        "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
        "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
        "signed_at": "2026-04-19T12:00:00Z",
        "rekor_log_index": 42
      },
      {
        "signature_format": "simplesigning",
        "discovery_method": "sidecar_tag",
        "key_backend": "keyless",
        "referrer_digest": "sha256:<64-hex>",
        "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
        "certificate_oidc_issuer": "https://token.actions.githubusercontent.com"
      }
    ]
  }
}

data fields:

FieldTypeDescription
subject_digeststring (sha256:...)Digest of the subject manifest whose signature was verified
referrer_digeststring (sha256:...)Digest of the OCI referrer manifest carrying the verified bundle
certificate_identitystringSubject Alternative Name (identity) read back from the Fulcio cert
certificate_oidc_issuerstringOIDC issuer URL read back from the Fulcio cert
signed_atstring (ISO-8601)Rekor integrated time of the signature entry
signaturesarrayEvery signature the subject carries, merged across all discovery shapes. Absent while empty — never [], which would read as "we looked and found none" about a command that only succeeds when it found one. The first row is the verdict, and the five flat fields above describe that same signature

data.signatures[] rows:

FieldTypeDescription
signature_formatstringbundle or simplesigning — which cosign wire shape carried this signature
discovery_methodstringreferrers_api, fallback_tag, or sidecar_tag — which discovery door it came through. A candidate reached through the mutable fallback tag is a weaker provenance claim than one the registry itself computed, so the report says which
key_backendstringkeyless, file, or a key-backend scheme (awskms, gcpkms, azurekms, hashivault, k8s)
referrer_digeststring (sha256:...)Digest of the referrer manifest carrying it — or, for a sidecar, of the payload layer, since one layer is one signature and the manifest digest would name all of them at once
certificate_identitystringCertificate SAN. Absent under a key, which carries no certificate — a legal shape, not malformed input
certificate_oidc_issuerstringCertificate OIDC issuer. Absent under a key, for the same reason
signed_atstring (ISO-8601)Rekor integratedTime. Absent when no transparency record backs this row — a key signature never uploaded to Rekor, or a simplesigning sidecar, which carries no transparency evidence at all
rekor_log_indexnumberRekor log index; the deduplication key when present. Absent in exactly the cases signed_at is

On error, ocx package verify emits a C-S1-1 error envelope. The error.detail field is a snake_case discriminant for programmatic matching:

json
{
  "schema_version": 1,
  "command": "package verify",
  "exit_code": 79,
  "error": {
    "kind": "not_found",
    "message": "no signatures found for registry.example/pkg:1.0",
    "context": {
      "identifier": "registry.example/pkg:1.0"
    }
  }
}

The envelope shape matches the package sign error envelope (see package sign), but the detail discriminants are different — package verify operates on a distinct error taxonomy. detail is omitted when no fine-grained discriminant applies.

detail discriminants for package verify (frozen contract C-S1-1):

detail valueExitMeaning
no_signatures_found79No referrers found for the target manifest — neither through the OCI Referrers API nor under the fallback referrers tag; publisher has not signed this platform
target_not_found79No manifest for the requested --platform under the target image index
target_not_an_index79--platform was given but the reference resolved to a single manifest, not an index. A distinct slug from target_not_found because the remedy differs: drop the flag, rather than go looking for a build that was never missing
no_usable_bundle79Referrers found but none has a recognized Sigstore bundle artifact type
candidate_limit_exhausted65The signature candidate examination cap was reached with unexamined referrers remaining and none of the examined candidates passed; the operator must reduce the referrer count or raise the cap
identity_mismatch77Certificate SAN does not satisfy the expected identity, whether supplied via --certificate-identity or resolved from a [[trust.policy]] entry
issuer_mismatch77Certificate OIDC issuer does not match the expected issuer, whether supplied via --certificate-oidc-issuer or resolved from a [[trust.policy]] entry
cert_chain_invalid65Certificate chain does not verify against the supplied trust root
signature_invalid65Signature does not verify over the subject manifest digest; also a key-mode signature no trusted key accepts, and a keyless cosign sidecar carrying no transparency-log entry (see --allow-unlogged-signature)
subject_digest_mismatch65The bundle's signed digest does not match the target manifest's digest
rekor_set_invalid65Rekor SET does not verify (bundle tampered)
transparency_body_mismatch65Rekor transparency-log entry body does not bind to the bundle, or to the cosign sidecar's signature — a previously-valid SET/body spliced onto a different subject
rekor_inclusion_proof_absent65Bundle carries a Rekor inclusion promise but no Merkle inclusion proof. The promise alone is not evidence the entry was published in a signed tree, so verification refuses it. Re-sign against a transparency log that returns an inclusion proof
rekor_set_absent_tsa_present83Rekor SET absent but RFC 3161 TSA timestamp present (Rekor v2 transition)
transparency_log_unavailable83Rekor transparency log unavailable during verify
bundle_parse_failed65Bundle is not valid Sigstore bundle v0.3 or is corrupted JSON
trust_root_unavailable78Embedded TUF trust root asset not present in this build (Slice 1)
trust_root_load78Trust root failed to load — malformed trusted-root JSON, no CT log key, TUF fetch failed, or --offline verify with no pinned Rekor key available (supply --sigstore-trusted-root, or run an online verify first to populate the cache)
trust_root_unreadable74The trusted-root file a path names could not be read — missing, permission denied, not a regular file, or larger than the 1 MiB read ceiling. Reaches here from --sigstore-trusted-root, OCX_SIGSTORE_TRUSTED_ROOT, [trust.sigstore] trusted_root, and a present-but-unreadable $OCX_HOME/sigstore/trusted-root.json. Same 74 as key_unreadable, for the same reason: an unusable path the operator typed is a filesystem failure, not a configuration one
forbidden_registry_target78The target registry is refused by policy before any verification is attempted
no_identity_provided64No identity to verify against: both certificate flags omitted and no [[trust.policy]] scope matched the target. (A lone flag is a clap parse error — still exit 64, but with no envelope and no detail.)
trust_policy_invalid78A matched [[trust.policy]] entry is malformed — identity XOR violation, or an identity_regexp that does not compile
key_unreadable74The key file a path reference names could not be read — missing, permission denied, not a regular file, or another I/O failure. Byte-identical outcome to package sign's 74 for the same --key <path>, so one flag with one value cannot mean two things depending on the verb. Reaches here from --key and from a key path signer in a matched [[trust.policy]] entry alike
invalid_endpoint_url64Malformed --rekor-url
invalid_endpoint_url69The --rekor-url host does not resolve at all
attestation_not_found79No attestation referrer found for the target (--attestation mode)
predicate_type_mismatch65The --type given does not match any verified attestation's predicateType
statement_subject_mismatch65The in-toto Statement's subject does not name the target manifest digest
statement_subject_absent65The in-toto Statement carries no subject entry at all
statement_subject_weak_algorithm65The Statement's subject digest uses an algorithm weaker than SHA-256
builder_mismatch65The attestation's SLSA provenance builder.id does not match the pinned builder in a [[trust.policy]] entry
statement_type_unsupported65The DSSE payload's _type is not a recognized in-toto Statement type
payload_type_unsupported65The DSSE envelope's payloadType is not application/vnd.in-toto+json
simple_signing_claim_unsupported65A cosign simplesigning payload declares a critical.type other than cosign container image signature. critical is by definition the part a verifier must understand, so a payload declaring another claim type is refused rather than skipped — skipping would let a registry relabel a signature into "none found"
multiple_attestations65More than one verified attestation candidate for the target and no --type narrowed it to one; the message names every candidate's referrer digest and every distinct predicate type in the set, so --type has a value to take — and says outright when a single shared type means --type cannot narrow further
unsupported_tlog_entry_kind65The Rekor transparency-log entry kind is neither hashedrekord nor dsse
tlog_binding_mismatch65The transparency-log entry does not bind to the DSSE envelope actually being verified
attestation_too_large65The attestation referrer exceeds its per-entry size limit
attestation_payload_too_large65The DSSE payload inside a verified attestation exceeds its size limit
too_many_attestations65More attestation candidates exist for the target than the examination cap allows
attestation_budget_exhausted65The cumulative byte budget across all examined attestation candidates was exhausted before a match was found
unsupported_key_backend85a key reference named a key backend OCX recognises but has not implemented — from --key, or from a key = "…" signer in a matched [[trust.policy]], which reach the same refusal through different doors and answer it with the same code — (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, so it is never reported as a missing file. Byte-identical spelling to package sign's, so one word covers one failure on both sides
key_malformed65A key file read in full whose bytes are not an SPKI public key. Distinct from key_unreadable (74) because the path was usable and the material was not, and from trust_policy_invalid (78) because a file is not config text — an inline key_pem that is not a key stays 78
key_reference_invalid64--key could not be parsed at all: an unrecognised scheme token, or nothing following the scheme. Separate from unsupported_key_backend because the remedy is to fix the reference, not to wait for a backend
internal1Unexpected internal error

Verifying attestations

--attestation swaps the referrer content type verify looks for: instead of a Sigstore-bundle signature over the manifest digest, it fetches a DSSE-enveloped in-toto Statement, verifies the identical five-step pipeline against it (referrer discovery, Fulcio chain, Rekor SET and inclusion proof, then the Statement's signature and subject digest), and additionally checks that the Statement's subject names the target digest with a strong algorithm. --type narrows which predicateType counts as a match — omit it to accept any predicate type carried by a verified attestation.

The success and error JSON envelopes are byte-identical in shape to signature-mode verify (see JSON output above) — data carries the same five fields regardless of mode, since a verified attestation and a verified signature both reduce to "this subject digest, this certificate, this timestamp." Use ocx package sbom when the predicate type or its content is the thing you need back.

Example — verify a package signed in CI, with flags

shell
ocx package verify \
  -p linux/amd64 \
  --sigstore-trusted-root /etc/ocx/trusted_root.json \
  --certificate-identity https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  registry.example/pkg:1.0

With no trust root from any rung of the ladder and no fresh trust-root cache entry, verify fetches the public-good trust root over TUF--sigstore-trusted-root here pins a self-hosted or private deployment's own trust material instead. Passing it on every invocation is the most expensive rung; Self-hosted Sigstore covers the config-driven alternatives. See also Current limitations.

Example — verify with a [[trust.policy]] covering the target, no flags

toml
# ocx.toml or config.toml
[[trust.policy]]
scope = "registry.example/pkg"

signers = [
  { kind = "keyless", identity = "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
                      oidc_issuer = "https://token.actions.githubusercontent.com" },
]
shell
ocx package verify -p linux/amd64 --sigstore-trusted-root /etc/ocx/trusted_root.json registry.example/pkg:1.0

See the configuration reference for the full schema, scope matching, and rotation semantics.

attest

Attaches an attestation — an SBOM, a provenance statement, or any other structured predicate — to a package manifest as an OCI Referrers artifact.

The shape depends on whether a signing identity is visible in the environment (see sign for the same override-token/ambient-CI check). With one present, the predicate you supply is wrapped in a DSSE-enveloped in-toto Statement naming the target manifest digest as its subject, signed through the identical keyless pipeline sign uses — an ephemeral ECDSA P-256 keypair, a Fulcio certificate bound to your OIDC identity, a Rekor transparency-log entry — and the resulting bundle is pushed as a referrer, discoverable and verifiable by ocx package verify --attestation and ocx package sbom. With none visible, --type must resolve to one of the three SBOM media types (cyclonedx, spdx, spdxjson, or a full URI resolving to one of them) — the predicate document is pushed as the referrer payload verbatim, typed by its own media type, with no DSSE envelope, no Fulcio certificate, and no Rekor entry. Any other --type with no signing identity present is refused before any network call (exit 64, unsigned_type_unsupported) — an unsigned provenance or vulnerability statement carries no attribution worth publishing. See Attestations for the full polarity rule and when each shape applies.

If a signing identity is detected but acquiring a usable token then fails (Fulcio unreachable, an ambient CI token rejected), that is a hard error — the command never falls back to publishing unsigned.

ocx package push --sbom <PATH> is sugar for ocx package attest --type cyclonedx against the digest a push just wrote, including this same polarity — see push. Use attest directly to attach a predicate standalone, attach an SPDX predicate, attach more than one predicate type to the same manifest, or attach an attestation to something other than a package this invocation just published.

Attesting requires registry access regardless of shape — --offline is rejected with exit 77, checked before the predicate file is even read.

Usage

shell
ocx package attest [OPTIONS] --predicate <PATH> --type <TYPE> <IDENTIFIER>

Arguments

  • <IDENTIFIER>: Package identifier to attest (registry/repo:tag[@digest]).

Options

NameShortDefaultPurpose
--predicate <PATH>(required)Path to the predicate file — the document the Statement wraps verbatim. Bounded to 15 MiB; a larger file is refused (exit 65, predicate_too_large) rather than truncated. The path must not be a symlink — it is opened with O_NOFOLLOW on Unix, and a symlink is refused, along with any other I/O error reading the file (exit 74 — see the exit-codes table below)
--type <TYPE>(required)The predicate's type. One of the cosign-compatible aliases — cyclonedx, spdx, spdxjson, slsaprovenance1, link, vuln, openvex, custom — or any absolute predicate-type URI, stored byte-exact. slsaprovenance and slsaprovenance02 are recognized aliases but both resolve to SLSA provenance v0.2, which attest refuses before any network call (exit 64, provenance_version_unsupported) — pass slsaprovenance1 instead. custom wraps the predicate bytes in cosign's {Data, Timestamp} envelope before signing; every other alias resolves to its canonical predicateType URI and signs the predicate bytes as given
--platform-p(the resolved object)Narrow into one platform of an image index. Omit it to act on whatever the reference resolves to — an index is then the subject itself, which is where cosign puts a multi-platform tag's signature. Given against a reference that resolves to a single manifest, there is nothing to narrow and the command fails
--fulcio-url([trust.sigstore].fulcio_url, else https://fulcio.sigstore.dev)Fulcio CA endpoint (override for private deployments)
--rekor-url([trust.sigstore].rekor_url, else https://rekor.sigstore.dev)Rekor transparency-log endpoint (override for private deployments)
--identity-token-file <PATH>Read the OIDC identity token from this file. Same permission, ownership and symlink checks as sign
--identity-token-stdinRead the OIDC identity token from stdin. Mutually exclusive with --identity-token-file
--no-ttyfalseSuppress the interactive browser OAuth fallback
--no-cachefalseBypass the per-registry referrers-capability cache for this invocation
--signature-format <FORMAT>bundleWhich cosign wire shape to publish the attestation in. bundle writes the OCI 1.1 referrer described above; simplesigning writes cosign's sha256-<hex>.att sidecar tag instead, whose layer is the bare DSSE envelope typed application/vnd.dsse.envelope.v1+json with the certificate and Rekor bundle in layer annotations; both writes each. It selects where the attestation is published, never how many times it is signed: one envelope is signed once and published in every shape asked for, so both costs one certificate and one log entry. Re-attesting a subject appends a layer to the sidecar rather than replacing it. An attach with no signing identity at all has no envelope to put in a sidecar and refuses simplesigning/both (exit 64, sidecar_requires_signature) rather than quietly writing the bundle shape. No sha256-<hex>.sbom tag is ever written — that is cosign attach sbom's unsigned convention, and a signed SBOM is an attestation, so it lands on .att
--key <REF>(keyless)Sign with a key pair instead of keyless Sigstore. Same key-reference grammar as sign; the awskms, gcpkms, azurekms, hashivault and k8s schemes are recognised and rejected by name (exit 85, unsupported_key_backend); a reference that cannot be parsed at all is a usage error (exit 64, key_reference_invalid)
--rekor-upload(on for keyless)Record the signature in the Rekor transparency log. Keyless signatures are always recorded, so this only has an effect alongside --key, where uploading is off by default
--no-rekor-upload(off for --key)Skip the Rekor entry. Only valid alongside --key; given without it, exit 64 (rekor_upload_required_for_keyless). See The Rekor rule
--tags <TAG,...>Sweep these tags instead of acting on the reference alone. Repeatable, and accepts a comma-separated list. Each tag is attested as the index it resolves to, in the repository the identifier names. Refused alongside --platform (exit 64)
--tags-file <PATH>Read the sweep's tags from a file, one per line or comma-separated — the same file ocx package push --tags-file writes and ocx package announce reads. Unioned with --tags when both are given. Refused alongside --platform (exit 64)

The sweep behaves exactly as it does for sign — same skip rule for a tag resolving to a single manifest, same continue-past-a-failure rule, same one-attestation-per-distinct-index rule (an attestation is a referrer of the subject digest too, so cascade aliases collapse to one run and the rest report covered), and the same aggregated document under --format json (with "command": "package attest").

Token precedence and the ambient-CI detection order are identical to sign — neither command has a --identity-token value flag; only file, stdin, an environment variable, and ambient CI detection.

Offline refusal runs before the predicate is even read

--offline fails the command before --predicate is opened and before any token is resolved — a local policy refusal never depends on what the predicate file contains or whether it exists.

Exit codes

CodeCondition
0Attestation published successfully
64InvalidEndpointUrl — malformed --fulcio-url or --rekor-url
69InvalidEndpointUrl — the --fulcio-url/--rekor-url host does not resolve at all; a rerun will not help until the host or the network is fixed
64ProvenanceVersionUnsupported--type resolved to a SLSA provenance predicate below v1.0 (slsaprovenance or slsaprovenance02); pass --type slsaprovenance1
64UnsignedTypeUnsupported — no signing identity is visible and --type did not resolve to one of the three SBOM media types; supply an identity to attach it signed, or use a cyclonedx/spdx/spdxjson type
64SidecarRequiresSignature--signature-format simplesigning or both was given for an attach with no signing identity; an .att sidecar layer is a signed DSSE envelope, so supply an identity or a key, or drop the flag
64KeyReferenceInvalid--key could not be parsed at all: an unrecognised scheme token, or nothing following the scheme
64RekorUploadRequiredForKeyless--no-rekor-upload was given without --key: a keyless signature must be recorded in Rekor, because a Fulcio certificate is valid for about ten minutes and the log entry's timestamp is the only lasting proof the signature was made while it was
65PredicateTooLarge — the --predicate file exceeds 15 MiB
65RekorSetMalformed — Rekor returned the log entry but its Signed Entry Timestamp could not be extracted or parsed
65PredicateNotJson — the --predicate file did not parse as JSON
65SubjectDigestUnsupported — the reference resolves to a subject addressed by sha384 or sha512. cosign artifacts address their subject by sha256 alone: the in-toto Statement binds on sha256, and the sidecar tag truncates the digest to 64 characters, so two subjects sharing a prefix would share one tag. Refused before anything is published or logged to Rekor, rather than at verify time after a permanent transparency-log entry has been burned
65KeyBackend — a --key <path> reference names a file that was read in full but whose bytes are not a key this backend accepts, or that exceeds the size cap. error.detail is key_backend
74An I/O error reading --predicate, --tags-file, or a --key <path> reference — missing file, permission denied, or the symlink refusal. error.kind is io_error; error.detail is absent for --predicate/--tags-file (a script must branch on error.kind for those two), but key_backend for the --key <path> door — the same door verify answers with key_unreadable
75FulcioUnavailable — Fulcio could not be reached, or answered 429 or 5xx; a transient outage, safe to retry
77OidcPreCheckFailed, OfflineAttestRefused (--offline is incompatible with attest; checked first), or IdentityTokenFilePermissive
78Fulcio rejected the certificate signing request as malformed
79TargetNotFound — no manifest for the requested --platform under the target image index
79TargetNotAnIndex--platform was given but the reference resolved to a single manifest, not an index. A distinct error.detail (target_not_an_index) from target_not_found because the remedy differs: drop the flag, rather than go looking for a build that was never missing
80Fulcio rejected the OIDC token
83Rekor transparency log unavailable, or it returned a log entry with no usable Merkle inclusion proof
84Registry serves neither the OCI Referrers API nor a writable fallback index
85UnsupportedKeyBackend--key named a key backend OCX recognises but has not implemented (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, before the reference is treated as a filename

JSON output (--format json)

On success, ocx package attest emits a success envelope. Signed:

json
{
  "schema_version": 1,
  "command": "package attest",
  "exit_code": 0,
  "data": {
    "identifier": "registry.example/pkg:1.0",
    "platform": "linux/amd64",
    "subject_digest": "sha256:<64-hex>",
    "predicate_type": "https://cyclonedx.org/bom",
    "bundle_digest": "sha256:<64-hex>",
    "referrer_digest": "sha256:<64-hex>",
    "signed": true,
    "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
    "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
    "key_backend": "keyless",
    "transparency_log_index": 42
  }
}

Unsigned — no signing identity was visible, so the three certificate fields and key_backend/public_key_hint are omitted (never emitted empty), bundle_digest is the SBOM document's own digest rather than a Sigstore bundle's, and transparency_log_index is null since no key model was involved at all:

json
{
  "schema_version": 1,
  "command": "package attest",
  "exit_code": 0,
  "data": {
    "identifier": "registry.example/pkg:1.0",
    "platform": "linux/amd64",
    "subject_digest": "sha256:<64-hex>",
    "predicate_type": "https://spdx.dev/Document",
    "bundle_digest": "sha256:<64-hex>",
    "referrer_digest": "sha256:<64-hex>",
    "signed": false,
    "transparency_log_index": null
  }
}

key_backend (keyless, file, or a key-backend scheme) and public_key_hint (the signing key's cosign hint, key mode only) are present on every signed attach, keyless included; transparency_log_index is always present, null included — under --key with no --rekor-upload, a missing record is a legal outcome the operator must see rather than infer from an absent field.

bundle_digest and referrer_digest describe the OCI 1.1 referrer, so both are omitted under --signature-format simplesigning, which publishes only the sidecar; sidecar_digest — the sha256-<hex>.att manifest — appears only when a sidecar was written. An invocation that does not pass --signature-format sees exactly the keys shown above.

predicate_type echoes the resolved predicateType URI actually written into the Statement (signed) or declared as the referrer's artifactType (unsigned) — for the cosign-alias spellings this differs from the --type value you passed (e.g. --type cyclonedx resolves to https://cyclonedx.org/bom); a literal URI passed to --type is echoed unchanged.

On error, ocx package attest emits the same envelope shape as sign. The error.detail field is a snake_case discriminant for programmatic matching:

detail discriminants for package attest (frozen contract C-S1-1):

detail valueExitMeaning
predicate_too_large65The --predicate file exceeds 15 MiB
rekor_set_malformed65Rekor returned the entry but the SET could not be extracted or parsed
predicate_not_json65The --predicate file did not parse as JSON
fulcio_bad_request78Fulcio rejected the CSR as malformed
fulcio_unavailable75Fulcio could not be reached, or answered 429 or 5xx — a transient outage, safe to retry
oidc_token_rejected80Fulcio rejected the OIDC token
transparency_log_unavailable83Rekor transparency log unavailable, or returned an entry with no usable Merkle inclusion proof
referrers_unsupported84Registry serves neither the OCI Referrers API nor a writable fallback index
target_not_found79No manifest for the requested --platform under the target image index
target_not_an_index79--platform was given but the reference resolved to a single manifest, not an index — drop the flag, rather than go looking for a build that was never missing
subject_digest_unsupported65The reference resolves to a subject addressed by sha384 or sha512; cosign artifacts address their subject by sha256 alone. Refused before anything is published or logged to Rekor, rather than at verify time after a permanent transparency-log entry has been burned
oidc_pre_check_failed77OIDC pre-check failed client-side before the token was sent to Fulcio
offline_attest_refused77--offline is incompatible with package attest
identity_token_file_permissive77Token file has permissive permissions, wrong owner, or is a symlink
forbidden_registry_target78The target registry is refused by policy
invalid_endpoint_url64Malformed --fulcio-url or --rekor-url
invalid_endpoint_url69The endpoint host does not resolve at all
provenance_version_unsupported64--type resolved to a SLSA provenance predicate below v1.0; pass --type slsaprovenance1
unsigned_type_unsupported64No signing identity is visible and --type did not resolve to a CycloneDX or SPDX predicate
sidecar_requires_signature64--signature-format simplesigning or both was given for an attach with no signing identity to build a DSSE envelope from
unsupported_key_backend85--key named a key backend OCX recognises but has not implemented (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary, so it is never reported as a missing file
key_backend74A --key <path> reference names a file that could not be read — missing, permission denied, a directory or device node rather than a regular file, or another I/O failure. The single detail every KeyBackendError variant collapses to; unsupported_key_backend above is the separate parse-time refusal
key_backend65A --key <path> reference names a file that was read in full but whose bytes are not a key this backend accepts, or that exceeds the size cap
key_reference_invalid64--key could not be parsed at all: an unrecognised scheme token, or nothing following the scheme
rekor_upload_required_for_keyless64--no-rekor-upload was given without --key; a keyless signature must be recorded in Rekor for the reason above
internal1Unexpected internal error

Human-readable output (default format) states the trust class outright rather than leaving it to be inferred from missing rows — a Signature field reads signed or unsigned (attached without an identity), and the three certificate rows are present only when signed.

Example — attach a CycloneDX SBOM in CI

yaml
- name: Attest SBOM
  run: |
    cyclonedx-cli ... > sbom.json
    ocx package attest \
      -p linux/amd64 \
      --predicate sbom.json --type cyclonedx \
      registry.example/pkg:1.0

The same ambient GitHub Actions OIDC token sign picks up automatically applies here — no --identity-token-* flag is needed.

sbom

Lists, or extracts, the SBOM attestations a package manifest carries — the read-side counterpart to attest. A manifest can carry two kinds: a signed attestation, a DSSE bundle with a Fulcio certificate and a Rekor entry behind it, and an unsigned attach, with no signature over it at all.

An unsigned attach arrives by one of two routes, and both list identically. A referrer — what oras attach and COSIGN_EXPERIMENTAL=1 cosign attach sbom --registry-referrers-mode oci-1-1 write — is found by the Referrers API. The cosign sha256-<hex>.sbom sidecar tag is not: its manifest declares neither artifactType nor subject, so no listing reaches it and it is read by tag, exactly as .sig and .att are on the verify side. In both cases the payload layer's own media type is the only claim about the document, so it is both the gate and the label — a layer typed outside the SBOM set is refused (65, sbom_media_type_unsupported) rather than listed as an SBOM.

Which of the two you get back, and whether anything is checked, is decided per invocation by one of two modes:

  • --verify — every listed document carries a signature that passed every check verify --attestation runs (referrer discovery, the Fulcio/Rekor/identity pipeline, the Statement's subject-digest binding). An unsigned attach is refused, never listed: the policy names who must have signed, and this document has no signer (exit 77 when it is all the package carries). This is the default whenever --certificate-identity/--certificate-oidc-issuer are given, or a [[trust.policy]] covers the package.
  • --no-verify — nothing is checked and no cryptography runs at all. Signed bundles and raw attachments alike are read for their document and reported verified: false, with no signer identity, because none was checked. This is the default when no identity source resolves, and it is what makes ocx package sbom work with no Sigstore setup: a consumer who has configured no trust policy can still read a published SBOM.

Naming both flags is not an error — the later one wins, as with every --x/--no-x pair in ocx — but --no-verify cannot be combined with --certificate-identity/--certificate-oidc-issuer (exit 64): supplying an identity while refusing to check it is contradictory rather than overridden. --verify with no identity source at all is also exit 64 — verification was demanded and nothing was named to verify against.

An unverified entry is never dressed up as a verified one. It carries verified: false in both the plain-text and JSON forms, no certificate fields, and the listing itself reports which mode produced it in summary.verification — so a script never has to infer why a row is unverified. See Attestations for when a package carries which kind.

Usage

shell
ocx package sbom [OPTIONS] <IDENTIFIER>

Arguments

  • <IDENTIFIER>: Package identifier to list SBOM attestations for (registry/repo:tag[@digest]).

Options

NameShortDefaultPurpose
--platform-p(the resolved object)Narrow into one platform of an image index. Omit it to act on whatever the reference resolves to — an index is then the subject itself, which is where cosign puts a multi-platform tag's signature. Given against a reference that resolves to a single manifest, there is nothing to narrow and the command fails
--type <TYPE>(any type)Restrict to one predicate type
--output <PATH|->-oWrite the matched predicate's bytes, byte-exact as the publisher wrote them, to PATH, or to stdout with -. Refuses more than one matching attestation (exit 65, multiple_attestations) — naming every candidate's referrer digest and every distinct predicate type in the set, since there is no correct one to pick silently. Under --no-verify the document was not checked, so one warning line naming the referrer digest goes to stderr; the written bytes are unaffected. - refuses a TTY destination (exit 64) — piped bytes are not something a terminal should render raw
--summaryfalseAugments the listing rather than replacing it: each plain-text row's Detail column gains component-count context (spec version, component count, top-level component name); each JSON entry gains a summary object, which also carries serial_number — a JSON-only field, never shown in the plain-text form. Restricted to specVersion 1.5-1.7; any other predicate type or an out-of-range CycloneDX version refuses that entry — it moves to refused with reason_kind sbom_summary_failed, naming the version it read and the --type cyclonedx remedy — never a silently empty summary and never the whole listing, so one unreadable document among five costs you that one
--certificate-identity / --certificate-oidc-issuer(policy-resolved)Same identity-resolution rule as verify
--key <REF>(keyless)Verify against a pinned public key instead of a Fulcio certificate — same reference grammar, same exit-85 refusal of an unimplemented backend, and the same conflict with the two certificate flags as verify. Additionally refused with --no-verify (exit 64): it names a key nothing would check
--signature-format <FORMAT>(bundle, then sidecar)Pin which cosign wire shape to accept, bundle or simplesigning; the pin decides discovery. Same rule as verify, both included — a usage error on the read side. It does not narrow the unsigned listing: an attached SBOM is not a signature, so it has no wire format to pin, and a --no-verify run reports the same documents under either value
--sigstore-trusted-root(public-good root over TUF)Same as verify
--rekor-url([trust.sigstore].rekor_url, else https://rekor.sigstore.dev)Rekor transparency-log endpoint
--no-cachefalseBypass the per-registry referrers-capability cache for this invocation
--verify(when an identity source resolves)Require a verified signature; refuse unsigned attachments. Exit 64 when no identity source resolves — nothing was named to verify against
--no-verify(when no identity source resolves)List every document without verifying anything. Conflicts with --verify and with the certificate flags (exit 64)

--output and --summary are mutually exclusive with each other and with the default listing mode. --summary works in both verification modes, on whatever the mode listed.

Exit codes

Shares verify's exit-code taxonomy under --verify — 79 when nothing verifies, 65 for any data-integrity failure, 78 for a trust-root or policy problem, 83 for Rekor unavailability. Exit 84 is not reachable from sbom at all: a registry serving no OCI Referrers API is read through the fallback referrers tag, so a registry with neither is "nothing found" (79), never a capability refusal. 84 belongs to the signing side — sign and attest, which must write a referrer. Under --no-verify the trust-material codes — 78 (trust root or policy), 77 (identity), 83 (Rekor), and the 65 signature classes — are unreachable, because no trust material is consulted; 64 for a malformed --rekor-url — or one the SSRF guard refuses as a forbidden address — stays reachable either way, since it is validated before the mode is resolved; so does 69, for a --rekor-url host that does not resolve at all. Seven sbom-specific additions:

CodeCondition
77unsigned_rejected_by_policy — an unsigned attach was found and this run demands a signature. Listed in refused when a signed attestation was also found; when unsigned attachments are all the subject carries, the refusal is promoted to the command's own error. --no-verify lists the same document instead
65MultipleAttestations under --output — more than one attestation matches and none was named by --type
65sbom_media_type_unsupported — a raw referrer's payload layer declares a media type outside the SBOM set. Reachable under --no-verify only, since --verify refuses raw referrers before reading them. Listed in refused when the scan found anything else on the subject; when it is the only candidate, the refusal is promoted to the command's own error
65key_malformed — a --key <path> reference names a file that was read in full but whose bytes are not an SPKI public key. Shared with verify
64--output - requested on a TTY, or --summary combined with --output. --no-verify combined with a certificate flag is a clap parse error, no envelope. no_identity_provided--verify demanded with no identity source to verify against: no certificate flags and no matching [[trust.policy]]
85unsupported_key_backend — a key reference named a key backend OCX recognises but has not implemented — from --key, or from a key = "…" signer in a matched [[trust.policy]], which reach the same refusal through different doors and answer it with the same code — (awskms://, gcpkms://, azurekms://, hashivault://, k8s://). Decided at the parse boundary — before any request and before the verification mode is resolved — so it is never reported as a missing file. A reference OCX cannot parse at all is exit 64 instead (key_reference_invalid)
74An I/O error reading --output — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one. Also key_unreadable, shared with verify: the key file a --key <path> reference names could not be read

A scan that finds nothing at all — no signed attestation and no unsigned attach — is AttestationNotFound (79), the same as an unqualified verify --attestation with no matching referrer. Under --summary an empty entries array is reachable at exit 0 — every document refused the summariser, so each one is reported in refused with summary.status partial_failure. The distinction is what was found, not what was listed: 79 means nothing at all was found, exit 0 with empty entries means every candidate was found but none could be read.

JSON output (--format json) — default listing mode

--output bypasses this envelope entirely, regardless of --format: the destination (a file, or stdout via -) receives the matched predicate's raw bytes and nothing else. Combining --output <file> with --format json leaves stdout empty — the bytes went to the file, and there is no listing to wrap in an envelope.

A verifying run (--verify, or an identity source resolving) over a manifest carrying one signed attestation:

json
{
  "schema_version": 1,
  "command": "package sbom",
  "exit_code": 0,
  "data": {
    "summary": {
      "status": "success",
      "verification": "verified",
      "exit_code": 0,
      "total": 1,
      "verified": 1,
      "unverified": 0,
      "refused": 0
    },
    "entries": [
      {
        "predicate_type": "https://cyclonedx.org/bom",
        "verified": true,
        "shadowed": false,
        "subject_digest": "sha256:<64-hex>",
        "referrer_digest": "sha256:<64-hex>",
        "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
        "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
        "signed_at": "2026-04-19T12:00:00Z"
      }
    ],
    "refused": []
  }
}

The same manifest under --no-verify, which checks nothing and reads the bundle's payload anyway:

json
{
  "summary": {
    "status": "success",
    "verification": "unverified",
    "exit_code": 0,
    "total": 1,
    "verified": 0,
    "unverified": 1,
    "refused": 0
  },
  "entries": [
    {
      "predicate_type": "https://cyclonedx.org/bom",
      "verified": false,
      "shadowed": false,
      "subject_digest": "sha256:<64-hex>",
      "referrer_digest": "sha256:<64-hex>"
    }
  ],
  "refused": []
}

summary.verification is verified or unverified and names the mode the whole listing was produced under. Branch on it, not on the rows: an unverified row means "nothing was checked" under verification: "unverified", and cannot occur at all under verification: "verified", where an unsigned attach is refused rather than listed.

A manifest carrying a signed attestation and a raw unsigned attach, read under --verify (or a matching policy): the signed document lists, the unsigned one moves to refused, and the whole command still exits 0 — a refusal beside a match is reported, not raised:

json
{
  "summary": {
    "status": "partial_failure",
    "verification": "verified",
    "exit_code": 0,
    "total": 2,
    "verified": 1,
    "unverified": 0,
    "refused": 1
  },
  "entries": [
    {
      "predicate_type": "https://cyclonedx.org/bom",
      "verified": true,
      "shadowed": false,
      "subject_digest": "sha256:<64-hex>",
      "referrer_digest": "sha256:<64-hex>",
      "certificate_identity": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main",
      "certificate_oidc_issuer": "https://token.actions.githubusercontent.com",
      "signed_at": "2026-04-19T12:00:00Z"
    }
  ],
  "refused": [
    {
      "referrer_digest": "sha256:<64-hex>",
      "reason": "SBOM referrer is attached without a signature, and verification is required; pass --no-verify to list it unverified",
      "reason_kind": "unsigned_rejected_by_policy"
    }
  ]
}

When the unsigned attach is the only candidate on the subject, there is nothing for the refusal to sit beside — it is promoted to the command's own top-level error instead of a refused row, exit 77.

Every entry carries predicate_type, verified, subject_digest and referrer_digest. certificate_identity, certificate_oidc_issuer and signed_at are present only when verified: true — omitted, not null, on an unverified entry, so an empty identity is never mistaken for a rendering failure. summary.verified and summary.unverified partition entries; summary.total is verified + unverified + refused.

Which subject a document is attached to, and what shadows what. An SBOM can sit on the image index or on a platform manifest, and both at once. Per-platform is preferred — dependencies genuinely differ per architecture, so one index-level SBOM is a lie for a multi-arch package — so with --platform given the command reads the platform manifest and the index behind it, and every document from both lands in entries, each naming its own subject_digest.

A platform-level SBOM then supersedes an index-level one only within the same predicate_type. A platform CycloneDX never hides an index-level SPDX: they are different documents for different consumers, not substitutes, and a consumer asking for SPDX would otherwise be told the package carries none. A superseded document stays in --format json with shadowed: true — the machine channel always gets the full picture — while the plain-text table collapses to the one that wins. shadowed is emitted on every entry, false included: nothing supersedes this document is a true statement, so a script can branch on the key without first testing for it.

Two documents of one predicate_type on the same subject shadow neither. Multiple SBOMs per package is normal — different formats, different lifecycle phases, rescans — and there is no disambiguation convention beyond org.opencontainers.image.created, so both are listed and policy decides. With no --platform nothing is narrowed, one subject is read, and nothing is ever shadowed.

--summary is CycloneDX-only, and deliberately narrower than the rest of the command. Discovery and verification are format-agnostic — the payload is opaque bytes — so an SPDX SBOM attaches, signs, verifies and lists exactly like a CycloneDX one. Only the summariser is format-specific: it parses CycloneDX 1.5-1.7 and nothing else. The asymmetry is by design, not a gap waiting to be filled, and it costs one entry rather than the listing: an SPDX document under --summary moves to refused with reason_kind sbom_summary_failed, and every CycloneDX document beside it still reports. Drop --summary, or narrow with --type cyclonedx, to list an SPDX document cleanly.

A scan that examined and rejected candidates reports them in refused, never silently — summary.status is partial_failure whenever refused is non-empty, success otherwise. Plain-format listings truncate refused to the first 20 with a ... and N more (see --json) trailer; --json is never truncated. Each refused entry carries reason (prose) and reason_kind (a frozen slug, e.g. unsigned_rejected_by_policy, multiple_attestations, bundle_parse_failed, sbom_media_type_unsupported) — scripts branch on reason_kind, never on reason. The verify pipeline's own refusals come first; a --summary document that could not be read follows them with reason_kind sbom_summary_failed, which is deliberately outside that slug set: the document was found (verified or not), and only the reading of its payload failed.

Under --summary, each entry gains a summary object:

json
"summary": {
  "spec_version": "1.6",
  "serial_number": "urn:uuid:...",
  "component_count": 42,
  "top_level_component": "acme/widget"
}

serial_number and top_level_component are omitted, not null, when the document does not carry one.

Example — list every SBOM a package carries

shell
ocx package sbom -p linux/amd64 registry.example/pkg:1.0

Example — extract the CycloneDX SBOM to a file

shell
ocx package sbom -p linux/amd64 --type cyclonedx --output sbom.json registry.example/pkg:1.0

Example — pipe a CycloneDX SBOM straight into another tool

shell
ocx package sbom -p linux/amd64 --type cyclonedx --output - registry.example/pkg:1.0 | jq .

description pull

Displays description metadata for one or more packages from the registry.

JSON output is an object keyed by the requested identifier ({"<id>": {...}|null}, keyed even for a single package); plain output always prints a == <id> == header line per package, even for a single package, followed by its description fields.

Usage

shell
ocx package description pull [OPTIONS] <IDENTIFIER>...

Arguments

  • <IDENTIFIER>...: One or more package identifiers (repository only).

Options

  • --save-readme <PATH>: Save the README to a file or directory. Requires exactly one identifier.
  • --save-logo <PATH>: Save the logo to a file or directory. Requires exactly one identifier.
  • -h, --help: Print help information.

install

Downloads and installs one or more packages into the local object store.

Installs packages into the object store and creates a candidate symlink for each package. If a package declares dependencies, all transitive dependencies are downloaded to the object store automatically — only the explicitly requested packages receive install symlinks.

This is the OCI-tier install command. For project-tier installs driven by ocx.toml, use ocx add.

When a [[trust.policy]] entry in the operator config.toml tier covers the package's registry/repository, install verifies its Sigstore signature automatically — at the metadata-first seam, after the manifest digest resolves and before any layer downloads. A failed check aborts before any package-store or symlink state is written, so a rejected artifact costs a manifest fetch, not a wasted download. Auto-verify consults the operator tier only; unlike package verify, a project ocx.toml policy is never considered here.

The same gate applies to every command that fetches a package, not just install: package pull, and every command that auto-installs on demand — package exec, package env, root env, exec, and patch discovery (patch why / patch test). Only install and pull carry the --verify / --no-verify flag; the others opt out via OCX_NO_VERIFY.

A package outside every policy's scope is not verified — trust is opt-in, and OCX logs an INFO line noting the skip. This opt-in is per scope: a covered package's transitive dependencies are verified only if a policy also covers their scope. When a policy does cover the package, a failed check exits with the same taxonomy package verify uses: 65 for a tampered bundle, 77 for a certificate identity or issuer mismatch, 78 for a trust-root or policy configuration problem, 79 for no signature found.

Pass --no-verify (below), or set OCX_NO_VERIFY for a CI-wide opt-out, to skip a policy-covered package's verification; the flag wins when both are set, and the bypass logs a single WARN per invocation. Under --offline (or OCX_OFFLINE), verification reuses whatever trust material is already local — a trust root from any rung of the ladder (OCX_SIGSTORE_TRUSTED_ROOT, [trust.sigstore], $OCX_HOME/sigstore/trusted-root.json) or a warm $OCX_HOME/state/trust_root/ cache entry — and fails closed with exit 78 when neither is available, rather than installing an artifact it could not check. See Verify by default in the user guide for the full model.

Usage

shell
ocx package install [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to install.

Options

FlagShortDescription
-p, --platformTarget platform — see Platforms for the grammar (e.g. linux/amd64, linux/amd64+libc.glibc, linux/amd64+libc.musl, darwin/arm64). Defaults to the auto-detected current platform. When a feature-tagged value is supplied, OCX selects the manifest whose os.features are a subset of the supplied features — use this to force a specific libc variant when you know it will run on the host. If the package ships for the host os/arch but no candidate's os.features are a subset of the resolved features (e.g. a glibc-only host against a musl-only entry), install exits 65 (DataError) and the error lists the available platforms to override with.
-s, --selectAfter installing, update the current symlink for each package to point to the newly installed version.
--verifyVerify the package's signature when a [[trust.policy]] covers it (default); re-enables verification for this invocation even if OCX_NO_VERIFY is set. No effect on a package outside every policy's scope.
--no-verifySkip that verification for this invocation. Equivalent env var: OCX_NO_VERIFY (the flag wins over the env).
-h, --helpPrint help information.

Host-only symlinks for foreign-platform installs

The candidate and current symlinks are written only when the resolved platform matches the host (or the package is platform-agnostic). Installing a foreign platform — e.g. -p windows/amd64 on Linux — still populates the object store, but leaves the host's candidates/{tag} and current slots untouched so a platformless which or env never resolves to a package the host cannot run. The install reports a null path in that case; reference the foreign platform by its digest instead.

Windows: PATHEXT must include .CMD

On Windows, package install prints a stderr warning when the host shell's PATHEXT is missing .CMD. Generated entrypoint launchers are .cmd files and require PATHEXT to advertise that extension before bare-name lookup (e.g. cmake) can find them.

uninstall

Removes the installed candidate for one or more packages.

Removes the candidate symlink and its back-reference. Object-store content is preserved unless --purge is given. To also remove the current symlink, pass --deselect or run package deselect separately. To remove all unreferenced objects at once, use clean.

Usage

shell
ocx package uninstall [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to uninstall.

Options

FlagShortDescription
-d, --deselectAlso remove the current symlink. Equivalent to running ocx package deselect after uninstall.
--purgeDelete the object from the store when no other references remain after uninstall.
-h, --helpPrint help information.

select

Selects one or more packages as the current version by updating the current symlink.

No downloading is performed — the package must already be installed.

Usage

shell
ocx package select [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to select.

Options

FlagShortDescription
-p, --platformTarget platform to consider.
-h, --helpPrint help information.

TIP

ocx package install --select installs and selects in one step.

deselect

Removes the current-version symlink for one or more packages.

The package is deselected but not uninstalled: its candidate symlink and object-store content remain intact. The symlink removal is idempotent — an already-absent link is not an error.

Usage

shell
ocx package deselect <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to deselect.

Options

  • -h, --help: Print help information.

exec

Alias: x.

Executes a command within the environment of one or more OCI-tier packages.

Identifiers are OCI references (e.g. kitware/cmake:3.28), resolved through the index and auto-installed when missing. Because it auto-installs, a package covered by a [[trust.policy]] is signature-verified before it runs — the same gate as package install (see its auto-verify contract). For project-tier execution driven by ocx.toml, use ocx exec.

If a package declares dependencies, their environment variables are applied in topological order before the package's own variables. Env entries layer in the order identifiers appear on the command line.

Generated launchers use ocx launcher exec, not ocx package exec

Entry-point launchers generated by ocx package install call the internal ocx launcher exec '<pkg-root>' -- <argv0> [args...] subcommand, not ocx package exec. That subcommand validates the package root, forces the self view internally, resolves ${installPath} (or its exact alias ${self.installPath}, each optionally :native/:posix) in any baked entry-point args, then prepends the resolved arguments before user-supplied ones and executes the resolved entrypoint. A ${deps.*} or ${self.env.*} token is not legal in entry-point args — neither is any other unrecognised token — and refuses the launcher at exit 65, at run time, after install. The wire ABI (<pkg-root> -- <argv0> [args...]) is frozen so launchers generated by older OCX releases keep working after an upgrade. See the Entry Points guide for the launcher ABI. On Windows, the native .exe shim makes this call without routing through cmd.exe, closing the %* argument-injection surface for default resolution.

Usage

shell
ocx package exec [OPTIONS] <PACKAGES>... -- <COMMAND> [ARGS...]

Arguments

  • <PACKAGES>: OCI identifiers to resolve (e.g. kitware/cmake:3.28).
  • <COMMAND>: The command to execute within the package environment.
  • [ARGS...]: Arguments to pass to the command.

Options

FlagShortDescription
-p, --platformTarget platform to consider.
--cleanStart with a clean environment; only package-declared variables and OCX_* config vars reach the child.
--selfUse the self view (expose private + public entries). Default: consumer view (public + interface only).
--lazy-mode <MODE>Top tier of the lazy-mode resolution ladder. always composes a shim instead of downloading content up front; the requested command's own invocation is what triggers materialization if it names one of the deferred package's entries. Typing always together with --self is a usage error (exit 64) — a shim is a consumer-facing launcher and --self selects the private view that bypasses launchers, so the two ask for contradictory things. An always merely inherited from OCX_LAZY_MODE is not: --self outranks it and composes eagerly.
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOO -> a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends); SEP qualifies list only (--env GODEBUG:list:,=gctrace=1) and, if omitted, inherits whatever separator another contributor to the key already declared, or a single space if none did. A relative path value resolves against the current directory. Applied last, so it overrides every package-declared variable. This is a per-invocation override, not project configuration -- it does not make this command read ocx.toml. A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64). See the PATH override warning under ocx exec.
--records-dir <DIR>Sink directory for the exec-time resolution record — one JSON file written immediately before the child starts, naming every package digest that composed the environment plus the resolved executable. Overrides the [records] dir config key and OCX_RECORDS_DIR. Unset at every tier means no record is written. Recording failure aborts the invocation when [records] required is true — exit 74 for an unwritable sink, exit 78 when the sink resolves through a symlink.
--records-name <TEMPLATE>Filename template for the sink, over the closed placeholder set in Filename grammar. Has no effect unless a sink directory is also active. Overrides the [records] name config key and OCX_RECORDS_NAME.
-h, --helpPrint help information.

Stdin always inherits

ocx package exec always inherits the parent's stdin so piped input flows into the child unchanged (echo hi | ocx package exec pkg -- cat prints hi). There is no opt-out — the previous --interactive flag was removed; matching standard shell exec semantics is the default.

Process replacement on Unix

On Unix, ocx package exec hands the current process image off to the target via execvp(2), so the child inherits ocx's PID. Signals reach the target without an ocx forwarder, pgrep <name> shows the wrapped binary, and the process tree drops the ocx layer entirely — matching the same semantics shells use when chaining exec "$@" in entry-point scripts. On Windows, ocx package exec spawns the target and waits for it, since CreateProcess has no exec equivalent; the propagated exit code is forwarded as ocx's own exit code.

<COMMAND> is resolved once, before the child starts, by searching only the composed packages' own PATH contributions — never the ambient PATH and never a working-directory fallback — and a match that is itself an ocx-generated launcher trampoline is refused rather than handed to the child.

Exit codes

CodeMeaning
0Command exited successfully (exec propagates the wrapped command's exit code).
65<COMMAND> does not resolve within the composed environment (no ambient-PATH or working-directory fallback); or <COMMAND> resolves to an ocx-generated launcher trampoline, refused to prevent an unbounded self-invocation loop.
74The exec-time resolution record could not be written and [records] required is true — the command never runs.
78The --records-name/OCX_RECORDS_NAME/[records] name template names an unrecognized placeholder, carries no varying component ({time}, {pid}, or {rand}), or renders to something other than a single plain filename; or the --records-dir/OCX_RECORDS_DIR/[records] dir sink resolves through a symlink to a different directory.
NWrapped command exited with code Nexec forwards the child status verbatim.

ocx launcher exec takes no --records-* flags

An entrypoint launcher's re-entry (ocx launcher exec) inherits the active sink only through the forwarded OCX_RECORDS_DIR/OCX_RECORDS_NAME environment variables — the same mechanism that forwards every other resolution-affecting setting into a launcher re-entry. See Two records per launcher invocation.

env

Print the resolved environment variables for one or more OCI-tier packages.

Output format is controlled by the root --format flag (default: plain). Plain format outputs an aligned table with Key, Type and Value columns. JSON format (ocx --format json package env) outputs {"entries": [...], "binaries": [...], "entrypoints": [...], "integrations": [...], "advisories": [...]}. entries is unchanged from before this field existed. binaries and entrypoints are top-level sibling arrays — not nested inside entries — of {"name": "...", "package": "..."} objects: one entry per admitted package's declared executables (binaries) or entry points (entrypoints). package is the canonical resolved identifier that declared the claim (registry/repo[:tag]@digest — the tag may be absent, so a tagless digest-pinned form is legal). Both arrays are always present, possibly empty.

integrations is a fourth top-level sibling array of {"namespace": "...", "package": "...", "payload": ...} objects — one row per (declaring package, integration namespace) pair, payload the interpolated block OCX never interprets or merges. Two packages declaring the same namespace produce two rows, never one merged row — a row count exceeding the distinct-namespace count is the visible proof nothing merged. The array is present, with attribution, even for a single root package — it is never collapsed to a bare object or omitted. Like binaries/entrypoints, it is always [] under --self (integrations reach only the interface surface a consumer sees) and never appears in --shell/--ci output. See Integrations for the field's grammar, size caps, and interpolation rules.

advisories is a fifth top-level sibling array of {"kind": "...", "package": "...", "key": "...", "message": "..."} objects, one per deferred tool whose declared metadata could not be fully validated at compose time (key is present only for the two variants that name an environment variable) — always present, empty unless a package composed with --lazy-mode always triggered one; warning-only, never a compose failure.

Use --shell[=NAME] for eval-safe shell export lines — the only sourceable form.

In plain format, the Key/Type/Value table itself is unchanged — a hint line follows it summarizing availability whenever any binaries, entry points, or integration namespaces are admitted, e.g. 5 binaries available (cmake, ctest, cpack, ...); 2 integration namespaces (com.jetbrains, com.microsoft.vscode); use --format json for the full list. The integrations clause names namespace keys only — payloads never render in plain output, for the same reason the entries table gained no fourth column. None of the three arrays ever appears in --shell/--ci output — those channels emit only shell-export lines / CI sink writes.

If a package declares dependencies, their environment variables are included in the output in topological order — dependencies before dependents.

In the default mode, packages are auto-installed if not already available locally (including transitive dependencies). Because it auto-installs, a package covered by a [[trust.policy]] is signature-verified before its environment is composed — the same gate as package install (see its auto-verify contract). See Path Resolution for the --candidate and --current modes.

Usage

shell
ocx package env [OPTIONS] <PACKAGE>...
ocx --format json package env [OPTIONS] <PACKAGE>...

Arguments

  • <PACKAGE>: Package identifiers to resolve the environment for.

Options

FlagShortDescription
-p, --platformTarget platform to consider.
--candidate, --currentPath resolution mode — see Path Resolution.
--selfSelf view: emits private + public entries. Default: consumer view (public + interface). integrations is always [] under --self — integrations reach only the interface surface, regardless of view.
--lazy-mode <MODE>Top tier of the lazy-mode resolution ladder. always composes a shim instead of downloading content up front. Has no effect together with --candidate/--current, which always resolve a materialized package. Typing always together with --self is a usage error (exit 64) — a shim is a consumer-facing launcher and --self selects the private view that bypasses launchers, so the two ask for contradictory things. An always merely inherited from OCX_LAZY_MODE is not: --self outranks it and composes eagerly.
--shell[=NAME]Emit eval-safe shell export lines for the named dialect. Same conventions as root ocx env --shell. Mutually exclusive with --ci.
--ci[=PROVIDER]Write the resolved environment into the CI system's persistence channel for later pipeline steps. PROVIDERgithub / github-actions, gitlab / gitlab-ci. Bare --ci auto-detects. Equals-form required. Mutually exclusive with --shell.
--export-file=PATHWrite GitLab CI/CD JSON-lines to PATH. Requires --ci=gitlab; exit 64 for --ci=github or without --ci.
--show-patchesAnnotate each entry with its origin. When [patches] is configured, companion overlay entries are appended after the package's own entries; this flag adds a Source column to the plain table (a "source" object in JSON) naming the descriptor rule and companion that produced each overlay entry. No effect when [patches] is not configured. Mutually exclusive with --shell and --ci.
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOO -> a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends); SEP qualifies list only (--env GODEBUG:list:,=gctrace=1) and, if omitted, inherits whatever separator another contributor to the key already declared, or a single space if none did. A relative path value resolves against the current directory. Applied last, so it overrides every package-declared variable. This is a per-invocation override, not project configuration -- it does not make this command read ocx.toml. A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64). See the PATH override warning under ocx exec.
-h, --helpPrint help information.

--ci=gitlab requires GitLab Functions / step runner

--ci=gitlab writes JSON-lines ({"name":"…","value":"…"}), which is the format consumed by the GitLab step runner via $ (experimental, run: keyword jobs only). It does not work with traditional script: jobs. See CI Integration for a full step-runner example.

Windows: synthetic PATHEXT ⊳ .CMD

On Windows, package env prepends .CMD to PATHEXT in its output when the host shell's PATHEXT does not already include it. Generated entrypoint launchers are .cmd files; this lets callers that adopt the printed env find launchers by bare name without further configuration.

patch

Manage site-infrastructure patch overlays. Patch descriptors map glob patterns over package identifiers to companion packages that carry operator-controlled environment overlays (CA bundles, proxy variables, license-server endpoints). The [patches] configuration tier must be set before any patch sub-command that contacts the registry.

For a full walkthrough, see the Patching packages guide.

Usage

shell
ocx patch <SUBCOMMAND>

Sub-commands

Sub-commandPurpose
freezeWrite a patches.snapshot.json file that pins companion digests for reproducible builds.
syncRefresh descriptors and install newly-referenced companion packages from the registry.
publishPush a patch descriptor to the configured patch registry.
testCompose a descriptor onto a base package locally without publishing (maintainer preview).
whyShow which companion, and which descriptor rule, contributes each patched env var to a base.

patch freeze

Resolves every companion and descriptor digest in the active patch overlay and writes patches.snapshot.json beside ocx.lock (or in $OCX_HOME under --global).

Set OCX_PATCH_SNAPSHOT to the file's path so all subsequent composition prefers the pinned digests over live tag lookups. Adopting a snapshot is a deliberate opt-in and is independent of --frozen, which scopes to the package tier: freeze the patch tier by pointing that variable at this file. Companions are pinned per repository:tag, so a descriptor naming one repository at two tags freezes both versions independently.

Works offline: only the local object store is consulted.

Usage

shell
ocx patch freeze

Options

  • -h, --help: Print help information.

Target the global toolchain

Pass --global before the subcommand to write patches.snapshot.json beside $OCX_HOME/ocx.lock: ocx --global patch freeze. See --global for the full root-flag reference.

Exit codes

CodeMeaning
0Snapshot written successfully.
65An existing snapshot on the read path carries a format version this ocx does not read — re-run this command to rewrite it.
74I/O error writing the snapshot file.
78No ocx.lock found for the project tier (run ocx lock first).

patch sync

Re-fetches every patch descriptor for all installed packages and the global descriptor. Installs any newly-referenced companion packages. Requires network access.

This command also picks up patches for packages installed before the [patches] tier was configured. All states are re-checked regardless of what was previously recorded. Running patch sync is equivalent to ocx index update for the patch tier — not to the similarly-named ocx index sync, despite the shared verb.

Without --platform, patch sync resolves every concrete ship platform (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64) — not just the host platform. This is patch sync's one sanctioned multi-platform fan-out: an explicit enumeration over the concrete matrix, not a selection among candidates. A synced descriptor/companion set is shared across a team the same way ocx lock is: it must cover every platform a teammate might run, or an offline or required-patch launch on their machine silently breaks. Pass a single --platform to narrow to just that one platform instead.

Usage

shell
ocx patch sync [OPTIONS]

Options

FlagShortDescription
--platform PLATFORM-pTarget platform for companion resolution. Single-valued: passing more than one exits 64. Bare (omitted) fans out to the full five-platform concrete ship matrix; an explicit value narrows to that one platform.
-h, --helpPrint help information.

Exit codes

CodeMeaning
0Sync complete, including a no-op when no [patches] tier is configured.
81--offline blocked the sync — patch sync is an explicit online action and always requires network access, unlike lazy discovery at install time.
otherA required companion failed to install for one of the known bases; the exit code reflects the underlying cause — see Exit codes (e.g. 79 not found, 69 registry unreachable, 80 authentication failure).

patch publish

Reads a descriptor JSON file, validates it, and pushes it to the configured [patches] registry. Use --global for a descriptor that applies to every package; supply a base identifier to publish a per-package descriptor. Publish companion packages separately with ocx package push before publishing the descriptor that references them.

Requires network access; fails in offline mode.

Usage

shell
ocx patch publish --descriptor <FILE> [--global | <BASE-ID>]

Arguments

  • <BASE-ID>: The base package whose per-package patch path receives the descriptor. Required unless --global is set.

Options

FlagShortDescription
--descriptor <FILE>Path to the patch descriptor JSON file. Required.
--globalPublish the descriptor to the reserved global repository under the patch registry so it applies to every base. Mutually exclusive with <BASE-ID>.
--registry <HOST/PATH>Patch registry to publish to, e.g. registry.corp.example/ocx-patches. Overrides the configured [patches] tier, so you can bootstrap a brand-new patch registry without first adding a config block. Defaults to the configured registry.
-h, --helpPrint help information.

Exit codes

CodeMeaning
0Descriptor published.
64No patch registry available — pass --registry <HOST/PATH>, configure a [patches] tier, or set OCX_PATCHES before publishing.
65Descriptor JSON is malformed or the version is unsupported.
69Registry unreachable.
74An I/O error reading --descriptor — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one.
81--offline blocked the publish — patch publish requires network access.

patch test

Composes a patch descriptor onto a base package in a scratch environment without publishing or modifying $OCX_HOME. Use this to verify a descriptor before publishing.

Without a trailing command, prints the composed environment so you can inspect the entries contributed by the matched companions. With -- <COMMAND>, runs the command in the composed environment. With --script, runs a Starlark test script against the composed environment.

Required companion packages must be resolvable (installed locally or pullable from the registry). An unresolvable required companion fails the command. An optional companion that cannot be resolved is warned-and-skipped, matching the production fail-open path.

Usage

shell
ocx patch test --descriptor <FILE> [OPTIONS] <BASE-ID> [-- COMMAND [ARGS...]]

Arguments

  • <BASE-ID>: The base package identifier to compose the descriptor onto. Required.
  • [-- COMMAND [ARGS...]]: Command to run in the composed environment. Mutually exclusive with --script. When neither is given, the composed environment is printed.

Options

FlagShortDescription
--descriptor <FILE>Path to the patch descriptor JSON file. Required.
--companion-archive <PATH>Local archive for a companion package; avoids a registry round-trip. Repeatable for multiple companions. There is no -i flag to name the companion — the archive's metadata sidecar (<archive-stem>-metadata.json, the same naming ocx package test's --metadata flag defaults to) must carry an identifier field matching one of the descriptor's companion entries exactly: registry, repository, and tag. A bare identifier (no registry) qualifies against your configured default registry, not the [patches] registry.
--platform <PLATFORM>-pTarget platform for composing the environment. Defaults to host platform.
--registry <HOST/PATH>Patch registry to compose against, e.g. registry.corp.example/ocx-patches. Overrides the configured [patches] tier, so you can preview a descriptor against a new patch registry without a config block. Defaults to the configured registry.
--script <FILE>Starlark test script to run in the composed environment. Mutually exclusive with -- COMMAND.
--env <KEY[:TYPE[:SEP]]=VALUE>Set an environment variable for this invocation only. Repeatable; later occurrences win over earlier ones for the same key. Splits on the first =, so --env FOO=a=b yields FOO -> a=b. TYPE is constant (replaces, the default when omitted), path (prepends), or list (appends); SEP qualifies list only (--env GODEBUG:list:,=gctrace=1) and, if omitted, inherits whatever separator another contributor to the key already declared, or a single space if none did. A relative path value resolves against the current directory. Applied last, so it overrides every package-declared variable. This is a per-invocation override, not project configuration -- it does not make this command read ocx.toml. A bare --env FOO with no =, a TYPE that names no modifier or is empty, a SEP that is empty, contains =, contains a newline or carriage return, qualifies a non-list type, or edges a list value, an invalid variable name, or an OCX_*/__OCX_* key is rejected (exit 64). See the PATH override warning under ocx exec.
-h, --helpPrint help information.

Exit codes

CodeMeaning
0Environment printed, or the trailing command/script exited 0.
(child's exit code)With a trailing command, the child's exit code is forwarded unchanged — a command that exits 7 makes patch test exit 7.
64No patch registry available — pass --registry <HOST/PATH>, configure a [patches] tier, or set OCX_PATCHES before testing; a --companion-archive metadata sidecar has no identifier field; or its identifier does not match a companion the descriptor names for the base (naming the nearest entry it found).
65Descriptor JSON is malformed or the version is unsupported; or two contributors to one env key declared conflicting list separators (see Separator agreement).
74An I/O error reading --descriptor — missing file, permission denied, or the symlink refusal. error.kind is io_error with no error.detail; a script must branch on error.kind for this one.
81--offline blocked resolving the base or a required companion.
otherA required companion could not be resolved; the exit code reflects the underlying cause — see Exit codes (e.g. 79 not found, 69 registry unreachable, 80 authentication failure).

With --script, the exit code follows the scripted-tests contract instead — assertion failures exit 1, script-level errors exit 64/65/74.

patch why

Shows which companion, and which descriptor rule, contributes each patched env var to a base package. Resolves <BASE-ID> directly against the configured [patches] registry — an OCI-tier diagnostic that never consults ocx.toml. Use this to trace a companion overlay back to the rule that admitted it, without reading through the full composed environment.

A base with no applicable patch (no [patches] tier configured, or no descriptor rule matches the base) prints a clean "no patches apply" result and exits 0 — not an error.

Usage

shell
ocx patch why [OPTIONS] <BASE-ID>

Arguments

  • <BASE-ID>: The base package identifier to trace patch provenance for. Required.

Options

FlagShortDescription
--platform <PLATFORM>-pTarget platform for resolving the base. Single-valued: passing more than one exits 64. Defaults to the host platform.
-h, --helpPrint help information.

Output follows the root --format flag like every other command — there is no subcommand-level --format override. With --format plain (default), the result is a Variable | Rule | Companion table, one row per patched env var:

shell
ocx patch why java:21
Variable     Rule          Companion
JAVA_TRUST   ocx.sh/java:* corp/jdk-trust:1.0

With --format json, the result is a bare array of { "variable", "rule", "companion" } objects ([] when no patches apply):

shell
ocx --format json patch why java:21
json
[
  { "variable": "JAVA_TRUST", "rule": "ocx.sh/java:*", "companion": "corp/jdk-trust:1.0" }
]

Exit codes

CodeMeaning
0Result printed — including a base with no applicable patch.
69Registry unreachable while resolving the base.
79Base identifier not found in the registry.

config

Manage the corporate managed-configuration tier ([managed]) — an operator-published config.toml payload synced from an OCI registry and merged above the user config on every invocation. The payload travels as an ordinary OCX package (its content is one config.toml), so publishing, versioning, cascade tags, and rollbacks all behave exactly like packages. Decoupled from ocx self update: this tier tracks an operator's config package, not the ocx binary itself.

Usage

shell
ocx config <SUBCOMMAND>

Sub-commands

Sub-commandPurpose
setupAdopt (or clear) the [managed] tier — the configuration-only counterpart to ocx self setup --managed-config (consumer side).
testValidate a candidate config file locally and preview the configuration it would produce, without publishing or adopting anything (operator side).
pushValidate and publish a config file as a managed-config package (operator side).
updateFetch and persist the managed-config snapshot — optionally pinned to a VERSION — or pause/resume the background tick, or report status with --check.

config setup

Adopts (or clears) the corporate managed-config tier without touching anything else: no binary bootstrap, no env shims, no shell profiles. This is the configuration entry point for automation and CI environments, where OCX arrives as a plain binary and the only setup that matters is which managed configuration to apply.

The command resolves its source with the same precedence as ocx self setup --managed-config — the explicit --managed-config flag, then OCX_MANAGED_CONFIG, then the existing [managed] seed — and runs the identical adoption sequence: synchronously fetch and persist the snapshot first, then write the [managed] seed fence in $OCX_HOME/config.toml only on success. A fetch failure during first adoption, or while self-healing a wiped or mismatched snapshot, leaves no partial state and fails the command.

A bare re-run against an already-adopted seed reconciles it every time, not just once at onboarding: the source is re-fetched, and a newer digest replaces the snapshot in place (refreshed) while unchanged content just confirms it (already_adopted, now verified rather than assumed). That re-sync is best-effort — a fetch failure warns on stderr, keeps the existing snapshot, and still exits 0 (refresh_unavailable); first adoption, the self-heal case above, and a failure writing the refreshed snapshot to disk stay hard-fail.

Unlike ocx self setup — where an unresolved source is a no-op (setup has other phases to run) — a bare ocx config setup with nothing configured at any of the three levels is a usage error (exit 64): the command exists only to set up this tier.

Usage

shell
ocx config setup [--managed-config REF] [--dry-run] [--force]

Options

FlagShortDescriptionDefault
--managed-config REFOCI reference of the managed-config artifact to adopt (published with config push). Pass an empty string (--managed-config "") to clear an existing seed and delete the snapshot. Omit to fall back to OCX_MANAGED_CONFIG, then the existing seed.(env var, then seed)
--dry-runReport the intended action without fetching or writing anything.off
--forceOverwrite a [managed] fence that carries user edits (the dirty state).off
-h, --helpPrint help information.

Output — the same managed_config entry ocx self setup reports ({"managed_config":{"status":"adopted","digest":"sha256:…"}}), so fleet tooling parses both commands with one schema. Statuses: adopted, already_adopted, refreshed, refresh_unavailable, cleared, dirty, would_adopt, would_refresh.

Exit codes

CodeMeaning
0Adopted, already adopted, refreshed, cleared, or --dry-run reported — including a re-sync of an already-adopted seed that could not reach the registry (refresh_unavailable; the existing snapshot is kept).
64Nothing to set up — no --managed-config, no OCX_MANAGED_CONFIG, no existing seed.
65The fetched managed-config package is malformed — digest mismatch, no any/any entry, missing config.toml, over 64 KiB, or invalid TOML.
69Registry unreachable while fetching the snapshot.
74Writing the snapshot or the [managed] fence failed.
78The reference is not a valid OCI identifier, or a system-locked tier would be redirected or cleared.
79The managed-config package does not exist in the registry.
80Authentication failed while fetching the snapshot.
82The [managed] fence carries user edits and --force was not passed. Nothing was touched.

Codes 65, 69, 74, 78, 79, and 80 apply to the fetch that establishes the seed — first adoption or self-heal of a wiped or mismatched snapshot. The re-sync of an already-adopted seed is best-effort instead: a failed re-sync reports refresh_unavailable and still exits 0.

CI recipe

ocx config setup --managed-config <ref> persists the seed and reconciles it on every invocation — a job that re-runs config setup each time picks up newly published content without a separate ocx config update step, and a failed re-sync keeps the last-known-good snapshot rather than failing the job. For ephemeral runners where persisting is pointless, the env-var pairing (OCX_MANAGED_CONFIG=… ocx config update) works without writing a seed — see OCX_MANAGED_CONFIG. Use ocx config update directly whenever a stale or unreachable snapshot must fail the job instead of being silently tolerated.

config test

Runs the same checks config push enforces before publishing — parses as an ocx config, carries no [managed] section, stays within 64 KiB — against a candidate file already on disk, then reports the configuration this machine would resolve if the payload were adopted: the effective [registry] default, [registries], [mirrors], and [patches] tiers, plus the machine's own [managed] posture. It touches the network for nothing — validation and the merge preview are both local — and never publishes, adopts, or writes anything.

The preview reproduces the adoption fold order exactly: the machine's discovered tiers (built-in defaults, system, user, $OCX_HOME) first, then the candidate payload, then any explicit --config/OCX_CONFIG overlay — never the machine's current managed snapshot, which the candidate stands in for. An explicit overlay therefore wins over the candidate in the report, exactly as it would once the payload is adopted, and only for the keys it actually sets — where the overlay is silent, the candidate's value still wins over the machine's own.

Past parsing, the merged result is run through the same gates every ocx invocation applies to its own config: an invalid [mirrors."<host>"] entry — an unparseable URL, or a plain-HTTP scheme whose host the candidate does not declare plaintext-eligible (its own [registries.<name>] insecure, or OCX_INSECURE_REGISTRIES on the testing machine) — fails the command (any forwarded OCX_MIRRORS entries are folded in first, same as ordinary resolution), and so does an empty [patches] registry = "". A payload that parses cleanly can still be one no machine could actually start under; catching that is the point of previewing. [patches] in the report reflects the same precedence an ordinary command uses: the merged config's own [patches] when it declares one, otherwise the forwarded OCX_PATCHES tier.

Keys the config schema does not recognize are listed under unknown_keys as warnings, never a rejection — an unknown key is equally a typo (registry.defalt) and a setting a newer ocx understands, and the report cannot tell the two apart. Coverage is best-effort: a [mirrors."<host>"] table is parsed value-first from raw TOML, so a typo inside one mirror entry is not caught here — it is not a schema field, so it can never be "unrecognized" the way registry.defalt is.

Usage

shell
ocx config test <CONFIG>

Arguments

ArgumentDescription
CONFIGPath to the candidate config file to check. Required.

Output — plain: a Field/Value table; rows with no payload for this candidate (no [registries.<name>], no [mirrors], no plain-HTTP host, no [patches], no configured [managed] tier, no unknown keys) are omitted; a Plain HTTP row appears once per plain-HTTP host. JSON: a fixed shape (candidate, valid, registry_default, registries, mirrors, plain_http, patches, managed, unknown_keys) — every field is always present, with null/[] where a tier is unconfigured, so a consumer can key on .valid/.unknown_keys without probing for the field first. plain_http is the resolved union this candidate would actually get: its own [registries.<name>] insecure = true entries plus this machine's OCX_INSECURE_REGISTRIES, minus anything a system-tier explicit insecure = false subtracts, sorted. It's the answer to "did my insecure entry take effect?" — each host appears exactly as written, which is what the transport compares against, so a mis-spelled [registries.<name>] key is visible as itself (private where you meant private.corp:5000) rather than silently granting nothing.

Exit codes

CodeMeaning
0Valid — report printed. Unknown keys are warnings and do not change this.
74Reading the candidate file failed for a reason other than not found or permission denied.
77The candidate file could not be read — permission denied.
78Payload rejected — not valid config TOML, contains a [managed] section, or exceeds 64 KiB (same rejection set as config push); or the merged result fails a resolution gate — an invalid or plain-HTTP [mirrors] entry, or [patches] registry = "".
79The candidate file does not exist.

Learn more

Managed-configuration walkthrough — where config test fits between authoring and publishing.

config push

Publishes a config file as a managed-config package. Validates the payload first — it must parse as an ocx config, must not contain a [managed] section (a published payload can never redirect the tier that fetches it), and must stay within 64 KiB — then stages it under the canonical entry name config.toml (whatever the input file is called), bundles it as a tar+gzip layer, and pushes it with the same machinery as ocx package push.

With --cascade, pushing user-1.4.2 also advances the rolling tags user-1.4, user-1, and user — the same cascade algebra packages use, so fleets track a floating tag while individual hosts can pin any published version.

Usage

shell
ocx config push -i <IDENTIFIER> [--cascade] [--platform PLATFORM] <CONFIG>

Options

FlagShortDescriptionDefault
--identifier ID-iIdentifier to publish under (e.g. corp/ocx-config:user-1.4.2). Required.
--cascade-cUpdate rolling variant tags derived from the version tag.off
--platform-pPlatform entry written into the package index. ocx config update only consumes the platform-independent any entry — keep the default.any
-h, --helpPrint help information.

Output — the same push report as ocx package push (identifier, status, manifest_digest, cascade_tags_written). The reported digest is the operator's trust-on-first-use signal: it is the value a digest-pinned seed and every consumer's config update --check compare against.

Exit codes

CodeMeaning
0Pushed.
69Registry unreachable.
74I/O error reading the payload file (other than not found or permission denied), or staging it for the push.
77The payload file could not be read — permission denied.
78Payload rejected — not valid config TOML, contains a [managed] section, or exceeds 64 KiB. Nothing was pushed.
79The payload file does not exist.
80Authentication failed.

config update

Fetches the configured managed-config package, persists a new snapshot, and reports what changed. Always bypasses the background-refresh throttle — explicit user intent, mirroring ocx self update.

An optional VERSION positional pins the sync to a specific tag, digest, or tag@digest combination — rollback is ocx config update <older-version>. The snapshot identity is the repository, not the tag, so a pinned snapshot still satisfies the required gate of a seed tracking a floating tag.

--pause <duration> holds the background tick for up to 7 days (a temporary hold — set refresh = "manual" in the seed for a permanent opt-out). Without a VERSION it freezes the on-disk state as-is (no fetch); with a VERSION it syncs the pin first and records the pause only after the persist succeeded. --resume clears the pause and syncs. Any explicit update without --pause clears an active pause. A pause affects only the background tick — never the required gate, never an explicit update.

With --check, only reports the tier's current status — effective source, snapshot digest and tag, last-fetch timestamp, refresh policy, pause state, active kill switches, and live drift against the registry when reachable — without fetching or swapping anything. Offline (or any fetch failure) degrades to a local-state-only report. --check never modifies the pause file.

The tier is adopted by ocx self setup --managed-config <ref> or by setting OCX_MANAGED_CONFIG; re-running either setup command also refreshes the snapshot, but ocx config update is the surface for explicit version pins, rollback, and --pause/--resume. See [managed] for the full tier schema and the managed-configuration walkthrough for onboarding, rollout, and CI recipes.

Usage

shell
ocx config update [VERSION] [--pause DURATION] [--resume] [--check]

Options

FlagShortDescriptionDefault
VERSIONVersion to sync: tag, sha256:<hex>, or tag@sha256:<hex> (the latter verifies the tag resolves to the given digest before persisting). Conflicts with --check and --resume.(seed source)
--pause DURATIONPause the background tick for \d+[smhd] (max 7d). Conflicts with --check and --resume.
--resumeClear an active pause and sync.off
--checkReport the tier's status without fetching or swapping.off
-h, --helpPrint help information.

Invocation matrix

InvocationFetches?Pause file
config updateyes, seed sourcecleared
config update 1.4.2yes, pinnedcleared
config update --pause 3dno (state frozen as-is)written
config update --pause 3d 1.4.2yes, pin firstwritten after the persist succeeds
config update --resumeyes, seed sourcecleared
config update --checkprobe onlyuntouched

Behavior without --check

Reports one of:

  • not_configured — no [managed] source is resolved (no seed, no OCX_MANAGED_CONFIG).
  • already_current — the local snapshot's digest already matches the registry.
  • updated — a new snapshot was fetched and persisted.
  • check_unavailable--pause without a VERSION: nothing was fetched or verified; the report is the local state plus the fresh pause window.

Behavior with --check

Probes the registry for the current top-level manifest digest (never the full payload, never a swap) and reports one of three outcomes: checked when the probe succeeds and the digest differs from the local snapshot, already_current when the probe succeeds and the digest matches, or check_unavailable when the probe could not run at all (offline, no managed-config client, source absent in the registry, authentication failure, or a registry error) — the report then degrades to a local-state-only summary (source/digest/tag/fetched-at/pause) instead of falsely claiming the tier is current.

JSON output (--format json)

json
{"status": "updated", "source": "internal.company.com/ocx-config:user-1.4.1", "digest": "sha256:ab12cd...", "policy": "notify", "tag": "user-1.4.1"}
{"status": "checked", "source": "internal.company.com/ocx-config:user", "digest": "sha256:ab12cd...", "fetched_at": "2026-07-04T00:00:00Z", "policy": "notify", "kill_switches": ["OCX_NO_CONFIG_REFRESH"], "drift": true, "tag": "user-1.4.1", "paused_until": "2026-07-08T12:00:00+00:00", "pinned": "user-1.4.1"}
FieldTypeDescription
statusstringnot_configured, already_current (probe ran and matched), updated (full-update path), checked (probe ran and detected drift), or check_unavailable (probe could not run — offline, source absent, auth, or registry error — or a fetch-free --pause).
sourcestringThe effective managed-config source (flag > env > seed), with any VERSION pin applied. Omitted when not_configured.
digeststringThe local snapshot's top-level manifest digest, sha256:<hex>. Omitted when no snapshot exists yet.
fetched_atstringISO-8601 UTC timestamp of the snapshot's last fetch. Status reports only.
policystringThe tier's refresh posture — apply, notify, or manual.
kill_switchesarray of stringsActive kill-switch env-var names (OCX_NO_CONFIG_REFRESH, OCX_NO_CONFIG) affecting this tier. Empty array when none are set.
driftboolean--check only: whether the registry's current digest differs from the local snapshot. Present only when the registry was reachable.
tagstringThe tag the snapshot was fetched under (the floating or pinned version this host tracks). Omitted for pre-v2 snapshots until their next sync.
paused_untilstringISO-8601 UTC end of an in-force pause. Omitted when no pause is active.
pinnedstringThe VERSION pinned alongside the pause (--pause <d> <VERSION>). Omitted when the pause carries no pin.

Exit codes

CodeMeaning
0Report printed — not_configured, already_current, updated, checked, or check_unavailable (an offline or otherwise unreachable --check degrades to a local-state report).
64Conflicting flags (--check with --pause/--resume/VERSION, --resume with --pause/VERSION), a malformed VERSION, or a --pause duration that is malformed or exceeds 7d.
65tag@digest immutability assertion failed (the tag resolved to a different digest — snapshot untouched), or the fetched payload is malformed (no any/any entry, no config.toml, digest mismatch, over the 64 KiB cap, or not valid TOML).
69Registry unreachable (full-update path — --check degrades to a local-state report instead of failing).
74I/O error writing the snapshot file.
78The effective managed-config source or interval is invalid (bad seed or OCX_MANAGED_CONFIG value).
79The resolved managed-config source has no package in the registry (full-update path).
80Authentication failed against the registry (full-update path only).