The cxpm command-line tool
cxpm fetch | configure | build | test | pack | install | uninstall [options] [directory]
cxpm run [task] [-- arguments...] [-C directory]
cxpm project create <name> [--type T] [--tests] [--example]
cxpm search | login | logout | publish | serve (the package index)
cxpm generate <kind> [directory]
cxpm help [command]
directory is the project, . by default. Exit status is 0 on success, 1 when the work failed
(a compile error, a failing test, a missing dependency) and 2 when the command line is wrong.
The lifecycle
| Stage | What it does | What it writes under .cxpm/ |
|---|---|---|
fetch | Clones the dependencies the manifest fetches, resolves the ones the package index publishes, and pins their commits | sources/<name>/, lock.json |
configure | Loads the manifests, selects the toolchain, resolves every dependency; builds the dependencies that come as source and installs them into the project’s sysroot | descriptors/, configuration.json, sysroot/ |
build | Compiles what changed and links | build/<name>/, compile_commands.json |
pack | Lays the result out under the install prefix and packs it | staging/<name>/, packages/ |
install | Gives the package to the package manager | nothing; it writes to the install root |
Each stage first runs the ones before it, and each of those does nothing when its result is still
valid: cxpm install on a fresh clone fetches, configures, builds, packs and installs; cxpm build right after it compiles nothing. --reconfigure (-r) resolves everything again,
--force (-f) also rebuilds what is up to date.
cxpm test builds the project, then builds each project the manifest lists in tests and runs
its executable from that project’s directory. It fails if any of them fails.
cxpm uninstall removes what cxpm install installed, through the same package manager.
Options common to the stages that build:
| Option | Meaning |
|---|---|
-t, --toolchain NAME | Build with this toolchain instead of the one the target selects |
--sysroot DIR | Build against this sysroot instead of the toolchain’s |
-j, --jobs N | How many sources compile at once (default: one per core) |
-p, --prefix DIR | Install prefix for pack, install, uninstall (default: the target’s, then /usr/local) |
--root DIR | install/uninstall under this root instead of the sysroot, as DESTDIR does |
--package-manager NAME | archive, pacman, rpm or deb, instead of the one the toolchain names |
--no-hooks | fetch, configure, build, test, pack, install, publish: do not run the pre_ and post_ tasks |
Tasks and hooks
A project declares tasks: a name and the shell text it runs. The target is what the package is; tasks are what you do around it, from generating a header to setting up a machine.
auto project = cx::pm::project()
.target_set(greeter)
.task_add("lint", "clang-tidy src/*.cpp -- -Iinclude")
.task_add("pre_build", "./tools/generate-version.sh > include/version.hpp")
.task_file_add("package_post_install", "packaging/postinst.in")
.create();
In package.json they are an object beside target; a task read from a file is
{"file": "path"}:
"tasks": {
"lint": "clang-tidy src/*.cpp -- -Iinclude",
"package_post_install": {"file": "packaging/postinst.in"}
}
A name is 1 to 64 of a-z 0-9 + . _ -, starting with a letter or a digit. task_file_add reads
the file, relative to the project, each time the manifest is loaded; a file outside the project
or missing fails the load. Three kinds of task share the one list:
| Kind | Names | Runs | On |
|---|---|---|---|
| Free task | any other name | cxpm run <name> | your machine |
| Developer hook | pre_<stage>, post_<stage> for fetch, configure, build, test, pack, install, publish | around that stage | your machine |
| Packaged hook | package_post_install, package_pre_remove, package_post_remove | embedded by pack, run by dpkg, rpm or pacman | the machine the package is installed on |
cxpm run <task> -- <arguments> runs a task with /bin/sh -e -c: it stops at the first command
that fails, $1, $2… are the arguments after --, the working directory is the project
(-C DIR picks another), its output goes straight to the terminal, and cxpm exits with the
task’s status. The task gets the environment cxpm test gives a tests project, the sysroot’s
usr/bin and usr/lib first on PATH and LD_LIBRARY_PATH once the sysroot exists, and:
| Variable | Value |
|---|---|
CXPM_PROJECT_DIR | The project directory |
CXPM_SYSROOT | The workspace sysroot, .cxpm/sysroot at its root, made or not yet |
CXPM_TARGET, CXPM_VERSION | The target’s name and version; empty for a workspace root |
CXPM_STAGE | The stage a developer hook wraps; run for any other task |
cxpm run alone lists the tasks: free tasks, then developer hooks, then packaged hooks, each in
the order declared. An unknown name fails and lists them.
Developer hooks. Each stage runs pre_<stage> before it starts and, when it succeeded,
post_<stage> after it. A stage that another runs fires its hooks inside the outer ones:
cxpm install runs pre_install, pre_pack, pre_build, the build, post_build,
post_pack, then installs and runs post_install; cxpm configure fires pre_fetch and
post_fetch around resolving the dependencies again. A failing pre_ hook stops the stage
before it does anything, and no post_ hook runs; any failing hook fails the command with
status 1 and a message naming the hook and its exit status. A stage runs the hooks of the
project it is given and of the members that project lists, pre_ in that order and post_ in
reverse; never those of a dependency it fetches, nor of a tests project. --no-hooks, or
CXPM_NO_HOOKS=1, runs the stages without them.
Packaged hooks become the deb postinst, prerm and postrm, the rpm %post, %preun
and %postun, and the pacman .INSTALL functions, with every @NAME@ replaced for the prefix
the package installs under: @CXPM_INSTALL_PREFIX@, @CXPM_INSTALL_BINDIR@,
@CXPM_INSTALL_LIBDIR@, @CXPM_INSTALL_DATADIR@, @CXPM_INSTALL_SYSCONFDIR@,
@CXPM_INSTALL_LOCALSTATEDIR@ and the other CXPM_INSTALL_ directories, @CXPM_NAME@,
@CXPM_VERSION@, and the target’s own variables. A name that is none of these, such as a
script’s $@, is left as it is. A plain
archive runs none, and pack warns when there are some. cxpm run package_post_install
tries one here first, its @NAME@s replaced for --prefix, else the target’s prefix, else
/usr/local.
Free tasks and developer hooks are code you wrote, run with your rights: they are not confined, and no package carries them. Only the packaged hooks leave your machine, inside the package.
The manifest: package.cpp or package.json
A manifest describes one project. A project has at most one target.
#include <cx/pm/project.hpp>
#include <cx/pm/target.hpp>
using namespace cx::pm;
auto package = cx::pm::target()
.name_set("greeter")
.version_set("0.1.0")
.description_set("Says hello")
.type_set("shared-library")
.sources_append({"src/*.cpp"})
.include_directories_append({"include"})
.options_append({compiler_options::standard_cxx23,
compiler_options::warnings_all})
.link_libraries_append({"m"})
.dependencies_append({"zlib >= 1.2"})
.create();
auto project = cx::pm::project()
.target_set(package)
.members_append({"example"})
.tests_append({"tests"})
.create();
The same project in package.json, which is read without compiling or running anything:
{
"schema": 3,
"target": {
"name": "greeter",
"version": "0.1.0",
"type": "shared-library",
"sources": ["src/*.cpp"],
"include_directories": ["include"],
"options": ["standard-c++23", "warnings-all"],
"link_libraries": ["m"],
"dependencies": ["zlib >= 1.2"]
},
"members": ["example"],
"tests": ["tests"]
}
package.cpp wins when a directory has both. cxpm generate package-cpp|package-json writes a
starting point; cxpm project create writes a whole project.
Target types
type | Built | Installed |
|---|---|---|
executable | A program | bin/ |
shared-library | lib<name>.so | lib/, headers in include/<name>/ |
static-library | lib<name>.a | lib/, headers in include/<name>/ |
header-library | Nothing | Headers in include/<name>/ |
data | Nothing | The files listed with data_append(source, destination), under the prefix |
Every type also installs lib/pkgconfig/<name>.pc, which is how a dependent project finds it.
The headers of a library are those under its include_directories.
A data target ships files as they are. cxpm’s own toolchain modules are packaged this way:
auto package = cx::pm::target()
.name_set("cxpm-toolchain-g++")
.type_set("data")
.data_append("src/toolchain.cpp", "share/cxpm/toolchains/g++")
.create();
source is a file or a pattern relative to the project; destination is a directory relative to
the install prefix.
Sources
sources entries are paths relative to the project or patterns: src/*.cpp matches in one
directory, src/**/*.cpp in every directory below. A pattern that matches nothing is an error.
Options
An option is a neutral name that each toolchain translates, or a raw flag.
compiler_options:: names: standard_c11, standard_c17, standard_cxx17, standard_cxx20,
standard_cxx23, warnings_all, warnings_extra, warnings_as_errors, optimize_none,
optimize_size, optimize_speed, optimize_maximum, debug_information, position_independent_code,
position_independent_executable, stack_protector_strong, stack_protector_all, threads,
link_time_optimization, address_sanitizer, undefined_behavior_sanitizer, coverage. In JSON
they are spelled as in the example above ("standard-c++23").
Anything that starts with - is passed to the compiler and the linker as written, and ties the
manifest to toolchains that understand it. A name that is neither stops the build with the list
of names the toolchain knows.
Dependencies
.dependencies_append({"zlib", "fmt >= 10"})
A name refers to, whichever exists:
- a project of the workspace (a member of the same root manifest): built first and installed
into
.cxpm/sysroot; - a fetched dependency: a git repository with a manifest of its own, cloned by
cxpm fetch, then built and installed into.cxpm/sysrootlike a member. It is fetched either because the manifest names itsrepository(below), or because the package index publishes the name; - an installed package, found by pkg-config in the sysroot.
#include <cx/pm/dependency.hpp>
.dependencies_append(cx::pm::dependency()
.name_set("greeter")
.constraint_set(">= 0.1")
.repository_set("https://example.org/greeter.git")
.revision_set("v0.1.0"))
revision is a branch, a tag or a commit. .cxpm/lock.json records the commit that was fetched,
and later fetches check out that commit until the manifest asks for another revision.
A dependency that cannot be resolved, or whose version does not satisfy the constraint, fails
configure with the name and what was found. link_libraries is only for libraries of the
system that have no pkg-config file, such as m or dl.
Workspaces
A manifest lists other projects by directory in members (built with it) and tests (built and
run by cxpm test). A root manifest may have no target at all and only tie projects together;
cxpm’s own is one:
auto project = cx::pm::project()
.members_append({"third-party", "libraries/cxpm-interface",
"applications/cxpm"})
.tests_append({"tests/unit", "tests/cli"})
.create();
Projects are built in dependency order; a cycle is an error that names the projects in it. The
workspace has one .cxpm/sysroot, at the root, and each project builds under its own
.cxpm/build.
The package index
The index maps a package name and version to a commit of a git repository. It holds no code:
cxpm fetch clones the repository.
Using packages
.dependencies_append({"greet >= 0.1"})
cxpm fetch asks the index for each name that is not a project of the workspace. A name the
index does not know is left to the sysroot. For one it knows, fetch picks the newest version that
every constraint in the workspace accepts — the constraints of fetched packages included — clones
it into .cxpm/sources/<name>, and records the version and its commit in .cxpm/lock.json:
{
"name": "greet",
"repository": "https://example.org/greet.git",
"revision": "ba5b12562248f856f2d8bda5cea758df42059e91",
"commit": "ba5b12562248f856f2d8bda5cea758df42059e91",
"version": "0.1.0",
"index": "https://soft-forge.dev/cxpm/index"
}
- Only
cxpm fetchcontacts the index. Every other command works from the lock file, so a build needs no network once the sources are there. - A locked version is kept until a constraint stops accepting it. To move to a newer one, tighten
the constraint (or delete the entry from the lock) and run
cxpm fetch. - The commit is the checksum. If the index later reports another commit for a locked version, fetch fails and names both.
- When two projects need versions that exclude each other, fetch fails listing who needs what.
- If the index cannot be reached, fetch warns, keeps what is locked, and leaves other names to the sysroot.
cxpm dependency edits the manifest instead of you:
cxpm dependency add greet # the newest version the index publishes, or a later one
cxpm dependency add "greet@>=0.1" # a constraint of your own
cxpm dependency add https://example.org/json.git#v3 --name json
cxpm dependency list
cxpm dependency remove greet
cxpm dependency sync # fetch what the manifest names
add asks the index for the name, refuses a constraint no published version satisfies, writes
the dependency into package.cpp or package.json, and fetches it (--no-sync leaves that to
you). It checks that the manifest still reads the same afterwards and puts the file back when it
does not; a package.cpp that builds its dependencies in some other way than
.dependencies_append(...) has to be edited by hand.
The index is --index URL, else CXPM_INDEX, else https://soft-forge.dev/cxpm/index.
off means no index. An https:// index is reached through curl, which must be installed; an
http:// one needs nothing.
Publishing
cxpm login # once per index: shows a code to confirm, keeps a token
cxpm publish # in the project, at the top of its git repository
cxpm search greet
The public index at soft-forge.dev/cxpm/index
signs you in with your
Soft Forge account and has no password of its own. cxpm login shows an address and a code; you open the address,
confirm the code there, and cxpm gets a token of its own and keeps only that. The first time, sign in on the index’s
page: there you choose a plan and the name you publish under. A publication over the plan is refused with the
address where the plan is changed. For CI, make a token on the page and set CXPM_TOKEN. An index you run yourself
(Your own index
) has no sign-in: its operator gives you a token, for
cxpm login --token <token>.
In a workspace, cxpm publish --package NAME publishes one of its projects; dependencies that are
other projects of the same workspace are left out of what is published. cxpm whoami names the
account the stored token belongs to.
publish sends the target’s name, version, description and dependencies, the address of the
origin remote, the project’s README when there is one, and the commit of HEAD (or
--revision REV). The commit must already be on the
remote. --repository URL publishes another address than origin.
The first account to publish a name owns it, and a published version never changes: to fix something, publish a new version.
cxpm deprecate greet@0.1.0 "use 0.2.0" # a warning for those who fetch it; --undo takes it back
cxpm yank greet@0.1.0 # nothing new resolves to it; --undo puts it back
cxpm unpublish greet 0.1.0 # deletes the version for good
cxpm unpublish greet # deletes the package, with every version
Three things take a version back, from the mildest. deprecate leaves it resolving, with a
message saying what to use instead. yank takes it out of what resolves and of the tags: the
projects whose lock pins it keep it, with a warning, and its number is never published again.
unpublish deletes it: the index no longer has it, so a project locked to it fails its next
cxpm fetch (the message names the package, the version, the index and the way out, cxpm dependency update greet or dropping the dependency), and cxpm outdated lists it as gone. Yank
when somebody may depend on the version; unpublish when it should never have been published.
unpublish says what goes and what breaks, then asks for the name of the package to be typed,
and sends nothing unless it matches. --yes skips the question, for scripts; the line is read
from standard input either way, so cxpm unpublish greet <<<greet works too. A maintainer
deletes a version, the owner alone the package. The index refuses the last version (delete the
package instead), and a version or package while another owner’s package depends on it (yank it
instead); each refusal is printed as the index says it, with the way out. A deleted number is
never published again, and a deleted name stays its owner’s: nobody else publishes it, and the
owner only above every number it had. When the index’s operator removes a package,
the name is blocked for everyone.
login stores the token in ~/.config/cxpm/credentials.json, readable by you alone. CXPM_TOKEN
is used instead when set, which is what a CI job wants. logout forgets the stored token, and
asks the index to revoke it where the index can (POST /-/logout).
Your own index
cxpm serve runs a package index of your own, for a team or a closed network: see
Your own index
.
The session: cxpm repl
cxpm with no arguments, at a terminal, opens a session; cxpm repl (or cxpm shell) does the
same. Every line is a cxpm command without the program’s name:
cxpm demo> search greet
cxpm demo> dependency add greet
cxpm demo> build
cxpm demo> publish
The session adds cd, pwd, status (the directory, its manifest, the index and the account
logged in to it), clear, help [command] and exit. Tab completes commands, options and
paths. A command runs in a process of its own, so Ctrl-C stops the command and not the session.
The prompt names the directory, in green when it holds a manifest, and shows the status of the
last command when it failed.
History is kept in $XDG_STATE_HOME/cxpm/shell_history (CXPM_SHELL_HISTORY names another file;
empty for none). Lines that carry --token or --password, and lines that start with a space,
are not written to it.
When standard input is not a terminal, cxpm repl reads one command per line and exits with the
status of the last one, so cxpm repl < script runs a script.
Toolchains
A toolchain says how to drive a compiler: executables, flag spellings, file names, the sysroot it
builds against and the package manager of that sysroot. It is a toolchain.cpp or a
toolchain.json:
#include <cx/pm/toolchain.hpp>
cx::pm::toolchain_descriptor toolchain =
cx::pm::toolchain()
.name_set("al2023")
.language_set("c++")
.compiler_executable_set("/usr/bin/g++")
.linker_executable_set("/usr/bin/g++")
.archiver_executable_set("/usr/bin/ar")
.sysroot_set("/opt/sysroots/amazonlinux-2023")
.package_manager_set("rpm")
.option_mappings_set(compiler_options::gnu);
cxpm ships g++, gcc, clang++, clang and nvcc, installed as source in
<prefix>/share/cxpm/toolchains/<name>/toolchain.cpp. It also looks in
~/.local/share/cxpm/toolchains and in the directories of cxpm_BUILD_EXTRA_MODULES_PATH. A
target picks one with toolchain_set("name"), --toolchain overrides it, and otherwise the
first toolchain for the target’s language is used.
Sysroots
Dependencies that cxpm builds go into the project’s own .cxpm/sysroot. Everything else comes
from the toolchain’s sysroot: / by default, which is your system.
With another sysroot (sysroot_set or --sysroot DIR), the compiler and the linker get
--sysroot, pkg-config only sees that sysroot’s packages, and nothing from the host leaks into
the build. That is how a project is built for another distribution from one machine: unpack that
distribution’s root filesystem with its development packages, and point a toolchain at it.
Packages
cxpm pack writes one package per project into .cxpm/packages/:
| Package manager | File | Installed with |
|---|---|---|
archive (default) | <name>-<version>.cxpm.tar | cxpm itself; a record in <prefix>/lib/cxpm/packages/ makes uninstall exact |
pacman | <name>-<version>-1-<arch>.pkg.tar.zst | pacman -U |
rpm | <name>-<version>-1.<arch>.rpm | rpm -i |
deb | <name>_<version>_<arch>.deb | dpkg -i |
Native packages are written by cxpm itself; makepkg, rpmbuild and dpkg-deb are not needed
to pack, only the package manager to install. The dependencies of the manifest become the
dependencies of the package. Installing into a system root usually needs root privileges.
Confinement
A package.cpp or toolchain.cpp is code, and so is every build step. When
bubblewrap (bwrap, from your distribution’s packages) is installed, cxpm runs them in a
namespace of their own:
- compiling a manifest, loading it, and every compile, link and archive command see the project,
its workspace, the sysroot and the system’s own directories (
/usr,/bin,/lib*,/etc,/opt), read-only except for the directories they write to; - they have no network, and do not see your home directory or anything else on the machine.
A compiled manifest is also never loaded into cxpm itself: the separate
cxpm-descriptor-sandbox process loads it, under CPU, memory and time limits, and hands the
description back as JSON.
CXPM_CONFINEMENT | Behaviour |
|---|---|
auto (default) | Confine when bwrap works; warn once and run unconfined when it does not |
required | Fail when confinement is not available |
off | Never confine |
CXPM_CONFINEMENT_READABLE is a :-separated list of extra directories to show read-only, for
a compiler installed outside the system directories.
Not confined: git (and curl, for an https:// index) during fetch, which need the network; the test executables cxpm test
runs; tasks and hooks
; and the package manager during install.
Machine-readable output
Everything cxpm says about its progress goes to standard error as the journal: Configuring x,
Compiling src/a.cpp, warning: …, error: …; standard output is only a command’s answer.
An editor or a CI job that wants to read it asks for JSON:
cxpm --log-format json build # before the command
CXPM_LOG_FORMAT=json cxpm build # or in the environment
Each journal entry is then one JSON object on one line (NDJSON), schema 1, described by
schemas/journal-1.json
:
{"schema":1,"time":"2026-11-08T14:03:12.418Z","level":"info","stage":"build","project":"greeter","message":"Compiling src/greeter.cpp","source":"src/greeter.cpp","index":1,"count":2}
{"schema":1,"time":"2026-11-08T14:03:13.900Z","level":"error","stage":"build","project":"greeter","message":"Compiling src/broken.cpp failed","source":"src/broken.cpp","diagnostics":[{"file":"/w/greeter/src/broken.cpp","line":2,"column":24,"severity":"error","message":"expected ';' before '}' token","tool":"compiler"}],"raw":"/w/greeter/src/broken.cpp:2:24: error: expected ';' before '}' token\n 2 | …"}
{"schema":1,"time":"2026-11-08T14:03:13.901Z","level":"error","stage":"build","project":"greeter","message":"Failed to build greeter"}
| Field | On | Meaning |
|---|---|---|
time, level, message | every entry | UTC with milliseconds; debug, info, warn or error; the text the plain format prints |
stage | inside a stage | fetch, configure, build, test, pack, install, publish, run |
project | once a project is being worked on | the target or node name |
source, index, count | Compiling <source> | the file, and its position in the batch of sources being compiled |
artifact | Linking <artifact> | the file name |
diagnostics, raw | a failed compile, link or manifest | each file:line:column: severity: message the compiler printed, each undefined reference and missing library the linker reported (file empty when the tool named none), and the untouched text |
The plain format stays the default and is unchanged; --log-format plain says so explicitly.
CXPM_VERBOSE=1 adds the debug entries, in both formats.
Environment
| Variable | Effect |
|---|---|
CXPM_HOST_CXX, then CXX | The compiler that compiles package.cpp/toolchain.cpp (default: c++) |
cxpm_BUILD_EXTRA_MODULES_PATH | Extra toolchain directories, :-separated, searched before the installed ones |
CXPM_HEADERS_PATH | Where the cx/pm/ manifest headers are, when not under the install prefix |
CXPM_SANDBOX_HELPER | Path to cxpm-descriptor-sandbox, when it is not beside cxpm |
CXPM_CONFINEMENT, CXPM_CONFINEMENT_READABLE | See Confinement |
CXPM_INDEX | The package index fetch, search, login and publish use; off for none |
CXPM_TOKEN | The token publish presents, instead of the one login stored |
PKG_CONFIG_PATH | Extra pkg-config directories, honoured when building against the host sysroot |
SOURCE_DATE_EPOCH | The timestamp written into packages, for reproducible ones |
CXPM_NO_HOOKS | 1 runs every stage without its pre_ and post_ tasks, as --no-hooks does |
What belongs in version control
Commit the manifests and, if you want pinned dependencies, .cxpm/lock.json. Ignore the rest of
.cxpm/.