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 — true if it already handled things (the user asked for help with ?, or a required argument was missing and it printed an error). When done is 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 (and done = true) if it's missing
  • default = <value> — used when the argument isn't given
  • type = "number" — coerces the value with tonumber
  • help = "..." — the per-argument line shown in mycmd ?

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 over D:/scripts, with an optional abbrev/priority header 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 end right after os.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/long locals
  • [ ] loops don't shadow the result table a (use w, t, x, not a)
  • [ ] os.run/os.spawn treated as returning a table, not multi-values
  • [ ] aliases are bare forwarders with no desc (kept out of help)

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.