Skip to content

:script — The Gateway

“Explore fast. Ship when ready.”

:script is :core with the training wheels on. It’s designed for the moments when you just want to get something done — explore an idea, prototype a solution, or crunch some data. When you’re ready to ship, one command promotes your script to production-ready :core code.

Status: SPEC-045 v1.4.0 RATIFIED (2026-08-02). The ratified scope is what runs today: top-level statements desugar to a synthesized main, implicit try on fallible calls, last-expression-is-value semantics, janus desugar / janus validate --promotable, the {.script: sysadmin.} template directive, and AOT-by-default execution. Some sections below describe specified but deferred features (the $-family, shell/path literals) — they are marked where they appear; see What is ratified, what is deferred.


  • Implicit types — The compiler figures it out
  • Top-level code — No main() wrapper required
  • AOT-first janus run — Compile through the normal backend, then run the native binary
  • Auto-imports — Common stdlib modules available by default
  • Script arena allocator — No explicit memory management

Specified, not yet shipped. The $-family positional sugar is formally deferred (SPEC-045 §15) and is not in the ratified v1.4.0 scope. The examples in this section show where :script is going, not what compiles today.

The $-family is what makes :script the replacement for awk, bash, Ruby, and Python. It’s inspired by awk’s $1, $NF — but with static type safety.

FormMeaning
$_Current pipeline element, whole
$1, $2, …Positional component of the current element
$#Index of the current element in the stream
$NNumber of positional components
$_1, $_2, …Closure arguments by position (multi-arg)
$@Full closure argument tuple

All resolved at compile time. If $5 doesn’t exist on your element type, you get a compile error — not a runtime crash.

Terminal window
janus run path/to/script.jans # Compile through AOT, then execute
janus ./path/to/script.jans arg1 # Shebang-friendly direct dispatch
janus run --jit path/to/script.jans # Development/debug runner

janus run uses the same AOT compilation path as janus build, writes a temporary script binary into a scratch location, forwards script arguments, and then executes the native binary. --jit is an explicit development escape hatch; --trace also opts into the JIT runner so trace output stays attached to the interpreter path.

Known --jit caveat: the JIT runner mishandles failing scripts (a pre-existing interpreter bug — infinite recursion instead of a clean error exit). The AOT default path is correct; use it when testing failure behavior.


What Your Exit Code Means (SPEC-045 §3.2, ratified 2026-08-02)

Section titled “What Your Exit Code Means (SPEC-045 §3.2, ratified 2026-08-02)”

Under janus run, a script’s last-expression value is discarded at the run boundary. The process exit code signals success/failure only:

  • Exit 0 — the script ran to completion, regardless of what value its last expression produced.
  • Exit 1 — the script failed. A failing implicit try aborts with PANIC: main returned with error and exit code 1.
func compute() -> i32 do
return 42
end
println("computed")
compute() // value 42 is DISCARDED — the process exits 0

The exit code never encodes your computed value. The value is for the screen and the logs; the exit code is for the shell that ran you. (Before this contract landed, a script ending in the expression 42 exited with code 42, and a failing implicit try could silently exit 0. Both are fixed.)

Use std.command when a script needs to orchestrate other binaries. The API is argv-first by default, like Go’s os/exec, so arguments are not interpreted by a shell unless you explicitly ask for shell execution.

use std.command
var out: [4096]u8 = undefined
let n = command.output1("printf", "janus", out[0..4096])
var err: [4096]u8 = undefined
let result = command.capture2("sh", "-c", "printf out; printf err >&2; exit 3", out[0..4096], err[0..4096])
let bounded = command.capture1_timeout_ms("sleep", "2", 100, out[0..4096], err[0..4096])
if bounded.timed_out == 1 do
eprintln("child exceeded deadline")
end
if command.run4("test", "!", "alpha", "=", "beta") != 0 do
eprintln("test command failed")
end
let child = command.spawn2("sh", "-c", "exit 0")
let code = command.wait(child)

Use command.shell(...) or command.shell_output(...) only when shell expansion, pipes, redirects, or shell builtins are intentionally part of the program.


ExcludedAvailable In
Not publishable via Hinge:core (after promotion)
No explicit allocator control:core, :service

Promotion is one command:

Terminal window
janus desugar script.jans > script.jan

Perfect for:

  • Learning Janus interactively
  • Prototyping an idea in 10 minutes
  • Data exploration and analysis
  • One-off automation scripts
  • AI agent tasks (short-lived, disposable code)
  • Homework and algorithm competitions

The rule: Use :script to explore. Use :core to ship.

:script is single-file, non-publishable, and AOT-cached by design. The moment your program outgrows those bounds, it is telling you it is ready to grow up. The six triggers — wanting to live longer than a session, needing a capability outside the template’s default set, needing tensors/actors/grains, needing a second file, needing hot reload, or writing a library — each mean the same thing: promote. The full list, with the Lua lesson behind it and the mechanical janus validate --promotable test, lives on its own page:

