A single file stops being enough as soon as you want tests, dependencies or a binary you can copy somewhere else. A project gives you all three, and it is one command away.
nyx init
$ nyx init my-app nyx v0.33.0 — init CAPABILITIES.md generado desde ~/.nyx/std (59 archivos escaneados) Initialized project: my-app Created: my-app/ Next: cd my-app && nyx run
That seeds nine files:
my-app/ nyx.toml .gitignore src/main.nx AGENTS.md CAPABILITIES.md docs/nyx/LLM.md docs/nyx/guides/write-a-program.md docs/nyx/guides/fix-a-compile-error.md docs/nyx/guides/report-friction.md
The first three are the project. The other six are the context an assistant needs in order to write Nyx here without guessing — step 04 is about them. If you want the documents in Spanish, nyx init my-app --lang es seeds the Spanish set instead; --agent= and --sdd add more, and step 04 covers both.
nyx.toml
[package] name = "my-app" version = "0.1.0" main = "src/main.nx" [dependencies]
name is also the name of the binary that nyx build produces, and the line the seeded .gitignore already ignores. main is the entry point every tool falls back to when you give it no file.
Build and run
$ nyx build nyx v0.33.0 — build -> building my-app v0.1.0 compiling src/main.nx ✓ Built: my-app build complete
That leaves ./my-app next to nyx.toml: a native executable you can copy to another machine of the same architecture. nyx run does the same build and then runs it.
Arguments after nyx run go to your program, not to Nyx. When an argument could be mistaken for a flag, put -- first:
fn main() { let args: Array = get_args() var i: int = 0 while i < args.length() { let a: String = args[i] print(int_to_string(i) + ": " + a) i = i + 1 } }
$ nyx run -- --verbose one nyx v0.33.0 — run -> building my-app v0.1.0 compiling src/main.nx ✓ Built: my-app 0: ./my-app 1: --verbose 2: one
Element 0 is the path of the binary itself, so your own arguments start at 1. Note the type annotation on a: an element read out of an Array without one is treated as an int, and printing it gives you a number instead of the string.
Tests
nyx test runs every tests/*.nx. A test is a test "name" { … } block — not a function:
test "greeting is not empty" { let msg: String = "hello" assert(msg.length() > 0) } test "sum works" { assert(1 + 1 == 2) }
$ nyx test === Nyx Test Runner === Found 1 test file(s) PASS tests/greet_test.nx (2 tests) =================================== Files: 1 passed, 0 failed (1 total) Tests: 2 passed, 0 failed (2 total) Status: ALL TESTS PASSED ===================================
The trap worth knowing before it bites. A file full of functions named test_something() is found, counted, and then skipped — the runner says No files with test blocks found and exits happily. Nothing failed, because nothing ran. If your test count is zero and you expected tests, this is why: the blocks are test "name" { … }, always.
Note also that assert() aborts the process on the first failure. Inside nyx test that is handled for you — it reports per test and exits at the end — but in a program of your own, a failed assertion is the end of the program.
Dependencies
$ nyx add some-package $ nyx add some-package --from https://example.com/some-package.git
The dependency is written into [dependencies] in nyx.toml, and nyx build clones what is missing into packages/ — which the seeded .gitignore ignores, on the assumption that you would rather fetch than vendor. Deleting that line and committing packages/ is the other reasonable choice: it makes the build reproducible without a network.
The standard library needs none of this. Modules like std/json, std/http or std/toml ship with the toolchain and are used with a plain import "std/json".
Separate compilation: [lib] modules
A project can declare which modules under src/ compile as separate units, once, and get reused across builds instead of being inlined into every nyx build again:
[lib] modules = ["src/util", "src/geo"]
Each entry is written the way you import it (no .nx), and never a std/ module — the manifest rejects both. nyx build compiles each declared module to target/nyx-lib/ and reuses the object while neither the module nor anything in its import closure (the prelude and any std/ it uses included) has changed, and neither has the compiler or the build flags. Touching a module recompiles it and, in cascade, the [lib] modules that import it — main.nx itself always compiles fresh, against the interfaces in effect. This only applies to native targets: with --target wasm32-wasi, [lib] modules inline like a normal import.
What crosses the boundary is a signature, not a body — like a C header: exported functions (export fn/pub fn, with their parameter and return types) and exported struct/enum. A generic fn, the methods of an impl (with or without a trait) and a trait need their body where they are called — monomorphization and method dispatch cannot cross a compiled-object boundary — so a [lib] module that declares any of those is inlined as usual instead (the program stays the same), with a NYX0302 warning.
Known limit. Type-checking of arguments across the boundary does not exist yet: calling a separately compiled function with the wrong argument type is not caught at compile time today.
nyx test uses [lib] too: it builds (or reuses) the libraries once per suite and links every test file against them, instead of recompiling everything it imports in each one. A test file that is itself a [lib] module is compiled whole, with its test blocks. --coverage still inlines everything.
Two commands you will want later
$ nyx info nyx v0.33.0 — info Project: my-app Version: 0.1.0 Main: src/main.nx Mode: GC (default)
And, after upgrading the toolchain, nyx update --sync-docs — run inside the project — refreshes the seeded documents to the version you now have, keeping a .bak of anything it replaces. What it refreshes, and why it matters, is the next step.