Skip to content

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.

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.

  • 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 is Clean. Never writes, never prompts.

  • knixl generate [--accept-drift] [--prune] : apply. Silent for Stale/Missing, refuses Drifted without --accept-drift, refuses version skew (points at upgrade), deletes Orphaned only with --prune.

    generate loads 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 run install or upgrade to resolve it (ADR 0010).

    When the project’s knixl.kdl declares a system { state-version "<rel>" } block, generate also emits generated/flake.nix, a generated and locked artefact defining nixosConfigurations.<host> for every host, each pinned to that host’s baseline nixpkgs rev. Consume it with nixos-rebuild switch --flake .#<host> or nixos-anywhere --flake .#<host>. Without system {}, generate produces 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 at install or upgrade.

    Declaring input nodes inside system {} 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 replaces nixpkgs-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 with nixpkgs 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 by upgrade: a declared rev= is recorded as-is, anything else resolves to the current commit (the built-in resolver handles github: refs, KNIXL_MODULE_RESOLVER overrides it), and a github: url that carries a branch or tag is refused, so pin with rev= instead. The pins land in knixl.lock.kdl as flake-input lines and the generated flake writes each rev into its input’s url, so the generated/flake.lock nix writes is fixed by the knixl lock. Hosts and images are built with nixpkgs.lib.nixosSystem, hosts and installers passing their system. formatter "<attr>" adds formatter.<system> = nixpkgs.legacyPackages.<system>.<attr> for each host system, so nix fmt works. An oracle-modules entry 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’s nixosSystem lists inputs."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 own oracle-modules block replaces the project’s set for that host as usual (ADR 0008). The oracle can only build options for a github: 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 imports inputs."sops-nix".nixosModules."sops" (once, even when oracle-modules also lists it, so declare it there too if you’d like the oracle to check sops.* paths), then adds a module after the host’s own that sets sops.defaultSopsFile and sops.age.sshKeyPaths and declares sops.secrets."<name>" = { } for every (secret) the host references. default-file is relative to knixl.kdl, must stay inside the project, and has to be tracked by git for the flake to see it. Without input=, secrets only chooses what a (secret) reference resolves to, as before. After the first generate (and after an upgrade that moves a rev), git add generated/ and then run nix flake lock in generated/ (in a git flake nix only sees tracked files, so locking before the add fails), and commit flake.lock: knixl doesn’t hash or prune it, and check fails (exit 5) when it’s missing or pins any input at a different rev than knixl.lock.kdl.

    A top-level installer "<name>" [system="<double>"] { <modules> } block in knixl.kdl declares installer media (ADR 0012). generate emits generated/installer/<name>.nix: the block’s children are ordinary knixl modules (tailscale, openssh, user, os, …) lowered like a host, with the minimal installation-cd base imported ahead of them. When system {} is also declared, the assembly flake gains a nixosConfigurations.<name> entry plus a packages.<system>."<name>-iso" output pinned to the project’s nixpkgs rev, so nix build .#<name>-iso produces a bootable ISO (e.g. one that joins a tailnet so nixos-anywhere can 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 nspawn containers.<name> (which is the guest module, ADR 0011). generate emits generated/guest-image/<name>.nix, lowering the module tree like a host with the lxc-container base imported. Alongside system {}, the flake gains a nixosConfigurations.<name> entry and two package outputs, packages.<system>."<name>-lxc" (the rootfs, config.system.build.tarball) and "<name>-lxc-metadata" (config.system.build.metadata), so nix build .#<name>-lxc and .#<name>-lxc-metadata produce what incus image import takes. knixl produces the image only; importing and launching it (incus image import, incus launch -p …) stays with the operator. A raw-nix seam 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 declared nixpkgs 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 under install). Additionally, resolves any declared fetched modules (declared in modules { module "name" flake="..." } blocks in knixl.kdl or a host’s oracle-modules override, see ADR 0010) that have no lock pin yet. Module resolution and all network requests happen only during upgrade (and install for package pins); both network and lock writes occur after the confirm gate (behind --yes on the CLI), so the lock is never updated unless the user has explicitly approved the changes.

  • knixl doc <node> : typed reference from schema(). 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 : node
    hardened : 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 a package "<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 form pkg@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 (honouring HTTP_PROXY/HTTPS_PROXY/NO_PROXY); KNIXL_PIN_RESOLVER overrides 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 via builtins.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 --build with 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 the default=#true host, then the sole host. A nix that is not on PATH is a warning that skips the eval, unless --strict makes 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 (see knixl 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 --yes it uses the plain [y/N] confirm.

    A version pin resolves to one of two emit strategies (ADR 0006): override (pkgs.<pkg>.overrideAttrs with version/src taken from the pinned commit, built against the host’s baseline deps; lean, tried first) or commit-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-testing override first and falling back to commit-mix only if it fails to build; --no-abi-check skips that build-feasibility test entirely and always takes commit-mix. The chosen strategy is recorded per pin in the lock, visible as strategy="override" on the pin line (an absent strategy attr means commit-mix).

    A host may also declare a baseline nixpkgs release: nixpkgs release="<rel>" as a child node of host (e.g. nixpkgs release="25.05"). It is metadata only, never emitted into the generated .nix. It resolves to a commit only at install/upgrade time (via the same built-in resolver, git ls-remote against the nixos-<rel> branch with a GitHub API fallback, or KNIXL_BASELINE_RESOLVER for an external override), and is recorded as a baseline release="<rel>" nixpkgs-rev="<commit>" options-hash="<hash>" line per host in the lock, beside that host’s pin lines. 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 to modules/<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.

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.

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; needs upgrade or --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
}
  • check : print the plan, return verdict. Nothing else. This is the line you put in CI.
  • generate : refuse outright if plan.requires_ack() (skew must go through upgrade). Otherwise apply Stale/Missing, apply Drifted only under --accept-drift (retaking the hash), leave other Drifted as exit 3, delete Orphaned only 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 check recomputes 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/upgrade split. generate refuses skew (exit 4), upgrade forces a human past notes and diff before rewriting versions.
  • Taint: the Drifted state and exit 3. Detected by the third hash. Forward paths are reconcile-to-KDL or an explicit --accept-drift that knowingly discards the edit.