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:

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.

OptionMeaning
--checkCheck only; run nothing. Exit 0 or 2.
--lintLike --check.
--check-filesAlso verify path literals (i"…", o"…") against the file system, read-only.
--dry-runRun only the simulation and print the plan on stdout; findings go to stderr.
--plan-format text|jsonPlan format for --dry-run.
--dry-loop-limit NLoop iterations simulated per loop in the dry pass (default 10).
--dryrun-timeout MSTime limit of the dry pass (default 100 ms before a normal run, 5000 ms for --dry-run).
--no-preflightSkip the dry pass before a normal run (the check still runs).
--trace FILEWrite an execution trace to FILE.
--resumepoint PATHStart a recover block at the named resume point.
--pref FILEOverlay a TOML properties file on PROPS.
--module SPEC=FILEUse a local file for a schema-module import.
--base-dir DIR, --source-name LABELImport resolution base and the file name used in diagnostics (useful with - and -e).
--dsl-signatures SCHEMAPrint a DSL schema's canonical signatures (see DSL blocks).
--version, -vPrint the version.
help, help NAME, help topicsList all functions by category, explain one function or built-in, list or show language topics.

Exit codes

CodeMeaning
0Success (also a complete or truncated dry run).
1Run-time failure: a run-time error, a failed require or assert, recorded error() calls, a usage error.
2Check error (syntax, undeclared name, bad block, missing path literal); also a failed dry run under --dry-run.
Nexit(N) with N from 0 to 255.
130 / 143Interrupted by SIGINT / SIGTERM.

Syntax basics

Values and types

ValueLiteraltype(x)
nullnullnull
booleantrue, falseboolean
number (64-bit float)3, 2.5number
string"a", 'a', f"…", r"…", """…"""string
list[1, "a", null], tuple (1, 2)array
map{name: "Ada", "key with space": 1}object
function referencea function's name, e.g. lenfunction

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

FormBehaviour
"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));

Lists, maps and methods

TypeMethods
mapget(key, default), has(key), len(), keys()
listget(index, default), len()
stringlen()

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"));

Data blocks

rows = _csv_;
name,score
Ada,95
end _csv_

cfg = _toml_;
[server]
port = ${port}
end _toml_
MarkerValue
_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

Standard library

lmscript help lists every function with its signature; lmscript help NAME explains one. Highlights by category:

CategoryExamples
Stringssplit 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
Listslen push pop first last range sort reverse unique flatten chunk take drop sum avg enumerate concat
Maps and datakeys values get set has_key merge pluck filter_by find_by json_parse json_stringify
Filesread_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
Systemsystem env set_env which sleep_ms now date strftime exit require assert error warning
Outputprintln print printf (printf adds no newline)
Typestype 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

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:

  1. Get the host's schema: lmhtml --schema --json > lmhtml.dsl.json (lmhtml --schema prints a short summary).
  2. 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");
  3. Compile by running the script: lmscript links.lms writes links.dslb.
  4. 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:

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

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.