lmscript user guide
Everything here was checked against lmscript 0.2.348. For a guided first session see the tutorial.
Overview
lmscript is a dynamically typed scripting language for automation. A script is a .lms text file run by the lmscript command. The language is deliberately small: statements, functions, lists and maps, a large standard library of plain functions, built-in blocks for CSV/JSON/TOML/properties/GraphQL/command data, and direct process execution without a shell.
Two properties shape how you work with it:
- Check before run. Every run first checks the whole script (syntax, undeclared names, wrong argument counts, unknown functions and methods, bad data blocks), including code in branches that will not run. Errors stop the script with exit code 2 before anything happens.
- Dry run before run. After the check, a normal run executes the script once in a simulation in which file writes, deletes and program runs are recorded instead of performed. Only if that pass finds no fatal failure does the real run start. --dry-run shows that plan instead of running.
Scripts run with your full local permissions. There is no sandbox: run only scripts you trust.
Install
lmscript is in the npm package @lmctl-ai/lmctl, together with lmctl. It needs Node.js 24.15 or later.
npm install -g @lmctl-ai/lmctl
lmscript --version lmscript 0.2.348
Update with the same command; npm view @lmctl-ai/lmctl version shows the latest published version.
Concepts
- Script
- A UTF-8 text file, conventionally NAME.lms, run as lmscript NAME.lms [args…]. Top-level statements run in order; functions are hoisted.
- Check
- Static analysis of the whole file and its imports. Diagnostics have the form error:FILE:LINE:COLUMN: message.
- Dry pass and plan
- A simulated execution. Effects are recorded as dummy calls with typed placeholder results (true, 0, empty string/list/map or null); read-only calls really run. The record is the plan.
- Strict access
- Reading a missing map key or list index is an error, not undefined. Use m.has(k), m.get(k, default) or l.get(i, default) when something may be absent.
- Module
- An lmscript file included with import; a file that starts with package NAME; exposes its functions as NAME.fn().
- Data block
- A marker such as _csv_ whose text body follows the statement and ends with end _csv_; the value is parsed data.
Reference
Command line
lmscript [OPTIONS] FILE [ARGS…] lmscript [OPTIONS] - [ARGS…] lmscript [OPTIONS] -e CODE [ARGS…]
Options go before the script; everything after FILE, - (read the script from standard input) or -e CODE is passed to the script in ARGV, including words that look like options.
| Option | Meaning |
|---|---|
| --check | Check only; run nothing. Exit 0 or 2. |
| --lint | Like --check. |
| --check-files | Also verify path literals (i"…", o"…") against the file system, read-only. |
| --dry-run | Run only the simulation and print the plan on stdout; findings go to stderr. |
| --plan-format text|json | Plan format for --dry-run. |
| --dry-loop-limit N | Loop iterations simulated per loop in the dry pass (default 10). |
| --dryrun-timeout MS | Time limit of the dry pass (default 100 ms before a normal run, 5000 ms for --dry-run). |
| --no-preflight | Skip the dry pass before a normal run (the check still runs). |
| --trace FILE | Write an execution trace to FILE. |
| --resumepoint PATH | Start a recover block at the named resume point. |
| --pref FILE | Overlay a TOML properties file on PROPS. |
| --module SPEC=FILE | Use a local file for a schema-module import. |
| --base-dir DIR, --source-name LABEL | Import resolution base and the file name used in diagnostics (useful with - and -e). |
| --dsl-signatures SCHEMA | Print a DSL schema's canonical signatures (see DSL blocks). |
| --version, -v | Print the version. |
| help, help NAME, help topics | List all functions by category, explain one function or built-in, list or show language topics. |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success (also a complete or truncated dry run). |
| 1 | Run-time failure: a run-time error, a failed require or assert, recorded error() calls, a usage error. |
| 2 | Check error (syntax, undeclared name, bad block, missing path literal); also a failed dry run under --dry-run. |
| N | exit(N) with N from 0 to 255. |
| 130 / 143 | Interrupted by SIGINT / SIGTERM. |
Syntax basics
- Every simple statement ends with ;: assignments, calls, let, return, break, continue, import, package, command statements. Blocks (if, while, for, fn) end with } and take no ;. A stray extra ; is an error.
- Every if/else/while/for body is a { … } block, even for one statement.
- Comments: # …, // … and /* … */.
- Identifiers: ASCII letters, digits and _, case-sensitive. Reserved words include fn function let if else while for return break continue true false null parallel sync import system cmd recover resumepoint.
- Numbers: 42, 1.5, .5; no exponent, hex or _ forms; - is an operator.
Values and types
| Value | Literal | type(x) |
|---|---|---|
| null | null | null |
| boolean | true, false | boolean |
| number (64-bit float) | 3, 2.5 | number |
| string | "a", 'a', f"…", r"…", """…""" | string |
| list | [1, "a", null], tuple (1, 2) | array |
| map | {name: "Ada", "key with space": 1} | object |
| function reference | a function's name, e.g. len | function |
Lists and maps are references: assigning one to another variable does not copy it. No trailing commas in list or map literals. Map keys are identifiers or plain strings; use m["if"] or m[k] for other keys. Keys are returned in JavaScript order: integer-like keys first in numeric order, then the others in insertion order.
False values are null, false, 0 and the empty string; empty lists and maps are true.
Strings
| Form | Behaviour |
|---|---|
| "text", 'text' | Plain string; \n \t \r \\ \" \' escapes. |
| f"Hi {name}" | Formatted: {expr} fields are checked expressions; {{ and }} give literal braces. Lists and maps insert as compact JSON. |
| r"C:\dir" | Raw: backslashes stay as written. |
| rf"…", fr"…" | Raw text with formatted fields. |
| """…""", '''…''' | Multi-line (also with f/r prefixes); no dedent. |
| i"in.txt", o"out.txt", io"f" | Path literals: a path the script reads, writes or both; checked to exist (or be writable) before the first statement runs. |
There are no heredocs. + concatenates (and converts the other operand to text); str(x) converts explicitly. expand(text, vars) fills ${name} placeholders from a map.
Operators
From lowest to highest precedence: assignment =; cond ? a : b; ||; &&; == !=; < <= > >=; + -; * / %; unary ! -; call, index and member access. Equality is strict (0 == false is false); lists and maps compare by identity. && and || return one of their operands (null || "x" is "x"). Division by zero is a run-time error. There is no ++, +=, **, bitwise or in operator.
Control flow
if (n > 10) {
println("big");
} else if (n > 0) {
println("small");
} else {
println("none");
}
while (queue_len > 0) { queue_len = queue_len - 1; }
for (let i = 0; i < 3; i = i + 1) { println(i); }
for (item in ["a", "b"]) { println(item); } # list values
for (key in {x: 1, y: 2}) { println(key); } # map keys
for (ch in "héllo") { println(ch); } # characters
break; and continue; act on the innermost loop. A for (x in …) loop iterates a snapshot of the sequence. Loops stop with an error after 1,000,000 iterations.
Functions and scope
fn area(w, h) {
return w * h;
}
println(area(3, 4));
op = area; # a function reference
println(op(2, 5));
- Functions are declared at top level only, with a fixed number of parameters; there are no anonymous functions or closures. A function without return returns null. Recursion depth is limited to 1000 calls.
- Assignments inside a function create local variables; they never change a global of the same name. Changing the contents of a list or map passed in does affect the caller.
- Blocks are scopes: a name first assigned inside an if or loop body does not exist after it (check error Undeclared variable). Assign it before the block.
- let x = …; declares explicitly (a second let x in the same scope is an error); let (a, b) = list; and [a, b] = list; destructure, filling missing values with null.
Lists, maps and methods
| Type | Methods |
|---|---|
| map | get(key, default), has(key), len(), keys() |
| list | get(index, default), len() |
| string | len() |
Everything else is a plain function: push(list, x), keys(m), contains(list, x), sort(list, "field"), set(m, "a.b", v). Writing a missing key creates it (m.k = v); reading one is an error that names the fix:
map user has no key "email"; test with user.has("email") or read user.get("email", default)
Modules and imports
# lib/textutil.lms
package textutil;
fn shout(s) { return upper(s) + "!"; }
# main.lms
import "lib/textutil.lms";
println(textutil.shout("hi"));
- import "path"; includes another lmscript file (relative to the importing file) or an https:// URL at that point. Each location loads once; import order matters (import a module before using it).
- Without package, the imported functions and variables are used unqualified. With package NAME; at the top, callers write NAME.fn().
- import "native:git"; loads the built-in native git module. A JSON tool schema (import "./tool.cli.schema.v1.json";) turns a command-line tool's published operations into functions.
- JavaScript and npm packages cannot be imported.
Data blocks
rows = _csv_;
name,score
Ada,95
end _csv_
cfg = _toml_;
[server]
port = ${port}
end _toml_
| Marker | Value |
|---|---|
| _csv_ | List of maps (first line is the header) or, with heading=false, list of lists. Option delimiter=";". Numbers and true/false/null are typed. |
| _json_ | The parsed JSON value (strict JSON). |
| _toml_ | A map (TOML 1.0). |
| _props_ | A flat map of strings (Java properties). |
| _cmd_ | An argument list for system, one command spread over several lines. |
| _graphql_ | A GraphQL document as a string, syntax-checked. |
| _dsl_ | Experimental: a compiled program for a native host (below). |
Rules: the statement ends with ; on the marker line; the body starts on the next line; the block closes with a line that is exactly end _csv_ (matching marker, column 1). Blank lines and lines starting with # are dropped. ${expr} inserts a value safely: it can never add rows, cells or quotes. All blocks are validated by the check, before anything runs.
Running programs
r = system git status --short; # run now, without a shell
if (r.code != 0) { exit(1); }
println(r.stdout);
argv = cmd git log -n ${count}; # build ["git","log","-n","5"]
r = system(argv, 10000); # computed argv, 10 s timeout
- system WORDS; runs a program and returns {stdout, stderr, code, signal, timed_out, duration_ms, pid}. A non-zero exit is data: check r.code.
- Words split on spaces; quotes group words; ${expr} inserts a value as one word, or a list as several words.
- Pipes, redirects, ;, globbing, ~ and $( ) are refused. For shell features run a shell explicitly: system(["bash", "-c", script]), passing data as extra arguments.
- Default timeout 60 s; output is limited to 10 MiB.
Standard library
lmscript help lists every function with its signature; lmscript help NAME explains one. Highlights by category:
| Category | Examples |
|---|---|
| Strings | split join trim upper lower replace replace_all starts_with ends_with contains index_of substring slice pad_start sprintf format words lines regex_test regex_match regex_find_all regex_replace |
| Lists | len push pop first last range sort reverse unique flatten chunk take drop sum avg enumerate concat |
| Maps and data | keys values get set has_key merge pluck filter_by find_by json_parse json_stringify |
| Files | read_file write_file append_file read_lines write_lines read_json write_json read_csv write_csv file_exists list_dir glob mkdir copy_file move_file delete_file path_join basename dirname temp_file |
| System | system env set_env which sleep_ms now date strftime exit require assert error warning |
| Output | println print printf (printf adds no newline) |
| Types | type str is_string is_number is_array is_map is_function is_empty is_blank |
With lmctl installed, the lm_* functions (for example lm_prompt) drive agent teams; see lmscript help lmctl.
Arguments, environment and properties
- ARGV: the list of words after the script name.
- env("NAME", default): an environment variable.
- PROPS.a.b: read-only properties from ~/.lmctl/init.toml (or $LMCTL_HOME/init.toml), overlaid by --pref FILE.
- Directory: the working directory.
Check, dry run and run in detail
A normal run is: check, then a silent dry pass, then the real run. The dry pass simulates up to 10 iterations per loop and gives up after 100 ms; a cut-short dry pass still allows the real run. If the dry pass hits a fatal error before any simulated effect, the real run does not start (exit 1). The dry pass is not a proof: data read at run time can differ, and untaken branches are not simulated. --dry-run --plan-format json prints one JSON report:
lmscript --dry-run --plan-format json pj.lms
{
"version": 1,
"status": "complete",
"entries": [
{
"seq": 1,
"kind": "dummy",
"file": "/home/me/pj.lms",
"line": 1,
"column": 1,
"function": "write_file",
"arguments": [
"a.txt",
"x"
],
"simulated": true
}
],
"stop": null,
"diagnostics": []
}
Other features
- parallel([f(a), g(b)])
- Runs the calls concurrently and returns their results in order.
- recover { resumepoint NAME: … }
- Named entry points; --resumepoint NAME restarts a failed script from there. It saves no state.
- require(value, message)
- Returns value or stops with message (exit 1) when it is null or empty.
Experimental: _dsl_ blocks for native hosts
Experimental. The syntax and the bytecode format may change without compatibility. A _dsl_ block is not lmscript: its body is code in a small, statically typed language, compiled at check time against a schema that a native host program publishes. lmscript only compiles and saves the program; the host loads and runs it. Hosts include lmhtml (HTML checking) and lmcad (3D shapes and images); hosts are built with the C++ runtime cpp-dsl-sdk.
The workflow, verified with lmscript 0.2.348 and lmhtml v0.1.0:
- Get the host's schema: lmhtml --schema --json > lmhtml.dsl.json (lmhtml --schema prints a short summary).
- Write a script with a _dsl_ block and savedsl:
check = _dsl_ schema="lmhtml.dsl.json"; let pages = html.load_dir("site"); for (i in range(0, pages.len())) { let page = pages.at(i); let links = page.links(); for (j in range(0, links.len())) { let url = links.at(j); if (!html.link_exists(page, url)) { report.add("broken-link", page.path(), 0, url + " not found"); } } } report.write_text("broken.txt"); end _dsl_ savedsl(check, "links.dslb"); - Compile by running the script: lmscript links.lms writes links.dslb.
- Run it in the host: lmhtml --dsl links.dslb prints one JSON result line, {"ok": true, "result": null}, and writes broken.txt.
The language inside the block:
- Statements: let x = expr; (type inferred, no annotations), x = expr;, if (c) { } else if (c) { } else { }, for (i in range(a, b)) { } (int bounds, end excluded), while (c) { } with break;/continue;, and calls. Every statement ends with ;, every body is a block.
- Types: int (64-bit, wraps), float (literals need a fraction: 1.0), string, bool, and the object types the schema defines. Arithmetic never mixes int and float; float(i) converts. "a" + "b" concatenates strings; str(x) converts a number or bool to text.
- The schema's context objects (for lmhtml: html and report) and functions are the only API. Host collections are walked with range(0, xs.len()) and xs.at(i).
- Not available: lmscript functions, lists and maps, user functions and types.
- ${expr} in the body inserts an lmscript value as a typed literal before compiling (string, int, float or bool).
- Type errors are check errors at the exact position, before anything runs:
error:/home/me/bad.lms:2:27: _dsl_: method Html.load_dir expects string for argument 1; found int
- The compiled value is opaque: printing it is an error; savedsl(value, path) is the only use (atomic write; nothing is written under --dry-run).
- lmscript --dsl-signatures SCHEMA prints the schema's functions and methods, one per line.
The bytecode records the SHA-256 of the exact schema bytes; a host refuses a file compiled against a different schema. Note: the built-in lmscript help dsl text in 0.2.348 still describes the previous version (no while, bytecode v4); the released compiler supports while/break/continue and writes bytecode v5, which current hosts require.
Tasks
Summarize a CSV file
rows = read_csv(i"sales.csv");
total = 0;
for (r in rows) { total = total + r.amount; }
write_json(o"summary.json", {rows: len(rows), total: total});
Run a tool and fail cleanly
r = system git rev-parse --short HEAD;
if (r.code != 0) {
println(f"git failed: {trim(r.stderr)}");
exit(1);
}
println(f"commit {trim(r.stdout)}");
Preview a destructive script
Run lmscript --dry-run script.lms and read the plan; then run it normally.
Run a one-liner
lmscript -e 'println(len(list_dir(".")));'
Limits
- No sandbox: scripts can read, write and run anything your user can.
- Loops: 1,000,000 iterations; call depth: 1000; program output captured by system: 10 MiB; default system timeout: 60 s.
- No classes, closures, anonymous functions, nested function declarations, ++ or compound assignment.
- Numbers are 64-bit floats (exact integers up to 2^53).
- The dry pass simulates at most 10 iterations per loop and 100 ms by default; it cannot predict data that only exists at run time.
- _dsl_ blocks are experimental.
Troubleshooting
- Expected ';' after the assignment; every statement ends with ';'
- Add the ; at the reported position. Blocks (}) take none.
- Undeclared variable 'x'
- The name is first assigned inside a block or misspelled. Assign it before the block.
- map m has no key "k"; …
- Use m.has("k") or m.get("k", default).
- Expected '{' after if (...); braces are required …
- Wrap the body in { }.
- A command with | or > is refused
- system runs no shell. Do the piping in lmscript or call bash -c explicitly.
- path literal i"x" (resolved ./x) does not exist
- The input file is missing relative to the working directory; this check runs before any effect.
- The real run did not start after a failure
- The dry pass hit a fatal error first. Run lmscript --dry-run script.lms to see where.
- _dsl_ error, or the host says LOAD_SCHEMA_MISMATCH
- Recompile against the exact schema the host prints with --schema --json; the bytecode embeds the schema hash.