Skip to content

Commands ​

Docker. $, $1, $2 and $*, explicit $[env], interpolation and its escaping, the raw hole #!{...}, run, the command ... on ... declarations that dispatch a bare $, and what is checked before a command runs: its shell syntax, the options a command schema describes, and whether the image has the programs declared on it.

bash
lask sync
lask eval probe --path /etc/os-release
lask eval inspect            # a command that exits 3, without raising
lask eval report
lask eval shell_users        # grep, checked against its schema

The thing to take away: a command string swallows the rest of its line. $ uname -s |> trim hands |> trim to the shell and returns the empty string; the pipe belongs on the next line.

To try the commands above, paste the source below into main.lask in an empty directory and run lask sync first, or clone the repository:

bash
git clone https://github.com/lask-task-runner/lask.git
cd lask/example/02-language/05-commands

Source ​

This is example/02-language/05-commands in the lask repository.

lask
// Commands
//
// Every way of running a program, and the rules that decide where it
// runs. Needs Docker for the container parts:
//
//   lask envs list       # every image this module can reach for
//   lask sync            # materialize them; run and eval never pull
//   lask eval greeting
//   lask run  probe --path /etc/os-release

alpine = #alpine:3.22.2

// A `$` with no environment gets one from the command words in the
// string, matched against these declarations. Matching is lexical and
// exact — `/bin/cat` matches nothing, a basename is never taken.
//
// `lask sync` also asks the image whether it has each program declared
// on it, and records the answer in lask.lock.json ("commands"). A
// program it lacks is E-IO-COMMAND-MISSING, from sync and before
// anything runs, so a task never gets halfway to a command that cannot
// start. Programs on #local are not asked about: a task may install
// them first.
command { "cat", "wc", "sort" } on alpine
command { "uname", "echo" } on #local

// What `grep` takes, so that a misspelt option is caught before it
// runs. A schema is an ordinary value of the built-in type
// CommandSchema, written with literals so that it is known without
// running anything; the annotation is needed, because a record literal
// alone infers every field it gives as required.
//
// `closed: true` says nothing exists but what is listed: an unknown
// option is then E-TYPE-COMMAND-OPTION. Without it, only an advisory.
// The editor completes and describes the options from the same value.
grep_schema: CommandSchema = {
  root: {
    closed: true,
    options: [
      { names: ["-c", "--count"], description: "print only a count of the matching lines" },
      { names: ["-i", "--ignore-case"], description: "ignore case distinctions" },
      { names: ["-v", "--invert-match"], description: "select the lines that do not match" },
      { names: ["-e", "--regexp"], value: "required", description: "the pattern to match" }
    ]
  }
}

command { "grep" } on alpine schema { "grep": grep_schema }

// The kernel of the host, from `uname`.
//
// `$` is the value of stdout, with a trailing newline like any program
// leaves. A non-zero exit code raises, carrying that code.
//
// @return The kernel name of this machine.
host_kernel(): String = $ uname -s
  |> trim

// Run a failing command and look at everything it left.
//
// The four forms differ only in what they hand back.
//
//   $  / $1   stdout, fails when the exit code is not 0
//   $2        stderr, same failure rule
//   $*        CommandResult {code, stdout, stderr}, never fails on the code
//
// @return The exit code, stdout and stderr of a command that fails.
inspect(): CommandResult = $*[alpine] sh -c 'echo out; echo err 1>&2; exit 3'

// Report whether a file is readable in the image.
//
// Because `$*` does not raise, it is how an expected failure is read.
//
// @example lask eval probe --path /etc/os-release
probe(--path: String = "/etc/os-release"): String = do {
  r = $* cat #{path}
  r.code == 0
    ? "found #{path}: #{size(lines(r.stdout))} lines"
    : "missing #{path} (exit #{r.code})"
}

// The first line of the image's /etc/os-release.
//
// An environment may also be named at the command, which overrides
// dispatch. Any expression of type Environment will do.
os_release(): String = $[alpine] cat /etc/os-release
  |> lines
  |> first

