CLI and exit codes
Full sketch in crates/knixl/src/main.rs. The design rule: Plan::compute is the only thing that inspects the world, every command is a thin policy over the same Plan, and exit codes are stable and documented so CI can branch on them.
Finding your project
Section titled “Finding your project”Every command discovers its project root by walking up from the current directory: the first directory holding either knixl.lock.kdl or a hosts/ directory wins (discover_root in crates/knixl/src/main.rs). If neither turns up before the filesystem root, the starting directory is used as-is. A local modules/ directory is optional: the curated stdlib modules are embedded in the binary, so most projects need no modules/ at all. When present it sits beside hosts/ at the root (resolved as <root>/modules) and its modules take precedence over the stdlib. Run knixl from inside a project tree, not above or beside it.
Commands
Section titled “Commands”-
knixl plan [--detailed-exitcode]: recompute and report, write nothing. Default exit 0; opt into scriptable codes (Terraform-style) with the flag. -
knixl check: CI gate. Succeed only if every file isClean. Never writes, never prompts. -
knixl generate [--accept-drift] [--prune]: apply. Silent forStale/Missing, refusesDriftedwithout--accept-drift, refuses version skew (points atupgrade), deletesOrphanedonly with--prune.generateloads all declared fetched modules offline from the lock-pinned cache, verifies each manifest’s content hash against its lock pin, and refuses with a hard error if any hash mismatch occurs (no silent refetch). A project that declares a fetched module with no lock pin fails with exit 5 (Validation), telling the user to runinstallorupgradeto resolve it (ADR 0010).When the project’s
knixl.kdldeclares asystem { state-version "<rel>" }block,generatealso emitsgenerated/flake.nix, a generated and locked artefact definingnixosConfigurations.<host>for every host, each pinned to that host’s baseline nixpkgs rev. Consume it withnixos-rebuild switch --flake .#<host>ornixos-anywhere --flake .#<host>. Withoutsystem {},generateproduces modules only, not a bootable system; the assembly flake remains a deliberate hand-written seam (ADR 0009). A host with no resolved baseline refuses to generate (exit 5), pointing atinstallorupgrade.Declaring
inputnodes insidesystem {}switches the flake to input mode (ADR 0014), for a system that needs modules from other flakes (disko, sops-nix and so on):system {state-version "25.11"formatter "nixfmt-rfc-style"input "nixpkgs" url="github:NixOS/nixpkgs"input "disko" url="github:nix-community/disko" rev="<commit>" {follows nixpkgs="nixpkgs"}}An
input "nixpkgs"is required once any input is declared, and it replacesnixpkgs-url(declaring both is refused). Its rev is the hosts’ baseline, so every host has to sit on the same baseline rev; a fleet split across releases stays on the input-free flake. A host can pin its baseline to an exact commit withnixpkgs release="unstable" rev="<commit>"(e.g. the commit a running system was built from), which is recorded as written rather than resolved from the release branch. Every other input is pinned byupgrade: a declaredrev=is recorded as-is, anything else resolves to the current commit (the built-in resolver handlesgithub:refs,KNIXL_MODULE_RESOLVERoverrides it), and agithub:url that carries a branch or tag is refused, so pin withrev=instead. The pins land inknixl.lock.kdlasflake-inputlines and the generated flake writes each rev into its input’s url, so thegenerated/flake.locknix writes is fixed by the knixl lock. Hosts and images are built withnixpkgs.lib.nixosSystem, hosts and installers passing theirsystem.formatter "<attr>"addsformatter.<system> = nixpkgs.legacyPackages.<system>.<attr>for each host system, sonix fmtworks. Anoracle-modulesentry can name an input in place of a flake ref,module "disko" input="disko" attr="disko", and then one declaration does both jobs: the oracle validates against that module’s options at the input’s pinned rev, and each host’snixosSystemlistsinputs."disko".nixosModules."disko"ahead of the host module. Input modules go to hosts only, never to installer or guest-image targets, and a host’s ownoracle-modulesblock replaces the project’s set for that host as usual (ADR 0008). The oracle can only build options for agithub:input. With sops-nix as an input,secrets backend="sops-nix" input="sops-nix" { default-file "secrets/<host>.yaml"; ssh-key-paths "/etc/ssh/ssh_host_ed25519_key" }wires it in for every host: the flake importsinputs."sops-nix".nixosModules."sops"(once, even whenoracle-modulesalso lists it, so declare it there too if you’d like the oracle to checksops.*paths), then adds a module after the host’s own that setssops.defaultSopsFileandsops.age.sshKeyPathsand declaressops.secrets."<name>" = { }for every(secret)the host references.default-fileis relative toknixl.kdl, must stay inside the project, and has to be tracked by git for the flake to see it. Withoutinput=,secretsonly chooses what a(secret)reference resolves to, as before. After the firstgenerate(and after anupgradethat moves a rev),git add generated/and then runnix flake lockingenerated/(in a git flake nix only sees tracked files, so locking before the add fails), and commitflake.lock: knixl doesn’t hash or prune it, andcheckfails (exit 5) when it’s missing or pins any input at a different rev thanknixl.lock.kdl.A top-level
installer "<name>" [system="<double>"] { <modules> }block inknixl.kdldeclares installer media (ADR 0012).generateemitsgenerated/installer/<name>.nix: the block’s children are ordinary knixl modules (tailscale,openssh,user,os, …) lowered like a host, with the minimalinstallation-cdbase imported ahead of them. Whensystem {}is also declared, the assembly flake gains anixosConfigurations.<name>entry plus apackages.<system>."<name>-iso"output pinned to the project’s nixpkgs rev, sonix build .#<name>-isoproduces a bootable ISO (e.g. one that joins a tailnet sonixos-anywherecan reach the target). The tailnet auth key is supplied at build time, not via the runtime(secret)mechanism, since a live ISO has no secret-decryption infrastructure.A
guest-image "<name>" [system="<double>"] { <modules> }block is the same mechanism with a different output (ADR 0013): a NixOS system built as an lxc image for Incus, rather than an nspawncontainers.<name>(which is theguestmodule, ADR 0011).generateemitsgenerated/guest-image/<name>.nix, lowering the module tree like a host with thelxc-containerbase imported. Alongsidesystem {}, the flake gains anixosConfigurations.<name>entry and two package outputs,packages.<system>."<name>-lxc"(the rootfs,config.system.build.tarball) and"<name>-lxc-metadata"(config.system.build.metadata), sonix build .#<name>-lxcand.#<name>-lxc-metadataproduce whatincus image importtakes. knixl produces the image only; importing and launching it (incus image import,incus launch -p …) stays with the operator. Araw-nixseam covers guest bits NixOS options do not model (e.g. a ROCm/ollama profile). -
knixl upgrade [--yes]: the only path that changes recorded versions. Shows per-module migration notes and a diff, applies on--yes, then bumps tool/module/formatter/oracle versions together. Also resolves any host’s declarednixpkgs release="<rel>"that has no lock entry yet, or whose lock entry is stale, writing the resolved baseline alongside the version bump (see the baseline note underinstall). Additionally, resolves any declared fetched modules (declared inmodules { module "name" flake="..." }blocks inknixl.kdlor a host’soracle-modulesoverride, see ADR 0010) that have no lock pin yet. Module resolution and all network requests happen only duringupgrade(andinstallfor package pins); both network and lock writes occur after the confirm gate (behind--yeson the CLI), so the lock is never updated unless the user has explicitly approved the changes. -
knixl doc <node>: typed reference fromschema(). For example,knixl doc web-service:web-service: Hardened nginx reverse-proxy virtual host.Arguments:host : string (required) Virtual host name.Children:upstream : string (required) Proxy target URL.acme : nodehardened : bool Add recommended security headers.alias : string (repeated) Additional server name.location : node (repeated) Extra proxied path. -
knixl install <pkg> [--host <name>] [--yes] [--strict] [--build] [--no-abi-check]: add a package to a host. Drafts apackage "<pkg>"node into the host KDL (format-preserving), verifies it (generate + oracle, then a nix eval that the package resolves and the file parses).<pkg>can be a package name (e.g.curl) or a versioned formpkg@version(e.g.curl@8.4.0); a version pins the package to a specific nixpkgs commit, resolved by default via a built-in resolver that queries the nixhub/devbox version index over HTTPS (honouringHTTP_PROXY/HTTPS_PROXY/NO_PROXY);KNIXL_PIN_RESOLVERoverrides it with an external<name> <version>-><commit>command; resolution is at install time, recorded per host in the lock, and emitted from that pinned commit viabuiltins.fetchGit { url; rev; }mixed into the host baseline (a full 40-char git rev is a complete pure pin on its own, so no sha256 is needed); an unresolvable version refuses (exit 5). With[--build], verification additionally builds the package derivation (pkgs.<pkg>) from the pinned rev; nix-absent skips unless--strict, and a failed build gates the apply; pairing--buildwith version pinning catches cross-rev build breakage. In the TUI this appears as a build status row per package (built once per package, not re-run on host switch); the pin state appears as a row when a version is set. Previews the change, and on confirmation regenerates through the apply path. Host order:--host, then thedefault=#truehost, then the sole host. A nix that is not on PATH is a warning that skips the eval, unless--strictmakes it an error; a package that does not resolve always refuses. On an interactive terminal (and without--yes) it opens the Install screen of the TUI (seeknixl tui): switch the target host, edit the package, watch it verify (async, with a spinner), scroll the generated.nix, and apply or cancel; piped, in CI, or under--yesit uses the plain[y/N]confirm.A version pin resolves to one of two emit strategies (ADR 0006):
override(pkgs.<pkg>.overrideAttrswithversion/srctaken from the pinned commit, built against the host’s baseline deps; lean, tried first) orcommit-mix(the ADR 0005 default and fallback: the whole historical package, built against its own era’s deps). Selection happens automatically at pin time by build-testingoverridefirst and falling back tocommit-mixonly if it fails to build;--no-abi-checkskips that build-feasibility test entirely and always takescommit-mix. The chosen strategy is recorded per pin in the lock, visible asstrategy="override"on the pin line (an absentstrategyattr meanscommit-mix).A host may also declare a baseline nixpkgs release:
nixpkgs release="<rel>"as a child node ofhost(e.g.nixpkgs release="25.05"). It is metadata only, never emitted into the generated.nix. It resolves to a commit only atinstall/upgradetime (via the same built-in resolver,git ls-remoteagainst thenixos-<rel>branch with a GitHub API fallback, orKNIXL_BASELINE_RESOLVERfor an external override), and is recorded as abaseline release="<rel>" nixpkgs-rev="<commit>" options-hash="<hash>"line per host in the lock, beside that host’spinlines. That baseline drives both the host’s oracle validation and its pin-strategy feasibility test. A declared release with no resolved lock entry refuses (exit 5), the same as an unresolved package pin. -
knixl tui: the interactive hub (bubbletea + lipgloss). Home routes to three screens: Install (as above), Browse (list registered modules built-in and declarative, read a module’s schema doc, and scaffold its node into a host), and New module (author a declarative module: build its schema (args, props, children, including structured children with nested sub-fields) and a free-text emit template, validated live against the dry type-pass as you type, then write it tomodules/<name>/knixl-module.kdl). A movable focus selector drives every control, the layout resizes to the terminal, and it refuses to launch on a non-TTY rather than hanging.
--json is global for machine-readable output. One object per run: {"files":[{"path","state"}],"warnings":[...]} normally, or {"validation":[...]} when validation refused (that path returns before a plan exists). Warnings are in the object as well as on stderr, so CI can branch on them rather than scraping the log.
Environment variables
Section titled “Environment variables”| Variable | Overrides | Default when unset |
|---|---|---|
KNIXL_FORMATTER |
The formatter binary run to format generated Nix. | Autodetected: the first of nixfmt, nixfmt-rfc-style that runs; nixfmt if neither does. |
KNIXL_NIX |
The nix evaluation binary used for the install package/parse checks. |
nix-instantiate |
KNIXL_NIX_BUILD |
The nix build binary used for install --build and the pin-strategy feasibility test. |
nix-build |
KNIXL_OPTIONS_JSON |
A prebuilt oracle options.json, used to validate every host regardless of its baseline rev. |
Per host: the cached options set for that host’s baseline rev (or the lock’s default rev), if cached; otherwise validation is skipped for that host. |
KNIXL_PIN_RESOLVER |
An external <bin> <name> <version> command that resolves a package pin to a nixpkgs commit. |
The built-in resolver: queries the nixhub/devbox version index over HTTPS. |
KNIXL_BASELINE_RESOLVER |
An external <bin> <release> command that resolves a host’s declared nixpkgs release to a commit. |
The built-in resolver: git ls-remote against the nixos-<release> branch, falling back to the GitHub commits API if git is unavailable or fails. |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY |
Proxying for the built-in pin and baseline resolvers’ HTTPS requests. | No proxy. |
Exit codes
Section titled “Exit codes”One enum, precedence spelled out (severity order is not numeric order):
0 Clean: nothing to do, or applied cleanly.1 Internal: a panic turned into an error.2 Usage: clap default.3 Drift: a generated file was hand-edited (tainted).4 NeedsAck: version skew would change output; needsupgradeor--yes.5 Validation: KDL schema error or oracle type / unknown-option error. Includes a node no module claims and an unknown child of a claimed one, at any depth: knixl refuses to emit from KDL it cannot fully interpret, because the lock records the emitted Nix rather than the intent of the KDL, so a dropped node is invisible to every later gate (#85). There is no flag to downgrade it.6 RegenPending:Stale/Missing/Orphaned; inputs changed, regeneration owed.
Precedence, most severe first: Validation beats everything (you cannot trust a plan built on invalid input). Drift beats skew (silent overwrite would lose human edits). Skew (NeedsAck) beats plain RegenPending (a version bump is a bigger claim than an input edit).
fn verdict(plan: &Plan) -> Code { if plan.has_validation_errors() { return Code::Validation; } if plan.any(FileState::is_drifted) { return Code::Drift; } if plan.requires_ack() { return Code::NeedsAck; } if plan.any(FileState::is_dirty) { return Code::RegenPending; } Code::Clean}How the commands map policy over the plan
Section titled “How the commands map policy over the plan”check: print the plan, returnverdict. Nothing else. This is the line you put in CI.generate: refuse outright ifplan.requires_ack()(skew must go throughupgrade). Otherwise applyStale/Missing, applyDriftedonly under--accept-drift(retaking the hash), leave otherDriftedas exit 3, deleteOrphanedonly under--prune. Commit the lock only on a fully clean apply, so a partial or refused run never leaves the recorded hashes lying about what is on disk.upgrade: print migration notes keyed by(module, version delta), print the plan, require--yes, then write files and bump every version in the lock together.
How this satisfies the original requirements
Section titled “How this satisfies the original requirements”- Immutable recreation:
knixl checkrecomputes expected output from committed KDL + locked versions, hashes it, exits 0 only if every file is byte-identical. Safe to run anywhere (no writes). - No silent regressions on upgrade: the
generate/upgradesplit.generaterefuses skew (exit 4),upgradeforces a human past notes and diff before rewriting versions. - Taint: the
Driftedstate and exit 3. Detected by the third hash. Forward paths are reconcile-to-KDL or an explicit--accept-driftthat knowingly discards the edit.
