AntOS Script Developer Quickstart (Luau)
How to write a command for AntOS. Commands are Luau scripts that live in
D:/scripts/ and run when you type their name at the console. This guide covers
the argument parser, the terminal lifecycle, the console glyph set, and the
conventions the built-in commands follow — so a new script feels native.
AntOS runs Luau (Roblox's typed Lua dialect), not stock Lua. The differences
that matter here: +=/-= compound assignment works, and string.pack-style
niceties may differ — when in doubt, test on-device. Porting existing Lua? See the
migration tutorials and the complete difference reference.
The shape of a command
Every command follows the same skeleton:
-- mycmd.lua - one-line description
local a, done = os.arguments{
name = "mycmd",
desc = "Short description (shown in help listings)",
long = "A longer explanation shown when the user runs: mycmd ?",
{ "*File", required = true, help = "the file to process" },
{ "+Count", type = "number", default = 1, help = "how many times" },
{ "-Verbose", help = "chatty output" },
}
if done then return end
-- ... your logic here, reading a.file, a.count, a.verbose ...
That's the whole contract. os.arguments parses the command line, and:
- returns
a— a table of the parsed values, keyed by the lower-cased word (*File→a.file,+Count→a.count,-Verbose→a.verbose) - returns
done—trueif it already handled things (the user asked for help with?, or a required argument was missing and it printed an error). Whendoneis true, stop —if done then return end.
Always write if done then return end immediately after the call. It's the
single most important line; skipping it means your script keeps running after a
help request or a parse error.
The three argument kinds
| Spec | Kind | Reads from | In a |
|---|---|---|---|
"*Word" |
main parameter | a bare token, in declaration order | a.word |
"+Word" |
value option | the token after the flag | a.word |
"-Word" |
boolean flag | presence on the line | a.word (true/false) |
{ "*Source", required = true, help = "..." }, -- copy A B -> a.source = "A"
{ "*Dest", required = true, help = "..." }, -- copy A B -> a.dest = "B"
{ "+Timeout", type = "number", default = 30 }, -- +timeout 5 -> a.timeout = 5
{ "-Force" }, -- -force -> a.force = true
Spec fields you can set on each argument:
required = true— parse fails (anddone = true) if it's missingdefault = <value>— used when the argument isn't giventype = "number"— coerces the value withtonumberhelp = "..."— the per-argument line shown inmycmd ?
The capital letter is the abbreviation pivot. In +Count, the capital C
means +c, +co, +cou, +coun, +count all match (case-insensitive). Put
the capital at the shortest unambiguous prefix. No capital ⇒ the whole word is
required.
Bare tokens beyond the * slots: the array part
Once every * slot is filled, any extra bare tokens land in the array part
of a (i.e. a[1], a[2], …). This is how you accept a variable-length list:
-- hash file1 file2 file3 (hash has no *File; all bare tokens are targets)
local targets = {}
for _, t in ipairs(a) do targets[#targets+1] = t end
Or to reassemble a free-text tail (a query, a message, an SQL statement):
-- eval print(1 + 2) -> join *Code plus the rest back into one string
local parts = { a.code }
for _, w in ipairs(a) do parts[#parts+1] = w end
local code = table.concat(parts, " ")
ipairs(a) only visits the array part (the numbered entries), never the named
fields — so it's safe to mix named args and a variadic tail.
Watch the name collision: inside a loop, don't reuse a as the loop
variable — for _, a in ipairs(a) shadows the result table. Use a different
name (for _, w in ...).
os.arguments is strict about unknown flags
If the user passes a -flag or +opt you didn't declare, os.arguments
errors with "unknown option". That's usually what you want. But if your
command has its own rich parser (a compiler with many flags, a lua -e-style
passthrough), don't route it through os.arguments. Instead handle ? by hand
and read the raw arg table yourself:
local args = arg or {}
if args[1] == "?" then
print("mycmd - what it does")
print("Usage: mycmd [options] <file>")
return
end
-- ... your own flag parser over `args` ...
The raw argument vector is always available as the global arg (a table;
arg[1] is the first token). os.arguments reads it for you; hand-rolled
commands read it directly.
The terminal: init / shutdown
Simple commands that just print() need nothing special. But any command that
takes over the screen — full-screen UI, cursor movement, raw key input — must
bracket its work with the terminal lifecycle:
local term = require("terminal")
term.init() -- claim the terminal (raw mode, alt screen, etc.)
-- ... interactive UI ...
term.shutdown() -- MUST run before every exit path
return
term.shutdown() must run on every return path — including early exits and
error branches — or the console is left in a bad state:
if not ok then
term.shutdown(); return -- don't forget it here
end
If you require("terminal") and call init(), audit every return in the
function to be sure shutdown() precedes it.
Simulator note: the AntOS simulator reads keys via Windows _getch(), so
cursor keys arrive as 0x100 | scancode, not as ANSI escape sequences.
Include console.h (in C) or use the KEY_EXT_* constants for portable key
handling.
The console glyph set (avoid ? boxes)
The console renders a fixed set of glyphs procedurally. Anything outside the
set renders as a literal ?. Keep script output within the set:
Supported: ASCII (0x20–0x7E) · box-drawing (light/heavy/double/rounded) ·
block/shade elements (0x2580–0x259F) · Braille (0x2800–0x28FF) · a few icons
(✓ ✗ ⚠ ℹ …). Arrows (← ↑ → ↓) and dashes (— –) render only on builds
with the extended font; older builds show ?.
Rule of thumb: keep script text ASCII unless you have a specific reason.
Use -> not →, - not —, deg not °, x not ×. Emoji (🌐 📁 ⚡)
are never supported — drop them or use a word.
Important exception — data patterns. A glyph inside a Lua pattern that matches incoming data (not console output) must be preserved, because it matches bytes, never renders. Write it as a byte-escape so the source stays glyph-clean:
-- matches "20°C" from a weather API. The ° is U+00B0 = UTF-8 bytes C2 B0.
local temp = line:match("([%+%-]?%d+)\194\176")
That keeps the file free of raw non-ASCII while parsing correctly.
Common libraries
Load with require. The ones you'll reach for:
require(...) |
For |
|---|---|
"fs" |
filesystem metadata & structure: fs.dir, fs.list, fs.stat, fs.exists, fs.mkdir, fs.remove, fs.pwd, fs.cd, fs.tag, fs.query |
"io" |
file content (streaming handles): io.open(path, mode) → f:read/f:write/f:lines/f:close |
"terminal" |
screen/keys: init, shutdown, print, write, color, attr, key, cols/rows |
"net" |
networking: net.get, net.post, net.dns, net.ping, net.connect |
"data" |
encoding: data.json_decode/data.json_encode, base64, hashing |
"db" |
raw DBFS/SQLite (below fs): db.query, db.exec, db.get, db.scalar, db.transaction |
fs.stat(path) returns a table with .kind ("file" / "dir") among other
fields — check it before recursing.
Running other scripts
os.run and os.spawn invoke another command. Both take (name, argv-table)
and return a result table (not multiple values):
local r = os.run("counter", { "10", "50" }) -- run and wait
if r.out then print(r.out) end
if not r.ok then
print("failed rc=" .. tostring(r.rc) .. (r.err and (": " .. r.err) or ""))
end
local s = os.spawn("watchdog", {}) -- background, returns immediately
if s.ok then print("spawned " .. tostring(s.taskName)) end
Result fields: .ok (bool), .out (captured output, run only), .rc (return
code), .err (error string), .taskName (spawn only). Don't assume an
ok, out, err multi-return — it's a single table.
Aliases: one-line forwarders
To give a command a second name, don't duplicate the code — forward to the canonical command:
-- ls.lua - Unix-name alias for `dir`.
os.run("dir", arg or {})
Keep aliases free of desc/long/name so they don't clutter help listings
(the help index only shows commands that declare themselves). AntOS uses this
for the DOS/Unix pairs: cat→type, rm→del, cp→copy, mv/ren→move,
mkdir→md, ls→dir.
(Design) Commands are also reachable by prefix-and-dot abbreviation at the console —
d.→dir— resolved alphabetically overD:/scripts, with an optionalabbrev/priorityheader field to claim or decline a prefix. See AntBASIC → abbreviations.
Help-header convention (for hand-rolled commands)
Commands using os.arguments get their help for free. If you hand-roll ?
(see the strict-flags section), follow the house style so it reads like the
rest: define desc, long, and usage locals, and print them on ?:
local desc = "..."
local long = "..."
local usage = "mycmd <file> [options]"
if args[1] == "?" then print(long); print(""); print(usage); return end
If your body references desc/long/usage anywhere else (e.g. an error
branch prints usage), make sure they stay defined — a common bug is deleting
the locals but leaving a print(usage) behind.
A minimal end-to-end example
-- wc.lua - count lines in a file
local a, done = os.arguments{
name = "wc",
desc = "Count the lines in a file",
long = "Reads a text file and prints how many lines it contains.",
{ "*File", required = true, help = "file to count" },
{ "-Chars", help = "also count characters" },
}
if done then return end
local f = io.open(a.file, "r")
if not f then
print("wc: no such file: " .. a.file)
return
end
local text = f:read("a") or ""
f:close()
local lines = select(2, text:gsub("\n", "\n"))
print(string.format("%d lines", lines))
if a.chars then
print(string.format("%d chars", #text))
end
Drop that in D:/scripts/wc.lua and it's a command: wc notes.txt -chars,
wc notes.txt -c (abbreviated), or wc ? for help. It self-documents,
abbreviates, and behaves like everything else.
Checklist before you ship a script
- [ ]
if done then return endright afteros.arguments - [ ] output is ASCII (or within the supported glyph set) — no
→ — ° emoji - [ ] data-matching patterns keep their glyphs as byte-escapes (
\194\176) - [ ] if
term.init()is called,term.shutdown()precedes every return - [ ] no leftover references to removed
usage/desc/longlocals - [ ] loops don't shadow the result table
a(usew,t,x, nota) - [ ]
os.run/os.spawntreated as returning a table, not multi-values - [ ] aliases are bare forwarders with no
desc(kept out ofhelp)