SCUA

Manual

Command-line interface

The scua binary runs a script, formats source, runs tests, and steps a program under a debugger. Run it with no arguments (or --help) to see the authoritative usage:

$ scua
usage: scua [options] <file.scua> [args...]
       (options precede the file; everything after the file — or after `--` — is passed to the
        script and read with `sys.args()`)
       scua fmt [--check] <file.scua>   (fixes indentation + trailing whitespace only)
       scua test [dir]                  (run *_test.scua files)
       scua schema <file.scua> --type T (emit JSON Schema for a record/enum type)
       scua pack [--tier=…] [--repo=DIR] (teach-a-model in-context pack)
       scua debug <file.scua>           (run under the terminal stepper)
       scua --version | --help

options:
  --fast                         fast-forward the clock (instant; for demos/sims)
  --dump-bytecode[=verbose]      print the compiled bytecode (annotated) and exit; don't run
  --log=<trace|debug|info|warn|severe>
                                 drop log statements below this level at compile time
  --log-time                     prefix log lines with an ISO-8601 timestamp
  --jit[=on|always|off]          enable the JIT (ARM64 + x86-64; ignored where unavailable)
  --jit-stats                    print JIT telemetry (compiles/entries/deopts) to stderr on exit
  -D name[=value]                set a `build` flag for `comptime if` (bare = true)
  --profile <name>               select a build profile from scua.toml (else its `default`)
  --mod-path DIR                 add a module search directory (repeatable; tried after the entry dir)
  --allow-fs[=DIR]               grant the `fs` capability, rooted at DIR (default: cwd)
  --allow-net[=hosts]            grant the `net` capability (HTTP); comma-separated host allowlist
  --allow-env[=NAMES]            grant the `env` capability; comma-separated name allowlist (bare = all)
  --allow-serve[=ADDR:PORT]      grant the `serve` capability (HTTP/TCP LISTEN) constrained to that bind;
                                 bare --allow-serve = any bind (trusted hosts only). Never implied by --allow-net.
  --allow-cluster[=SECRET]       grant the `cluster` capability (the `shared` config table); SECRET is
                                 the cluster's shared MAC secret. Bare = empty (demo/trusted).
  --allow-udp[=PEERS]            grant the `udp` capability (DATAGRAMS); comma-separated allowlist of
                                 ADDR[:PORT] or CIDR[:PORT] (e.g. 1.1.1.1:53, 10.0.0.0/8:7946).
                                 bare --allow-udp = any peer (trusted hosts only). `udp.bind` also needs
                                 --allow-serve. Never implied by --allow-net: datagrams can amplify.
  --udp-max-payload=N            raise the datagram payload cap from the 1200-byte portable-safe default
  --io=async                     run fs/http capability I/O on an offload pool; default: blocking
  --serve-debug                  put located fault text in http.serve 500 bodies (local dev only)
  --locale=<tag|host>            set sys.locale() (BCP-47, e.g. de-DE; `host` reads the device)
  --tz=<±HH:MM|host>             set sys.timezone() offset (e.g. +02:00, +05:45; `host` reads the device)
  --max-ops=N                    cap reduction steps per turn (suffix k/m/g); a runaway loop is killed
  --max-mem=BYTES                cap the script's extra live memory (suffix k/m/g, e.g. 64M)

scua fmt, scua test, and scua debug are subcommands (they take the place of <file.scua>); --version (or -V) prints the version and exits. Each subcommand and option is detailed below.

#Running a file

$ scua file.scua

This compiles and runs the script. With no flags, timers run in real time: a script that waits five seconds takes five seconds. The options below change that and a few other things.

If the file can't be read, scua exits with status 1 and an error like:

scua: missing.scua: cannot read file

#Passing arguments to the script

$ scua [scua-options] <file.scua> [program-arguments...]

scua's own options come before the file. Everything after the file name is passed to the program, verbatim — including tokens that look like flags — and read with sys.args():

$ scua --fast game.scua  level3  --hard       # sys.args() == ["level3", "--hard"]

Use a bare -- before the file to stop scua's option parsing early (e.g. for a file or argument that begins with -). A program argument that isn't valid UTF-8 fails the launch (a SCUA string must be UTF-8). See Read command-line arguments.

scua fmt [--check] <file>

