A configurable task runner for rapid development. Define commands in a YAML file and run them from the terminal.
- β¨ Configurable tasks - Define tasks in YAML, no code needed
- π§ Flexible arguments - Positional, flags, wildcards, arrays with type validation
- π Variable interpolation - Environment variables with hierarchical scoping
- π Shell-aware execution - Auto-detects shell binary, overridable at any level
- ποΈ Dry-run mode - Preview commands before execution
- π‘οΈ Graceful shutdown - Proper process management on interrupt signals
- π Env file imports - Load variables from .env files
- π YAML config imports - Import nested YAML configs with dot-notation access
- π Nested groups - Organize commands in hierarchical groups
- π Working directory - Set
dirper command or group (relative tolota.yml) - οΏ½ Tee logging - Write stdout/stderr to files while still printing to terminal
- οΏ½ Command dependencies -
dependsfor automatic prerequisite execution with cycle detection - π Upward config search - Find
lota.ymlin parent directories up to the git root
curl -fsSL https://raw.githubusercontent.com/quonaro/lota/main/scripts/install.sh | bashOr with specific version:
curl -fsSL https://raw.githubusercontent.com/quonaro/lota/main/scripts/install.sh | bash -s -- -V v0.1.0Script verifies SHA256 checksum and installs to ~/.local/bin (or /usr/local/bin with sudo).
Requires Go 1.26+
go install github.com/quonaro/lota@latestOr manually:
git clone https://github.com/quonaro/lota.git
cd lota && go build -o lota . && sudo mv lota /usr/local/bin/Initialize a new configuration:
lota --initThis creates a lota.yml in your current directory.
Or create it manually:
build:
desc: Build the application
script: go build -o bin/app .
dev:
desc: Development commands
run:
desc: Run with hot reload
script: air
test:
desc: Run tests
script: go test ./...Run a command:
lota build
lota dev run
lota dev test| Feature | Lota | Task | Just | npm scripts |
|---|---|---|---|---|
| Declarative YAML | β | β | β | β |
| Type-safe arguments | β | β | β | β |
| Variable interpolation | β | β | β | β |
| Nested groups | β | β | β | β |
Working directory (dir) |
β | β | β | β |
| Command dependencies | β | β | β | β |
| Upward config search | β | β | β | β |
| Env file imports | β | β | β | β |
| Shell auto-detection | β | β | β | β |
Simple build command:
# Lota
build:
script: go build -o app .
# Task (Taskfile.yml)
build:
cmds:
- go build -o app .
# Just
build:
go build -o app .
# npm scripts
"build": "go build -o app ."With arguments:
# Lota
dev:
args:
- port|p:int=3000
script: npm start -- --port $port
# Task (Taskfile.yml)
build:
vars:
PORT: 3000
cmds:
- npm start -- --port {{.PORT}}
# Just
port := "3000"
dev:
npm start -- --port {{port}}
# npm scripts
"dev": "npm start -- --port ${PORT:-3000}"With dependencies:
# Lota
test:
depends:
- build
script: go test ./...
# Task (Taskfile.yml)
test:
deps: [build]
cmds:
- go test ./...
# Just
build:
go build -o app .
test: build
go test ./...
# npm scripts
"test": "npm run build && go test ./..."shell: bash
vars:
- import:env .env
- NODE_ENV=development
args:
- port|p:int=3000
dev:
desc: Development commands
install:
desc: Install dependencies
script: npm install
start:
desc: Start development server
args:
- hot|h:bool
script: |
if [ "$hot" = "true" ]; then
npm run dev
else
npm start
fi
build:
desc: Build for production
before: npm run clean
script: npm run build
after: echo "Build completed successfully"
test:
desc: Run tests
args:
- coverage|c:bool
script: |
if [ "$coverage" = "true" ]; then
npm test -- --coverage
else
npm test
fishell: bash
vars:
- DOCKER_COMPOSE=docker-compose
- KUBECTL=kubectl
infra:
desc: Infrastructure management
docker:
desc: Docker operations
up:
desc: Start all services
script: $DOCKER_COMPOSE up -d
down:
desc: Stop all services
script: $DOCKER_COMPOSE down
logs:
desc: View logs
args:
- service:str
- ...tail
script: $DOCKER_COMPOSE logs -f "$service" "$tail"
k8s:
desc: Kubernetes operations
namespace:
desc: Namespace operations
create:
desc: Create namespace
args:
- name:str
script: $KUBECTL create namespace "$name"
delete:
desc: Delete namespace
args:
- name:str
script: $KUBECTL delete namespace "$name"
deploy:
desc: Deploy application
args:
- env|e:str=dev
- dry|d:bool
script: |
if [ "$dry" = "true" ]; then
$KUBECTL apply --dry-run=client -f "k8s/$env/"
else
$KUBECTL apply -f "k8s/$env/"
fishell: bash
vars:
- GOOS=linux
- GOARCH=amd64
- BINARY_NAME=app
args:
- target|t:str=linux/amd64
build:
desc: Build the application
args:
- output|o:str=./bin
- race|r:bool
script: |
if [ "$race" = "true" ]; then
go build -race -o "$output/$BINARY_NAME" .
else
go build -o "$output/$BINARY_NAME" .
fi
test:
desc: Run tests
args:
- verbose|v:bool
- cover|c:bool
script: |
FLAGS=""
if [ "$verbose" = "true" ]; then
FLAGS="$FLAGS -v"
fi
if [ "$cover" = "true" ]; then
FLAGS="$FLAGS -cover"
fi
go test $FLAGS ./...
release:
desc: Build release binaries
before: echo "Building release for $target"
script: |
IFS=/ read -r GOOS GOARCH <<< "$target"
go build -o ./dist/${BINARY_NAME}-${GOOS}-${GOARCH} .
after: ls -lh ./dist/shell: bash
vars:
- import:env .env.local
- import:env .env.shared
- import:yaml config/secrets.yaml@public app # Import public config section
args:
- environment|env:str=dev
db:
desc: Database operations
migrate:
desc: Run database migrations
script: |
case "$environment" in
dev) npm run db:migrate:dev ;;
staging) npm run db:migrate:staging ;;
prod) npm run db:migrate:prod ;;
*) echo "Unknown environment" ;;
esac
seed:
desc: Seed database with test data
before: echo "Seeding $environment database..."
script: npm run "db:seed:$environment"
after: echo "Database seeded successfully"
deploy:
desc: Deployment operations
staging:
desc: Deploy to staging
script: |
npm run build:staging
npm run deploy:staging
production:
desc: Deploy to production
args:
- confirm|c:bool
script: |
if [ "$confirm" != "true" ]; then
echo "Use --confirm to deploy to production"
exit 1
fi
npm run build:prod
npm run deploy:prodshell: bash # Optional: default shell (auto-detected if omitted)
vars: # global environment variables
- KEY=value
- import:env .env # Import from .env file
args: # global argument definitions
- name:type=default
group-name: # command group
desc: ...
color: cyan # Optional: highlight group name in help
inherit_color: true # Optional: inherit color from parent group
shell: sh # Optional: override shell for this group
vars: # group-level variables
- KEY=value
args: # group-level arguments
- name:type=default
log: # Optional: group-level tee logging
path: group.log
command-name:
desc: ...
color: green # Optional: highlight command name in help
script: ...
command-name: # top-level command
desc: ...
color: red # Optional: highlight command name in help
script: ...
log: # Optional: command-level tee logging
path: cmd.logVariables are exported as environment variables into scripts. Both vars and args share a unified environment pool β CLI args override vars on name collision. They support three scopes with priority: app < group < command.
vars:
- DOCKER=docker compose # app-level
dev:
vars:
- DOCKER=docker # overrides app-level for this group
run:
vars:
- DOCKER=podman # overrides group-level for this command
script: $DOCKER upLoad variables from environment files:
vars:
- import:env .env
- import:env config/prod.envImport nested YAML configurations with automatic flattening to dot-notation:
vars:
- import:yaml config.yaml # Import all with original keys
- import:yaml config.yaml app # Import all with 'app.' prefix
- import:yaml config.yaml@public # Import only 'public' section
- import:yaml secrets.yaml@db cfg # Import 'db' section with 'cfg.' prefixSyntax: import:yaml <file>[@<section>] [<prefix>]
Note: The old
!import:yamlsyntax is deprecated but still supported. Useimport:yamlinstead.
- file - Path to YAML file
- section (optional) - Import only specific top-level section via
@section - prefix (optional) - Add prefix to all imported keys
Example YAML file:
# config.yaml
public:
app_name: MyApp
version: 1.0.0
database:
host: localhost
port: 5432
private:
api_key: secret123Resulting variables:
# import:yaml config.yaml@public app
vars:
app.app_name: "MyApp"
app.version: "1.0.0"
app.database.host: "localhost"
app.database.port: "5432"Access in scripts: $app_app_name, $app_database_host
Arguments are passed from the CLI and exported as environment variables, accessible via $name in scripts.
Format: name|short:type=default
| Part | Description | Example |
|---|---|---|
name |
Long name | output |
|short |
Short alias (optional) | |o |
:type |
Type (optional) | :str, :int, :bool, :arr |
=default |
Default value (optional) | =./bin |
Positional β passed by position, no flag needed:
args:
- filename:str
- count:int
script: process "$filename" "$count"lota cmd file.txt 5Flag β passed by name using --flag or -f. Any arg with a short alias (|short) or type bool becomes a flag:
args:
- output|o:str=./bin
- verbose|v:bool
script: go build -o "$output"lota cmd --output ./dist
lota cmd -o ./dist --verboseWildcard β captures all remaining positional arguments:
args:
- service:str
- ...cmd
script: docker exec "$service" "$cmd"lota cmd backend python manage.py shell
# service=backend, cmd="python manage.py shell"Array β collects multiple consecutive positional values:
args:
- files:arr[5] # collect up to 5 values
script: lint $fileslota cmd a.go b.go c.goBool args support negation via --!name:
lota cmd --verbose # verbose=true
lota cmd --!verbose # verbose=false
lota cmd --verbose=false # verbose=falseLike vars, args can be defined at app, group, or command level and are merged with the same priority (command wins):
args:
- env:str=dev # available to all commands
deploy:
args:
- env:str=prod # overrides app-level for this group
run:
script: ./deploy.sh --env="$env"Deprecation: Using
{{name}}for variable and argument interpolation is deprecated. Use$nameinstead.{{name}}will be removed in a future version.
Reserved Variables: System environment variable names (PATH, HOME, USER, SHELL, etc.) are reserved and cannot be overridden in
vars.
Important: Lota selects the shell interpreter, but the script itself is shell-specific. Write scripts for the shell you target.
Lota auto-detects the shell binary from the system environment. If detection fails, it falls back to bash.
Override the shell at any level:
shell: zsh # app-level
dev:
shell: bash # group-level override
run:
shell: sh # command-level override
script: echo $0Supported shells: bash, sh, zsh, dash, ksh, mksh, pdksh, ash, busybox, sash, tcsh, csh, fish
Set the working directory for commands and groups. The path is resolved relative to the lota.yml file location.
backend:
dir: ./backend # group-level default
build:
desc: Build backend
script: go build .
test:
desc: Run backend tests
dir: ./backend/tests # command-level override
script: go test ./...Priority: command > group > cwd. Useful in monorepos where different commands run in different subprojects.
Reference other commands that must run before the current one. Dependencies are specified as full dot-separated paths.
build:
desc: Build the application
script: go build -o bin/app .
test:
desc: Run tests
depends:
- build
script: go test ./...
deploy:
desc: Deploy to production
depends:
- build
- test
script: ./deploy.shDependencies execute with their own context (shell, vars, dir, default args). Circular dependencies are detected automatically and produce an error.
Independent dependencies run in parallel by default, with output prefixed by colored task name (like Docker Compose):
\x1b[35m[build]\x1b[0m go build -o bin/app .
\x1b[36m[lint]\x1b[0m golangci-lint run
\x1b[35m[build]\x1b[0m β done
\x1b[36m[lint]\x1b[0m β done
\x1b[33m[deploy]\x1b[0m ./deploy.shEach task gets its own color: either from its color field, inherited from a parent group, or a deterministic color derived from the task name.
To force sequential execution, set parallel: false:
ci:
depends:
- lint
- test
parallel: false
script: echo "CI done"Shared dependencies are executed once (deduplication). If
buildis a dependency of bothtestanddeploy, runninglota deploywill executebuildexactly once.
TODO: A TUI for interactive task monitoring is under consideration for future versions.
Lota provides five execution stages per command. You only use what you need β a simple script is enough for most tasks.
before β script β after β finally
β
fallback β finally
| Stage | Purpose | Runs on error? |
|---|---|---|
before |
Preparation (compile, check env) | Skips script, triggers fallback |
script |
Main command | Triggers fallback |
after |
Post-success action (notify, log) | Triggers fallback |
fallback |
Recovery / alternative path (rollback, alert, degrade) | If succeeds, command returns 0 |
finally |
Cleanup (stop containers, remove temp files) | Always runs |
Return code:
0ifbefore+script+aftersucceeded, or iffallbacksucceeded after a failure. Otherwise the first error's exit code.finallyerrors are printed to stderr but do not change the return code.
build:
before: echo "Compiling..."
script: go build -o bin/app .
after: echo "Build complete"Happy path: before β script β after β return 0
If script fails: before β script (exit 1) β return 1. after is skipped.
Use finally for operations that must run regardless of success or failure.
test:
before: docker-compose up -d test-db
script: go test ./...
finally: docker-compose down test-dbAny outcome: before β script β finally β return 0 or 1. The database container is always stopped.
Use fallback to react to failures β rollback, send alerts, write crash reports.
deploy:
before: echo "Starting deploy..."
script: ./deploy.sh
after: echo "Deploy successful"
fallback: ./rollback.sh
finally: echo "Deploy finished"Happy path: before β script β after β finally β return 0 Script fails: before β script (fail) β fallback β finally β return 1 After fails: before β script β after (fail) β fallback β finally β return 1
db:
migrate:
before: |
echo "Creating backup..."
pg_dump mydb > /tmp/backup.sql
script: |
echo "Running migrations..."
migrate -path ./migrations -database "$DATABASE_URL" up
after: echo "Migration complete"
fallback: |
echo "Migration failed, restoring backup..."
psql mydb < /tmp/backup.sql
finally: rm -f /tmp/backup.sql| Scenario | Flow |
|---|---|
| Success | before β script β after β finally (backup deleted) |
| Migration fails | before β script (fail) β fallback (restore) β finally (backup deleted) |
| After fails | before β script β after (fail) β fallback (restore) β finally (backup deleted) |
Write command output to log files while still printing to the terminal. Logs support additive inheritance: a command writes to its own log file plus all ancestor log files, unless independent: true breaks the chain.
log:
path: logs/all.log # app-level: all commands inherit this
build:
desc: Build the application
script: go build -o bin/app .
log:
path: logs/build.log # writes to both all.log and build.log
truncate: true # overwrite on each run (default: append)
test:
desc: Run tests
script: go test ./...
log:
path: logs/test.log
independent: true # writes ONLY to test.log, skips all.log| Field | Type | Default | Description |
|---|---|---|---|
path |
string | required | Log file path (relative to lota.yml). Supports variable interpolation ($var). |
truncate |
bool | false |
If true, overwrite the file on each run. If false, append. |
independent |
bool | false |
If true, discard all ancestor logs and write only to this file. Not allowed at app level. |
Inheritance behavior:
independent: false(default): the command writes to its ownpathplus all ancestorpaths.independent: true: the command writes only to its ownpath; ancestor logs are skipped.truncateapplies only to thepathdeclared on the same level.
log:
path: logs/global.log
infra:
desc: Infrastructure
log:
path: logs/infra.log
independent: true # infra commands skip global.log
docker:
desc: Docker ops
log:
path: logs/docker.log # writes to infra.log + docker.log
up:
script: docker-compose up -dRuntime errors (missing parent dir, permission denied, path is a directory) are printed to stderr as [log error] but do not fail the command.
Organize commands in hierarchical groups:
infra:
desc: Infrastructure commands
docker:
desc: Docker operations
up:
script: docker-compose up
down:
script: docker-compose down
k8s:
desc: Kubernetes operations
apply:
script: kubectl apply -f k8s/lota infra docker up
lota infra k8s applyHighlight group and command names in lota help output using named ANSI colors or hex values:
dev:
desc: Development commands
color: cyan
frontend:
desc: Frontend commands
inherit_color: true
start:
desc: Start dev server
inherit_color: true
script: npm run dev
build:
desc: Build frontend
color: yellow
script: npm run build| Option | Description |
|---|---|
color |
Named ANSI color (black, red, green, yellow, blue, magenta, cyan, white, and hi* variants) or any #RRGGBB hex value (e.g. #FF5733) |
inherit_color |
true to inherit the nearest ancestor color. Defaults to null (no inheritance) |
Color resolution priority: direct color > inherited color > default. inherit_color: true walks up the group chain and uses the first non-empty color found. Hex colors work in true-color capable terminals.
| Flag | Description |
|---|---|
-h, --help |
Show help |
-V |
Show version only (machine-friendly) |
--version |
Show version with ASCII banner |
-v, --verbose |
Enable verbose output |
--dry-run |
Show commands without executing |
--init |
Create a template lota.yml |
--config |
Specify config file or directory |
--install-completion |
Install shell completion script (auto-detects shell) |
--install-completion zsh|bash|fish |
Install completion for a specific shell |
--completion-script zsh|bash|fish |
Print completion script to stdout |
Lota provides built-in shell completion for bash, zsh, and fish.
lota --install-completionLota detects your shell from $SHELL and writes the completion script to the standard location.
lota --install-completion bash
lota --install-completion zsh
lota --install-completion fishBash:
lota --completion-script bash >> ~/.bashrcZsh:
lota --completion-script zsh > ~/.config/zsh/completions/_lotaFish:
lota --completion-script fish > ~/.config/fish/completions/lota.fishIf lota behaves like a completion engine instead of executing commands:
unset COMP_LINE COMP_POINTThen regenerate and reinstall the completion script for your shell.
If lota.yml is not found in the current directory, Lota searches upward through parent directories until it finds one or reaches the git root (.git directory) or the filesystem root (/).
This is critical for monorepos and nested projects where you might run commands from subdirectories:
cd backend/src
lota build # finds lota.yml in project rootPass --help after a command to see its arguments:
lota dev run --helpPreview what would be executed without actually running it:
lota build --dry-run- Go 1.26+
- Python 3.8+ (for pre-commit)
- cocogitto (cog) - for conventional commits
Install git hooks for commit validation and code quality:
# Install pre-commit
pip install pre-commit
# Install git hooks
./scripts/install-hooks.shThis installs:
- commit-msg hook - validates conventional commits via cocogitto
- post-commit hook - automatic version tagging
- pre-commit hooks - runs go fmt, go vet, go test, and golangci-lint
Run pre-commit manually without committing:
# Run all hooks
pre-commit run --all-files
# Run specific hook
pre-commit run go-fmt --all-files# Run all tests
go test ./...
# Run with race detector
go test -race ./...
# Run with coverage
go test -cover ./...# Run golangci-lint
golangci-lint run
# Run go vet
go vet ./...
# Format code
gofmt -w -s .Lota follows a strict layered architecture:
- config/ - YAML parsing and configuration models
- runner/ - Command execution, argument parsing, interpolation
- cli/ - CLI orchestration (only orchestrates, doesn't import runner)
- shared/ - Constants and shared utilities
Key design principles:
- Stateless - no global variables
- Context-aware execution with graceful shutdown
- Pure functions for interpolation and parsing (testable)
- Clean error handling with wrapped errors