SCUA

News

SCUA 0.20.0 is out

August 31, 2026

SCUA can now exec. exec starts git, a build, a test suite, or whatever an agent just decided to run, streams the output as it arrives, and takes the whole process tree with it when you cancel. Alongside it: a list of commands you can declare up front so a program may run only those, month and weekday names in thirteen languages, frames that write CSV, and an exit status for each way a run can end.

#SCUA can now exec

import exec

match exec.run_any(["zig", "build", "test"])
  Ok(r)    -> print(`the build exited {r.status}`)
  Error(e) -> print(`it never started: {e.kind}`)
end

Run it with --allow-exec-any. Ok means the command ran and status is what it said, so a test suite failing is an Ok, because it worked. Error means it never started or could not finish, and kind is one of not_allowed, bad_argument, spawn_failed, timeout or unsupported, so a retry policy can tell "try again" from "never going to work" without parsing an error message.

There is no shell. argv is an array that goes to the operating system unparsed, so a branch name containing a semicolon stays a branch name and shell injection is not reachable. When you want pipes and globs, spawn ["sh", "-c", …] yourself.

A live command can be read as it produces output, polled, waited on and cancelled. exec.kill signals the whole process group, so a command that started its own children takes them with it. Every call carries a deadline that cannot be switched off, because a command burns wall clock instead of interpreter steps and --max-ops cannot see it. Captured output is capped, and when the cap fires you keep both ends of it: a build that fails after a megabyte of warnings puts the reason on the last line.

exec works on Windows with the same surface and the same guarantees. Cancellation there goes through a job object a child cannot escape, and which the OS tears down even if SCUA dies first. Under --io=async a command yields the way http and fs already do, so four actors each running a one-second command take about a second between them.

There is one thing to know before you switch it on. --allow-all does not grant exec, and it is the only capability it skips. Every other grant narrows what a script can reach. A command you run inherits your operating-system user instead, so a script holding exec and no fs can still run cat. Granting it hands over your account, which should be a deliberate choice.

#You can now declare which commands a program may run

When you already know what a program needs to run, say so, and then it can run only that. The declarations live in the capability file --allow-file reads:

exec = [
  ["zig", "build", "test"],
  ["git", "log", "--oneline", "-n", "{int}"],
  ["grep", "-c", "{string}", "--", "{string}"],
]

and the call drops the _any:

exec.run(["zig", "build", "test"])

The call site is the same line either way, so hardening a working program means pasting the argvs you already wrote into a file. If tightening security meant rewriting every call site, nobody would bother.

A declaration pins the program and its flags. Holes are for the values you pass, and your argv must match a declaration exactly: same length, literals identical. A hole fills exactly one argument and is never re-parsed, so a b; echo c is a single argument containing a space and a semicolon. A hole before a literal -- refuses anything starting with a dash, so a search term cannot turn into a flag and change what the command does. Anything unmatched comes back as Error({ kind = "not_declared" }) naming the closest declaration, which is usually one wrong flag away.

Mistakes in the file are refused at startup: a hole where the program name should be, an invented hole like {count}, an empty template. So is exec = true, because there is no "all commands" here. That is exec_any, and you have to name it.

#Logs can now go to standard error

When a caller parses your tool's stdout as JSON, the log lines need to go somewhere else. --log-stderr sends them to standard error:

$ scua --log-stderr tool.scua
{"matches":2,"file":"notes.txt"}

The log lines are still there, on the other channel, where the caller reading the answer will not trip over them:

[info] scanning 412 files
[warn] skipped 3 unreadable paths

It is a flag on the run rather than something the script chooses, because the program reading stdout is the one that needs it clean. You write logging the same way either way, and the flag combines with --log and --log-time.

#Improved exit codes from the scua command

When you run a script, the exit code scua returns tells you what kind of thing happened. A syntax error, a missing permission, a crash and a clean run that simply found no match each have their own status, so a wrapper can tell them apart without reading the output.

Exit code What it means
0 1 2 Whatever your script decided, set with sys.exit and sys.fail
3 The script did not compile
4 The script asked for a capability the run was not granted
5 The script crashed
6 --max-ops or --max-mem stopped the run

0, 1 and 2 are still yours to use however you like. scua reserves 3 to 6 for its own outcomes.

4 is the useful one if you run SCUA scripts from another program. It means the script itself is fine and the command line was wrong: it asked for a capability you did not grant. A wrapper that sees 4 can add the grant and run it again. Before, it saw 1 and had no way to tell that apart from a crash.

#Records now check the types you declared

A record field declared { string } or as an enum is now checked when the value arrives from outside, and the error says what arrived:

field 'files' of Args is string, expected { string }

This applies wherever outside data becomes a record: sys.parse_args, a typed let, a typed function parameter, a message arriving at a partition handler, and a loaded save. A list of scalars checks each element and names the index of the first wrong one. A flags field must hold a set of that flags type. A { f32 }-style numeric buffer field is converted to its declared element kind, so data arriving from JSON or a save file gets the packed storage the annotation promised.

Worth knowing when you write a tool: a command line carries text, so a { string } argument can only come from a JSON body on stdin, and an enum argument can't come from either route. Declare a string and convert it in your own code.

#fs now supports renaming files

import fs

fn replace(path, contents)
  fs.write(`{path}.new`, contents)?
  return fs.rename(`{path}.new`, path)
end