When to Promote from :script


# Just run this - no main() needed
print("Hello from :script!")

Deferred syntax. This example uses the $-family and <<p"...">> path literals — both specified but not yet shipped (SPEC-045 §15). It shows the design target.

This is the syntax that makes Nexus operators delete their ~/.zshrc:

<<p"access.log">>
|> map($_.fields())
|> filter($5.to_int()? >= 500)
|> group_by($7)
|> map(($_1, $_2.len()))
|> sort_by($2, desc)
|> take(10)
|> for_each(println)

What this does:

  1. Stream lines from access.log
  2. Split each line into fields (whitespace-separated)
  3. Filter to status codes >= 500 (errors)
  4. Group by the 7th field (URL path)
  5. Count items per group
  6. Sort by count descending
  7. Take top 10
  8. Print each

Compare to the shell equivalent:

Terminal window
cat access.log | awk '{print $5, $7}' | grep -v '^[0-4]' | sort | uniq -c | sort -rn | head -10

The Janus version is:

  • Type-checked$5.to_int()? fails at compile time if not a number
  • Provenance tracked — every line knows its source file and line number
  • Fused — compiles to a single loop, zero intermediate arrays

Deferred syntax — same status as the example above. Running this today emits a stopgap loud-fail diagnostic and returns empty (see the note in What is ratified, what is deferred).

<<p"server.log">>
|> grep(r/ERROR.*connection reset/)
|> map($_.split(":").1)
|> unique()
|> for_each(println)
# Read JSON, filter, transform, output CSV
let data := read_json("users.json")
|> filter($_.age >= 18)
|> map($.name)
|> sort()
for name in data do
println(name)
end

ConcernawkBashRubyPythonJanus :script
Positional $N
Pipeline operator
Type-checked positions
Zero-alloc fusion
Sub-10ms cold start
Static binary output
Promotion to production

The fusion and cold-start wins in the table above are not backend accidents. They fall out of the language being small and regular. Roberto Ierusalimschy draws this lesson from Lua outperforming Python despite both being dynamic: Lua is faster partly because the VM fits in cache (smallness), and partly because Lua is less dynamic — fewer indirections, fewer “everything can mean something else” surprises, so there is less runtime type dispatch to bail out of. LuaJIT’s trace compiler works at all because Lua is regular; a trace compiler on a language with more dynamic surprises would deoptimize to the interpreter constantly.

The principle is backend-independent, and it is why every :script sugar form desugars mechanically to :core rather than introducing a new runtime path. Pipeline fusion specifically depends on a deeper property Roberto names: type stability through a pipeline is what enables fusion. LuaJIT traces win in the dynamic world when a variable is not reused across types; Janus fusion wins in the static world because every stage’s output type is known at compile time, so the next stage’s input shape is provable without dispatch. This is the load-bearing reason the $-family and TextStream can promise zero-allocation walks — and the reason any future “just make it dynamic” pressure must be resisted.


SPEC-045 v1.4.0 (ratified 2026-08-02, RFC-057 scope) covers the language you can run today:

  • Top-level statements desugar to a synthesized main (Pass 1)
  • Implicit try on fallible calls (Pass 2)
  • Last-expression-is-value semantics + the exit-code contract above
  • Optional field default sugar (§3.5.13)
  • The {.script: sysadmin.} template directive, with rejection of unknown template names (E3118)
  • janus desugar + janus validate --promotable tooling and the Script Law round-trip
  • AOT-by-default execution (--jit is an explicit dev flag)

Be honest with yourself about the rest. Ratified :script is not yet the sysadmin product. The ten-module auto-import set (std.io, std.os.*, std.text.*, std.collections, std.fmt) is provisional — it works, but it is pending the template split that migrates to public facades. Runnable Artifact Store caching on the default janus run path is now shipped (unstable @ 06a95f8c, gated to import-free scripts until semantic-CID plumbing). And these remain formally deferred (SPEC-045 §15): entry-point allocator injection, the capability sandbox, the REPL, and the $-family positional sugar.

Unbacked-literal note (2026-08-04, unstable @ e935c354): the path/stream/regex literal syntax (p"...", <<...>>, r/.../) tokenizes and lowers, but the runtime backing is not yet implemented. These literals are a stopgap loud-fail: they emit a diagnostic to stderr naming the feature and SPEC section and return an empty string, rather than silently passing the input through. Note the stopgap does NOT yet force a non-zero exit — a script whose final expression is an unbacked literal can still exit 0 with empty output. True loud failure (non-zero exit) lands with the E3115/E3116/E3117 diagnostics. The shell literal (`...`) IS implemented (via popen) and works. The $-family resolves at compile time for the implemented positional rules (tuple/Vec), but several forms ($*, out-of-range $k) are stubs — the libjanus worklist (diagnostics, real runtime implementations) is tracked in JANUS/.agents/specs/_HANDOFF/SCRIPT-SUGAR-DIAGNOSTICS-SPEC.md.



Explore fast. Ship with confidence.