nix-workspace#
A Nickel-powered workspace manager for Nix flakes.
nix-workspace replaces flakelight and similar flake frameworks with a configuration layer built on Nickel. It leverages Nickel's contract system and gradual typing to provide validated, well-documented workspace configuration with clear error messages — for both humans and AI agents.
How it works#
┌─────────────────┐ Nickel ┌──────────────┐ JSON ┌─────────────┐
│ workspace.ncl │ ──evaluate──▶ │ Validated │ ──export──▶ │ Nix library │
│ (user config) │ │ config tree │ │ (builders) │
└─────────────────┘ └──────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ Flake │
│ outputs │
└─────────────┘
- Nickel layer — Defines workspace structure. Contracts validate all configuration. Exports a JSON-serializable config tree.
- Nix layer — A library consumes the evaluated config and produces standard flake outputs using nixpkgs builders.
- Flake shim — A thin
flake.nixthat callsnix-workspacewith the workspace root.
Quick start#
1. Create flake.nix#
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nix-workspace.url = "git+https://tangled.org/@overby.me/overby.me?dir=nix-workspace";
};
outputs = inputs:
inputs.nix-workspace ./. {
inherit inputs;
};
}
2. Create workspace.ncl#
{
name = "my-project",
description = "My Nix workspace",
systems = ["x86_64-linux", "aarch64-linux"],
nixpkgs = {
allow-unfree = true,
},
packages = {
my-tool = {
src = "./src",
build-system = "rust",
description = "A CLI tool",
},
},
shells = {
default = {
packages = ["cargo", "rustc", "rust-analyzer"],
},
},
}
3. Build and develop#
nix build .#my-tool
nix develop
Alternative: Use the CLI#
# Install the CLI
nix profile install "git+https://tangled.org/@overby.me/overby.me?dir=nix-workspace"
# Initialize a new workspace
nix-workspace init my-project
cd my-project
# Validate configuration
nix-workspace check
# Show workspace structure
nix-workspace info
# Build a package
nix-workspace build my-tool
# Enter a dev shell
nix-workspace shell
Directory conventions#
Place .ncl files in convention directories and they are auto-discovered:
| Directory | Flake output | Description |
|---|---|---|
packages/ |
packages.<system>.<name> |
Package definitions |
shells/ |
devShells.<system>.<name> |
Development shells |
machines/ |
nixosConfigurations.<name> |
NixOS machine configs |
modules/ |
nixosModules.<name> |
NixOS modules |
home/ |
homeModules.<name> |
Home-manager modules |
overlays/ |
overlays.<name> |
Nixpkgs overlays |
lib/ |
lib.<name> |
Library functions |
templates/ |
templates.<name> |
Flake templates |
checks/ |
checks.<system>.<name> |
CI checks |
Convention directories are configurable:
{
conventions = {
packages.path = "pkgs", # Use pkgs/ instead of packages/
overlays.auto-discover = false, # Disable auto-discovery for overlays
},
}
CLI#
The nix-workspace CLI provides a standalone interface for working with workspaces without writing Nix code directly.
Commands#
| Command | Description |
|---|---|
nix-workspace init [path] |
Initialize a new workspace with scaffold files |
nix-workspace check |
Validate workspace.ncl against contracts |
nix-workspace info |
Show workspace structure and discovered outputs |
nix-workspace build [name] |
Build a package (delegates to nix build) |
nix-workspace shell [name] |
Enter a dev shell (delegates to nix develop) |
Global flags#
| Flag | Description |
|---|---|
--format human|json |
Output format (default: human) |
--workspace-dir DIR |
Override workspace root directory |
JSON diagnostic output#
All commands support --format json for machine-parseable output, following the structured diagnostics schema from the spec:
nix-workspace check --format json
{
"diagnostics": [
{
"code": "NW001",
"severity": "error",
"file": "workspace.ncl",
"line": 3,
"message": "Expected System, got \"x86-linux\"",
"hint": "Did you mean \"x86_64-linux\"?"
}
]
}
On-the-fly flake generation#
If a workspace has workspace.ncl but no flake.nix, the CLI generates a temporary flake automatically when running build or shell commands:
# Works even without a flake.nix!
nix-workspace build hello
nix-workspace shell
The generated flake reference can be customized via the NIX_WORKSPACE_FLAKE_REF environment variable.
Init options#
# Initialize with specific systems and plugins
nix-workspace init my-project \
--systems x86_64-linux,aarch64-linux,aarch64-darwin \
--plugins nix-workspace-rust \
--conventions packages,shells,modules
# Initialize without generating a flake.nix (standalone mode)
nix-workspace init my-project --no-flake
Environment variables#
| Variable | Description |
|---|---|
NIX_WORKSPACE_CONTRACTS |
Path to the nix-workspace contracts directory |
NIX_WORKSPACE_PLUGINS |
Path to the nix-workspace plugins directory |
NIX_WORKSPACE_FLAKE_REF |
Flake reference for on-the-fly generation |
System multiplexing#
Declare systems once — they apply everywhere:
{
systems = ["x86_64-linux", "aarch64-linux"],
packages = {
my-tool = {
build-system = "rust",
},
linux-only = {
systems = ["x86_64-linux"], # Override for this package only
build-system = "rust",
},
},
}
You never write packages.x86_64-linux.my-tool — you write packages.my-tool and the system dimension is managed for you.
NixOS machines#
Define NixOS machine configurations directly in workspace.ncl or auto-discover them from the machines/ directory:
{
name = "my-infra",
machines = {
workstation = {
system = "x86_64-linux",
state-version = "25.05",
modules = ["desktop"], # References modules/ directory
host-name = "my-workstation",
time-zone = "Europe/Copenhagen",
locale = "en_US.UTF-8",
boot-loader = 'systemd-boot,
file-systems = {
"/" = {
device = "/dev/disk/by-label/nixos",
fs-type = "ext4",
},
"/boot" = {
device = "/dev/disk/by-label/boot",
fs-type = "vfat",
needed-for-boot = true,
},
},
networking = {
firewall = {
enable = true,
allowed-tcp-ports = [22, 80, 443],
},
},
users = {
alice = {
extra-groups = ["wheel", "docker", "video"],
home-manager = true,
home-modules = ["shell", "editor"],
},
},
},
},
}
Each machine entry produces a nixosConfigurations.<name> flake output built with nixpkgs.lib.nixosSystem.
Machine config fields#
| Field | Type | Default | Description |
|---|---|---|---|
system |
System |
(required) | Target architecture |
state-version |
StateVersion |
"25.05" |
NixOS state version ("YY.MM") |
modules |
Array ModuleRef |
[] |
NixOS modules to include |
host-name |
String |
machine name | Hostname |
special-args |
{ _ : Dyn } |
{} |
Extra args passed to NixOS modules |
users |
{ _ : UserConfig } |
{} |
Per-user configurations |
boot-loader |
'systemd-boot | 'grub | 'none |
'systemd-boot |
Boot loader to configure |
file-systems |
{ _ : FileSystemConfig } |
{} |
Mount points and devices |
networking |
NetworkingConfig |
{} |
Networking and firewall settings |
time-zone |
String |
(optional) | Time zone (e.g. "Europe/Copenhagen") |
locale |
String |
(optional) | Default locale (e.g. "en_US.UTF-8") |
extra-config |
{ _ : Dyn } |
{} |
Escape hatch: raw NixOS config options |
Usage#
# Build the system configuration
nix build .#nixosConfigurations.workstation.config.system.build.toplevel
# Switch to the new configuration
sudo nixos-rebuild switch --flake .#workstation
NixOS modules#
NixOS modules can be declared in workspace.ncl or auto-discovered from the modules/ directory. Each module has two parts:
modules/<name>.ncl— Nickel config (metadata, imports, validation)modules/<name>.nix— NixOS module implementation
# modules/desktop.ncl
{
description = "Desktop environment with GNOME",
imports = [],
options-namespace = "services.xserver",
}
# modules/desktop.nix
{ config, lib, pkgs, ... }: {
services.xserver.enable = true;
services.xserver.desktopManager.gnome.enable = true;
# ...
}
Modules are referenced by name in machine configs (modules = ["desktop"]) and are exposed as nixosModules.<name> flake outputs.
Module config fields#
| Field | Type | Default | Description |
|---|---|---|---|
description |
String |
(optional) | Human-readable description |
imports |
Array ModuleRef |
[] |
Other modules this module depends on |
options-namespace |
String |
(optional) | NixOS option path (e.g. "services.x") |
platforms |
Array String |
(optional) | Compatible systems |
path |
String |
(optional) | Explicit path to the .nix module file |
extra-config |
{ _ : Dyn } |
{} |
Additional config merged into the module |
Home-manager modules#
Home-manager modules follow the same pattern as NixOS modules but live in the home/ directory and are exposed as homeModules.<name> flake outputs.
# In workspace.ncl or home/shell.ncl
{
home = {
shell = {
description = "ZSH shell configuration",
path = "./home/shell.nix",
},
editor = {
description = "Neovim editor configuration",
imports = ["shell"],
},
},
}
Home modules are referenced in machine user configs:
{
machines = {
my-pc = {
system = "x86_64-linux",
users = {
alice = {
home-manager = true,
home-modules = ["shell", "editor"],
},
},
},
},
}
Contracts#
nix-workspace ships Nickel contracts that validate your configuration and provide clear error messages:
error: contract broken by the value of `system`
invalid system "x86-linux"
┌─ contracts/machine.ncl:39:9
│
39 │ | System
│ ------ expected type
│
┌─ machines/my-machine.ncl:3:13
│
3 │ system = "x86-linux",
│ ^^^^^^^^^^^ applied to this expression
│
= Valid systems: x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin
error: contract broken by the value of `state-version`
invalid state version "unstable"
│
= State version must match the pattern "YY.MM" (e.g. "24.11", "25.05").
= This corresponds to the NixOS release version.
error: missing definition for `system`
┌─ contracts/machine.ncl:38:5
│
38 │ system
│ ^^^^^^ required here
Contract hierarchy#
Workspace
├── WorkspaceConfig # Top-level workspace.ncl structure
├── NixpkgsConfig # nixpkgs settings
├── ConventionConfig # Directory convention override
│
├── PackageConfig # Package definition
├── ShellConfig # Development shell
│
├── MachineConfig # NixOS machine configuration
│ ├── UserConfig # Per-user settings (home-manager, groups)
│ ├── FileSystemConfig # File system mount points
│ ├── NetworkingConfig # Networking settings
│ │ ├── FirewallConfig # Firewall rules
│ │ └── InterfaceConfig # Network interface settings
│ └── StateVersion # NixOS release version ("YY.MM")
│
├── ModuleConfig # NixOS module definition
├── HomeConfig # Home-manager module definition
│
├── OverlayConfig # Nixpkgs overlay definition
├── CheckConfig # CI check definition
├── TemplateConfig # Flake template definition
│
├── PluginConfig # Plugin definition contract
│ ├── PluginConvention # Convention directory mapping
│ └── PluginBuilder # Builder metadata + defaults
│
└── Common
├── System # Valid Nix system triple
├── Name # Valid derivation name
├── NonEmptyString # Non-empty string
├── RelativePath # Relative file path
└── ModuleRef # Module name or path reference
Standalone validation#
You can validate your workspace configuration without building anything:
nickel typecheck workspace.ncl
nickel export workspace.ncl # Produces validated JSON
Package build systems#
The build-system field selects the Nix builder:
| Value | Nix builder |
|---|---|
"generic" |
stdenv.mkDerivation (default) |
"rust" |
rustPlatform.buildRustPackage |
"go" |
buildGoModule |
Package config fields#
{
src = "./src", # Source directory (relative)
build-system = "generic", # Builder to use
description = "My package", # Human-readable description
systems = ["x86_64-linux"], # Override workspace systems
build-inputs = ["openssl", "zlib"], # Runtime dependencies
native-build-inputs = ["pkg-config"], # Build-time dependencies
env = { MY_VAR = "value" }, # Build environment variables
meta = { # Package metadata
homepage = "https://example.com",
license = "MIT",
},
override = { }, # Escape hatch: raw Nix attrs
}
Shell config fields#
{
packages = ["cargo", "rustc"], # Packages in the shell
env = { RUST_LOG = "debug" }, # Environment variables
shell-hook = "echo hello", # Script to run on entry
tools = { rust-analyzer = "" }, # Tool specifications
systems = ["x86_64-linux"], # Override workspace systems
inputs-from = ["my-tool"], # Include build inputs from packages
}
Diagnostic codes#
Diagnostic codes are prefixed NW (nix-workspace) and grouped by category:
| Range | Category |
|---|---|
NW0xx |
Contract violations (type/value errors) |
NW1xx |
Discovery errors (missing files, bad directory structure) |
NW2xx |
Namespace conflicts (duplicate names, invalid derivation names) |
NW3xx |
Module errors (missing dependencies, circular imports) |
NW4xx |
System/plugin errors (unsupported system, missing input) |
NW5xx |
CLI errors (missing tool, tool failure) |
Project structure#
nix-workspace/
├── SPEC.md # Full specification
├── README.md # This file
├── flake.nix # Project flake (exposes the library)
│
├── lib/ # Nix library
│ ├── default.nix # Main entry point (mkWorkspace)
│ ├── discover.nix # Directory auto-discovery
│ ├── systems.nix # System multiplexing
│ ├── eval-nickel.nix # Nickel evaluation via IFD
│ └── builders/
│ ├── packages.nix # Package builder
│ ├── shells.nix # Shell builder
│ ├── machines.nix # NixOS machine builder
│ └── modules.nix # NixOS/home-manager module builder
│
├── contracts/ # Nickel contracts
│ ├── workspace.ncl # WorkspaceConfig contract
│ ├── package.ncl # PackageConfig contract
│ ├── shell.ncl # ShellConfig contract
│ ├── machine.ncl # MachineConfig contract
│ ├── module.ncl # ModuleConfig + HomeConfig contracts
│ └── common.ncl # Shared types (System, Name, etc.)
│
├── examples/
│ ├── minimal/ # Minimal workspace example
│ │ ├── flake.nix
│ │ ├── workspace.ncl
│ │ └── packages/
│ │ └── hello.ncl
│ └── nixos/ # NixOS machine configuration example
│ ├── flake.nix
│ ├── workspace.ncl
│ ├── machines/
│ │ └── my-machine.ncl
│ └── modules/
│ ├── desktop.ncl
│ └── desktop.nix
│
└── tests/
├── unit/ # Nickel contract unit tests
│ ├── common.ncl # 44 tests — System, Name, etc.
│ ├── package.ncl # PackageConfig tests
│ ├── machine.ncl # 93 tests — MachineConfig, UserConfig, etc.
│ ├── module.ncl # 80 tests — ModuleConfig, HomeConfig
│ └── workspace.ncl # 82 tests — Full workspace validation
└── errors/ # Error message snapshot tests
├── invalid-system.ncl
├── invalid-build-system.ncl
├── missing-field.ncl
├── invalid-machine-system.ncl
├── invalid-state-version.ncl
└── missing-machine-system.ncl
Development#
# Enter the dev shell
nix develop
# Run all checks (contracts, integration tests, CLI tests)
nix flake check
# Validate contracts manually
nickel typecheck contracts/common.ncl
nickel typecheck contracts/package.ncl
nickel typecheck contracts/shell.ncl
nickel typecheck contracts/machine.ncl
nickel typecheck contracts/module.ncl
nickel typecheck contracts/workspace.ncl
# Run unit tests
nickel eval tests/unit/common.ncl # 44 tests
nickel eval tests/unit/package.ncl # PackageConfig tests
nickel eval tests/unit/machine.ncl # 93 tests
nickel eval tests/unit/module.ncl # 80 tests
nickel eval tests/unit/workspace.ncl # 82 tests
# Run CLI tests
cd cli && cargo test # 77 tests
# Build the CLI
cd cli && cargo build
# or via Nix:
nix build
Documentation#
Detailed guides are available in the docs/ directory:
| Document | Description |
|---|---|
| Error Catalog | Comprehensive reference for all NWxxx diagnostic codes |
| Migration Guide | Migrating from flakelight or flake-parts |
| Editor Integration | LSP setup for VS Code, Neovim, Helix, Zed, and Emacs |
| CI Integration | GitHub Actions, GitLab CI, and generic CI setup |
| Stability Guarantees | Contract and API stability commitments from v1.0 |
Roadmap#
See SPEC.md for the full specification and milestone details.
- v0.1 — Foundation — Core contracts, package/shell discovery, system multiplexing ✅
- v0.2 — NixOS integration — Machine and module configs, home-manager support ✅
- v0.3 — Subworkspaces — Monorepo support with auto-namespacing ✅
- v0.4 — Plugin system — Extensible build systems and conventions ✅
- v0.5 — Standalone CLI —
nix-workspace init,check,build,shell✅ - v1.0 — Production ready — Full coverage, migration guides, editor integration ✅
License#
See LICENSE.