Reformat a file in place. The formatter is deliberately conservative: it fixes each line's leading indentation (two spaces per block level) and trims trailing whitespace. It does not reorder tokens, change spacing inside a line, or touch the contents of strings and comments. A reformatted file always means exactly what it did before.

Given this file with a missing indent and some trailing spaces:

fn greet(who)
return `hi {who}`   
end
print(greet("Sam"))

Running scua fmt rewrites it to:

fn greet(who)
  return `hi {who}`
end
print(greet("Sam"))

Add --check to verify without writing. It changes nothing on disk and exits 0 if the file is already formatted, or non-zero with a message if it isn't, which is what you want in CI:

$ scua fmt --check greet.scua
scua: greet.scua: not formatted (run `scua fmt greet.scua`)

Once the file is formatted, the same command exits 0 and prints nothing.

Because the formatter only touches indentation and trailing whitespace, spacing inside a line is left exactly as you wrote it. a+b stays a+b, and let x = 1 keeps its inner spaces. So scua fmt and --check are no-ops on a file whose indentation is already correct, even if its operator spacing isn't to your taste. Normalizing intra-line spacing is not something the formatter does today.

scua schema <file> --type Name

Emit a JSON Schema draft 2020-12 document for a record or enum declared in <file>. Used by agent harnesses and MCP as inputSchema / outputSchema. Only the decidable subset of where refinements maps; SCUA contracts stay authoritative. See Write tools for AI agents.

$ scua schema examples/agent_tool.scua --type Args

scua pack [--tier=compact|full|both] [--repo=DIR] [--out=PATH|-]

Generate a teach-a-model markdown pack (grammar, I/O model, stdlib cheatsheet, few-shots from examples/, reserved words, pitfalls). Token estimates print on stderr. Default --tier=both writes scua-pack-compact.md and scua-pack-full.md. Prefer compact in a system prompt. See Write tools for AI agents.

$ scua pack --repo=. --tier=compact --out=-

#scua test [dir]

