lmscript skill
For an LLM agent that writes or runs lmscript. lmscript looks like JavaScript or Python but is neither; most mistakes come from borrowing their syntax. Follow the rules below, then let lmscript --check and --dry-run prove the script before you run it. Verified against lmscript 0.2.348.
Purpose
lmscript automates local work: read and write files, transform CSV/JSON/TOML data, run other programs without a shell, and drive lmctl agent teams. A script is checked statically and simulated (dry pass) before every real run, so a correct script fails early instead of half-way.
When to use it
- Use it for file and data plumbing, running command-line tools in sequence with checked exit codes, and lmctl workflows (lm_* functions).
- Use it when the user wants a reviewable plan: --dry-run lists every write, delete and program run before it happens.
- Do not use it for long-running services, user interfaces, numeric heavy lifting or anything that needs npm packages; it cannot import JavaScript.
- Do not use it to run untrusted input as code: there is no sandbox.
Install
Requires Node.js 24.15 or later.
npm install -g @lmctl-ai/lmctl lmscript --version lmscript 0.2.348
Commands
| Command | Use |
|---|---|
| lmscript --check FILE | Static check only. Run after every edit. |
| lmscript --check-files FILE | Check plus existence of path literals (i"…", o"…"). |
| lmscript --dry-run FILE [ARGS] | Simulate and print the plan of effects; changes nothing. |
| lmscript FILE [ARGS] | Check, silent dry pass, then the real run. |
| lmscript -e 'CODE' | Run a one-line program (quote it for the shell). |
| lmscript help NAME | Signature, description and example of a function; lmscript help lists all; help topics lists topics (csv, json, cmd, dsl, path, methods, …). |
| lmscript --dsl-signatures SCHEMA | List a DSL schema's functions and methods (experimental _dsl_ blocks). |
Options go before the file; every word after the file is script data in ARGV.
Inputs and outputs
- Input: the .lms file (or - for stdin, or -e CODE), arguments in ARGV, environment via env("NAME", default), files the script reads.
- Output: println/print write a line to stdout; printf writes exactly its text (add \n yourself); diagnostics go to stderr as error:FILE:LINE:COLUMN: message.
- Exit code: 0 success, 1 run-time failure, 2 check error, or the value given to exit(n).
- --dry-run prints a plan (--plan-format json for one JSON object).
Syntax rules that differ from JavaScript and Python
- Every simple statement ends with ;. A block's closing } takes no ;. Never write ;; or };.
- Every if/else/while/for body has braces, even one statement.
- Functions: fn name(a, b) { … } at top level only. No arrow functions, lambdas, closures, nested functions, default parameters, classes or this.
- No ++, +=, **, ===, in operator, spread or optional chaining. Write i = i + 1;.
- Loops: for (x in list), for (k in map) (keys), for (let i = 0; i < n; i = i + 1), while (c). range(a, b) excludes b.
- Strings: f"Hello {name}" for interpolation (not `…${x}`); """…""" for multi-line; no heredocs.
- A name first assigned inside a block does not exist after it. Assign it before the if/loop.
- Assigning to a global inside a function creates a local; return values instead. Mutating a passed list/map does affect the caller.
- Missing map keys and list indexes are errors. Use m.has("k"), m.get("k", default), list.get(i, default).
- Methods exist only for map.get/has/len/keys, list.get/len, string.len. Everything else is a function: push(list, x), keys(m), upper(s), split(s, ","), join(list, ","), contains(x, v). Never list.push(x), s.upper(), s.split().
- No trailing commas in list or map literals. type() reports lists as array and maps as object.
- Equality is strict; 0 == false is false. Empty lists and maps are truthy.
- Do not invent function names: check with lmscript help NAME.
Safety rules
- Scripts run with the user's full permissions. Never run a script you have not checked, and never build lmscript source from untrusted text.
- Before any script that writes, moves or deletes files or runs programs, run lmscript --dry-run and read the plan. Report the plan to the user when the effects are destructive.
- Run programs with system/cmd: no shell is involved, so ${value} is always one argument and cannot inject commands. Pipes, redirects, ;, globs and $( ) are refused on purpose; do not work around that with bash -c plus string-built commands. If a shell is truly needed, pass data as separate arguments: system(["bash", "-c", "grep -c \"$1\" \"$2\"", "_", pattern, file]).
- A non-zero exit of a program is data: always check r.code (and r.timed_out) and stop with exit(1) or require on failure.
- Use path literals i"input.csv" / o"out.json" for fixed paths so missing inputs fail before any effect.
- Do not wrap lmctl agent calls in a shell timeout; long agent turns are normal.
- Do not print secrets: the dry-run plan shows call arguments.
Worked examples
Transform data with checked failures
rows = read_csv(i"orders.csv"); # fails before any effect if missing
require(len(rows) > 0, "orders.csv has no rows");
totals = {};
for (r in rows) {
totals[r.customer] = totals.get(r.customer, 0) + r.amount;
}
write_json(o"totals.json", totals);
println(f"{len(keys(totals))} customers");
Run a tool and use its result
r = system git status --porcelain;
if (r.code != 0) {
println(f"git failed: {trim(r.stderr)}");
exit(1);
}
changed = [];
for (line in lines(r.stdout)) {
if (!is_blank(line)) {
push(changed, line);
}
}
println(f"{len(changed)} changed file(s)");
Show the plan before deleting
lmscript --dry-run cleanup.lms PLAN complete 1. /home/me/cleanup.lms:1:1 dummy write_file ["out/report.txt","done\n"] 2. /home/me/cleanup.lms:2:1 dummy delete_file ["out/notes.txt"] 3. /home/me/cleanup.lms:3:1 handled println ["cleaned up"]
Experimental: compile a _dsl_ program for a native host
Experimental; the syntax may change. A _dsl_ block holds code in a separate, statically typed language that a native host program (for example lmhtml or lmcad) runs. lmscript compiles it at check time against the host's schema and savedsl writes the bytecode; the host runs it.
- Get the schema from the host: lmhtml --schema --json > lmhtml.dsl.json. Read lmhtml --schema (or lmscript --dsl-signatures lmhtml.dsl.json) for the available context objects, functions and methods. Use only those.
- Write the block; the statement ends with ; on the marker line and the block closes with a line exactly end _dsl_:
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");
lmscript links.lms
lmhtml --dsl links.dslb
{"ok": true, "result": null}
cat broken.txt
broken-link site/index.html:0: missing.html not found
Rules inside _dsl_: let x = expr; with inferred types, if/else, for (i in range(a, b)), while with break/continue; types int, float (write 1.0), string, bool and the schema's object types; int and float never mix (float(i) converts); strings join with + and str(x); host lists via xs.len() and xs.at(i). No lmscript functions, lists, maps or user functions inside the block. The compiled value is opaque: only savedsl(value, "file.dslb") accepts it. The host refuses bytecode compiled against a different schema (it checks a SHA-256 of the schema bytes), so recompile when the host changes. The built-in lmscript help dsl text in 0.2.348 is older than the compiler: while/break/continue do work.
Errors and exit codes
| Exit | Meaning | What to do |
|---|---|---|
| 0 | Success | Verify the outputs (below). |
| 1 | Run-time failure, failed require/assert, recorded error(), usage error | Read the error:FILE:LINE:COL line; fix the data handling or the guard. |
| 2 | Check error: syntax, undeclared name, wrong arity, unknown function or method, bad data or _dsl_ block, missing i"…" path | Nothing ran. Fix the reported position and run --check again. |
| N | exit(N) | Script-defined. |
| Message (start) | Fix |
|---|---|
| Expected ';' after the assignment | Add ;. |
| Expected '{' after if (...) | Add braces around the body. |
| Undeclared variable 'x' | Assign x before the block, or fix the spelling. |
| Unexpected token '+' in expression | Usually x++ or x += 1: write x = x + 1;. |
| Unexpected token 'fn' in expression | No anonymous functions; declare a top-level fn and pass its name. |
| Function declarations are only allowed at top level | Move the fn out of the other function. |
| map m has no key "k" | m.has("k") or m.get("k", default). |
| Division by zero | Guard the divisor. |
| _dsl_: method T.m expects … | Match the schema signature (--dsl-signatures). |
How to verify
- lmscript --check script.lms prints nothing and exits 0. Repeat after every edit.
- lmscript --check-files script.lms when the script uses path literals.
- lmscript --dry-run script.lms ARGS: the plan lists exactly the writes, deletes and program runs you intend, with the right arguments.
- Run it; confirm exit code 0 (or the documented code) and that the expected files or output exist, for example by reading them back with read_file/read_json or ls.
- For _dsl_: the script exits 0 and writes the .dslb; HOST --dsl file.dslb prints {"ok": true, …} with exit 0; check the files the host program wrote.
- Report what you ran and what you checked; never claim success from exit code 0 alone when files were supposed to change.