scadman
A project-oriented package manager for OpenSCAD libraries and scripts.
Instead of copying libraries into OpenSCAD’s global libraries/ folder and hoping every
project still works when one of them changes, scadman gives each project a manifest
(scadman.toml) and a lockfile (scadman.lock) that pin exact dependency versions. Most
OpenSCAD libraries are git repositories that never publish releases, so scadman is
GitHub-first: add a library by URL, track its branch, and pin it to an exact commit at
lock time.
New here? Start with Getting started.
How it works
A dependency is resolved to an exact commit, its content is hashed and stored immutably
under that hash (~/.local/share/scadman/store/<hash>/), and sync builds a per-project
environment (.scadman/env/) that exposes each library under its own name, symlinked to
the store, with OPENSCADPATH pointing at it. So a library’s own include <BOSL2/…>
resolves to exactly the pinned content.
OpenSCAD has a flat namespace (include is textual, no module scoping), so scadman
enforces one resolved version per library and warns when installed code imports a
library you didn’t declare. OpenSCAD still searches its own user and installation
library folders after OPENSCADPATH: declared dependencies shadow globally installed
ones, but scadman cannot hide them.
Status and scope
An alpha, usable end-to-end (init → add → lock → update → sync → run) and validated
against real libraries (BOSL2, NopSCADlib, MCAD, dotSCAD, Round-Anything) plus a
12-project dogfooding pass. Expect rough edges and breaking changes. Current limits:
- Git and local path sources only; no hosted registry or version-range dependencies yet.
- One resolved version per library, a consequence of OpenSCAD’s flat
includenamespace. - Linux and macOS; Windows isn’t supported yet. Lockfiles are reproducible across both, though git’s line-ending conversion or Unicode filename normalization can still change a content hash in edge cases.
- Path dependencies are a local-development convenience, not a reproducible pin.
- Nothing prunes the store yet
(#96); it is safe to delete,
and the next
syncfetches what the lockfile needs.
Known bugs are on the issue tracker; planned work is in the post-alpha roadmap.
In these docs
Using scadman
- Getting started — install, a first project, and every command
- Manifest and lockfile —
scadman.tomlandscadman.lockreference, and where scadman keeps its files - Path dependencies — co-developing with a local sibling library
- Library roots — consuming src-layout libraries (
--root,--on-path) - Editor and GUI integration — language servers, VS Code, the OpenSCAD GUI
Project
Design records (background and history, not user documentation)
- Foundational decisions — the load-bearing choices and their rationale
- Ecosystem survey — the survey evidence the design rests on
- Resolver direction — why v1 resolution is collect-and-unify, not a solver
These pages are published at https://cameronbrooks11.github.io/scadman/.
Getting started
Install
Grab the prebuilt binary and checksums.txt from the
latest release (Linux x86_64
only). In the directory you downloaded both into:
sha256sum --ignore-missing -c checksums.txt
tar -xzf scadman-*-x86_64-unknown-linux-gnu.tar.gz
install -Dm755 scadman ~/.local/bin/scadman # or anywhere on your PATH
On macOS, or another Linux architecture, build from source with a Rust toolchain (≥ 1.88):
cargo install --locked --git https://github.com/CameronBrooks11/scadman --tag v0.1.0-alpha.4 scadman-cli
Either way, git and openscad need to be on PATH. scadman doctor reports what it
finds.
A first project
In your project directory:
scadman init # create scadman.toml
scadman add BOSL2 https://github.com/BelfrySCAD/BOSL2 # track its default branch
scadman lock # pin it to an exact commit
scadman run -- model.scad -o out.stl # render with BOSL2 available
model.scad imports the library under the name you gave it:
include <BOSL2/std.scad>
Commit scadman.toml and scadman.lock. The .scadman/ directory is machine-local;
init adds it to .gitignore. Run scadman from the project root, where scadman.toml
lives.
Commands
| Command | What it does |
|---|---|
scadman init [--name N] | Create scadman.toml and ignore .scadman/. |
scadman add <name> <git-url> | Declare a git dependency. --tag, --branch or --rev picks the ref; with none, it tracks the remote’s default branch. |
scadman add <name> --path <dir> | Declare a local directory as a dependency. |
scadman remove <name> | Drop a dependency from scadman.toml. |
scadman list | Show declared dependencies and the commit each is locked to. |
scadman lock | Resolve every dependency and write scadman.lock. |
scadman update [name…] | Move branch and tag dependencies to their newest commit, all or just the named ones, and report what moved. |
scadman sync | Build the project environment from the lockfile and warn about undeclared imports. |
scadman run -- <openscad args> | Sync, then run OpenSCAD with the environment on OPENSCADPATH. |
scadman env [--json | --write-vscode] | Sync, then print the OPENSCADPATH for editors and tools. |
scadman graph [--json] | Print the locked dependency graph. |
scadman doctor | Report on OpenSCAD, the store, the manifest, the lockfile and the environment. |
A branch or tag is pinned to an exact commit at lock time and stays there until you run
scadman update. A --rev pin never moves; give it the full 40-character commit hash,
since an abbreviated one is refused.
After editing scadman.toml, by hand or with add and remove, run scadman lock.
sync and run stop with an “out of date” error for some stale lockfiles, but they do
not catch every edit. The details are in Manifest and lockfile.
Local libraries and unusual layouts
- To develop a project alongside a library on your own disk, depend on it by path:
scadman add mylib --path ../mylib. See Path dependencies. - Some libraries, dotSCAD for example, keep their code under a subdirectory like
src/and import relative to it. Add them with--root src --on-path. See Library roots.
Undeclared imports
Many libraries pull in others without declaring it; agentscad, for example, uses
scad-utils and list-comprehension-demos. sync follows your project’s imports into
each dependency and names any undeclared library it reaches:
warning: `agentscad` imports `scad-utils/` (in mesh.scad) but it is not in your dependencies
scadman add each named library, then lock and sync again. Imports of a bare
filename (use <file.scad>) can’t be attributed to a library and aren’t flagged.
Minimum OpenSCAD version
Declare openscad = "2021.01" under [project] (a >= prefix is accepted; ranges and
other operators aren’t) and doctor and run warn when the installed OpenSCAD is
older. Advisory only, not a hard gate.
Manifest and lockfile reference
A scadman project is a directory holding scadman.toml, which you write, and
scadman.lock, which scadman writes. Commit both. Run scadman from that directory; it
does not search parent directories.
scadman.toml
[project]
name = "enclosure"
openscad = ">=2021.01" # optional
[dependencies]
BOSL2 = { git = "https://github.com/BelfrySCAD/BOSL2", branch = "master" }
Round-Anything = { git = "https://github.com/Irev-Dev/Round-Anything", rev = "061fef7c429628808e847696bb345a9b0ec6e279" }
dotSCAD = { git = "https://github.com/JustinSDK/dotSCAD", rev = "bb33edfd75cba0edbb7971606a077b9c69d0b7d2", root = "src", on_path = true }
mylib = { path = "../mylib" }
[project]
| Key | Required | Meaning |
|---|---|---|
name | yes | The project’s name, shown by graph. |
openscad | no | Minimum OpenSCAD version, as "2021.01" or ">=2021.01". doctor and run warn when the installed version is older; nothing is blocked. |
[dependencies]
Each key is the name the library is exposed under: BOSL2 makes include <BOSL2/…>
resolve. Use the name the library’s own files use for themselves, since many libraries
import their own files as <Name/…>. A name is a single path component: no / or \,
and not . or ...
A git dependency:
| Key | Required | Meaning |
|---|---|---|
git | yes | The repository URL. |
rev | one of these three | An exact commit, as the full 40-character hash; an abbreviated one is refused. Never moves. |
tag | A tag. Locked to the commit it points at; scadman update re-reads it. | |
branch | A branch. Locked to its tip at lock time; scadman update advances it. | |
root | no | Subdirectory to expose instead of the repository root. See library-roots.md. |
on_path | no | Also put the exposed directory on OPENSCADPATH. See library-roots.md. |
Give exactly one of rev, tag and branch; an entry with none, or with more than
one, is refused at lock time.
A path dependency takes path (a directory, relative to the project or absolute) and
the same optional root and on_path. See path-dependencies.md.
An unknown table or key anywhere in your scadman.toml is an error, so a misspelled
[dependencies] header or key cannot be silently ignored. A bare version string
(BOSL2 = "^2.0") is reserved for a future registry and is refused at lock time.
One version per library
OpenSCAD has one flat namespace, so each library appears once in a project. lock
stops with an error, and writes no lockfile, when:
- one source is requested at two different commits;
- two different sources are given the same name;
- one source is given two names.
There is no override syntax yet; the fix is to make the declarations agree.
Two URLs are the same source when they match after a trailing / and a .git suffix
are removed. Other spellings of one repository, such as different letter case or ssh
against https, count as different sources
(#98), so write a library’s URL
the same way everywhere.
Dependencies of dependencies
If a git dependency ships its own scadman.toml, its dependencies are resolved too,
under the same one-version rule. It may not declare path dependencies. Unknown keys
in a dependency’s manifest are ignored, apart from those inside its dependency tables,
so a library written for a newer scadman stays readable.
Almost no OpenSCAD library ships a manifest today. When a library imports another
without declaring it, sync names the missing library and you add it to your own
manifest.
scadman.lock
Generated by lock and update; do not edit it. One entry per resolved library:
version = 1
[[package]]
name = "BOSL2"
source = "https://github.com/BelfrySCAD/BOSL2"
rev = "afe82db884ee4409aa76ecfcfbbf54d446964af1"
hash = "…"
| Field | Meaning |
|---|---|
name | The name the library is exposed under. |
source | The git URL, or path: followed by the absolute directory of a path dependency. |
rev | The exact commit. For a path dependency, its content hash. |
hash | SHA-256 of the library’s files: the key of its copy in the store. |
dependencies | Names of the libraries this one declares, if any. |
root, on_path | Copied from the manifest when set. |
version is the lockfile format. Every command refuses a lockfile whose version it
does not know, and lock and update will not overwrite one: upgrade scadman, or
delete the file to write a fresh one.
On a machine that does not have a library’s content yet, sync fetches rev from
source and refuses to continue if the content does not hash to hash.
sync, run and env use the lockfile as it is. They create it if it is missing, and
rewrite it when a path dependency’s content has changed. They stop and ask for
scadman lock when the manifest and the lockfile disagree:
- the manifest declares a library the lockfile lacks;
- the lockfile holds a library the manifest no longer leads to, as after
scadman remove; - a library’s source,
rev,rootoron_pathno longer matches.
One edit is not detected: a changed tag or branch name on an unchanged URL. Running
scadman lock after every manifest edit covers it.
Where scadman keeps things
| Path | What | Commit it? |
|---|---|---|
scadman.toml | The manifest. | yes |
scadman.lock | The lockfile. | yes |
.scadman/env/ | The project environment: one symlink per library, rebuilt on every sync. | no (init ignores it) |
$XDG_DATA_HOME/scadman/store/, or ~/.local/share/scadman/store/ | The store: one immutable directory per content hash, shared by all projects. | not in the project |
A library’s stored copy is its working tree without .git. Symbolic links inside a
library are not copied. File modes do not affect the hash.
Nothing removes store entries yet
(#96). Deleting the store is
safe: the next sync restores what the lockfile needs.
Path dependencies
To co-develop a project alongside a local library, depend on it by path instead of a git source:
scadman add mylib --path ../mylib
or in scadman.toml:
[dependencies]
mylib = { path = "../mylib" }
What tracking means
A path dependency tracks the directory’s current content. sync, run, and env
re-read it — rewriting the lock if it changed — so edits to the sibling’s code show up
immediately.
Git dependencies beside it stay pinned to their locked commits and are served from the
store, so those commands still work offline. If the sibling declares a new dependency of
its own, the next sync resolves it automatically (that first fetch needs the network).
Changing a git dependency’s pin in your own manifest still needs an explicit
scadman lock; sync says so.
Caveats
- The whole directory is copied into the store, minus
.gitand symlinks — keep build output and nested environments out of the library root. - Every change to the directory is stored as a new copy, and nothing prunes the store
yet (#96). Delete the store
when it grows; the next
syncrestores what is needed. - Only the root manifest may declare a path dependency; a dependency of your project cannot bring one in.
- A path dependency is a local-development convenience, not a reproducible pin: the lockfile records the directory’s absolute path on your machine, so it is not portable to another one.
scadman update <name>refuses a path dependency; there is nothing to advance.--rootand--on-pathwork as with a git source, for a src-layout sibling — see library-roots.md.
Library roots and on_path (src-layout support)
Most OpenSCAD libraries put their code at the repo root and self-root their imports
(include <BOSL2/std.scad>), which fits scadman’s default model: expose each package as
env/<name>/ → its store content, with OPENSCADPATH pointing at env. Validated
end-to-end (BOSL2, NopSCADlib, MCAD, Round-Anything).
src-layout libraries break that assumption. dotSCAD keeps its code under src/, and
its files import from that root (use <util/…>, use <__comm__/…>, bare
<polyline_join.scad>) — which resolve only when src/ itself is on OPENSCADPATH,
regardless of how deep the importing file is. Exposing the repo root under dotSCAD/
leaves those internal imports unresolvable (deep modules fail to render).
Design — two per-dependency knobs
[dependencies]
BOSL2 = { git = "…", rev = "…" } # default
dotSCAD = { git = "…", rev = "…", root = "src", on_path = true }
root(subdir, default".") — exposeenv/<name>→store/<hash>/<root>instead of the repo root. Clean and collision-safe; handles libraries whose code is under a subdir. Checked at lock time: it must be a relative path with no.., and the directory must exist in the library.on_path(bool, defaultfalse) — also addenv/<name>toOPENSCADPATH, so the library’s own root-relative imports resolve at any depth.OPENSCADPATHbecomesenv : env/<name> : …(env first, so<name/…>and named libraries resolve; the on-path dirs follow, for those libraries’ internals).
Validated: with root = "src", on_path = true, dotSCAD renders end-to-end via scadman run, including deep src/_impl/… modules (an #[ignore]d e2e test covers it).
The tradeoff of on_path
on_path puts a library’s root directly on the global search path, which globalizes its
top-level filenames — the flat-namespace risk scadman’s named-dir model exists to avoid.
It is therefore opt-in per dependency and rare (only OPENSCADPATH-root libraries need
it). In practice the risk is contained: a consumer imports via <name/…>, so their own
imports don’t clash; the collision is only between two on_path libraries that share a
top-level filename. scadman warns when that happens.
Consuming a src-layout library
There are two ways to depend on a src-layout library like dotSCAD, chosen to match how your
own .scad files import it:
- (a) Reach into
src/. Add it at the repo root (no knobs) and keep the subdir in the import path:use <dotSCAD/src/shape_trapezium.scad>. The module you name resolves underenv/dotSCAD/src/, and the siblings it imports resolve relative to that file (OpenSCAD searches an included file’s own directory). Simplest when you use a few specific files, and how existing code that already wrotedotSCAD/src/…keeps working. It does not putsrc/on the path, so files that import src-root-relative need approach (b). - (b) Expose
src/as the root. Add it withroot = "src", on_path = trueand drop the subdir:use <dotSCAD/shape_trapezium.scad>. This matches how the library documents its own imports and putssrc/onOPENSCADPATH, so deep internal imports resolve at any depth. Prefer it for heavy use, or when the library’s docs import as<dotSCAD/…>.
Both render correctly — pick the one that matches the import style already in your files.
Discovery
A consumer must currently know a library needs root/on_path (documented per-library; a
direct mis-import in your own code shows up as an OpenSCAD render failure — the cue to set
them). The include-scan covers the transitive case: it follows the project’s imports into
each dependency and names any undeclared library those dependency files reach, so a
manifest-less lib’s own hidden dependency surfaces as a precise warning rather than a raw
failure. Because the scan is reachability-based, it reports only libraries the used files
actually reach — never a dependency’s unused internals. An import that an on_path
library provides from its root counts as provided, whichever library makes it. A future registry/adapter layer can
declare a library’s layout on its behalf, so consumers don’t have to — but the manifest
knobs are the primitive that layer would populate.
Editor and GUI integration
scadman env prints the project’s OPENSCADPATH, and scadman env --json emits a
machine-readable report of the resolved libraries. Point an OpenSCAD language server or
preview extension at that path and editor features resolve against the project’s pinned
dependency versions:
export OPENSCADPATH="$(scadman env)"
env syncs first, so the path it prints always exists and matches the lockfile.
VS Code
scadman env --write-vscode writes the path into .vscode/settings.json as
openscad.search_paths (for openscad-LSP),
keeping the other settings. Paths are written relative to ${workspaceFolder}, so the
file is safe to commit.
An existing settings.json must be plain JSON: one with comments is refused, and you
add the setting by hand.
The OpenSCAD GUI
scadman run -- model.scad opens the model with OPENSCADPATH set. Or export the
variable, as above, in the shell you launch OpenSCAD from.
For tool authors
scadman env --json reports the search path and, for each library, its name, source,
exact revision, content hash and the directory its content is stored in.
scadman graph --json reports the project name, its direct dependencies and every
locked library with the libraries it declares.
Changelog
All notable changes to scadman are recorded here. Format loosely follows Keep a Changelog; versions follow SemVer (pre-1.0: expect breaking changes between minor versions).
[Unreleased]
0.1.0-alpha.4 — 2026-11-10
Fourth alpha. A documentation site, and a run of fixes to how the manifest and the
lockfile are read, most of them cases where scadman did the wrong thing without saying
so. Some can stop a project that used to pass: a scadman.toml with unknown keys, a git
dependency naming two refs, an abbreviated rev, and sync straight after remove.
Each now fails with a message that says what to change.
Added
- A documentation site at https://cameronbrooks11.github.io/scadman/, built from
docs/. The first-project walkthrough and the command table moved there from the README, which keeps install and a four-command summary; editor and GUI integration has its own page. - A manifest and lockfile reference (
docs/manifest.md): thescadman.tomlkeys, whatscadman.lockrecords, which manifest editssyncdetects, and where scadman keeps its files. CONTRIBUTING.md: setup, the check and test commands, pull-request conventions, and the release steps.
Fixed
- An unknown table or key in
scadman.tomlis now an error. A misspelled[dependencies]header used to read as a project with no dependencies:list,lockanddoctorall reported success. The same went for a misspelled key under[project]. The error names the table or key and its line. A dependency’s ownscadman.tomlstill tolerates unknown keys outside its dependency tables, so a library written for a newer scadman stays readable. Breaking for a manifest that carried extra keys: remove them. - A git dependency naming more than one of
rev,tagandbranchis now refused.lockused to accept it and silently userevovertag, andtagoverbranch, so addingtag = "v2"beside an oldrevline locked the old commit without a word.addalready rejected the combination; a hand-edited manifest, and a dependency’s own, are now held to the same rule. - A tag or branch whose name looks like a commit hash is held at its locked commit.
In a project with a path dependency, a tag such as
20210101(seven or more hex digits) was taken for a commit, so everysyncre-fetched it: the lock moved on its own when the tag moved upstream, andsyncfailed without the network. Whether a reference is a commit now comes from the manifest key it was declared under, not from its spelling. - Every command now refuses a lockfile of an unknown version. Only
sync,runandenvchecked it.graph,listanddoctorread a newer lockfile without comment, andupdate <name>used it as its base and wrote it back in the old format.lockandupdateno longer overwrite one either; the error says to upgrade scadman or delete the file. The version is read on its own, so a newer lockfile that does not parse is still reported by its version. - A removed dependency no longer stays available. After
scadman remove, the library stayed in the lockfile:syncandrunwent on exposing it, the project’s own import of it kept rendering, anddoctorcalled the lockfile up to date.sync,runandenvnow stop with the same “out of date … runscadman lock” error an added-but-unlocked dependency gets, anddoctorreports it. Libraries that are in the lockfile because another one depends on them are unaffected. - A lockfile
hashthat is not a content hash is refused. The hash is joined onto the store root, sohash = "/some/dir"orhash = "../../dir"madesynclink the package to that directory and put it onOPENSCADPATH. A hash must now be 64 lowercase hex digits, checked wherever the lockfile is read and again where the environment is built, before the existing environment is touched. - A failed copy into the store cleans up after itself. When an insert failed
partway (a full disk, for one), its
.staging-*directory stayed in the store for good: the name is unique to the run, so nothing later reused or removed it. - A git dependency’s
rootis checked at lock time. An absolute or..root on a git dependency locked cleanly, was written intoscadman.lock, and failed only atsync; only path dependencies andadd --rootwere checked earlier. A root that did not exist in the library (a typo) also locked, andsyncthen said the package was “not in the store — run a sync”.locknow refuses both, for any dependency from any manifest, and says which root is wrong. A bad root is also no longer reported as an invalid dependency name. - An abbreviated
revis refused with a message that says so. git fetches a commit only by its full name, sorev = "061fef7"could never lock: it failed with git’scouldn't find remote ref.add --revaccepted it anyway, andsyncprefix-matched it against the lockfile as if it were supported.addandlocknow ask for the full 40-digit hash, or a tag or branch, and arevis compared with the lockfile whole. - The include-scan knows what an
on_pathlibrary provides. When a dependency imported a path such as<util/x.scad>that anon_pathlibrary ships at its root, OpenSCAD resolved it andsyncstill warned that the dependency “importsutil/but it is not in your dependencies”, naming a library that does not exist. Such an import is now followed instead of reported, so scadman’s warnings match what OpenSCAD can and cannot open. - Both crates declare the minimum Rust version.
rust-version = "1.88"was set for the workspace but neither crate inherited it. An older toolchain was already turned away, but only because a dependency (sha2) requires 1.88 too; scadman’s own requirement was not stated where cargo reads it. cargo now namesscadman-cliandscadman-corein that message. scadman add <url>now says the URL is in the name position. Passing only a git URL used to fail with the package-name character error, which read as if the URL were malformed. It now prints`<url>` looks like a git URL, not a package name — usage: `scadman add <NAME> <GIT>`with a corrected command using the repository’s name, e.g.scadman add BOSL https://github.com/revarbat/BOSL.
Changed
- Release binaries are built from the committed
Cargo.lock. The release build now passes--locked, so a lockfile that disagrees withCargo.tomlfails the release rather than being rewritten inside the job. CI checks the same thing on every pull request, so a stale lock is caught before it reaches a tag.
0.1.0-alpha.3 — 2026-09-02
Third alpha. A doctor fix, a raised minimum Rust version, and the first release
built by a pipeline that ships checksums and publishes a full release rather than a
pre-release.
Fixed
scadman doctorno longer reports an unparseable manifest as missing. It loaded the manifest in a way that collapsed a parse error into the same result as an absent file, so a typo inscadman.tomlprintedscadman.toml not found — run `scadman init`— advice that then fails withscadman.toml already exists. Doctor now separates the cases and prints the TOML parse error, with the offending line, which is what it exists to do.
Changed
- The minimum supported Rust version is now 1.88 (was 1.85).
scadman-core’s resolver uses let-chains, which stabilised in 1.88. This affects building from source only — the prebuilt binary is unaffected. CI now checks against the version declared inCargo.tomlrather than a separately pinned one, so the two cannot drift. - Polished CLI output.
lockandsyncreportLocked 3 packagesrather thanLocked 3 package(s);doctordescribes an absent store as(not created yet)rather than(empty); and the error for a version dependency now gives the reason and the way out — registries aren’t supported yet, use a git or path source. - Release archives ship with a
checksums.txt(SHA-256,sha256sum -cformat) so a downloaded binary can be verified before it goes onPATH. The README’s install step does exactly that. - Releases are no longer marked pre-release. GitHub excludes pre-releases
from
/releases/latest, so that endpoint returned 404 and scripted installs had nothing to resolve against. The version string still carries the maturity signal — this is an alpha.
0.1.0-alpha.2 — 2026-07-30
Second alpha. Rounds out the CLI (graph, doctor, update), adds local path
dependencies and editor integration, makes the include-scan precise, and smooths first-run
onboarding. Validated against 12 real projects (a dogfooding pass).
Added
scadman graph— print the resolved dependency graph as a tree, or--jsonfor tooling.scadman doctor— a setup report: OpenSCAD, store, manifest, lockfile freshness, and the built environment, each line nudging the next step.scadman update [name…]— advance dependencies to newer commits and report what moved. With no arguments it re-resolves everything (likelock, but with a delta report); with names it advances only those and holds the rest at their locked revisions. Exactrevpins never move;doctornotes when dependencies track a branch/tag.- Path dependencies — depend on a local sibling library with
Name = { path = "../lib" }(orscadman add Name --path ../lib;--root/--on-pathfor a src-layout sibling).sync/run/envre-read it so code edits show up immediately, while git dependencies beside it stay pinned and are served from the store (offline-capable). Only the root manifest may declare one, and a lockfile referencing one is local, not reproducible. - Minimum OpenSCAD version — declare
openscad = "2021.01"under[project](a>=prefix is accepted) anddoctor/runwarn when the installed OpenSCAD is older. Advisory only; OpenSCAD compatibility is really feature-based. - Editor integration —
scadman env --write-vscodewritesopenscad.search_paths(for openscad-LSP), relative to${workspaceFolder}so.vscode/settings.jsonis committable; andscadman run -- model.scadopens the OpenSCAD GUI with dependencies resolved.
Changed
scadman add <url>with no ref now tracks the remote’s default branch (likegit clone) — pass--tag/--branch/--revto pin otherwise.- The include-scan is reachability-based — it follows the project’s imports and warns only about undeclared libraries the files you actually use reach, instead of every file a dependency ships (far fewer false positives on real libraries).
- Content hashes are documented as reproducible across Linux and macOS (path-separator- and file-mode-independent).
0.1.0-alpha — 2026-07-28
First public alpha. A working, project-oriented OpenSCAD dependency manager, grounded in a 228-repo ecosystem survey and validated against real libraries.
Added
- Project model —
scadman.tomlmanifest +scadman.locklockfile pinning each dependency to an exact commit and content hash. - CLI —
init,add,remove,list,lock,sync,run,env. - Content-addressed store — immutable, deduplicated package content with atomic, symlink-skipping inserts.
- Collect-and-unify resolver — one resolved version per package identity, structured
conflict diagnostics, no version-range solving (none is needed yet — see
docs/resolver-direction.md). - Per-project environment — exposes each package under its own name via
OPENSCADPATH, so a library’s self-rootedinclude <Name/…>resolves to the pinned content. - Library roots —
root(expose a subdir as the library root) andon_path(place it onOPENSCADPATH) support src-layout libraries like dotSCAD. - Include-scan — warns when installed code imports a library you did not declare.
- Editor integration —
scadman env/env --jsonfor openscad-LSP and preview extensions to resolve against pinned versions.
Known limitations
GitHub-hosted git sources only; one version per identity; per-OS lockfile hashes; no hosted registry or native OpenSCAD-GUI integration yet. See the README’s Status & scope.
Contributing
Bug reports, feedback and patches are welcome. For anything larger than a small fix, open an issue first so the approach can be agreed before the work is done.
Setup
You need a Rust toolchain (1.88 or newer, via rustup),
just, pre-commit and
git. openscad is needed only for the real-library tests below, and
mdbook only to preview the documentation site.
just setup # rustfmt, clippy, and the pre-commit git hooks
Build, check, test
just fmt # format
just check # fmt-check + clippy with warnings as errors
just test # unit and end-to-end tests
just run doctor # run the CLI from source; arguments pass through
just check && just test must pass before a commit. The tests build throwaway git
repositories under a temporary directory and use a temporary store, so they need git
but no network and do not touch your own scadman store.
Two tests fetch real libraries from GitHub and render them with OpenSCAD. They are ignored by default:
cargo test -- --ignored
Layout
crates/scadman-core/ data model and logic: manifest, lockfile, resolver, store, environment, include-scan
crates/scadman-cli/ the `scadman` binary and its end-to-end tests
docs/ user guides and design records; the source of the documentation site
fixtures/ real libraries pinned for tests, by reference
Logic belongs in scadman-core; the CLI stays a thin shell over it.
AGENTS.md holds the short form of the conventions and the design constraints. It is written for AI coding agents and applies equally to people. The reasoning behind the constraints is in the foundational decisions.
Documentation
The site at https://cameronbrooks11.github.io/scadman/ is built by mdBook from
docs/, configured in book.toml. docs/SUMMARY.md is its table of contents: a new
page is published only once it is listed there. Link between pages with relative
.md links, and to anything outside docs/ with a full URL, so a link works both on
the site and on GitHub.
just docs-serve # preview at http://localhost:3000, rebuilding on change
just docs-build # one-shot build into book/
A pull request that touches the docs builds the site in CI; merging to main publishes
it.
Changes
- Branch from
mainand open a pull request against it. - Conventional Commits:
type(scope): subject, imperative and lowercase. One logical change per commit. - A behaviour change comes with a test. A bug fix comes with a test that fails without the fix.
- Add a line under
## [Unreleased]inCHANGELOG.mdfor anything a user would notice. - Update the docs in the same change when behaviour they describe moves.
- Never bypass the pre-commit hooks.
CI runs on every pull request: formatting, a type-check, a check that Cargo.lock
matches Cargo.toml, the test suite, and a type-check on the minimum supported Rust
version. Clippy also runs, as an advisory job that does not block.
Releasing
- Set the new version in the root
Cargo.tomland refreshCargo.lock(cargo check). - Rename
## [Unreleased]inCHANGELOG.mdto the version and date, add a fresh empty## [Unreleased]above it, and add the link at the bottom of the file. - Update the version in the
cargo installline, in bothREADME.mdanddocs/getting-started.md. - Merge, then tag the merge commit
v<version>and push the tag.
The tag triggers the release workflow, which builds the Linux binary from the committed
Cargo.lock, writes checksums.txt, and publishes a GitHub release whose notes are
that version’s CHANGELOG section. It fails if the section is missing.
scadman — Foundational decisions
Design record — the load-bearing choices made before and during early development, kept as history. Not user documentation; to use scadman, start at the Getting started.
scadman is a greenfield, Rust, project-local OpenSCAD dependency and environment
manager, licensed Apache-2.0. The predecessor tool olman is reference-only — no code
or metadata inheritance. The first real work was an ecosystem survey (a Python spike
in a separate private repository) whose outputs — the
findings in ecosystem-survey.md and the fixture set in
fixtures/ — were promoted here once stable.
The product model
scadman models three distinct things instead of forcing everything into one “package” shape:
- Artifact — any reusable OpenSCAD content (a single
.scad, a repo, a tagged release), with or without metadata. - Package — an artifact with an explicit contract: identity, version, license, declared roots, dependencies, integrity.
- Project — a reproducible environment that consumes packages and raw artifacts via a manifest and an exact lockfile.
This distinction is the bet the ecosystem survey was designed to test: most OpenSCAD content in the wild is an artifact, not a package, so a tool that demands full package metadata up front would exclude nearly everything worth depending on.
D1. Product strategy — greenfield, not evolve olman
Decision: build a new product.
Options were a greenfield tool, fork-and-evolve olman’s Python client, or both in parallel. Evolving olman would have been faster to something usable but inherits the global-install, one-version model that is the core limitation being fixed; the value added over olman/OPM is the product model (project-local reproducibility first), not a cleaner install loop. A fork could not get there without becoming a rewrite anyway.
D2. Implementation language — Rust
Decision: Rust.
Considered against Go (faster to a reliable v1, simpler contributor onboarding) and staying in Python (only sensible when evolving olman). Rust fits immutable models, lockfiles, and transactional filesystem state; ships as a single binary; and keeps a realistic long-term path to native integration with OpenSCAD’s C++. The accepted cost is a slower v1 and steeper onboarding for casual contributors.
D3. Name — product and binary both scadman
Decision: one name, scadman, for the product, the repo, and the binary.
An earlier draft used scadpkg for the CLI; two names would have to be explained
forever, so they were collapsed.
D4. License — Apache-2.0
Decision: Apache-2.0 (relicensed from an initial MPL-2.0).
Native OpenSCAD integration is a real goal, and permissive licensing with an explicit patent grant removes a bundling obstacle later; it is also the convention for Rust tooling. MPL’s file-level copyleft bought nothing scadman needs.
D5. Relationship to olman — reference only
Decision: read olman for design and behavior; share no code and no metadata.
In particular, the registry is not seeded from olman’s accepted_repositories /
remote_index.json: the survey built its own repository list from primary sources
(openscad.org/libraries.html, GitHub topic search), using olman’s list only as a
cross-check. No migrate olman command is in scope.
Upstream collaboration with the OpenSCAD maintainers was also considered and remains open — deferred until there is working software and survey data to point at, to avoid a competing-fork dynamic.
D6. First real work — survey before product code
Decision: run the ecosystem survey first; write no product code until it lands.
The artifacts-aren’t-packages thesis (see The product model) is a bet about what
the ecosystem actually looks like. A cheap crawler either validates or kills
downstream design (workspaces, adapters, a kind taxonomy) before any Rust exists.
The alternative — build the vertical slice first — risks baking in assumptions the
data would have corrected. The survey’s results are in
ecosystem-survey.md.
D7. Repo shape — this repo is the product
Decision: this repo is the product (Rust workspace, docs, fixtures). The survey lives in a separate private repository.
The survey is throwaway-ish Python at a different quality bar and would pollute a repo meant to stay clean; only its stable outputs (findings report, curated fixture list) are promoted here. Splitting product and registry into separate repos is deferred until governance demands it.
D8. Housekeeping
Decision: Rust .gitignore template; the README was rewritten to carry the
product thesis; registry topology (static curated index + direct git,
federation-ready) is real but deferred until well after a working client — GitHub names for a future registry
org are already reserved so nothing blocks it later.
D9. Dependency resolver (v1) — collect-and-unify, not a solver
Decision: v1 resolves by deterministic DFS: each ref resolves to an exact SHA, one identity gets one rev, and a genuine conflict is a hard error. Adopt a real solver (pubgrub-rs) only when a registry brings semver ranges or real library-on-library depth appears.
The survey shows there is nothing to solve — 81% of wild repos have zero
dependencies, max depth 2, zero version conflicts in 196 repos, zero formal
manifests. Cut from v1 (zero triggers in the corpus): pseudo-versions, a reserved
semver-range variant, a polished conflict renderer. Added to v1: the include-scan
flagging unmet imports, the one failure mode the data shows firing today. Other
settled choices: identity is a name bound to a canonical URL (URL authoritative);
manifest-less transitive dependencies are declared by the user, with the
include-scan warning; overrides deferred entirely (unknown keys in dependency
tables are rejected, so adding per-dependency syntax later is non-breaking); branch
refs allowed but
SHA-locked; OpenSCAD’s bundled MCAD is fetchable and shadowable via OPENSCADPATH.
Full rationale in resolver-direction.md.
Summary
| # | Decision | Call |
|---|---|---|
| D1 | Product strategy | Greenfield scadman |
| D2 | Language | Rust |
| D3 | Name | scadman (product + binary) |
| D4 | License | Apache-2.0 |
| D5 | olman relationship | Reference only |
| D6 | First work | Ecosystem survey, before product code |
| D7 | Repo shape | scadman = product; survey in a separate private repo |
| D8 | Housekeeping | Rust gitignore · README thesis · registry deferred |
| D9 | Resolver (v1) | Collect-and-unify; include-scan; pubgrub-rs later |
Ecosystem survey — evidence for the design
Design record — the evidence behind scadman’s model, not user documentation.
scadman’s model (see DECISIONS.md) rests on a claim: the OpenSCAD library ecosystem is a heterogeneous mix of single files, conventional libraries, monorepos, and frameworks — not a uniform set of “packages” — and libraries carry implicit assumptions that constrain how a manager may install them.
Two surveys tested that claim: pass 1, the 28 repositories on
openscad.org/libraries.html plus five archetype
fixtures; and pass 2, a wider sample of 200 repositories discovered by GitHub search
(topic:openscad ∪ language:OpenSCAD, forks/archived excluded), 168 of them
content-analyzed. Method and raw data live in a separate private repository
(metadata via the GitHub API; structure via shallow clones + a .scad
include-graph / symbol scan). Findings are quoted pass 1 → pass 2 so the trend is
visible; unless noted, the wider sample confirms.
Findings and what each locks in
1. Most libraries do not release. → exact-revision handling is mandatory
- 54% have no git tags at all (pass 2: 63%, and 65% among repos containing
.scad); only 39% use version-like (vX.Y) tags (pass 2: 32%).
A tag-as-version model (as in olman) cannot address the majority of the ecosystem. scadman must resolve a manifest reference to an exact commit + content hash, and support unversioned sources as first-class, not an afterthought.
2. Libraries assume install-under-their-own-name. → the environment must honor it
- 18% reference themselves by their own root name — e.g. files inside BOSL2 do
include <BOSL2/std.scad>, and NopSCADlib, MCAD, and BOSL do the same.
A library that includes itself via <Name/...> breaks unless it is installed at a
path where Name/ resolves. scadman’s per-project environment must expose each
library under its own canonical name on the search path; renaming or flattening
the install directory is not safe.
Pass 2’s aggregate self-rooted rate falls to 2% — but only because the wider sample is
mostly end-user projects, not installable libraries. The repos that are libraries
(threadlib, agentscad, …) still self-root, and pass-2 consumers overwhelmingly import
libraries by qualified root name (MCAD/…, BOSL2/…). Install-under-own-name holds.
3. The namespace is flat and already colliding. → one version per identity
- 412 module/function names are declared in ≥2 surveyed repos.
reverseappears in 7,tubein 6, andarc/chamfer/flatten/ring/screw/unitin 5 each. Some libraries even redeclare OpenSCAD built-ins (rotate,scale).
OpenSCAD has no module namespacing, and include is textual. Exposing two libraries
(or two versions of one) on the same path risks silent redefinition. This is direct
evidence for one resolved version per package identity per environment and against
a shared global library directory. At pass-2 scale the collision set grows to 2,705
names (top name in 22 repos) — the risk scales with the ecosystem.
4. Imports use two resolution styles. → the environment must serve both
Of 4,461 import edges across the corpus (74% resolve within a repo, 26% do not), the external edges split into:
- qualified (635) — name a library directory, e.g.
<BOSL2/std.scad>; - bare (514) — a plain filename resolved only via
OPENSCADPATH, e.g.<arc.scad>.
use and include are used in near-equal measure (2,408 / 2,053). The environment
scadman constructs must satisfy both styles: named-library directories and a
configured OPENSCADPATH search root. Pass 2 sharpens this: 79% of external imports
are qualified (name a library root), up from 55% — the named-directory case dominates.
5. Structure is genuinely heterogeneous. → artifact/package/project split holds
- Layouts: nested 71%, flat-multi 14%, single-file 11%, src-layout 4% (pass 2: nested 58%, single-file 21% — the small end gets heavier in the wild).
.scadfiles per repo range 1 → 964 (median 16); pass 2 includes a generated corpus of 36k files.- Only 21% ship
tests/; 54% shipexamples/.
No single “package” shape fits. This supports declared library-roots, optional workspaces, and treating raw artifacts (single files, unversioned repos) as first-class — the artifact/package/project distinction rather than a forced package abstraction.
6. Much of the ecosystem is unlicensed. → license is optional metadata
- Pass 1’s curated set was 93% licensed, but in the wild 41% of repos declare no license at all (pass 2, n=200); of those that do, MIT 18% and GPL-3.0 16% lead.
scadman’s package and registry model must treat a license as optional/unknown rather than assumed, and surface “unlicensed” honestly rather than guessing one.
Caveats
Findings come from two samples, not a census: pass 1 (28 curated repos, deliberately
eyeball-able) and pass 2 (200 search-discovered repos, 168 content-analyzed). Pass 2
confirmed the direction of every finding above; the one rate divergence (self-rooting)
is explained by pass 2 being weighted toward end-user projects rather than libraries.
Internal-edge counts are an upper bound (resolution is checked importer-relative or
repo-root, looser than OpenSCAD’s own), and parsing is regex-based. One generated corpus
(36k .scad files) was excluded from edge aggregates — itself a signal that
registry-scale tooling must survive degenerate-scale repos.
Resolver direction (v1)
Design record — background on a settled decision, not user documentation.
Grounded in a research pass over the ecosystem survey (ecosystem-survey.md,
28 curated + 200 wild repos) and solver prior art (PubGrub, Go MVS, backtracking, OPM,
olman). This records the decision so it isn’t relitigated.
Finding: there is nothing to solve yet
- 81% of wild repos declare zero external dependencies. Of the 19% that do, 75% have exactly one; the corpus max is 5 — all direct, all leaves.
- Max dependency depth is 2, converging on a single version. Zero version conflicts and zero divergent diamonds in 196 repos.
- Zero formal dependency manifests exist in the wild. The only real declaration mechanism is git submodules (8 repos) — exact-rev pins, i.e. scadman’s model already.
- 54–63% of libraries have no tags, so an exact git SHA is the only expressible constraint → at most one candidate per package. The search that PubGrub/backtracking/MVS exist to perform structurally cannot occur.
So v1 resolution is collect + unify + detect-conflict, not constraint solving.
Decision: “collect-and-unify”
Deterministic DFS from the root scadman.toml. For each dependency: resolve its ref to
an exact commit SHA via the existing git-acquisition layer, fetch it into the store,
recurse into its scadman.toml if it ships one (cycle guard + depth cap), unify by
identity. Two different revs for one identity is a hard error — no automatic join.
~100–300 lines, zero new dependencies.
Formalize the data model now even though the algorithm is trivial, so evolution is contained:
Requirement { identity, constraint, required_by }— keep provenance (olman’s fatal bug was dropping it and ending atException("Can't construct graph")).- A structured
Conflicterror type. - A narrow interface:
manifest + fetcher → ResolvedSet | Conflict. The lockfile writer, store, and env builder consumeResolvedSetand never see the algorithm — so a later swap to pubgrub-rs is a contained change.
Cut from v1 (trigger conditions occur 0× in 196 repos)
- Pseudo-version strings — plain SHAs only (a sortable-looking string invites bad sorts).
- A reserved
Range(semver)enum variant — adding it later is a contained change. - The polished two-chain conflict renderer — keep
required_by+ a plain duplicate-identity error; build the pretty renderer when depth-2 manifests actually exist.
Added to v1 (the one thing the data shows firing today)
- Post-install include-scan. Both real transitive chains (
agentscad,openscad_annotations) are manifest-less, so a user addingagentscadotherwise gets a raw OpenSCADcan't open include file "scad-utils/…". The survey’sexternal_libsextraction is this scan (~50 lines): diff installed deps’use/includetargets against the resolved set → “agentscadimportsscad-utils/, not in your manifest — add it.” It is also the honest replacement for a hermeticity guaranteeOPENSCADPATHcannot deliver (see corrections). As shipped, the scan became reachability-based — it starts from the project’s own.scadfiles and follows import edges, warning only about undeclared libraries the project actually reaches — after the diff-everything design produced false positives on real libraries.
Alternatives and why they lost
| Option | Verdict |
|---|---|
| PubGrub (pubgrub-rs) | CDCL never fires with ≤1 candidate; git SHAs have no ordering (uv itself pins git deps before PubGrub). Right aspiration (its error-message quality), wrong machinery. Clean swap-in later. |
| Go MVS | Its max() join needs a semver-compatibility contract this ecosystem lacks (63% untagged); under exact pins it degenerates to collect-and-unify. Adopt its properties (determinism, integrity records), not its algorithm. |
| Backtracking (olman/resolvelib) | Only matters with multiple candidates, which don’t exist under exact pins → untestable dead branches now. If search becomes real, go straight to pubgrub-rs. |
| npm nesting / OPM source-rewriting | Mechanically impossible under a flat namespace; rewriting breaks content-addressing and verification. Already a recorded decision. |
v1 scope
Does: recursively collect deps (root + any dep shipping a scadman.toml); resolve
every ref → exact SHA at lock time; write lockfile entries {identity, source, commit, content-hash}; enforce one identity → one rev; detect identity collisions (same name,
different canonical URL); terminate on cycles; deterministic output. Plus the
post-install include-scan.
Does not: solve/compare/order versions; backtrack or learn clauses; auto-resolve conflicts; rewrite source; nest versions; touch a registry; manage global installs.
Settled decisions
- Identity = name bound to canonical source URL, URL authoritative. This is the one
choice that becomes a lockfile migration if wrong. As built, normalization strips a
trailing slash and a
.gitsuffix only. Unifying https/ssh and letter case, and following GitHub renames and transfers, was planned as part of this rule and is not implemented: two spellings of one repository are two identities (#98). - Manifest-less transitive deps: the user declares them directly; the include-scan guides them. No curated metadata overlay in v1.
- Overrides: deferred entirely. Dependency tables reject unknown keys, so adding per-dependency override syntax later is a non-breaking change; the hard identity-conflict error must not promise a remedy that doesn’t exist yet.
- Branch refs: allowed, but always locked to an exact SHA in the lockfile.
- Bundled libs (MCAD): fetchable in v1, no special “provided” casing. It is
shadowable via
OPENSCADPATH, so a pinned copy is reproducible.
Corrections carried forward
- MCAD is shadowable via
OPENSCADPATH— scadman can manage the most-depended-on library. - Replacing
OPENSCADPATHdoes not stop OpenSCAD searching the user and installation library folders. The honest guarantee is “declared deps shadow globals,” not “undeclared deps fail” — hence the include-scan. - The lock step (manifest → lockfile) is not pure: a branch/moving-tag resolves to different SHAs on different days. Determinism holds for lockfile → environment.
Upgrade trigger
Adopt pubgrub-rs (not a hand-rolled backtracker) when either: a registry introduces real semver ranges with multiple candidates per identity, or library-on-library depth materializes with multi-step conflict chains (currently ~1% of repos). Until then a solver is dead machinery.