A Justfile/Makefile for LLM prompts — define tasks as natural language, run them with any LLM.
Project description
makethlm
A task runner where tasks are LLM prompts.
makethlm is a command-line task runner in the tradition of Make and Just, but designed for a world where tasks are described in natural language and executed by LLMs. Define your build, deploy, review, and maintenance workflows as prose, interleave them with shell commands, and let your LLM of choice do the heavy lifting.
# Promptfile
project := "my-web-app"
llm claude [model=opus]
task build:
!mkdir -p dist
check if src/ has changed since the last build.
if so, compile the TypeScript and bundle with esbuild.
task test: build
!npm test
if any tests failed, explain the root cause and suggest/apply a fix.
task deploy(target, port="8080"): build test
!systemctl restart {{project}}
verify {{project}} is running on {{target}} port {{port}}.
$ makethlm deploy staging
[ok] build
...
[ok] test
...
[ok] deploy
Verified my-web-app is running on staging port 8080. All health checks pass.
Table of Contents
Installation
pip install makethlm
Requires Python 3.10 or newer.
By default, makethlm uses the Claude CLI as its LLM backend. Make sure it is installed and authenticated:
claude --version
To use a different provider, see LLM Provider Selection.
Quick Start
Simple a C project where the LLM generates a library and shell commands compile it:
# Promptfile
project := "hello"
llm claude [model=sonnet]
task generate-lib:
!mkdir -p src
Write a small C library with a header file.
The library should provide two functions:
- char *greet(const char *name) that returns "Hello, <name>!"
- int add(int a, int b) that returns the sum
Output ONLY the contents of two files, clearly separated:
First src/mylib.h (the header), then src/mylib.c (the implementation).
Use standard C, no external dependencies.
Generate src/main.c for the library as well
task build:
!gcc -c src/mylib.c -o src/mylib.o
!gcc src/main.c src/mylib.o -o {{project}}
!echo "Build complete: ./{{project}}"
task run: build
!./{{project}}
task clean:
!rm -f src/*.o {{project}}
!echo "Cleaned."
makethlm generate-lib # LLM writes the C code
makethlm run # compiles (via build dependency) and runs
makethlm --dry-run run # preview without executing
makethlm --list # see all tasks
Lines starting with ! are shell commands. Everything else is a prompt sent to the LLM. Tasks can depend on each other (run: build means "run build first").
More examples in examples/.
Promptfile Syntax Reference
Promptfile looks for a file named Promptfile, promptfile, Promptfile.pf,
or promptfile.pf in the current directory (or specify one with -f).
Comments
Lines starting with # are comments and are ignored by the parser.
# This is a comment
task build:
build the project # inline text is NOT a comment -- this is part of the prompt
Variables
Define variables with := and reference them with {{name}} in prompts and
shell commands.
project := "my-web-app"
env := "staging"
task deploy:
deploy {{project}} to {{env}}
Variable values must be double-quoted strings. Escaped quotes (\") and
escaped backslashes (\\) are supported inside the value:
greeting := "hello \"world\""
Backtick variables execute a shell command at parse time and capture its stdout:
version := `git describe --tags`
task release:
release {{version}} to production
Variables can be overridden from the CLI with --var / -V:
makethlm deploy -V env=production
Tasks
A task is defined with the task keyword, a name, and a colon. The indented
body that follows is a mix of LLM prompts (natural language) and shell commands.
task build:
!mkdir -p dist
check if moo.md is newer than the Dockerfile.
if so, rebuild the docker image from scratch.
tag it as {{project}}:latest.
The first task defined in the file is the default task. Running
makethlm with no arguments executes it.
Consecutive lines of natural language are merged into a single prompt and sent
to the LLM together. Shell commands (lines starting with !) break prompt
boundaries, so LLM prompts before and after a shell command become separate LLM
calls.
Dependencies
A task can depend on other tasks. Dependencies are listed after the colon:
task deploy: build test
deploy to production
This means: run build first, then test, then deploy. Dependencies are
resolved via topological sort, so transitive dependencies and diamond
dependencies work correctly. Cycles are detected and reported as errors.
task a:
do a
task b: a
do b
task c: a
do c
task d: b c
do d
Running makethlm d executes: a, then b and c (in dependency order),
then d.
Task Arguments
Tasks can accept positional arguments with optional defaults:
task deploy(target, port="8080"):
deploy {{project}} to {{target}} on port {{port}}
Pass arguments on the command line after the task name:
makethlm deploy staging # target=staging, port=8080 (default)
makethlm deploy prod 443 # target=prod, port=443
Arguments are interpolated into both prompt text and shell commands via the
same {{name}} syntax as variables. If a required argument (one without a
default) is not provided, makethlm exits with an error.
Arguments are scoped to the target task -- they are not passed to dependency tasks.
Shell Commands
Lines starting with ! are shell commands executed as subprocesses:
task setup:
!mkdir -p dist
!npm install
!npm run build
Shell commands support two modifier prefixes:
| Prefix | Effect |
|---|---|
@silent |
Suppress the command's stdout/stderr output |
@ignore |
Continue execution even if the command exits non-zero |
Modifiers are placed between ! and the command:
task clean:
!@silent rm -rf dist/
!@ignore docker rmi old-image:latest
!@silent @ignore docker system prune -f
Shell commands and LLM prompts can be freely interleaved in a single task. This is one of makethlm's defining features -- run a command, reason about its output, run another command:
task analyze:
!git diff --name-only > /tmp/changed.txt
review the changed files listed in /tmp/changed.txt for security issues
!npm test
if any tests failed, explain the root cause
Variable interpolation ({{name}}) works inside shell commands:
project := "myapp"
task build:
!docker build -t {{project}}:latest .
Functions
Functions are reusable prompt templates defined with fn. They are injected
into task bodies with the @use directive.
fn security_review:
Review the code for security vulnerabilities.
Check specifically for:
- SQL injection
- XSS (cross-site scripting)
- Command injection
- Path traversal
Be concise and actionable.
fn code_quality:
Check for code quality issues:
- Functions longer than 50 lines
- Duplicated logic
- Missing error handling
task review:
@use security_review
Focus on the git diff for the current PR.
task full-review:
@use security_review
@use code_quality
Apply to the entire src/ directory.
When a task is executed, every @use name line is replaced with the full text
of the named function. Multiple @use directives can appear in the same task.
Functions cannot themselves contain @use (no recursive expansion).
Docker Support
The docker block lets you describe a Docker image in natural language. The
LLM generates a Dockerfile, and makethlm builds it automatically.
docker api-server [tag=latest]:
A Python 3.11 slim image.
Install requirements.txt with pip, no cache.
Copy the app/ directory to /app.
Set the working directory to /app.
Expose port 8080.
Run with gunicorn, 4 workers, binding 0.0.0.0:8080.
Running makethlm api-server will:
- Send the description to the LLM with instructions to output a raw Dockerfile.
- Write the generated Dockerfile to the configured path.
- Run
docker buildwith the specified tag and context.
Docker blocks accept the following options:
| Option | Default | Description |
|---|---|---|
tag |
latest |
Image tag |
context |
. |
Build context directory |
file |
Dockerfile |
Dockerfile path |
docker frontend [tag=v2, context=./client, file=Dockerfile.prod]:
Node 20 alpine image.
Run npm ci, then npm run build.
Serve with nginx on port 80.
Docker blocks appear in the task list and can be used as dependencies:
docker api:
Python 3.11 slim image. Install requirements.txt.
task deploy: api
push the api image to the registry
LLM Provider Selection
makethlm supports multiple LLM providers. Declare them globally and optionally override per task.
llm claude [model=opus]
llm openai [model=gpt-4, key=$OPENAI_API_KEY]
llm ollama [model=llama3, base_url=http://localhost:11434]
llm custom [template=my-cli {prompt}]
The first declared provider is the default for all tasks.
Provider options:
| Option | Description |
|---|---|
model |
Model name/ID to use |
key |
API key (prefix with $ to read from an environment variable) |
base_url |
Custom API endpoint URL |
template |
Shell command template for custom CLI providers (see below) |
Custom CLI providers let you use any LLM tool that accepts a prompt on the
command line. The {prompt} placeholder is replaced with the actual prompt
text:
llm custom [template=my-llm run {prompt}]
llm local [template=ollama run llama3 {prompt}]
Per-task override -- use a different provider (or model) for specific tasks:
# Use opus for thorough security reviews
task review [llm=claude, model=opus]:
@use security_review
Focus on the current PR.
# Use a cheaper/faster model for linting
task lint [llm=openai]:
@use code_quality
Apply to src/.
You can also override the model from the CLI:
makethlm review --model sonnet
Or bypass all configured providers and use an arbitrary CLI:
makethlm review --shell 'ollama run llama3 "{prompt}"'
Host Inventory (SSH)
makethlm includes an Ansible-like host inventory for running shell commands on remote machines via SSH.
Define host groups:
hosts web [user=deploy, port=22]:
web1.prod.internal
web2.prod.internal
web3.prod.internal
hosts db [user=postgres, port=5433]:
db-primary.prod.internal
db-replica.prod.internal
Target a host group from a task:
task deploy [on=web]:
!systemctl restart my-web-app
verify the app is responding on port 8080
When a task has [on=<group>]:
- Shell commands (
!lines) execute on every host in the group via SSH, sequentially. If any host fails, execution stops. - Prompt steps (natural language) still execute locally via the LLM.
This lets you interleave remote operations with local LLM reasoning:
task deploy [on=web]:
!systemctl restart myapp # runs on each web host via SSH
verify the restart was successful # runs locally via LLM
!curl -sf http://localhost/health # runs on each web host via SSH
Host group options:
| Option | Default | Description |
|---|---|---|
user |
(none -- uses SSH default) | SSH username |
port |
(none -- uses SSH default of 22) | SSH port |
SSH connections use BatchMode=yes for non-interactive operation.
Includes
Split large Promptfiles across multiple files with the include directive:
include "common/variables.pf"
include "tasks/deploy.pf"
include "tasks/docker.pf"
Included files are resolved relative to the file containing the include
statement. All definitions (variables, functions, tasks, LLM providers, host
groups) from the included file are merged into the current file.
Override precedence: If both the included file and the including file define the same variable, function, or task, the local definition wins.
Circular include detection: If file A includes file B, and file B includes file A, the parser raises an error.
# common.pf
env := "default"
fn preamble:
You are a helpful assistant.
# Promptfile
include "common.pf"
env := "production" # overrides the included value
task greet:
@use preamble
Deploy to {{env}} # resolves to "production"
Environment Variables
Reference environment variables in prompts with ${VAR} syntax. An optional
default value can be provided with :-:
task deploy:
deploy to ${DEPLOY_TARGET}
use credentials from ${SECRET_PATH:-/etc/secrets/default}
| Syntax | Behavior |
|---|---|
${VAR} |
Replaced with the value of $VAR, or empty string if unset |
${VAR:-fallback} |
Replaced with $VAR if set, otherwise fallback |
Environment variables are resolved in prompt steps only (not in shell
commands, where the shell itself handles $VAR expansion).
Task Metadata Options
Tasks accept metadata options in square brackets. These control LLM behavior, execution targets, and CLI presentation.
task review [llm=claude, model=opus, temperature=0.2, max_tokens=4096]:
review the code carefully
Full option reference:
| Option | Type | Description |
|---|---|---|
model |
string | LLM model to use for this task |
temperature |
float | Sampling temperature (e.g., 0.2 for deterministic, 0.9 for creative) |
max_tokens |
int | Maximum tokens in the LLM response |
llm |
string | Name of the LLM provider to use (must be declared globally) |
on |
string | Host group to execute shell commands on via SSH |
private |
flag | Hide this task from --list output (also: _-prefixed tasks) |
group |
string | Group heading for --list (e.g., group="deploy") |
doc |
string | Description shown in --list output |
confirm |
flag/string | Prompt for confirmation before running. Use confirm for the default message or confirm="Are you sure?" for a custom one |
os |
string | Only run this task on the specified OS (e.g., os=linux) |
linux |
flag | Shorthand for os=linux (Justfile-compatible) |
macos |
flag | Shorthand for os=macos (Justfile-compatible) |
windows |
flag | Shorthand for os=windows (Justfile-compatible) |
unix |
flag | Shorthand for os=unix (matches Linux and macOS) |
working-dir |
string | Change to this directory before executing the task |
no-cd |
flag | Don't change to working directory (overrides set working-dir) |
no-exit-message |
flag | Suppress error message on failure |
no-quiet |
flag | Override global set quiet for this task |
positional-arguments |
flag | Per-task override for positional argument passing |
Options can be combined with dependencies:
task deploy(target) [llm=openai, on=web, model=gpt-4]: build test
deploy {{project}} to {{target}}
Set Directives
Global configuration directives that affect the entire Promptfile:
set dotenv-load
set dotenv-load ".env.local"
set shell "bash"
set working-dir "/home/deploy/app"
| Directive | Description |
|---|---|
set dotenv-load |
Automatically load .env from the working directory |
set dotenv-load "path" |
Load a specific env file (enables loading and sets the path) |
set dotenv-path "path" |
Custom env file path (implicitly enables dotenv-load) |
set dotenv-required |
Error if the env file is missing |
set shell "name" |
Set the shell used for ! commands (default: system shell) |
set working-dir "path" |
Set the working directory for all tasks |
set export |
Export all variables to the environment |
set positional-arguments |
Pass task arguments as $1, $2, etc. |
set ignore-comments |
Strip # comments from shell commands |
set quiet |
Suppress command echoing globally |
set tempdir "path" |
Temporary directory for recipes |
set allow-duplicate-tasks |
Allow redefining tasks (last wins) |
set allow-duplicate-variables |
Allow redefining variables |
dotenv-load accepts an optional file path. When a path is provided, it both enables loading and sets the file — equivalent to set dotenv-load plus set dotenv-path. Setting dotenv-path alone also implicitly enables loading. At runtime, dotenv paths also support $ENV_VAR and ~ expansion.
String directive values support the same expressions as variable declarations — concatenation with +, backtick commands, and if/else:
project := "/opt/myapp"
set working-dir project + "/src"
set dotenv-load project + "/.env.production"
Export Variables
Variables prefixed with export are passed to the environment of shell commands:
export API_KEY := "secret"
export DATABASE_URL := "postgres://..."
task deploy:
!echo $API_KEY # accessible in shell commands
deploy with API key
Use set export to export all variables:
set export
project := "myapp" # exported automatically
version := "1.0" # exported automatically
String Concatenation
Variables support string concatenation with +:
prefix := "my"
project := prefix + "-app" + "-v1" # "my-app-v1"
Conditional Expressions
Justfile-compatible if/else expressions in variables and templates:
env := "production"
message := if env == "production" { "deploy carefully" } else { "test freely" }
task deploy:
{{if env == "production" { "running production deploy" } else { "running dev deploy" }}}
Operators: ==, !=.
Built-in Functions
Justfile-compatible built-in functions, available in {{ }} templates:
| Function | Description |
|---|---|
{{os()}} |
Current OS: linux, macos, or windows |
{{os_family()}} |
OS family: unix or windows |
{{arch()}} |
CPU architecture (e.g., x86_64, aarch64) |
{{num_cpus()}} |
Number of CPU cores |
{{home_directory()}} |
User's home directory |
task info:
running on {{os()}} ({{arch()}}) with {{num_cpus()}} cores
home: {{home_directory()}}
String Functions
String manipulation functions can be used inside {{ }} templates on variables:
String manipulation:
| Function | Example | Result |
|---|---|---|
uppercase(s) |
{{uppercase(name)}} |
HELLO |
lowercase(s) |
{{lowercase(name)}} |
hello |
trim(s) |
{{trim(padded)}} |
hello |
trim_start(s) |
{{trim_start(padded)}} |
hello |
trim_end(s) |
{{trim_end(padded)}} |
hello |
replace(s, from, to) |
{{replace(path, "/", "-")}} |
src-main |
replace_regex(s, pat, to) |
{{replace_regex(ver, "\\d+$", "0")}} |
1.2.0 |
quote(s) |
{{quote(cmd)}} |
'hello world' |
join(sep, a, b, ...) |
{{join(", ", "a", "b")}} |
a, b |
len(s) |
{{len(name)}} |
5 |
substr(s, start[, len]) |
{{substr(name, 0, 3)}} |
hel |
match(s, regex) |
{{match(ver, "^\\d+")}} |
true |
Path functions:
| Function | Example | Result |
|---|---|---|
file_name(p) |
{{file_name("/tmp/a.txt")}} |
a.txt |
file_stem(p) |
{{file_stem("/tmp/a.txt")}} |
a |
extension(p) |
{{extension("a.tar.gz")}} |
.gz |
without_extension(p) |
{{without_extension("a.tar.gz")}} |
a.tar |
parent_directory(p) |
{{parent_directory("/tmp/a.txt")}} |
/tmp |
Boolean functions (return "true" or "false"):
| Function | Description |
|---|---|
contains(s, sub) |
Whether s contains sub |
starts_with(s, prefix) |
Whether s starts with prefix |
ends_with(s, suffix) |
Whether s ends with suffix |
Version functions:
| Function | Example | Result |
|---|---|---|
version_major(v) |
{{version_major("1.2.3")}} |
1 |
version_minor(v) |
{{version_minor("1.2.3")}} |
2 |
version_patch(v) |
{{version_patch("1.2.3")}} |
3 |
bump_major(v) |
{{bump_major("1.2.3")}} |
2.0.0 |
bump_minor(v) |
{{bump_minor("1.2.3")}} |
1.3.0 |
bump_patch(v) |
{{bump_patch("1.2.3")}} |
1.2.4 |
version := "1.2.3"
task release:
@echo "Current: {{version}}, next: {{bump_minor(version)}}"
!git tag v{{bump_minor(version)}}
deploy version {{uppercase(version)}} to production
Variadic Arguments
Tasks can accept variadic arguments (Justfile-compatible):
# One or more (required)
task deploy(+targets):
deploy to {{targets}}
# Zero or more (optional)
task greet(*names):
hello {{names}}
makethlm deploy staging prod # targets="staging prod"
makethlm greet alice bob # names="alice bob"
makethlm greet # names="" (ok for *)
Line Continuation
Long lines can be split with \:
task build:
this is a very long prompt \
that continues on the next line
Aliases
Create short aliases for tasks:
alias d := deploy
alias t := test
alias r := review
After defining an alias, makethlm d is equivalent to makethlm deploy.
CLI Reference
makethlm [OPTIONS] [TASK] [ARGS...]
Positional arguments:
| Argument | Description |
|---|---|
TASK |
Task to run (default: first task in file) |
ARGS |
Positional arguments passed to the task |
Options:
| Flag | Short | Description |
|---|---|---|
--file PATH |
-f |
Path to Promptfile (default: auto-detect in current directory) |
--list |
-l |
List all tasks, functions, LLM providers, and host groups |
--summary |
-s |
List task names only (compact, one per line) |
--dump |
Dump parsed Promptfile structure (variables, settings, tasks) | |
--evaluate EXPR |
Evaluate an expression and print the result | |
--dry-run |
Print prompts and commands without executing them | |
--model MODEL |
-m |
Override the LLM model for all tasks |
--var NAME=VALUE |
-V |
Override a variable (can be repeated) |
--shell TEMPLATE |
Use an arbitrary LLM CLI template (e.g., 'ollama run llama3 "{prompt}"') |
|
--quiet |
-q |
Suppress command echoing |
--verbose |
Verbose output with step details |
Examples:
# Run the default task
makethlm
# Run a specific task
makethlm deploy
# Run with arguments
makethlm deploy staging 443
# Override a variable
makethlm deploy -V env=production
# Preview what would happen
makethlm --dry-run deploy staging
# Use a different model
makethlm review -m sonnet
# Use a different Promptfile
makethlm -f ops/Promptfile.pf deploy
# Use any LLM CLI
makethlm review --shell 'ollama run llama3 "{prompt}"'
# List everything
makethlm --list
--list output includes tasks (with dependencies, arguments, options),
functions, LLM providers (with the default marked), and host groups:
$ makethlm --list
build
check if moo.md is newer than the Dockerfile...
test (depends: build)
run all tests
review (args: scope; llm: claude)
Review code for security vulnerabilities...
deploy (args: target, port="8080"; depends: build, test; on: web)
deploy my-web-app to target on port port...
functions:
security_review: Review the code for security vulnerabilities...
code_quality: Check for code quality issues...
llm providers:
claude model=opus (default)
openai model=gpt-4
host groups:
web user=deploy: web1.prod.internal, web2.prod.internal
db user=postgres port=5433: db-primary.prod.internal
Comparison with Make and Just
| Feature | Make | Just | makethlm |
|---|---|---|---|
| Task definitions | Targets with recipes | Recipes with commands | Tasks with prompts + commands |
| Task body language | Shell commands | Shell commands | Natural language + shell |
| LLM integration | None | None | First-class, multi-provider |
| Variable interpolation | $(VAR) |
{{VAR}} |
{{VAR}} |
| String concatenation | N/A | + operator |
+ operator |
| Conditional expressions | N/A | if/else |
if/else |
| Built-in functions | N/A | os(), arch(), etc. |
os(), arch(), etc. |
| Environment variables | $$VAR |
$VAR |
${VAR} with defaults |
| Export variables | export |
export |
export / set export |
| Dependencies | File-based (mtime) | Task-based | Task-based (topological) |
| Task arguments | None | Positional + defaults + variadic | Positional + defaults + variadic |
| Shell command prefix | (tab-indented) | (indented) | ! prefix |
| Reusable templates | None | None | fn / @use |
| Docker generation | None | None | docker blocks |
| Remote execution | None | None | SSH host inventory |
| Multi-LLM routing | N/A | N/A | Per-task [llm=...] |
| OS-specific tasks | N/A | [linux], [macos] |
[linux], [macos], [unix] |
| Private tasks | N/A | _ prefix / [private] |
_ prefix / [private] |
| Confirmation | N/A | [confirm] |
[confirm] / [confirm="msg"] |
| Line continuation | \ |
\ |
\ |
| Quiet mode | N/A | @ prefix / set quiet |
@ prefix / set quiet |
| Fallback search | N/A | set fallback |
N/A |
| File composition | include |
import |
include |
| Dry run | -n flag |
--dry-run |
--dry-run |
| Dump structure | N/A | --dump |
--dump |
| File name | Makefile |
justfile |
Promptfile |
File Format at a Glance
# Variables
name := "value"
backtick_var := `command`
concat := "a" + name + "b"
conditional := if name == "value" { "yes" } else { "no" }
export secret := "key"
# Set directives (Justfile-compatible)
set dotenv-load
set dotenv-load ".env.local"
set shell "bash"
set working-dir "/path"
set export
set positional-arguments
set ignore-comments
set quiet
# LLM providers
llm <name> [model=..., key=$..., base_url=..., template=...]
# Host groups
hosts <name> [user=..., port=...]:
hostname1
hostname2
# Functions
fn <name>:
reusable prompt text
# Tasks
task <name>[(arg1, arg2="default", +variadic)] [options]: [dep1 dep2]
!shell command
!@silent command # suppress output
!@ignore command # continue on failure
!@command # quiet (suppress echoing)
natural language prompt
@use function_name
running on {{os()}} / {{arch()}}
{{if var == "val" { "yes" } else { "no" }}}
# Docker
docker <name> [tag=..., context=..., file=...]:
description of the image in prose
# Includes
include "path/to/file.pf"
# Aliases
alias <short> := <task>
# Line continuation
long_line := "this is a very long" + \
" value that spans lines"
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file makethlm-0.0.1-py3-none-any.whl.
File metadata
- Download URL: makethlm-0.0.1-py3-none-any.whl
- Upload date:
- Size: 48.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1cf47d4c627d80c1f018b46a2f3ac2b7413d91f99c30f5e5a5f3574c316330cc
|
|
| MD5 |
5347e95be7a7b972c09dea31787c1861
|
|
| BLAKE2b-256 |
00a472772cfae8a5dec3620afb44527c3d74d5d5540ac7587c125329c7891472
|