// The last line of a file, sorted.
//
// A command string runs to the end of its line, and *everything* still
// on that line is command text: `$ uname -s |> trim` hands `|>` and
// `trim` to the shell and quietly returns the empty string. The pipe
// therefore goes on the next line, where a leading binary operator
// continues the expression (6.5) instead.
//
// A line ending in `\` continues the command itself, which is how a
// long shell pipeline is broken across lines — the command ends at the
// first line that does not end in `\`.
longest_line(--file: String = "/etc/passwd"): String =
  $[alpine] cat #{file} | \
    sort | \
    tail -n 1
  |> trim

// Greet someone from inside the container.
//
// Interpolation takes any stringifiable value. A `String | Null` is not
// one — resolve it with `case` before it reaches a command.
greeting(--name: String = "Lask"): String = do {
  greeting = $[alpine] echo "hello, #{name}"
  trim(greeting)
}

// Pass text through the shell as a single word.
//
// An interpolated value is a value, not shell source: the hole is
// escaped for the quotes it sits in (6.6), so `text` reaches `echo` as
// one word, `;` and all, and inside "..." or '...' it stays text too.
echo_text(--text: String = "a b; rm -rf /"): String =
  $[alpine] echo #{text}
  |> trim

// Pass flags through the shell as words of their own.
//
// `#!{flags}` is the one hole the shell parses: each flag in `flags`
// becomes its own word. It has a delimiter of its own, so where the
// shell parses a value is visible in the command itself. Never put a
// value from outside the program in it, such as a CLI argument — the
// shell would run what it contains.
list_root(): String = do {
  flags = "-1 -a"
  $[alpine] ls #!{flags} /
  |> trim
}

// Count lines with a command assembled at run time.
//
// `run` is the same execution as a function call: the command
// line comes from a String, so it can be assembled at runtime. It
// returns a CommandResult and never raises on the exit code.
//
// @param args  Passed to `wc`, e.g. `-l`.
count(--args: String = "-l", --file: String = "/etc/passwd"): Number = do {
  r = run(alpine, "wc #{args} #{file}")
  // No command string here, so this whole line is ordinary expression.
  r.stdout |> trim |> split_first |> to_number
}

internal split_first(s: String): String = first(split(s, " "))

// Count the accounts whose login shell is the one given.
//
// The command is checked against grep_schema before it runs:
// `$ grep -cnt ...` would be E-TYPE-COMMAND-OPTION ('grep' has no
// option '-n'), and `$ grep -c -e` E-TYPE-COMMAND-OPTION too (the
// option '-e' requires a value). So is the shell syntax of every
// command string: `$ grep -c sh /etc/passwd |` is E-TYPE-COMMAND-SYNTAX.
// A hole in place of a value is fine — what it holds is not known
// until it runs, and nothing is guessed about it.
//
// @example lask eval shell_users --shell /bin/sh
shell_users(--shell: String = "/bin/sh"): Number =
  $ grep -c -e :#{shell} /etc/passwd
  |> trim
  |> to_number

// Two commands in one string, one environment.
//
// Commands chain with the shell's own operators, and dispatch then has
// to agree on one environment for the whole string: `cat x && wc -l x`
// is fine because both words are declared on `alpine`, while mixing in
// a `#local` program would be E-TYPE-COMMAND-CONFLICT — and a word
// declared nowhere is E-TYPE-COMMAND-NOENV, never a fall back to the host.
words_in(--file: String = "/etc/hostname"): String =
  $ cat #{file} && wc -c #{file}

// Everything above, in one value.
//
// Commands compose like anything else: this task runs three of them,
// two of which are in a container and one on the host.
//
// @return A short report about this machine and the image.
report(): Record<host: String, image: String, passwd_lines: Number> = {
  host: host_kernel(),
  image: os_release(),
  passwd_lines: count()
}

Released under the MIT License.