Warning
This project is perfectly usable but still in an early phase. Expect breaking changes and consider pinning a release in your flake input if you want stability.
A simple task runner that combines the power of Nix and Bash.
- Single definition: Write your task once then run across CLI, editor, git hooks, checks and CI
- Toolchain control: Reproducible outcome on any environment
- Declarative arguments: Flags, options and positional arguments are automatically parsed and ready to use in your tasks
- Task composition: Use task to create new tasks
- Project composition: Create your own task library to reuse across projects
- Auto-Discovery: No complex file wiring,
letspicks up your task files automatically - Base Tasks: Import presets and reduce boilerplate code
- Text formatter: Colorize and format your output with helper functions
- Shell completions: Auto-complete task names and show their description
- Auto-documentation: Help output and task inspection auto-generated from your task definitions
- Add
letsas an input. - Define a flake variable with
lets.lib.mkFlakeproviding:systems: list the systems you want to supporttasks: input your task definitionsnixpkgs: so tasks resolve the same packages as the rest of your flake
- Inherit packages and apps from the flake variable
- Pull the
letsshell into your devShell viainputsFrom
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
lets.url = "github:JeffDess/lets";
};
outputs =
{ nixpkgs, lets, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
letsFlake = lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
# Your task definitions
# It can also be a file or a directory path (see "Wiring tasks")
tasks =
{ mkTask, ... }:
{
greet = mkTask {
description = "Say hello";
run = ''bold_green "Hello!"'';
};
};
};
in
{
inherit (letsFlake) apps packages;
devShells.x86_64-linux.default = pkgs.mkShell {
inputsFrom = [ letsFlake.devShells.x86_64-linux.lets ];
};
};
}Then in your project:
$ nix develop # or `direnv reload`
$ lets greet
# Hello!Note
Under the hood, mkFlake creates :
- An
appfor each of your tasks, so you cannix run .#my-task. - A dev shell containing the
letscommand, and theletspackage for every system you list.
Already using flake-parts? Import the module and set
perSystem.lets.tasks instead.
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
lets.url = "github:JeffDess/lets";
};
outputs =
inputs@{ flake-parts, lets, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" ];
imports = [ lets.flakeModules.default ];
perSystem =
{ config, pkgs, ... }:
{
lets.tasks = ./tasks;
devShells.default = pkgs.mkShell {
inputsFrom = [ config.lets.devShell ];
};
};
};
}lets <task> # Run task
lets -h / --help [task] # Display help, optionally for one task
lets -s / --show <task> # Display task details
lets -c / --completions <shell> # Print a shell completion script
lets -v / --version # Display version
The repo also includes a demo task you can
run directly from the flake:
nix run github:JeffDess/lets demoThe quickstart passed tasks inline. The tasks argument accepts four shapes,
so you can pick whatever fits your preferences.
Every shape receives the same scope: pkgs, lib, system, mkTask, tasks
(for composition), baseTasks and anything
you add via specialArgs.
Keep tasks in the flake, as a function of that scope:
tasks =
{ pkgs, mkTask, ... }:
{
greet = mkTask {
description = "Say hello";
run = ''bold_green "Hello!"'';
};
};Move that same function into tasks.nix next to your flake:
.
├── flake.nix
└── tasks.nix
tasks = ./tasks.nix;As your project grows, give each task its own file and point tasks at the
directory: every Nix file in it becomes a task:
tasks = ./tasks;.
├── flake.nix
└── tasks/
├── lib/
├── test.nix
└── release/
├── default.nix
├── changelog.tpl
└── release.sh
It discovers both layouts:
<dir>/<name>.nix: a single file<dir>/<name>/default.nix: a task directory (for tasks with their own scripts/fixtures)
So here lets test and lets release are wired automatically. Directories
without a default.nix (e.g. a shared tasks/lib/) are ignored, so you can
keep helper scripts next to your tasks. A single file may declare more than one
task.
Tasks have those attributes:
| Attribute | Type | Required | Description |
|---|---|---|---|
description |
string | Yes | Shown in lets --help. |
name |
string | No | The built binary (the command you run). Defaults to the attribute key. You rarely need to set it. |
runtimeInputs |
list of packages | No | Packages your task runs at runtime. Optional if no package is used in run, required for reproducibility. |
run |
string | Yes | The implementation as an inline string or builtins.readFile ./my-task.sh to run an external script. |
args |
attrset or list | No | CLI arguments (options, flags and positionals) parsed and passed to run as variables. See the declarative arguments section. |
Important
Use underscores in task key/names when you want multi-word commands.
So lint_nix will be called with lets lint nix
Dashes stay literal inside each word, so lint_nix-bash will be
called with lets lint nix-bash
Task execution can be:
- defined directly in Nix (
run = '' ... '';) - backed by shell scripts (
run = builtins.readFile ./scripts/my-task.sh;)
Minimal task example with external script:
{ mkTask,...}:
{
greet = mkTask {
description = "Hello world";
run = builtins.readFile ./scripts/greet.sh;
};
}Tasks can use packages from any sources. runtimeInputs takes real derivations,
so you can mix pkgs, sibling tasks, a flake input,
or a second nixpkgs (e.g. stable alongside unstable) in one list.
Pass those extra sources once, in specialArgs, they are merged into
every task's scope:
Note
With the flake-parts module, set perSystem.lets.specialArgs instead.
# flake.nix
lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks = ./tasks;
specialArgs = { inherit inputs; };
};# tasks/greet.nix
{ pkgs, system, inputs, mkTask, tasks, ... }:
let
stable = inputs.nixpkgs-stable.legacyPackages.${system};
foo = inputs.foo.packages.${system}.foo;
in
{
greet = mkTask {
description = "Hello World with multiple input sources";
runtimeInputs = with pkgs; [
hello # from pkgs
stable.baz # from alternative pkgs
foo # from your flake inputs
tasks.bar.app # from your own tasks
];
run = ''# Your script here'';
};
}Every task's run gets a small ANSI formatting toolkit injected automatically.
It comes in four flavors:
# Constants (uppercase):
echo "${BOLD}Hello${RESET} ${BLUE}World${RESET}!"
# Functions (lowercase):
green "✅ done"
bold "Important"
# Merged style + color (<style>_<color>):
bold_blue "Heading"
underline_red "Error"
# Log helpers:
info "listening on :8080"
warn "low disk space"
error "build failed"Available names:
| Kind | Names |
|---|---|
| Colors | black red green yellow blue magenta cyan white |
| Styles | bold dim italic underline |
- Functions (lowercase) exist for every color and style. They print the text
formatted, followed by a newline (like
echo), and already append the reset, soblue "hi"closes itself and there is noresetfunction to call. - Merged functions combine any style with any color as
<style>_<color>(e.g.bold_green,dim_cyan,underline_yellow). - Log helpers print a
LEVEL: messageline, with the level word colored by severity (see Logging below). - Constants (uppercase:
RED,BOLD, …) exist for the same names. Use them when you assemble strings yourself, then you must close the sequence with theRESETconstant:echo "${BLUE}hi${RESET}".RESETexists only as a constant, for this manual form.
Formatting is emitted only when it makes sense, following the common conventions:
- disabled when stdout is not a terminal (piped or redirected), so logs and captured output stay clean;
- disabled when
NO_COLORis set to a non-empty value (takes precedence); - forced on when
FORCE_COLORis set (handy to keep colors through a pipe or in tests).
When formatting is off, both the constants and the functions degrade gracefully: constants become empty strings and functions print plain text.
Log helpers print LEVEL: message, with the level word colored by severity.
error and warn write to stderr; info, debug and trace write to
stdout. Each follows color detection on its own stream, so info "x" | cat
degrades to plain INFO: x, and error stays colored on a terminal even when
stdout is redirected.
error "build failed"
warn "deprecated flag"
info "listening on :8080"
debug "cache hit"
trace "entering handler"| Helper | Level | Color |
|---|---|---|
error |
ERROR |
red |
warn |
WARN |
yellow |
info |
INFO |
green |
debug |
DEBUG |
blue |
trace |
TRACE |
cyan |
Each prints the uppercased level followed by : and your message, e.g.
info "ready" → INFO: ready.
Important
These names are reserved in every task's run: the constants, the color
and style functions, and the log helper functions listed above.
Avoid redefining them in your scripts.
Here's a minimal example:
{ mkTask, ... }:
{
greet = mkTask {
description = "Hello world in color";
run = ''
bold_blue "Hello, World!"
'';
};
}Full example: greet-color
$ lets greet
# Hello, World! (bold blue on a terminal)
$ lets greet | cat
# Hello, World! (plain text when piped)While you could parse arguments in your tasks as in any standard bash script,
mkTask lets you declare an args attrset to automatically parse them from the
command line. Each argument name becomes a bash variable in run.
Note
A declarative argument is one of:
- an option, which takes a value (
--name Foo) - a flag, which is a boolean toggle (
--dry-run) - a positional, bound by its position on the received command
The type attribute picks which one you get (option by default).
Adding only the long form of self explanatory options is really simple. By default, arguments are long form options:
{ mkTask, ... }:
{
greet = mkTask {
description = "Hello world with input";
args = [ "firstname" "lastname" ];
run = ''
echo "Hello $firstname $lastname"
'';
};
}Full example: greet-args
$ lets greet --firstname Foo --lastname Bar
# Hello Foo BarOrder does not matter, but argument names must match exactly.
There's also another way of doing this if you want to unlock more options:
{ mkTask, ... }:
{
greet = mkTask {
description = "Hello world with input";
args = {
name = {
# Added to `lets --help`
description = "Hello world with input and default value";
# Accept -n as an alias to --name
short = "n";
# See Default section below
default = [ "$USER" "World" ];
# Error if --name or -n is not provided
required = true;
# "option" (default), "flag" or "positional"
type = "option";
};
};
run = ''
echo "Hello $name"
'';
};
}Full example: greet-parametrized
Then you'd get:
$ lets greet --name Foo
# Hello Foo
$ lets greet -n Foo
# Hello Foo
# If $USER is set to Foo
$ lets greet
# Hello Foo
# If $USER is unset
$ lets greet
# Hello WorldImportant
Underscores map to dashes in the long form, so a dry_run argument exposes
--dry-run and the variable $dry_run.
You can add a shorthand for passing your argument, like -n for --name in
the example. mkTask validates the declaration at evaluation time and fails
with a clear message on a duplicate argument name, a duplicate short, a name
that is not a bash identifier, or a short that is not a single letter.
An option's value can be passed as a separate word (--name Foo) or attached to
the long form with an = (the inline value form):
$ lets greet --name=Foo
# Hello FooShort forms always take their value as the next word (-n Foo).
A value is resolved as CLI argument first, then fallbacks to default value if
argument wasn't passed.
default is either a single value or a list of fallbacks.
- Literal: a plain string or any Nix value like
default = users.foo.name; - Environment variable: an element shaped like
"$VAR"or"${VAR}"
Environment references are tried in the order listed, then the literal is the final fallback, wherever you place it.
default = [ "$USERNAME" "$USER" "Foo" ];
default = [ "Foo" "$USERNAME" "$USER" ]; # literal position doesn't matterTo keep things understandable, stick with the real effective order.
A flag (type = "flag") takes no value: its presence sets the variable to
true (default false).
A positional is bound by its place on the command line rather than by a
--name, using a 1-based index (index 0 is the command itself). It supports
default and required like an option, but not short, and indices must be
contiguous starting at 1.
{ mkTask, ... }:
{
greet = mkTask {
description = "Hello world with positional arguments";
args = {
name = { type = "positional"; index = 1; required = true; };
};
run = ''
echo "Hello $name (the rest stays in \$@: $*)"
'';
};
}Full example: greet-positional
Options and flags are parsed first, then the leftover words fill the positionals
in index order. Anything past the declared positionals stays in $@:
$ lets greet Foo Bar
# Hello Foo (the rest stays in $@: Bar)
$ lets greet -- Foo Bar # This is safer, no possible collision with sub-commands
# Hello Foo (the rest stays in $@: Bar)Note
lets finds where the (sub-)command name ends by matching the longest run of
leading words against your task names, so lets fmt flake.nix runs the fmt
task with flake.nix as a positional. Use -- to force a word to be an
argument (lets fmt -- nix, even if a fmt_nix task exists). Options must
come before positionals.
You might have noticed that, since all argument parameters are optional, the
first form args = [ "name" ]; is short for args = { name = { }; }.
An empty { } definition is a plain --name <value> option, so the two styles
can be mixed this way.
With that in mind, we could do something like:
{ mkTask, ... }:
{
greet = mkTask {
description = "Hello world with uppercase option";
args = {
firstname = { default = "World"; };
lastname = { }; # same as `lastname = { type = "option"; };`
uppercase = { type = "flag"; };
};
run = ''
msg="Hello $firstname''${lastname:+ $lastname}!"
if [ "$uppercase" = true ]; then
msg="''${msg^^}"
fi
echo "$msg"
'';
};
}Full example: greet-together
Result:
$ lets greet
# Hello World!
$ lets greet --firstname Foo
# Hello Foo!
$ lets greet --firstname Foo --uppercase
# HELLO FOO!
$ lets greet --firstname Foo --lastname Bar
# Hello Foo Bar!A task can run another. Reach the other task through the injected tasks
argument, the fully-resolved set, so you can compose across any file.
You might use other Nix features to achieve this, but this supported form
works everywhere:
{ tasks, mkTask, ... }:
{
check = mkTask {
description = "Run all checks";
runtimeInputs = with tasks; [ lint_nix.app test.app ];
run = ''
lint_nix
test
'';
};
}Note
A task's binary is named after the attribute key it is bound under (e.g.
lint_nix = mkTask { … } builds .../bin/lint_nix), even when one file
declares several tasks. Set name only when you want the command to differ
from the key.
lets ships with a small set of tasks you can reuse directly or compose in your
own tasks. They are available as baseTasks in every task's scope:
lint_bash- Lints bash fragments embedded in Nix files and all.shfiles usingshfmtandshellcheck.lint_nix- Lints Nix files withstatixanddeadnix.lint_nix-bash- Lints just the bash fragments embedded in Nix files.lint- Runs all lint tasks in this flake (but you probably want to define your own).version [level]- Bumps the flake'sversion, refreshesCHANGELOG.md, commits and tagsvX.Y.Z.[level]is one ofmajor,minor,patch,prerelorrelease(viasemver-tool); omit it to auto-detect the bump from the conventional commits since the last tag (viagit-cliff). Pass--dry-runto preview. Expects aversion = "X.Y.Z";line.release- Publishes a GitHub release for the current tag, with notes generated bygit-cliff. Fails if the tag does not match the flakeversion. Pass--dry-runto print the notes without publishing.
Those tasks are completely optional. Just cherry-pick the presets you need in
baseTasks alongside your own:
# flake.nix
lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks =
{ mkTask, baseTasks, ... }:
{ inherit (baseTasks) lint_nix version release; } # Only what you need
// {
greet = mkTask {
description = "Say hello";
run = ''bold_green "Hello!"'';
};
};
};Alternatively, with a single tasks.nix file, the same tasks content goes
right into the file.
With the directory form, add a small file (e.g. tasks/presets.nix) that pulls
in the presets you want:
# tasks/base.nix
{ baseTasks, ... }: { inherit (baseTasks) lint_nix version release; }Tasks compose across files, but they also compose across flakes! A shared
"task library" flake can sit between lets and your projects, so several
projects run your favorite tasks with no copy-paste or out-of-sync scripts.
A task module is just a function, when a project pulls it in, the tasks are
rebuilt with the project's own pkgs and mkTask, so the library's nixpkgs
never leaks downstream. Each task keeps its exact, reproducible toolchain, no
matter the project it runs in.
A library exposes its tasks as a plain function of the scope, you define it just as you would normally:
# library: tasks.nix
{ mkTask, ... }:
{
share = mkTask {
description = "Task from shared library";
run = ''bold_green "Hello from shared library!"'';
};
}The library wires those tasks for itself with mkFlake (so it is runnable
on its own with lets share), then re-exports the module and lets library as
extra outputs, so projects can reach it:
# library: flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
lets.url = "github:JeffDess/lets";
lets.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ nixpkgs, lets, ... }:
let
letsTasks = import ./tasks.nix;
in
lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks = letsTasks;
}
// {
inherit letsTasks;
inherit (lets) lib;
};
}A project adds the library as an input and merges its module into the project's
own tasks function. Local keys win on conflict.
I have set up lets-demo-lib as an
example, but you need to point inputs.lets.url to your own repo:
# project: flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
lets = {
url = "github:JeffDess/lets-demo-lib"; # Point to your own repo
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, lets, ... } :
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
letsFlake = lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks =
scope:
(lets.letsTasks scope)
// {
greet = scope.mkTask {
description = "Task from local project";
run = ''bold_blue "Hello from local task!"'';
};
};
};
in {
inherit (letsFlake) apps packages;
devShells.x86_64-linux.default = pkgs.mkShell {
inputsFrom = [ letsFlake.devShells.x86_64-linux.lets ];
};
};
}Now lets share and lets greet both run in the project, show up
in lets --help, and get shell completions with no duplication.
They also compose through the tasks fixpoint, across the
library/project boundary.
$ lets greet
# Hello from local task!
$ lets share
# Hello from shared library!Pull in only the tasks you want, the same way you cherry-pick base tasks:
# project: flake.nix
tasks =
scope:
{ inherit (lets.letsTasks scope) share; }
// {
greet = scope.mkTask { /* ... */ };
};The module above is rebuilt in each project, which is usually what you
want. If instead every project must run the exact same binaries, the
library can pre-build with lets.lib.loadTasks and export that per system:
# library: flake.nix (per-system output)
letsTasks = lets.lib.loadTasks {
pkgs = nixpkgs.legacyPackages.${system};
src = ./tasks.nix;
};Projects then merge lets.letsTasks.${system} directly.
The trade-off is that those tasks are frozen to the library's nixpkgs,
so a project on a different nixpkgs gets a mixed closure.
Completions cover the lets flags and options, every task (including nested
ones), each task's options and flags, and the task name expected by
lets --show and lets --help.
Supported shells are: bash, zsh, fish and nushell.
The completions package is project-agnostic, it drops the functions on the
standard search paths (share/zsh/site-functions, …), so bash/zsh/fish load
them automatically.
Add lets as an input to your config flake (i.e. system, not project flake):
inputs.lets.url = "github:JeffDess/lets";Then install the completions package. With NixOS:
{ inputs, pkgs, ... }:
{
programs.zsh.enable = true;
environment.systemPackages = [
inputs.lets.packages.${pkgs.system}.completions
];
}Alternatively, with home-manager:
{ inputs, pkgs, ... }:
{
programs.zsh.enable = true;
home.packages = [ inputs.lets.packages.${pkgs.system}.completions ];
}And for nushell:
{ inputs, pkgs, ... }:
let
completions = inputs.lets.packages.${pkgs.system}.completions;
in
{
programs.nushell.extraConfig = ''
source ${completions}/share/lets/completions/lets.nu
'';
}If you don't want to use a declarative config:
nix profile install github:JeffDess/lets#completions.
Tip
You can test without installing anything, for instance with zsh, just run:
source <(lets -c zsh)
Completion will be added to your current shell only
Save the function into the directory your shell autoloads completions from:
# bash-completion autoloads this directory
lets -c bash > ~/.local/share/bash-completion/completions/lets# any directory on your $fpath works
lets -c zsh > ~/.config/zsh/completions/_letslets -c fish > ~/.config/fish/completions/lets.fish# nushell has no autoload directory: save it, then source from config.nu
lets -c nushell | save -f ~/.config/nushell/lets.nu
source ~/.config/nushell/lets.nuYour completion file doesn't need to be regenerated when your tasks change.
Since this project is largely built with Nix and Bash, most editors will support those filetypes out of the box.
An exception to that is the run task attribute, which embeds Bash syntax
inside a Nix file. Editors backed by the tree-sitter grammar only inject
Bash highlighting (and LSP features, through tools like
otter.nvim) for a fixed set of attribute
names. run is not one of them, so it stays unhighlighted by default.
In Neovim, add an injection query so tree-sitter treats run as Bash. Create
~/.config/nvim/after/queries/nix/injections.scm:
;extends
; lets: inject Bash into the task `run` attribute
(binding
attrpath: (attrpath (identifier) @_path)
expression: [
(string_expression
((string_fragment) @injection.content
(#set! injection.language "bash")))
(indented_string_expression
((string_fragment) @injection.content
(#set! injection.language "bash")))
]
(#eq? @_path "run")
(#set! injection.combined))The ;extends directive keeps the grammar's built-in injections and only adds
the run attribute on top. For a one-off string without any setup, prefix it
with a language hint instead: run = /* bash */ ''…'';.
If you wish to run a task in CI, you can just run it from the flake. It will use the exact same packages as on your local dev shell.
It's important to use nix run and not nix shell or nix develop, as the
latter would install extra packages that aren't needed to run the tasks.
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v21
- uses: DeterminateSystems/magic-nix-cache-action@v13
- name: Run lint task
run: nix run .#lintRefer to this project's Github CI workflow as an example.
Tip
Use a self-hosted NixOS shell executor for blazingly fast job runs
variables:
NIX_CONFIG: experimental-features = nix-command flakes
lint:
stage: lint
script:
- nix run .#lint
You can see a complete file in the Gitlab CI Pipeline example.
Releases are cut from conventional commits with
semver-tool and
git-cliff, driven by two base tasks
and a tag-triggered workflow.
-
Bump the version locally:
lets version # auto-detect bump from conventional commits lets version minor # or force: major | minor | patch | prerel | release
This rewrites
versioninflake.nix, regeneratesCHANGELOG.md, commitschore(release): vX.Y.Zand creates the matching tag. Add--dry-runto preview without writing anything. -
Review, then push the commit and tag:
git push --follow-tags
-
Pushing a
v*tag triggersrelease.yml. It runsnix run .#releaseto publish a GitHub release with the tag's changelog notes. The task fails if the tag does not match the flakeversion.
The changelog format lives in cliff.toml.