fs.rename replaces one name with another in a single step, so either the old file is there or the new one is. That is how you replace a file safely: write the new copy under a temp name, then rename it over the old one.

fs can also manage directories now: fs.mkdir creates one including missing parents, fs.remove takes a file or an empty directory, and fs.remove_all takes a tree. The granted root bounds all three, the way it bounds every other fs path, and the root itself is refused however you spell it.

fs.read and fs.read_text take { max_bytes = N }, in either direction, so a script reading a large export can raise the limit and one expecting a small config can lower it.

#Tasks now take turns

spawn do
  let i = 0
  while i < 3000000 do i = i + 1 end
  print("worker finished")
end
spawn do print("still here") end

The second task runs while the first is still counting. A long loop hands control back every few thousand steps and resumes exactly where it stopped, so a background job shares the program with everything else. There is nothing to switch on, and neither task is written differently.

Two caveats for now: this covers interpreted code, so a loop hot enough to be compiled still runs to completion, and it does not yet reach inside a partition handler. Budgets are untouched, so --max-ops=N still means this turn may run at most N steps however many times it paused. There is also --max-ops=none, for when you want no CPU limit at all.

#Dates and money in thirteen languages

time.format gained month and weekday names, so t.format("D MMM YYYY") gives 3 Jun 2024, and the AM/PM marker landed with them so h and hh work. The render layer gained matching components: @{d:month.short}, @{d:weekday}, @{d:day}, @{d:year}. One rule names all of them: a formatter names a field, and .short is the only modifier.

Both come off one table, so t.format("MMMM") and @{d:month} cannot disagree about what June is called. Thirteen languages ship, sys.locales() lists them, and anything else falls back to English.

Date order now follows the region rather than the language, so en_GB, en_AU, en_NZ, en_IE, en_IN and en_ZA are day-first. money.format follows the run locale too: a Euro amount reads 1.234.567,89 € under de_DE and 1 234 567,89 € under fr_FR. Under the neutral default and en_US the output is byte-identical to 0.19, so if you have never set a locale nothing moved.

Money values can also be taken apart now. money.amount(m) and money.code(m) read the two halves back out, and money.of(money.amount(m), money.code(m)) gives back exactly what you started with, at the original scale, including for zero-decimal currencies like JPY and three-decimal ones like KWD. Use those when you need the parts. Parsing the formatted string tells you little, since $ covers USD, CAD and AUD alike.

#Frames can now write CSV

f.to_csv()

That gives you the frame as RFC 4180 CSV, with the quoting handled: a field containing a comma, a quote, CR or LF is quoted, an inner quote is doubled, a nil cell is an empty field. csv(f.to_csv()) gives back an equal frame.

It belongs in the language because joining with commas works right up until a field contains one, and then writes a file that is wrong by one column from that row on.

Reading got stricter too. A cell is typed only when the typed value renders back to exactly the text that was there, so 42 is still an int and 1.50 is still an exact decimal, while 007, +7, -0 and 1_000 stay strings. Zip codes, product codes and zero-padded order ids survive the trip. Quoting is authoritative as well: a newline inside a quoted field stays inside it, and " padded " keeps its spaces.

#Also in 0.20.0

math.erf, math.erfc and math.lgamma arrived, the special functions statistics needs. Use math.erfc(x) rather than 1 - math.erf(x) for a tail, since erf(8) rounds to exactly 1.0 and the subtraction gives zero where the answer is about 1.1e-29.

hash.sha384 and hash.sha512 sit beside hash.sha256, take text or raw bytes, and run at native speed. They are the sizes specs ask for by name, HS384 and HS512 among them.

scua test runs every file you name, in any mix of files and directories, repeated as often as you like.

An HTTP server can log: a print inside an http.serve handler reaches the terminal now. A spawned task releases its memory when it finishes, which matters most to the servers that spawn one per request.

Error messages name what to do about them. A CPU budget that runs out gives the limit, the value and the flag that raises it, and points at the line inside the loop. An fs path refusal names which rule the path broke, rather than listing every rule and leaving you to pick. A missing import is reported as a missing import, at the import.

#What changes when you upgrade

If a script or CI step matches on exit code 1 to mean "the program is broken", it needs updating, because that case is now 2 to 6.

--jit-stats no longer switches the JIT back on. If you have ever measured with --jit=off --jit-stats and been surprised by the number, that was why, and it depended on the order you typed the flags in.

time.format("h:mm") now refuses. It used to render 02:05 and 14:05 identically. Use HH, or add the A marker that now exists.

csv() keeps leading zeros, so 007 stays 007, and it keeps whitespace inside quotes. It also refuses a duplicate header, and join refuses a suffix that collides with an existing column, because either one produced a frame that read back two different ways.

{ max = 0 } and { max_per_file = 0 } in fs.grep mean zero results, where they used to mean "no cap given".

#scua-pkg supports 0.20

scua-pkg 0.3.7 supports this release, so the package commands work against it. Its window is now 0.18 to 0.20, and 0.17 has dropped out of it.

#Next

The public registry is the launch step for scua-pkg, and it is the piece being built now: a static, CDN-served index and a signed transparency log, live from the first public publish, because first-seen history cannot be retrofitted afterwards.

Two questions from the team building on exec this month need a design decision rather than a patch, so both get written up first: whether log levels should go to standard error by default, and whether fs.grep should learn to match across lines.

The changelog has the full accounting.