CMake Builds skill for Codex
Target-based CMake that configures the same everywhere. Codex audits CMakeLists for directory-wide legacy commands with a bundled script, then moves requirements onto targets with the right PUBLIC, PRIVATE or INTERFACE scope. Builds run from committed presets, dependencies come from one mechanism with pinned versions, and libraries ship install and export rules that are proven by a throwaway consumer calling find_package.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cmake-builds Installs to ~/.agents/skills/cmake-builds/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cmake-builds The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cmake-builds Installs to .agents/skills/cmake-builds/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cmake-builds.zipunzip -o cmake-builds.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cmake-builds PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cmake-builds, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and repairs modern target-based CMake - target_link_libraries with PUBLIC/PRIVATE/INTERFACE, CMakePresets.json, find_package and FetchContent, vcpkg or Conan toolchains, install and export rules for libraries, and CMake 4 minimum-version breakage - and audits legacy CMakeLists for global flags. Use when a CMake build fails to configure, a dependency will not link, CMakeLists.txt uses include_directories or CMAKE_CXX_FLAGS, or a library needs to be consumable with find_package.
Use it when
- A CMake configure or link step fails
- CMakeLists uses include_directories, add_definitions or CMAKE_CXX_FLAGS
- Adding a dependency via find_package, FetchContent, vcpkg or Conan
- Setting up CMakePresets.json for local and CI builds
- Packaging a C or C++ library for downstream consumers
Use something else when
- The issue is C++ code style or ownership, not the build: use cpp-modern
- The project builds with Make or Task runners only: use makefiles-taskfiles
- You are configuring a microcontroller toolchain via PlatformIO: use platformio
How to check the result
A fresh configure with the CI preset is warning-free, ctest passes, the legacy audit script reports nothing new, and a scratch consumer can find_package the installed library.
The mistake it prevents
Marking a dependency PRIVATE while its headers are included from your public headers, so your own build passes and every consumer fails.
A modern CMake project describes targets and their usage requirements; nothing is set
globally that one target needs. Every developer and CI job configures with the same
CMakePresets.json, dependencies come from one mechanism, out-of-source builds are the
only kind, and a library installs a package config so consumers just call
find_package.
Before changing anything
- Read
AGENTS.md, the top-levelCMakeLists.txt,CMakePresets.json/CMakeUserPresets.json,vcpkg.jsonorconanfile.*, and the CI build script. cmake --versionlocally and in CI. Thecmake_minimum_requiredline plus the installed version decide available commands.- Audit the existing files for legacy patterns:
python3 scripts/cmake_legacy_audit.py .(stdlib only; lists file:line findings and exits 1 if any exist). Fix the ones in files you are touching; do not rewrite the whole build in a feature change. - Configure into a fresh build directory to reproduce the failure. Never debug against
a stale
CMakeCache.txt; delete only the build directory you created.
Project skeleton
cmake_minimum_required(VERSION 3.25...3.31)
project(acme VERSION 1.4.0 LANGUAGES CXX)
option(ACME_BUILD_TESTS "Build tests" ${PROJECT_IS_TOP_LEVEL})
add_library(acme_core
src/parser.cpp
src/store.cpp)
add_library(acme::core ALIAS acme_core)
target_compile_features(acme_core PUBLIC cxx_std_20)
target_include_directories(acme_core
PUBLIC $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE src)
find_package(fmt CONFIG REQUIRED)
target_link_libraries(acme_core PUBLIC fmt::fmt PRIVATE Threads::Threads)
add_executable(acme_cli app/main.cpp)
target_link_libraries(acme_cli PRIVATE acme::core)
if(ACME_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
VERSION min...maxsets policies up tomaxwhile accepting older CMake down tomin.- Use
PROJECT_IS_TOP_LEVEL(3.21+) so the project does not build tests or install rules when consumed viaadd_subdirectory/FetchContent. - Always link through namespaced
ALIAS/ imported targets (fmt::fmt): a typo is a configure error instead of a silent-lfmttat link time. - List sources explicitly.
file(GLOB)does not notice added files without a re-configure; if the repo already uses it, addCONFIGURE_DEPENDSat minimum.
PUBLIC, PRIVATE, INTERFACE
| Keyword | The requirement is used by | Example |
|---|---|---|
PRIVATE | this target only | a .cpp includes <sqlite3.h> |
INTERFACE | consumers only | header-only library include dir |
PUBLIC | this target and consumers | a public header includes <fmt/format.h> |
The rule: if a dependency appears in your public headers, it is PUBLIC; otherwise
PRIVATE. Over-using PUBLIC leaks include paths and slows builds; under-using it gives
consumers “header not found”.
Legacy to modern
| Legacy | Modern |
|---|---|
include_directories(x) | target_include_directories(tgt PRIVATE x) |
link_directories + target_link_libraries(tgt foo) | find_package(Foo) + target_link_libraries(tgt PRIVATE Foo::Foo) |
add_definitions(-DFOO) | target_compile_definitions(tgt PRIVATE FOO) |
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") | target_compile_options(tgt PRIVATE -Wall) or a warnings INTERFACE target |
set(CMAKE_CXX_STANDARD 20) at top (acceptable) | target_compile_features(tgt PUBLIC cxx_std_20) for libraries |
${FOO_INCLUDE_DIRS} / ${FOO_LIBRARIES} variables | imported targets Foo::Foo |
ExternalProject_Add for a source dep | FetchContent_MakeAvailable or a package manager |
Shared warning flags without globals:
add_library(acme_warnings INTERFACE)
target_compile_options(acme_warnings INTERFACE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall -Wextra -Wpedantic -Wconversion>
$<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->)
target_link_libraries(acme_core PRIVATE acme_warnings)
Use CMAKE_COMPILE_WARNING_AS_ERROR (3.24+) from a CI preset rather than hard-coding
-Werror, so consumers with newer compilers are not broken.
Presets
{
"version": 6,
"configurePresets": [
{
"name": "dev",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "ci",
"inherits": "dev",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
"CMAKE_COMPILE_WARNING_AS_ERROR": "ON"
}
},
{
"name": "asan",
"inherits": "dev",
"cacheVariables": {
"CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
}
}
],
"buildPresets": [ { "name": "dev", "configurePreset": "dev" }, { "name": "ci", "configurePreset": "ci" } ],
"testPresets": [
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
{ "name": "ci", "configurePreset": "ci", "output": { "outputOnFailure": true } }
],
"workflowPresets": [
{ "name": "ci", "steps": [
{ "type": "configure", "name": "ci" }, { "type": "build", "name": "ci" }, { "type": "test", "name": "ci" } ] }
]
}
cmake --preset dev && cmake --build --preset dev && ctest --preset dev
cmake --workflow --preset ci # configure, build, test in one command (3.25+)
CMakePresets.json is committed; personal tweaks go in the git-ignored
CMakeUserPresets.json. Setting CMAKE_CXX_FLAGS from a preset (as in asan) is fine:
it is a per-build choice, not something baked into targets.
Dependencies: pick one mechanism
| Situation | Choose |
|---|---|
| Already a vcpkg or Conan project | keep it; add the dep to vcpkg.json / conanfile |
| Few small deps, no package manager | FetchContent with FIND_PACKAGE_ARGS so a system package wins when present |
| System libraries (OpenSSL, zlib, Threads) | find_package with the imported target |
| Large deps (Boost, Qt, LLVM) | package manager or system install, not FetchContent |
include(FetchContent)
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1 # pin a tag or a commit hash, never a branch
FIND_PACKAGE_ARGS 3) # 3.24+: try find_package(Catch2 3) first
FetchContent_MakeAvailable(Catch2)
vcpkg manifest mode: set "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
in the configure preset. Conan 2: conan install . --build=missing -s build_type=Debug
generates conan_toolchain.cmake and presets you include. Fetching dependencies needs
network access; ask before the first configure in a sandbox without it.
Installing and exporting a library
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
install(TARGETS acme_core EXPORT acmeTargets)
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT acmeTargets NAMESPACE acme:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/acme)
configure_package_config_file(cmake/acmeConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/acmeConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/acme)
write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/acmeConfigVersion.cmake
COMPATIBILITY SameMajorVersion)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/acmeConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/acmeConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/acme)
acmeConfig.cmake.in must call find_dependency(fmt) for every PUBLIC dependency,
then include("${CMAKE_CURRENT_LIST_DIR}/acmeTargets.cmake"). Set
EXPORT_NAME core on the target so consumers see acme::core.
Common failures
| Symptom | Cause and fix |
|---|---|
Compatibility with CMake < 3.5 has been removed (CMake 4.x) | a dependency’s old cmake_minimum_required; upgrade the dep, or configure with -DCMAKE_POLICY_VERSION_MINIMUM=3.5 as a temporary workaround |
Could not find a package configuration file provided by "Foo" | set CMAKE_PREFIX_PATH to the install prefix or use the package manager toolchain; do not hard-code paths in CMakeLists |
| Header found when building, missing for consumers | dependency is PRIVATE but used in a public header; make it PUBLIC |
| Undefined references only in static builds | transitive deps not propagated; link them with the right keyword, check link order is not hand-managed |
| Changes to options ignored | stale cache; reconfigure in a fresh preset binary dir |
| Works with Make, fails with Ninja | missing dependency edge, often a generated header without add_custom_command OUTPUT listed in sources |
Verification
- Fresh configure from a clean preset binary dir succeeds with no warnings
(
cmake --preset ci --log-level=WARNING). - Build and
ctest --preset cipass. -
python3 scripts/cmake_legacy_audit.py <changed dirs>reports nothing new. - For libraries:
cmake --install build/ci --prefix /tmp/acme-install, then a throwaway consumer project withfind_package(acme CONFIG REQUIRED)and-DCMAKE_PREFIX_PATH=/tmp/acme-installconfigures and links. -
compile_commands.jsonis generated for tooling (clangd, clang-tidy).
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CMake Builds"
short_description: "Target-based CMake that configures the same everywhere"
default_prompt: "$cmake-builds Modernise this CMakeLists.txt and get rid of the global include_directories" scripts/cmake_legacy_audit.py
#!/usr/bin/env python3
"""Audit CMakeLists.txt and *.cmake files for legacy, directory-scoped patterns.
Usage: python3 cmake_legacy_audit.py [PATH ...] (default: current directory)
Prints file:line: CODE message for each finding. Exits 1 when findings exist,
0 when clean, 2 on usage errors. Build trees, _deps and .git are skipped.
"""
import os
import re
import sys
SKIP_DIRS = {".git", "_deps", "node_modules", "vcpkg_installed", ".cache"}
SKIP_PREFIXES = ("build", "cmake-build-", "out")
CMD = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
KEYWORDS = {"PUBLIC", "PRIVATE", "INTERFACE", "LINK_PUBLIC", "LINK_PRIVATE", "LINK_INTERFACE_LIBRARIES"}
SIMPLE = {
"include_directories": ("C001", "include_directories is directory-wide; use target_include_directories"),
"link_directories": ("C002", "link_directories hides where libraries come from; link imported targets"),
"add_definitions": ("C003", "add_definitions is directory-wide; use target_compile_definitions"),
"add_compile_options": ("C004", "add_compile_options is directory-wide; prefer target_compile_options or an INTERFACE warnings target"),
"link_libraries": ("C005", "link_libraries is directory-wide; use target_link_libraries"),
}
def strip_comments(text):
out = []
for line in text.splitlines():
in_str = False
cut = len(line)
for i, ch in enumerate(line):
if ch == '"' and (i == 0 or line[i - 1] != "\\"):
in_str = not in_str
elif ch == "#" and not in_str:
cut = i
break
out.append(line[:cut])
return "\n".join(out)
def commands(text):
"""Yield (name_lower, args_string, line_number) for each command invocation."""
pos = 0
while True:
m = CMD.search(text, pos)
if not m:
return
depth, i = 1, m.end()
while i < len(text) and depth:
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
i += 1
yield m.group(1).lower(), text[m.end():i - 1], text.count("\n", 0, m.start()) + 1
pos = i
def audit_file(path):
with open(path, encoding="utf-8", errors="replace") as fh:
text = strip_comments(fh.read())
found = []
for name, args, line in commands(text):
tokens = args.split()
if name in SIMPLE:
found.append((line, *SIMPLE[name]))
elif name == "set" and tokens and re.fullmatch(r"CMAKE_(C|CXX)_FLAGS(_[A-Z]+)?", tokens[0]):
found.append((line, "C010", f"setting {tokens[0]} in CMakeLists affects every target; use target_compile_options (presets/toolchains may set it)"))
elif name == "file" and tokens and tokens[0] in ("GLOB", "GLOB_RECURSE") and "CONFIGURE_DEPENDS" not in tokens:
found.append((line, "C011", "file(GLOB) without CONFIGURE_DEPENDS misses new files until reconfigure; list sources explicitly"))
elif name == "target_link_libraries" and len(tokens) > 1 and not (KEYWORDS & set(tokens[1:])):
found.append((line, "C012", f"target_link_libraries({tokens[0]} ...) without PUBLIC/PRIVATE/INTERFACE"))
elif name == "cmake_minimum_required":
m = re.search(r"VERSION\s+(\d+)\.(\d+)", args)
if m:
ver = (int(m.group(1)), int(m.group(2)))
if ver < (3, 5):
found.append((line, "C020", "minimum below 3.5 fails to configure on CMake 4.x"))
elif ver < (3, 16):
found.append((line, "C021", f"minimum {ver[0]}.{ver[1]} enables old policies; raise it (e.g. 3.25...3.31)"))
if re.search(r"\$\{\w+_(LIBRARIES|INCLUDE_DIRS?)\}", args):
found.append((line, "C030", "uses Find-module result variables; link the imported target (Foo::Foo) instead"))
if "${CMAKE_SOURCE_DIR}" in args or "${CMAKE_BINARY_DIR}" in args:
found.append((line, "C031", "CMAKE_SOURCE_DIR/BINARY_DIR break when this project is a subproject; use PROJECT_SOURCE_DIR or CMAKE_CURRENT_SOURCE_DIR"))
return found
def cmake_files(root):
if os.path.isfile(root):
yield root
return
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(SKIP_PREFIXES)]
for f in filenames:
if f == "CMakeLists.txt" or f.endswith(".cmake"):
yield os.path.join(dirpath, f)
def main(argv):
roots = argv[1:] or ["."]
total = files = 0
for root in roots:
if not os.path.exists(root):
print(f"no such path: {root}", file=sys.stderr)
return 2
for path in cmake_files(root):
files += 1
for line, code, msg in audit_file(path):
print(f"{path}:{line}: {code} {msg}")
total += 1
print(f"{total} finding(s) in {files} CMake file(s)", file=sys.stderr)
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.