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
- Environment files: Load
.envfiles and encrypted secrets in your tasks - 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";
lets.inputs.flake-parts.follows = "flake-parts";
};
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 -E / --env-file <file> <task> # Load an env file for this run
lets -e / --env KEY=VALUE <task> # Set a variable for this run
lets -S / --secret <name> <task> # Decrypt secrets for this run
lets --no-env-files <task> # Skip envFiles and secrets.files
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. |
envFiles |
list of strings | No | Env files loaded before this task runs, after the project's. See environment files. |
secrets |
list of strings | No | Secrets decrypted before the task runs, with the project-wide secrets.command. See secrets for a task. |
keepGoing |
bool | No | Keep running when a composed task fails, then fail at the end. Defaults to false. See task composition. |
aliases |
list of strings | No | Other names for the task. See task aliases. |
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;
};
}An alias works anywhere lets accepts a task name. Write it like a task key:
{ mkTask, ... }:
{
color = mkTask {
description = "Show colors";
aliases = [ "colour" "print_colors" ];
run = "echo red";
};
}$ lets colour
# red
$ lets print colors
# redTasks 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'';
};
}List the env files every task loads, and add more to a single task:
Note
With the flake-parts module, set perSystem.lets.envFiles instead.
# flake.nix
lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks = ./tasks;
envFiles = [ ".env" "~/.config/lets/local.env" ];
};# tasks/deploy.nix
{ mkTask, ... }:
{
deploy = mkTask {
description = "Deploy";
envFiles = [ ".deploy.env" ];
run = ''echo "Deploying to $DB_HOST"'';
};
}Files are read at runtime, so they never end up in the Nix store and
can stay git-ignored. Paths are relative to the directory the task runs in,
which is the project root with lets, and may use ~/ or environment
variables (write "\${VAR}" in Nix). A missing file is skipped with a warning.
Values are loaded before arguments are parsed, so they can feed
defaults, e.g. default = [ "$REGISTRY" ];.
Env files must be POSIX compliant (see dotenv-spec). Nothing in them ever runs as a command:
# example.env
APP_NAME=demo # comments are fine
export DB_HOST=${DB_HOST:-localhost} # defaults
API_URL="https://$DB_HOST/api" # variables
TOKEN=${TOKEN:?set TOKEN first} # required valuesA file with errors fails the task, and error messages never show values to
prevent leaking secrets.
Names lets or bash rely on, like PATH, IFS, BASH_* or the
formatting names, and the task's
arguments can't be set from a file: they are
skipped with a warning.
Pass files, secrets or variables to lets for a single run:
$ lets -E .prod.env deploy
# Deploying to prod.example.com
$ lets -E .prod.env -e DB_HOST=db.local deploy
# Deploying to db.localWhere there is no lets command (CI, git hooks), set LETS_ENV_FILES
(colon-separated), LETS_SECRETS (same), LETS_ENV_VARS (one KEY=VALUE
per line) or LETS_NO_ENV_FILES=1 instead:
$ LETS_ENV_FILES=.prod.env nix run .#deploy
# Deploying to prod.example.comUnlike envFiles, a missing file given here fails the task, and -E paths
are relative to your current directory. --no-env-files skips envFiles and
secrets.files for one run. These flags go before the task name.
Configure the command that fetches your secrets, like decrypting a file or calling a secret manager that prints an env file:
Note
With the flake-parts module, set perSystem.lets.secrets instead.
# flake.nix
lets.lib.mkFlake {
inherit nixpkgs;
systems = [ "x86_64-linux" ];
tasks = ./tasks;
secrets = {
command = "sops -d {}";
runtimeInputs = pkgs: [ pkgs.sops ];
pattern = ".{name}.enc.env"; # Defaults to "{name}"
files = []; # Default
onError = "fail"; # Default
};
};pattern turns a secret's name into the argument that replaces {}, so
lets -S dev deploy runs sops -d .dev.enc.env in the directory the task
runs in. Write command as a list, e.g. [ "sops" "-d" "{}" ], when an
argument contains spaces.
The command must print an env file in the same format. With
sops, encrypt your .env with --input-type dotenv: YAML or JSON files
printed with --output-type dotenv don't quote their values.
Decrypted values stay in memory and are never written to disk, and tasks that never decrypt don't carry the decryption tool around.
A task that always needs a secret declares it:
# tasks/up.nix
{ mkTask, ... }:
{
up = mkTask {
description = "Start the stack";
secrets = [ "dev" ]; # Uses secrets.pattern
run = "docker compose up";
};
}secrets.files = [ "base" ] decrypts a file in every task, right after
the project's envFiles, using secrets.pattern like a task's secrets.
--no-env-files skips it too.
Tip
Think twice before configuring it: your lint task probably doesn't need
to load your secrets!
A failed decryption fails the task. For secrets.files only, set
onError = "warn" (or "ignore") to run without them, e.g. when not every
developer has the key.
Your shell wins over envFiles and secrets, the command line wins over
everything, and when several files set the same variable, the last one wins:
project envFiles < secrets.files < task envFiles < task secrets
< your shell < -E and -S < -e
When a task runs another one, the child sees the parent's values as its shell.
More examples: env-files
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, the log helper functions listed above, and names
starting with _lets_, _LETS_, dotenv_, _dotenv_ or DOTENV_.
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.
Short forms can be combined in a single word, like most command-line tools. A letter that takes a value uses the rest of the word, or the next word if nothing is left:
lets greet -eu # same as -e -u
lets greet -nFoo # same as -n Foo
lets greet -eun Foo # same as -e -u -n FooFull example: greet-short
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 take their value as the next word (-n Foo) or attached to the
letter (-nFoo).
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.
Options and positional arguments accept an aliases attribute set mapping
shortcut names to string values. Flags do not support aliases.
For example, you can customize the base browse task in your flake by
redefining browse in your project:
{ mkTask, baseTasks, ... }:
let
base = baseTasks.browse;
in
{
browse = mkTask {
inherit (base) description runtimeInputs run;
args = base.args // {
url = base.args.url // {
required = false;
default = "https://github.com/JeffDess/lets";
aliases = {
docs = "https://github.com/JeffDess/lets/blob/main/README.md";
issues = "https://github.com/JeffDess/lets/issues";
};
};
};
};
}lets browse opens the default URL, lets browse docs opens the docs URL,
and lets browse https://other.example.com opens the supplied URL.
Alias names join any explicit choices as completion suggestions, and those
overrides are reflected in --help and --show commands.
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
'';
};
}run stops at the first failing command, so check skips test when
lint_nix fails. Set keepGoing = true to run every composed task anyway:
the task fails at the end and lists the failed tasks. Its own commands still
stop it at the first failure.
{ tasks, mkTask, ... }:
{
check = mkTask {
description = "Run all checks";
keepGoing = true;
runtimeInputs = with tasks; [ lint_nix.app test.app ];
run = ''
lint_nix
test
'';
};
}Full example: keep-going
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_commits- Lints commit messages with convco, from the latestv*tag toHEADby default. Rejectsfixup!,squash!andamend!commits unless you pass--allow-fixup. See commit message linting to run it in hooks and CI.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.update_flake- Runsnix flake update, and also ondev/when it holds aflake.nix(e.g. a flake-parts dev partition).browse <url>- Opens a URL in your default browser on Linux, macOS, or WSL. WSL requires wslview installed on the host.
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, the task name expected by
lets --show and lets --help, file names after -E, and secret names after
-S (found through secrets.pattern).
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.
The lint_commits base task lints commit messages with
convco, in hooks and in CI:
| Option | Default | Purpose |
|---|---|---|
--from <ref> |
$PRE_COMMIT_FROM_REF, else the latest v* tag, else the whole history |
Excluded base commit |
--to <ref> |
$PRE_COMMIT_TO_REF, else HEAD |
Last commit to lint |
--message-file <path> |
Lint one message file; --from and --to are ignored |
|
--allow-fixup |
Let fixup!, squash! and amend! commits through |
|
--protect <branch> |
Reject fixups only when $PRE_COMMIT_REMOTE_BRANCH is that branch (or unknown) |
With git-hooks.nix, allow fixups
while committing and reject them when pushing to main:
hooks = {
convco = {
enable = true;
entry = "${lib.getExe self.packages.${system}.lint_commits} --allow-fixup --message-file";
};
convco-push = {
enable = true;
name = "convco (push)";
stages = [ "pre-push" ];
pass_filenames = false;
always_run = true;
entry = "${lib.getExe self.packages.${system}.lint_commits} --protect main";
};
};With flake-parts, use config.packages.lint_commits instead. The
PRE_COMMIT_* variables come from the pre-commit framework: with another hook
manager, pass --from, --to and the push target yourself.
In CI, pick the base commit and the fixup policy from the event.
You can see an example on the GitHub CI workflow
lint-commits job: fixups pass on draft pull requests, and fail on ready
pull requests and pushes to main. A GitLab equivalent:
lint-commits:
stage: lint
variables:
GIT_DEPTH: 0
rules:
- if: $CI_MERGE_REQUEST_DRAFT == "true"
variables:
FROM: $CI_MERGE_REQUEST_DIFF_BASE_SHA
FIXUP: --allow-fixup
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
FROM: $CI_MERGE_REQUEST_DIFF_BASE_SHA
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
FROM: $CI_COMMIT_BEFORE_SHA
- if: $CI_COMMIT_BRANCH
variables:
FROM: origin/$CI_DEFAULT_BRANCH
FIXUP: --allow-fixup
script:
- git fetch origin "$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH"
- git rev-parse --verify --quiet "$FROM^{commit}" >/dev/null || FROM=
- nix run .#lint_commits -- $FIXUP --from "$FROM" --to "$CI_COMMIT_SHA"As it relies on a commit range, lint_commits is not part of the lint task.
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.
