A small CLI that vendors C/C++ dependencies into your source tree from a declarative Lua config, recording exact pins plus file hashes in a lockfile.
After cdeps runs, every dependency file lives in your repo. Commit them, and
anyone who clones can build with no extra download step and no dependency on
cdeps. It's go mod vendor ergonomics for the world of single-header C
libraries and amalgamations.
-- deps.lua
return {
config = { dir = "deps" }, -- each dep gets its own deps/<name>/ folder (subdir defaults to the dep's name)
{ "floooh/sokol", files = { "sokol_app.h", "sokol_gfx.h", "sokol_glue.h" } }, -- -> deps/sokol/*.h
{ "nothings/stb", files = { "stb_image.h" } }, -- -> deps/stb/stb_image.h
{ "recp/cglm", tag = "v0.9.4", files = { "include/**" } }, -- -> deps/cglm/include/cglm/*.h
}$ cdeps # vendors the files into deps/ and writes deps.lockSingle-header libraries and amalgamations (sokol, stb, miniaudio, sqlite, …) are
usually pulled in with ad-hoc wget/curl rules in a Makefile, or by manually
copying files and forgetting where they came from. There's no record of which
version you have and no way to tell if a file was tampered with.
cdeps replaces that with a declarative manifest and a committed lockfile:
- Declare intent once in
deps.lua(GitHubuser/repoby default, Lazy-style). - Pin exactly — commit, tag, version, or default-branch HEAD — resolved to a
concrete commit in
deps.lock. - Verify integrity — every vendored file is hashed;
cdeps verifycatches drift or tampering, making it a cheap CI gate. - Build offline — the build system consumes the vendored files like any other source. cdeps is a setup-time tool only; it is never invoked by the build and generates no build glue.
cdeps is a single self-contained binary (a thin C shell with the logic in
embedded Lua bytecode — see Design). Build it with make:
$ make
$ make install # copies ./cdeps to /usr/local/bin (override PREFIX=)Requires a C compiler and make. The Lua runtime is vendored under
deps/lua-5.5.0 and built in — there's no dependency on a system Lua.
Runtime tools cdeps shells out to: git (git transport), curl
(archive/file transport), and tar/unzip (archive extraction).
Platform: macOS and Linux. Windows is out of scope (the native helpers are POSIX-only); MSYS2/Git-Bash may work incidentally, untested.
cdeps [install|sync] vendor anything in deps.lua missing from the tree
cdeps add <user/repo|url> scaffold a spec, vendor it, update the lock
cdeps update [name] re-resolve refs, re-fetch, re-hash, rewrite lock
cdeps verify re-hash deps/ against the lock (CI gate)
cdeps remove <name> delete owned files + drop from the lock
cdeps tidy reconcile deps.lua <-> lock <-> deps/
cdeps help show this help
-y, --yes assume yes for update confirmations
Bare cdeps is the default verb: install/sync from deps.lua + deps.lock,
fetching only what's missing (a no-op when the committed tree is intact).
A Lua file returning a list of specs. The array part is the spec list; an
optional config key holds global settings (mirrors Lazy's "specs + opts").
return {
-- base dir (default "."); each dep gets its own deps/<name>/ via subdir (default: the dep's name)
config = { dir = "deps" },
-- github user/repo, default branch, fetch the listed files -> deps/sokol/*.h
{ "floooh/sokol", files = { "sokol_app.h", "sokol_gfx.h" } },
-- pin: commit > tag > version (semver) > default branch HEAD
-- `include/**` keeps its subdir paths (flatten defaults to false); add
-- `flatten = true` to vendor matched files by basename only. -> deps/cglm/include/cglm/*
{ "recp/cglm", tag = "v0.9.4", files = { "include/**" } },
{ "g-truc/glm", commit = "0af55cc" }, -- no files -> whole repo into deps/glm
-- full url override (non-github host)
{ url = "https://gitlab.com/bztsrc/jsonc.git", files = { "jsonc.c" } },
-- archive: download + extract + filter (transport auto-detected by extension)
{ url = "https://sqlite.org/2025/sqlite-amalgamation-3500400.zip",
files = { "sqlite3.c", "sqlite3.h" } },
-- single file: just download
{ url = "https://raw.githubusercontent.com/x/y/master/single.h" },
-- dest escape hatch: literal path (== dir = "third_party", subdir = "")
{ "floooh/sokol", files = { "sokol_gfx.h" }, dest = "third_party" },
}| field | meaning | default |
|---|---|---|
[1] |
"user/repo" shorthand |
— (or use url) |
url |
full URL; overrides the shorthand | https://github.com/<u/r>.git |
name |
dep identity: lock key + CLI handle + default <dir>/<name> dir. Must be unique across entries (two entries from one repo each need their own name). |
repo name (git) / repo segment of a GitHub archive URL / filename stem |
branch/tag/commit/version |
the pin (version = semver) |
remote default branch HEAD |
dev |
local-dev override: copy from this local folder instead of fetching (keeps the declared source as identity) | off (fetch remotely) |
files |
glob filter (**, *); keep only matches |
keep everything |
dir |
base dir for this entry; overrides config.dir, still feeds subdir/name |
config.dir (or .) |
subdir |
folder name under dir for this dep; "" flattens into <dir>/ directly |
the dep's name |
flatten |
true keeps only the basename; false preserves matched files' subdir paths |
false (or config.flatten) |
dest |
escape hatch: literal output dir, bypassing dir/subdir/name (see below) |
derived from dir+subdir |
strip_prefix |
archive: drop a leading path component | auto (single top-level dir) |
submodules |
git: recurse submodules so their files vendor too | true |
build |
function(ctx) post-fetch compile/codegen hook |
none |
Transport is auto-detected from the resolved URL: .git / user/repo →
git (blobless clone + checkout pin); .tar.gz/.tgz/.tar.bz2/.zip → archive
(download + extract); anything else → file (download single file).
Output layout is built from four composable knobs — dir, name, subdir,
flatten — which together cover essentially every layout you'd want:
dir— the base directory (per-entry, elseconfig.dir, else.).name— the dep's identity (auto-derived from the URL if unset).subdir(inter-dep layout — a folder per dep) — a string naming the folder underdir:- unset → defaults to
name, so each dep gets its own folder<dir>/<name>(e.g.deps/sokol/sokol_gfx.h,deps/raylib/...). ""→ all deps land flat in<dir>/(e.g.deps/sokol_gfx.h).- any other string → a folder name of your choosing, e.g.
subdir = "tool"→<dir>/tool/..., independent ofname.
- unset → defaults to
flatten(intra-dep layout — what happens to a matched file's own subpath):false→ keep the file's path (e.g.util/sokol_imgui.h→<dest>/util/sokol_imgui.h).true→ basename only (e.g.util/sokol_imgui.h→<dest>/sokol_imgui.h).
They compose freely: default subdir, flatten=true → deps/sokol/sokol_nuklear.h.
dest — the escape hatch. dest = "X" sets the output dir to a literal path,
bypassing dir/subdir/name entirely. It's exactly equivalent to
dir = "X", subdir = "", just shorter and clearer when you want a deliberate,
hand-placed location (e.g. dest = "." to drop a build header at the project root,
or dest = "deps/lua-5.5.0" to pin a folder name that differs from the lock name).
Reach for the four knobs first; use dest only when you want to say "put it exactly
here."
Splitting one repo across destinations. To send different files from a single
repo to different places, use one entry per destination — but give each a distinct
name (the lock key / CLI handle must be unique; cdeps errors on a collision):
{ "you/lib", subdir = "", files = { "lib/base.h" } }, -- -> deps/base.h
{ "you/lib", name = "lib-tools", dir = "tool", subdir = "", -- -> tool/bin2c.c, …
files = { "tool/bin2c/bin2c.c", "tool/sql2c/sql2c.c" } },The blobless clone is cached, so the second entry reuses the first's fetch.
Local development (dev). While hacking on a dependency's own source, point an
entry at your local checkout with dev (à la Lazy.nvim) — cdeps copies from that
folder instead of fetching:
{ "timwmillard/cbase", dev = "~/cprogs/cbase", files = { "lib/base.h" } },- The declared source (
"timwmillard/cbase") stays the dep's identity;devonly swaps where the files come from. Removedevand the nextinstallfetches the real remote and writes a normal pinned lock entry. ~and relative paths (from the project root) are supported.- It copies (cdeps stays a vendoring tool): edit your local source, then re-run
cdeps installto resync — every run re-copies dev entries. - A dev entry isn't a reproducible pin, so the lock records only its paths (marked
dev = "<path>", no hashes) andverifyskips it. Don't commit adeventry as a permanent dependency — it points at a path only you have.
| config key | meaning | default |
|---|---|---|
dir |
base directory the default output layout is built from | "." |
flatten |
default flatten for every entry (per-entry flatten wins) |
false |
subdir is per-entry only (it defaults to that entry's own name, so there's no
useful shared global value — set subdir = "" on individual entries to flatten
them).
deps.lock is a Lua table (cdeps dofiles it) that records, for each dep, the
resolved commit plus a content hash — a per-file list (files deps) or a
whole-tree digest (whole-repo git deps). It's deterministic and meant to be
committed:
- On any fetch of a pinned ref, if the recomputed hash ≠ the lock, cdeps stops and warns (force-moved tag, rewritten upstream, lying mirror).
- The committed lock is your checksum DB — a permanent, reviewable, trust-on- first-use record shared by everyone who clones. (No central server, unlike Go.)
- cdeps never executes code shipped by a dependency. The only hook (
build) is author-written in your owndeps.lua.
cdeps always does the same thing — fetch into dest, write deps.lock. What you
commit decides the model:
- Vendored (the aim): commit
deps/+deps.lua+deps.lock. Clone → build with no tooling, no network, no cdeps. Resilient to upstream disappearing, andcdeps updateshows real source diffs in review. - Fetch-mode: gitignore
deps/, commit onlydeps.lua+deps.lock; runcdepsafter clone to repopulate. Smaller repo, but needs cdeps + network on first setup. The committed lock makes it reproducible (like npm + lockfile).
cdeps prefers neither, but warns (doesn't error) if its dest is gitignored —
usually that means you think you're vendoring but nothing is committed.
cdeps emits no build glue; you wire it up by hand, once, over plain vendored files:
# loose files (single-header / amalgamation)
target_include_directories(app PRIVATE deps)
target_sources(app PRIVATE deps/sqlite3.c)
# whole repo that ships its own build
add_subdirectory(deps/raylib)
target_link_libraries(app PRIVATE raylib)cdeps is a thin C shell embedding Lua, with the logic in cdeps.lua. Lua is
required at runtime anyway — deps.lua can contain functions and computed config,
and deps.lock is itself a Lua table — so the orchestration (shell out to
git/curl/tar, walk/copy/hash files, glob, serialize) lives in Lua, and C provides
only the runtime plus the few primitives Lua's stdlib lacks (sha256, a little
filesystem traversal). cdeps.lua is precompiled with luac and embedded as
bytecode, so the result is one self-contained binary.
cdeps fetches through a persistent cache (~/.cache/cdeps/, honors
XDG_CACHE_HOME) for speed, stages each fetch in a throwaway tmp dir, then copies
the filtered files into dest. The cache changes speed, never outcome — the
lock's commit/sha256 fully determine what gets vendored, so it's safe to
delete at any time.
cdeps manages its own dependency (the Lua runtime), dogfooding the tool:
rm -rf deps && cdeps restores everything, Lua included, from deps.lua +
deps.lock. The built binary embeds its own Lua, so deps/lua-5.5.0 is only a
build input — the binary can repopulate the very dir that held its source.
Dev loop: set CDEPS_DEV=1 to load cdeps.lua from disk (no rebuild needed
for logic changes), or run lua cdeps.lua <cmd> under a system Lua. Rebuild the C
shell only when adding a native primitive.
See docs/PLAN.md for the full design and docs/TODO.md for the current implementation status. A real-world config is in examples/deps.lua.
The config ergonomics are borrowed from
Lazy.nvim: the positional "user/repo"
shorthand, GitHub by default, the branch/tag/commit/version pins, and the
"specs + opts" table split (the array part is the spec list, the config key
holds settings). cdeps extends it with transport auto-detection (git/archive/file)
since, unlike Lazy, it isn't git-only.
The integrity model is borrowed from Go modules: cdeps
is closest to go mod vendor — copy deps into the tree, build offline from them,
with a manifest plus checksums. A committed lockfile as a trust-on-first-use
checksum DB, immutable-pin tamper detection, and running no code shipped by a
dependency all come from there — minus Go's server-side machinery, because the
lock is committed and the files are vendored.
MIT — see LICENSE.