lask-task-runner/lask

A task runner with a small typed language: tasks run in pinned containers and are checked before anything executes.

Haskell

2

270 commits

updated Oct 4, 2026

See the code

See what people are saying

README

Lask

test release license

Docker is a trademark of Docker, Inc. Other names belong to their owners; their use does not imply endorsement.

Lask (lambda + task) is a task runner with a small language behind it, giving automation what shell scripts and CI YAML never had: portability, reproducibility, and verification before anything runs.

Lask is for anyone who finds Makefiles and Taskfiles not quite enough, and Dagger too much. The first are screwdrivers from the kitchen drawer; the second, a factory floor of your own to operate — its own engine, its own SDK, a general-purpose language and its entire ecosystem. Lask is the garage in between: install Docker and Lask, and you have everything you need.

Lask aims to stay simple and light to use, while bringing along the parts of the heavyweight platforms that most automation needs: pinned environments, checks before anything runs, concurrency and reuse.

The recording above and the excerpt below come from example/01-projects/02-webapp-on-aws, which builds and deploys a full AWS stack — a Python Lambda API, a React front end, RDS Postgres, Cognito, CloudFront and S3, with Playwright end-to-end tests — from a machine with none of those tools installed. For the language itself, example/02-language is a tour of the whole language, one runnable module per topic.

An excerpt of the example's main.lask: Python and Node commands declared on pinned images from an imported tools module, a one-line unit-test task, and a typed end-to-end test task that runs Playwright in its own image

See the full main.lask →

Why Lask

Lask makes automation approachable, verifiable, portable, programmable, runnable and secure.

Approachable. One .lask file in a directory is already a project: no scaffolding, no config, nothing to install but Lask and Docker. The syntax is borrowed from languages you already write — C-family braces, try / catch, async / await, TypeScript's type notation — and the whole language and CLI fit on one Quick Reference page, short enough for you to read in ten minutes and for a coding model to hold in context.

Verifiable. lask check resolves every name, argument and type before a single command runs, over the very definitions CI will execute — no second copy in YAML to drift. The same errors appear in your editor as you type, so a typo costs seconds, not a red CI log.

Portable. An execution environment is a value: pin an image once with command { "go" } on #golang:1.22, and every command that names it runs there, on your laptop and in CI alike. A command that names no environment is a static error, never a silent fall back to the host.

Programmable. A task is an ordinary function — typed keyword arguments with defaults, a return value — and control flow, error handling and concurrency belong to the language, not to shell convention. Tasks call each other inside a project, and are imported across projects, along with the environments they run in: import command { "go" } from "tools". Being a DSL, it takes fewer lines than an SDK in Go or TypeScript, with no project to build around it.

Runnable. Every piece runs on its own: a task with lask run, an expression in the REPL, a one-off command in its container with lask cmd go test ./.... A task's signature is its command line — release(--dry_run = false) is lask run release --dry-run true — and its doc comment (@param, @return, @example) is the single source for both --help and the editor's hover, alongside the inferred return type and every image the task will need.