Find and run tests. It discovers every *_test.scua file under dir (the current directory if you don't give one), runs each, and reports pass/fail. It exits non-zero if anything failed, so it drops straight into CI.

A test file can work two ways. If it declares fn test_*() functions, each one runs on its own: a fault in one (a failed assert, say) fails just that test, and the others still run. If it has no test_* functions, the whole file is a single test that passes if it runs without an uncaught fault.

-- math_test.scua
fn test_addition()
  assert_eq(1 + 1, 2)
end
fn test_division()
  assert_eq(7 // 2, 3)
end
$ scua test
PASS  ./math_test.scua  test_addition
PASS  ./math_test.scua  test_division

2 passed, 0 failed (1 file)

Write assertions with assert(cond, msg?) or the comparison helpers assert_eq(got, want), assert_ne(a, b), and assert_near(got, want, eps) (float-tolerant). A failing comparison reports both values. See Test your code for the full walkthrough.

#debug

$ scua debug file.scua

Runs the script under a terminal stepper instead of straight through, so you can step line by line and inspect state as it executes. This is an interactive session in your terminal, not a flag you combine with normal output.

#--fast

Fast-forward the virtual clock. Timer-based waits resolve instantly instead of taking real time, while producing the same output and the same reported timestamps. Use it for demos, simulations, and tests where you don't want to sit through the waits.

A scheduling script that describes five seconds of game time runs in milliseconds:

$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A  (t+0ms)
[regen]  +10 hp     (t+1000ms)
[regen]  +10 hp     (t+2000ms)
[patrol] move to B  (t+3000ms)
[patrol] back to A  (t+5000ms)

The t+...ms values are the in-program clock, so they still read 1000, 2000, and so on even though no real time passed.

--dump-bytecode[=verbose]

Compile the script, print the bytecode the compiler produced, and exit without running it. It's a diagnostic window into how your code lowers, handy when you're curious what a construct compiles to or filing a bug. --dump-bytecode=verbose prints more detail per instruction.

$ scua --dump-bytecode hello.scua

The format is meant for a human reading it, not as a stable interface, so it can change between releases.

#--log=<level>

Set the minimum log level. SCUA's logging has five levels, lowest to highest: trace, debug, info, warn, severe. Anything below the level you pass is removed at compile time, so those statements and their arguments never run. The default prints everything.

Given a script that logs at several levels, the default shows them all:

$ scua logging.scua
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...

With --log=info, the trace and debug lines are gone (and any work done only to build them is skipped):

$ scua --log=info logging.scua
[info] player Mara joined
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...

An unknown level is rejected:

$ scua --log=bogus file.scua
usage: --log=<trace|debug|info|warn|severe>

See Logging for the levels and how log statements are written.

#--log-time

Prefix each log line with an ISO-8601 timestamp. Useful when you want logs you can correlate with other systems.

$ scua --log-time logging.scua
2026-06-18T23:29:55.478Z [info] player Mara joined
2026-06-18T23:29:55.478Z [debug] hp is 30/100
...

#--jit[=on|always|off]

Turn on the optional JIT. By default SCUA runs on its interpreter; --jit=on compiles the hot loops — the ones that run enough times to be worth it — to native code, which makes long-running numeric and table-heavy work much faster. The result is identical to the interpreter: the JIT changes speed, not behaviour.

fn total(n)
  let sum = 0
  for i in 1:n do
    sum = sum + i
  end
  return sum
end
print(total(1000000))
$ scua --jit=on sum.scua
499999500000

The three values:

  • --jit=on compiles hot loops as they warm up. This is what you reach for on a compute-heavy script.
  • --jit=off is the default: run everything on the interpreter.
  • --jit=always compiles eagerly instead of waiting for a loop to warm up. It's slower to start and mainly useful for testing that the JIT and the interpreter agree.

The JIT runs on ARM64 (Apple Silicon and ARM Linux) and x86-64 (macOS and Linux). On any other platform the flag is accepted and ignored, and the script runs on the interpreter, so a script and its output are the same everywhere, with or without the flag. See Make scripts run faster with the JIT.

#--jit-stats

Print a one-line summary of what the JIT did to stderr when the run ends: how many functions it compiled, how many times native code was entered, and how many times it fell back to the interpreter (a "deopt"). Use it to confirm the JIT is engaging on your hot path. Passing --jit-stats also turns the JIT on, so you can use it on its own.

$ scua --jit-stats game.scua
[jit] mode=on compiled=2 native-entries=5 deopts=3 ...

The exact counters are for diagnostics and vary from run to run and release to release. A compiled= above zero with a healthy native-entries= means your hot loop is running as native code; all zeros means nothing ran long enough to compile.

#-D name[=value]

Set a build flag that comptime if reads from the build config. comptime if chooses a branch at compile time, and only the chosen branch is compiled into the program. A bare -D name sets the flag to true; -D name=value gives it a value. You can pass -D more than once.

Given a script that gates output on build.debug and build.tier, with no flags both gates take their default branch:

$ scua conditional.scua
free edition
game starting

Setting the flags selects the other branches:

$ scua -D debug -D tier=pro conditional.scua
[info] debug diagnostics enabled
pro edition: all features unlocked
game starting

See Conditional compilation for how comptime if and build work.

#--profile <name>

Select a named build profile from a scua.toml project manifest. A profile is a bundle of build flags, so you don't have to type the same -D flags every time. The manifest names a default profile that's used when you don't pass --profile.

For example, a scua.toml like this:

default = "debug"

[build.debug]
debug = true
tier = "free"

[build.release]
debug = false
tier = "pro"

A plain run uses the default (here debug) profile:

$ scua app.scua
[info] debug diagnostics on
free edition

Asking for the release profile applies its flags instead:

$ scua --profile release app.scua
pro edition

The flags set by a profile are the same build flags -D sets, so the same comptime if gates respond to both.

#--mod-path DIR

Add a directory to search when resolving an import. By default import name looks only next to the entry file (<entry-dir>/name.scua); each --mod-path DIR adds another directory, tried in order, after the entry directory. Repeat the flag for several directories. This is how you keep shared modules in a separate folder, or split a project across directories.

$ scua --mod-path libs app.scua
$ scua --mod-path libs --mod-path ../shared app.scua

With app.scua containing import greet, the runner tries ./greet.scua, then libs/greet.scua, then ../shared/greet.scua, and uses the first that exists. The built-in standard-library modules (math, list, …) always resolve first and can't be shadowed. If no directory has the module, the error names how many it tried:

$ scua app.scua
scua: app.scua:1: cannot resolve module 'greet' (tried 1 resolver)

(The directories form an ordered resolver chain. Today the chain is set on the command line; a future release adds project-manifest and in-script control.)

#--allow-fs[=DIR]

Grant the script the fs capability — the ability to read and write files — rooted at a directory. Without this flag a script has no filesystem access at all: import fs is a compile error, because the fs module isn't installed (this is the default-deny capability model). With it, fs is available and every path the script uses is resolved within the granted directory; absolute paths and .. are rejected, so the script can't reach outside the root.

--allow-fs alone roots at the current working directory; --allow-fs=DIR roots at DIR.

import fs
match fs.read("notes.txt")
  Ok(text) -> print(text)
  Error(why) -> print(why)
end
$ scua notes.scua
notes.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant it with `--allow-fs` on the CLI, or the host's capability API when embedding

$ scua --allow-fs notes.scua
(the contents of notes.txt)

See Read and write files for the full surface (fs.read/fs.read_text/fs.write/fs.list/fs.exists/fs.stat/fs.find/fs.grep) and the rationale.

#--allow-net[=hosts]

Grant the script the net capability — the ability to make HTTP requests via the http module. Without it, import http is a compile error (default-deny). With it, the http module is available and the CLI backs it with a built-in client (Zig's std.http and its pure-Zig TLS, so HTTPS works with no system dependency).

A bare --allow-net allows any host. --allow-net=host1,host2 restricts the script to a comma-separated allowlist — a request to any other host returns an Error before a packet is sent.

import http
match http.get("https://example.com")
  Ok(resp) -> print(resp.status)
  Error(why) -> print(why)
end
$ scua fetch.scua
fetch.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI, or the host's capability API when embedding

$ scua --allow-net=example.com fetch.scua
200

See Fetch data over HTTP for http.get/http.post, the response shape, and the allowlist.

--allow-serve[=ADDR:PORT]

Grant the script the serve capability — the ability to listen and answer requests, via http.serve and the raw net.listen/net.accept. This is a different authority from --allow-net: making outbound requests never lets a script open a port, so listening is its own grant. Without it, http.serve and net.listen don't exist to call (default-deny), and --allow-net, however broad, never confers it.

The grant names the address the script may bind: --allow-serve=127.0.0.1:8080 allows binding exactly that address and port, and nothing else. A bare --allow-serve allows any bind — reserve it for a host you trust.

$ scua --allow-serve=127.0.0.1:8080 serve.scua

See Serve HTTP requests for the handler shape, the request/response records, and raw TCP.

#--serve-debug

Put the fault text into an http.serve 500 body. By default, when a handler faults or returns an Error, the client gets a bare 500 and the located message is withheld — production bodies don't leak an internal detail to whoever is calling. --serve-debug puts that message in the response body so you can see it while developing locally. Leave it off in anything real.

$ scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua

#--allow-env[=NAMES]

Grant the script the env capability — the ability to read environment variables via the env module. Without it, import env is a compile error (default-deny). With it, the env module is available and env.get(name) reads from a snapshot of the environment taken when the run started.

A per-name allowlist is the safe default: --allow-env=NAME1,NAME2 exposes only those variables, and env.get returns nil for anything else, so a script can't read a secret you didn't name. A bare --allow-env exposes every variable.

import env
let home = env.get("HOME") ?? "(not set or not allowed)"
print(home)
$ scua whoami.scua
whoami.scua:1: module `env` needs the `env` capability, which this run was not granted — grant it with `--allow-env` on the CLI, or the host's capability API when embedding

$ scua --allow-env=HOME whoami.scua
/Users/sam

See Read environment variables for env.get, the snapshot semantics, and the allowlist.

--allow-cluster[=SECRET]

Grant the script the cluster capability — a small cluster-wide config table that converges across nodes, via the cluster module. Without it, import cluster is a compile error (default-deny). SECRET is the cluster's shared MAC secret: every node meshing the same table must present the same one, and a mismatch is refused rather than trusted. A bare --allow-cluster uses the empty secret, which is fine for local dev and demos but not for anything real.

$ scua --allow-cluster=hunter2 config.scua
$ scua --allow-cluster config.scua           # empty secret (local/demo)

See Share config across a cluster for the table surface and the multi-node story.

#--allow-udp[=PEERS]

Grant the script the udp capability — sending and receiving UDP datagrams via the udp module. Without it, import udp is a compile error (default-deny). This is a separate grant from --allow-net, never implied by it: a datagram can be aimed at a third party, so unscoped UDP is its own authority.

The grant names peers, not just "yes". --allow-udp=1.1.1.1:53,10.0.0.0/8:7946 is a comma-separated allowlist of ADDR[:PORT] or CIDR[:PORT]. A host is resolved and then the resulting address is checked, so a name that resolves outside the allowlist is refused. A bare --allow-udp allows any peer — keep it to a host you trust.

Receiving from anyone (udp.bind) additionally needs --allow-serve, since hearing from strangers is listen authority on top of datagram authority.

$ scua --allow-udp=1.1.1.1:53 dns_query.scua

See Send datagrams with UDP for the socket surface and the guardrails.

#--udp-max-payload=N

Raise the datagram payload cap above its 1200-byte default. That default is the portable-safe floor: a fragmented datagram is all-or-nothing, so fragmenting multiplies your loss. Raise it only when you know the path can carry larger packets. A send over the cap is a loud Error, never a silent truncation.

#--io=<async|blocking>

Choose how the script's capability I/O (fs and http calls) runs. The default is --io=blocking: each call blocks until it returns, which is exactly right for a straight-line script. --io=async runs those calls on an offload pool instead, so that when several are in flight under wait_all/map_all the waits genuinely overlap.

This is an optimization flag, not a requirement. The results are identical either way — same values, same order, same all-settled behaviour — only the wall-clock differs. Write the code once and add the flag when overlap pays off.

$ scua --io=async --allow-net=api.example.com fetch_all.scua

--locale=<tag|host> and --tz=<offset|host>

Set the system locale and timezone the script reads via sys.locale() and sys.timezone(). Both are neutral by default — with no flag, sys.locale() is "und" (undetermined) and sys.timezone() is 0 (UTC) — so a run is reproducible across machines unless you say otherwise.

  • --locale=de-DE sets the locale tag (any BCP-47 string; stored verbatim).
  • --tz=+02:00 sets the UTC offset; accepts ±HH:MM, ±HH, and UTC/Z. Non-hour offsets work: --tz=+05:45. IANA zone names (Europe/Berlin) are not supported — use an offset.
  • --locale=host / --tz=host read the real device setting (from LC_ALL/LC_CTYPE/LANG and TZ). This is the one explicit, opt-in source of nondeterminism — like --fast for the clock. --tz=host needs a numeric TZ offset; if the host only has a zone name, pass --tz=±HH:MM instead.
$ scua app.scua                              # locale=und, tz=0 (UTC)
$ scua --locale=de-DE --tz=+02:00 app.scua   # pin both (great for tests)
$ scua --locale=host --tz=host app.scua      # read the device

The timezone offset feeds the date layer: time.now() and time.of(...) default to it. See Set the locale and timezone and Dates and times.

#--max-ops=N and --max-mem=BYTES

Bound what an untrusted script may consume. Both take a count with an optional k/m/g suffix (--max-ops counts in 1000s, --max-mem in 1024-byte units), so --max-ops=5m is five million and --max-mem=64M is 64 MiB. These are the CLI equivalents of the op_budget and mem_cap arguments an embedding host passes to scua_eval.

--max-ops caps the reduction steps — loop iterations plus function calls — any single turn may run. When a script exceeds it, an uncatchable kill-switch stops the run with execution budget exceeded. A runaway loop is bounded in CPU, so it can't spin forever:

$ scua --max-ops=100k spin.scua
spin.scua:2: execution budget exceeded (possible infinite loop)

There is always a built-in ceiling even without the flag, but it's deliberately high (so long legitimate scripts run); set --max-ops to bound untrusted input tightly, or raise it for a script you trust to run long. The budget is per turn, so it also caps each actor or coroutine turn the script spawns.

--max-mem caps the extra live memory the script may hold beyond what it starts with. Over the cap, the run stops with memory limit exceeded instead of growing without bound:

$ scua --max-mem=64K hog.scua
hog.scua: memory limit exceeded

The cap is on the live set (it's checked after garbage is reclaimed), so ordinary churn that frees as it goes won't trip it — only genuinely holding more than the cap does.