Secure. Secrets stay out of the code and out of the log: a !! binding is masked wherever its value appears, and an environment variable like {vault://secret/aws#secret_key} is resolved from Vault when read, so the same program runs with or without a vault. Imports are pinned by content hash in a committed lock file, and only lask deps sync touches the network. Every command runs in a container you can lock down with network = "none", read_only = true or cap_drop, and a task like deploy can ask for a typed confirmation before it runs.

Comparison

Lask is a task runner, not a build system — and not a CI platform. It does not replace GitHub Actions, GitLab CI, or Jenkins; it replaces what your jobs run, so one definition executes on your laptop and inside whatever runner you already have. Your provider's YAML keeps the part it is genuinely good at — triggers, permissions, secrets — wrapped around a step that calls lask run. Switching providers then means rewriting that step, not your pipeline.

Here is how Lask compares to the lighter tools it replaces and to the heavier one it stops short of:

LaskmakeTaskfileDagger
Static checks before execution✅ types, names, arity (lask check)—schema onlyvia the SDK's language
Typed task arguments with defaults✅ --name: String = "World"—untyped vars✅ in the SDK's language
Execution environments as values✅ command { "go" } on #golang:1.22——✅ containers in the API
Concurrency✅ async / await-j (per-target)deps run in parallel✅ implicit in the DAG
Code reuse across projects✅ hash-pinned module importsincludeincludes✅ Git modules
Incremental rebuilds—✅ file targets✅ checksum / timestamp✅ content-addressed cache
Config formattyped DSLMakefileYAMLGo / Python / TypeScript
What you install✅ Lask + Docker, and nothing a task usesmake, plus every tool a task usessingle binary, plus every tool a task usesbinary + Docker + an SDK toolchain

When to use something else:

  • If your tasks are primarily "rebuild only what changed" over file targets, make (or a real build system like Bazel) is the right tool. Lask does not track file freshness.
  • If you need artifact caching at build-system scale, or you want your pipeline written in Go or TypeScript with a full SDK behind it, Dagger goes further than Lask does — at the cost of an engine to run and an ecosystem to keep.

When Lask pays off: tasks that take arguments, call each other, run in pinned Docker environments, or run concurrently — the point where Makefiles and YAML pipelines usually turn into untestable shell scripts. lask check verifies all of it before anything executes.

Install

macOS · Homebrew
$ brew tap lask-task-runner/tap
$ brew trust lask-task-runner/tap
$ brew install lask
macOS / Linux · download the binary
$ VERSION=$(curl -fsSL https://api.github.com/repos/lask-task-runner/lask/releases/latest | grep -m1 '"tag_name"' | cut -d '"' -f4)
$ TARGET=linux-amd64 # or macos-amd64, macos-arm64
$ curl -fsSL -o lask.tar.gz "https://github.com/lask-task-runner/lask/releases/download/${VERSION}/lask-${VERSION}-${TARGET}.tar.gz"
$ tar -xzf lask.tar.gz
$ sudo mv ./lask /usr/local/bin

Uninstall with sudo rm /usr/local/bin/lask.

Debian / Ubuntu · .deb package
$ VERSION=$(curl -fsSL https://api.github.com/repos/lask-task-runner/lask/releases/latest | grep -m1 '"tag_name"' | cut -d '"' -f4)
$ curl -fsSL -o lask.deb "https://github.com/lask-task-runner/lask/releases/download/${VERSION}/lask-${VERSION}-linux-amd64.deb"
$ sudo dpkg -i lask.deb

amd64 only; on another architecture, take the tarball above. Uninstall with sudo apt remove lask.

Windows · PowerShell
> $version = (Invoke-RestMethod https://api.github.com/repos/lask-task-runner/lask/releases/latest).tag_name
> Invoke-WebRequest -Uri "https://github.com/lask-task-runner/lask/releases/download/$version/lask-$version-windows-amd64.zip" -OutFile lask.zip
> Expand-Archive -Path lask.zip -DestinationPath "$env:LOCALAPPDATA\Programs\lask" -Force
> setx PATH "$env:PATH;$env:LOCALAPPDATA\Programs\lask"

Restart your terminal for the updated PATH to take effect. Uninstall with Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Programs\lask".

From source · Haskell toolchain

Needs GHCup or brew install haskell-stack.

$ stack --local-bin-path /usr/local/bin/ install

Verify with lask --help. Archives and packages for every platform are on the latest release; an APT repository and Chocolatey support are planned.

Shell completion · bash, zsh, fish

Completion knows your module, not just the CLI: it completes the functions the repository you are standing in defines, each function's keyword parameters, and the commands it declares.

$ lask run <TAB>
build_on_docker     install             test                uninstall_completion
doctest             install_completion  uninstall           unittest
$ lask run install --<TAB>
--output  --env  --help
$ lask run install --env <TAB>
docker  local
$ lask cmd <TAB>
mv  rm  stack  uname

fish — nothing else to do:

$ lask completion fish > ~/.config/fish/completions/lask.fish

bash — write the script somewhere and source it:

$ mkdir -p ~/.bash_completion.d
$ lask completion bash > ~/.bash_completion.d/lask

then, in ~/.bash_profile (macOS Terminal starts a login shell, which does not read ~/.bashrc) or in ~/.bashrc (Linux):

source ~/.bash_completion.d/lask

With the bash-completion package installed — most Linux distributions have it — writing the script to ~/.local/share/bash-completion/completions/lask instead loads it on demand, with nothing added to your rc file. That directory does nothing on a system without the package, which includes a stock macOS. Note that source <(lask completion bash) cannot be used there either: the source builtin in bash 3.2, still macOS's /bin/bash, silently reads nothing from a process substitution.

zsh — the completion system has to be switched on, which macOS does not do for you:

$ mkdir -p ~/.zsh/completions
$ lask completion zsh > ~/.zsh/completions/_lask

then, in ~/.zshrc:

fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit

If compinit already runs in your ~/.zshrc — every framework does it for you — only the fpath line is new, and any directory already on $fpath works just as well. compinit caches what it found, so after adding a file, delete ~/.zcompdump* and open a new shell. If completion does nothing and command not found: compdef appears when zsh starts, compinit has not run.

The script only ever asks the binary, so it keeps working across upgrades. Completion reads your module without running it: no task, no default value, and no environment is ever evaluated to answer a <TAB>.

Example

Before a task first runs in a container, lask env build pulls its image and pins the digest in lask.lock.json; lask run itself never reaches the network, so every machine runs the image the lock names.

Run a command in any image straight from the REPL, with nothing installed locally:

$ lask repl
lask> $[#rancher/cowsay] cowsay "Lask"

Editor Support

lask serve is a language server built into the same binary. Install the Lask extension from the VS Code Marketplace, or search for "Lask" in VS Code.

Usage

$ lask check                       # static validation
$ lask run <function> [args...]    # execute (result not printed)
$ lask eval <function> [args...]   # execute and print the result as JSON
$ lask cmd <command> [args...]     # run a declared command in its declared image
$ lask envs [--check]              # list/check referenced environments
$ lask env build | list            # materialize / inspect container images
$ lask deps sync                   # fetch + verify external dependencies
$ lask deps add <name> <source>    # add a dependency: --git <url> --rev <rev>, or --url <url>
$ lask deps why <name>             # show why a dependency is in the graph
$ lask repl                        # interactive session
$ lask serve                       # language server (LSP)
$ lask version                     # print the lask version

The Quick Reference covers the whole language and CLI in ten minutes; doc/spec.md is the full specification behind it.

Status

Lask is pre-1.0: features are experimental until 1.0, and breaking changes are still possible. See doc/compatibility.md for what stable will mean once released. Until then, feedback shapes what becomes stable.

Feedback

Questions, ideas and feedback of any kind are welcome in GitHub Discussions: ask in Q&A, suggest a feature in Ideas, or share what you built in Show and tell. If something in this README or the Quick Reference was unclear, or a task you wanted to write did not fit the language, that is worth a discussion too.

Found a bug? Please open an issue.

Development

$ lask run test
automation
build-tool
ci-cd
cli
developer-tools
devops
docker
haskell
reproducible-builds
task-runner

lask-task-runner/lask

A task runner with a small typed language: tasks run in pinned containers and are checked before anything executes.

Haskell

2

270 commits

updated Oct 4, 2026

See the code

See what people are saying

README

Lask

test release license

Docker is a trademark of Docker, Inc. Other names belong to their owners; their use does not imply endorsement.

Lask (lambda + task) is a task runner with a small language behind it, giving automation what shell scripts and CI YAML never had: portability, reproducibility, and verification before anything runs.

Lask is for anyone who finds Makefiles and Taskfiles not quite enough, and Dagger too much. The first are screwdrivers from the kitchen drawer; the second, a factory floor of your own to operate — its own engine, its own SDK, a general-purpose language and its entire ecosystem. Lask is the garage in between: install Docker and Lask, and you have everything you need.

Lask aims to stay simple and light to use, while bringing along the parts of the heavyweight platforms that most automation needs: pinned environments, checks before anything runs, concurrency and reuse.

The recording above and the excerpt below come from example/01-projects/02-webapp-on-aws, which builds and deploys a full AWS stack — a Python Lambda API, a React front end, RDS Postgres, Cognito, CloudFront and S3, with Playwright end-to-end tests — from a machine with none of those tools installed. For the language itself, example/02-language is a tour of the whole language, one runnable module per topic.

An excerpt of the example's main.lask: Python and Node commands declared on pinned images from an imported tools module, a one-line unit-test task, and a typed end-to-end test task that runs Playwright in its own image

See the full main.lask →

Why Lask

Lask makes automation approachable, verifiable, portable, programmable, runnable and secure.

Approachable. One .lask file in a directory is already a project: no scaffolding, no config, nothing to install but Lask and Docker. The syntax is borrowed from languages you already write — C-family braces, try / catch, async / await, TypeScript's type notation — and the whole language and CLI fit on one Quick Reference page, short enough for you to read in ten minutes and for a coding model to hold in context.

Verifiable. lask check resolves every name, argument and type before a single command runs, over the very definitions CI will execute — no second copy in YAML to drift. The same errors appear in your editor as you type, so a typo costs seconds, not a red CI log.

Portable. An execution environment is a value: pin an image once with command { "go" } on #golang:1.22, and every command that names it runs there, on your laptop and in CI alike. A command that names no environment is a static error, never a silent fall back to the host.

Programmable. A task is an ordinary function — typed keyword arguments with defaults, a return value — and control flow, error handling and concurrency belong to the language, not to shell convention. Tasks call each other inside a project, and are imported across projects, along with the environments they run in: import command { "go" } from "tools". Being a DSL, it takes fewer lines than an SDK in Go or TypeScript, with no project to build around it.

Runnable. Every piece runs on its own: a task with lask run, an expression in the REPL, a one-off command in its container with lask cmd go test ./.... A task's signature is its command line — release(--dry_run = false) is lask run release --dry-run true — and its doc comment (@param, @return, @example) is the single source for both --help and the editor's hover, alongside the inferred return type and every image the task will need.

Secure. Secrets stay out of the code and out of the log: a !! binding is masked wherever its value appears, and an environment variable like {vault://secret/aws#secret_key} is resolved from Vault when read, so the same program runs with or without a vault. Imports are pinned by content hash in a committed lock file, and only lask deps sync touches the network. Every command runs in a container you can lock down with network = "none", read_only = true or cap_drop, and a task like deploy can ask for a typed confirmation before it runs.

Comparison

Lask is a task runner, not a build system — and not a CI platform. It does not replace GitHub Actions, GitLab CI, or Jenkins; it replaces what your jobs run, so one definition executes on your laptop and inside whatever runner you already have. Your provider's YAML keeps the part it is genuinely good at — triggers, permissions, secrets — wrapped around a step that calls lask run. Switching providers then means rewriting that step, not your pipeline.

Here is how Lask compares to the lighter tools it replaces and to the heavier one it stops short of:

LaskmakeTaskfileDagger
Static checks before execution✅ types, names, arity (lask check)—schema onlyvia the SDK's language
Typed task arguments with defaults✅ --name: String = "World"—untyped vars✅ in the SDK's language
Execution environments as values✅ command { "go" } on #golang:1.22——✅ containers in the API
Concurrency✅ async / await-j (per-target)deps run in parallel✅ implicit in the DAG
Code reuse across projects✅ hash-pinned module importsincludeincludes✅ Git modules
Incremental rebuilds—✅ file targets✅ checksum / timestamp✅ content-addressed cache
Config formattyped DSLMakefileYAMLGo / Python / TypeScript
What you install✅ Lask + Docker, and nothing a task usesmake, plus every tool a task usessingle binary, plus every tool a task usesbinary + Docker + an SDK toolchain

When to use something else:

  • If your tasks are primarily "rebuild only what changed" over file targets, make (or a real build system like Bazel) is the right tool. Lask does not track file freshness.
  • If you need artifact caching at build-system scale, or you want your pipeline written in Go or TypeScript with a full SDK behind it, Dagger goes further than Lask does — at the cost of an engine to run and an ecosystem to keep.

When Lask pays off: tasks that take arguments, call each other, run in pinned Docker environments, or run concurrently — the point where Makefiles and YAML pipelines usually turn into untestable shell scripts. lask check verifies all of it before anything executes.

Install

macOS · Homebrew
$ brew tap lask-task-runner/tap
$ brew trust lask-task-runner/tap
$ brew install lask
macOS / Linux · download the binary
$ VERSION=$(curl -fsSL https://api.github.com/repos/lask-task-runner/lask/releases/latest | grep -m1 '"tag_name"' | cut -d '"' -f4)
$ TARGET=linux-amd64 # or macos-amd64, macos-arm64
$ curl -fsSL -o lask.tar.gz "https://github.com/lask-task-runner/lask/releases/download/${VERSION}/lask-${VERSION}-${TARGET}.tar.gz"
$ tar -xzf lask.tar.gz
$ sudo mv ./lask /usr/local/bin

Uninstall with sudo rm /usr/local/bin/lask.

Debian / Ubuntu · .deb package
$ VERSION=$(curl -fsSL https://api.github.com/repos/lask-task-runner/lask/releases/latest | grep -m1 '"tag_name"' | cut -d '"' -f4)
$ curl -fsSL -o lask.deb "https://github.com/lask-task-runner/lask/releases/download/${VERSION}/lask-${VERSION}-linux-amd64.deb"
$ sudo dpkg -i lask.deb

amd64 only; on another architecture, take the tarball above. Uninstall with sudo apt remove lask.

Windows · PowerShell
> $version = (Invoke-RestMethod https://api.github.com/repos/lask-task-runner/lask/releases/latest).tag_name
> Invoke-WebRequest -Uri "https://github.com/lask-task-runner/lask/releases/download/$version/lask-$version-windows-amd64.zip" -OutFile lask.zip
> Expand-Archive -Path lask.zip -DestinationPath "$env:LOCALAPPDATA\Programs\lask" -Force
> setx PATH "$env:PATH;$env:LOCALAPPDATA\Programs\lask"

Restart your terminal for the updated PATH to take effect. Uninstall with Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Programs\lask".

From source · Haskell toolchain

Needs GHCup or brew install haskell-stack.

$ stack --local-bin-path /usr/local/bin/ install

Verify with lask --help. Archives and packages for every platform are on the latest release; an APT repository and Chocolatey support are planned.

Shell completion · bash, zsh, fish

Completion knows your module, not just the CLI: it completes the functions the repository you are standing in defines, each function's keyword parameters, and the commands it declares.

$ lask run <TAB>
build_on_docker     install             test                uninstall_completion
doctest             install_completion  uninstall           unittest
$ lask run install --<TAB>
--output  --env  --help
$ lask run install --env <TAB>
docker  local
$ lask cmd <TAB>
mv  rm  stack  uname

fish — nothing else to do:

$ lask completion fish > ~/.config/fish/completions/lask.fish

bash — write the script somewhere and source it:

$ mkdir -p ~/.bash_completion.d
$ lask completion bash > ~/.bash_completion.d/lask

then, in ~/.bash_profile (macOS Terminal starts a login shell, which does not read ~/.bashrc) or in ~/.bashrc (Linux):

source ~/.bash_completion.d/lask

With the bash-completion package installed — most Linux distributions have it — writing the script to ~/.local/share/bash-completion/completions/lask instead loads it on demand, with nothing added to your rc file. That directory does nothing on a system without the package, which includes a stock macOS. Note that source <(lask completion bash) cannot be used there either: the source builtin in bash 3.2, still macOS's /bin/bash, silently reads nothing from a process substitution.

zsh — the completion system has to be switched on, which macOS does not do for you:

$ mkdir -p ~/.zsh/completions
$ lask completion zsh > ~/.zsh/completions/_lask

then, in ~/.zshrc:

fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit

If compinit already runs in your ~/.zshrc — every framework does it for you — only the fpath line is new, and any directory already on $fpath works just as well. compinit caches what it found, so after adding a file, delete ~/.zcompdump* and open a new shell. If completion does nothing and command not found: compdef appears when zsh starts, compinit has not run.

The script only ever asks the binary, so it keeps working across upgrades. Completion reads your module without running it: no task, no default value, and no environment is ever evaluated to answer a <TAB>.

Example

Before a task first runs in a container, lask env build pulls its image and pins the digest in lask.lock.json; lask run itself never reaches the network, so every machine runs the image the lock names.

Run a command in any image straight from the REPL, with nothing installed locally:

$ lask repl
lask> $[#rancher/cowsay] cowsay "Lask"

Editor Support

lask serve is a language server built into the same binary. Install the Lask extension from the VS Code Marketplace, or search for "Lask" in VS Code.

Usage

$ lask check                       # static validation
$ lask run <function> [args...]    # execute (result not printed)
$ lask eval <function> [args...]   # execute and print the result as JSON
$ lask cmd <command> [args...]     # run a declared command in its declared image
$ lask envs [--check]              # list/check referenced environments
$ lask env build | list            # materialize / inspect container images
$ lask deps sync                   # fetch + verify external dependencies
$ lask deps add <name> <source>    # add a dependency: --git <url> --rev <rev>, or --url <url>
$ lask deps why <name>             # show why a dependency is in the graph
$ lask repl                        # interactive session
$ lask serve                       # language server (LSP)
$ lask version                     # print the lask version

The Quick Reference covers the whole language and CLI in ten minutes; doc/spec.md is the full specification behind it.

Status

Lask is pre-1.0: features are experimental until 1.0, and breaking changes are still possible. See doc/compatibility.md for what stable will mean once released. Until then, feedback shapes what becomes stable.

Feedback

Questions, ideas and feedback of any kind are welcome in GitHub Discussions: ask in Q&A, suggest a feature in Ideas, or share what you built in Show and tell. If something in this README or the Quick Reference was unclear, or a task you wanted to write did not fit the language, that is worth a discussion too.

Found a bug? Please open an issue.

Development

$ lask run test
automation
build-tool
ci-cd
cli
developer-tools
devops
docker
haskell
reproducible-builds
task-runner