
Justfile
- 45 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
Create and manage command recipes for project task automation.
About
Justfile Skill. just is a command runner that saves and runs project-specific commands in a file called Justfile.
- Templates (root + module, copy-and-adapt)
- just language reference (variables, args, deps, conditionals, attributes)
Justfile by the numbers
- 45 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #327 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill justfileAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
Create and manage command recipes for project task automation.
Files
Justfile Skill
just is a command runner (not a build system) that saves and runs project-specific commands in a file called Justfile. It uses make-inspired syntax but is simpler and more portable, with none of make's idiosyncrasies (.PHONY, tab sensitivity, implicit rules, timestamp tracking).
This skill enforces a consistent house style so every Justfile looks the same across projects. The rules below are the authoritative convention; Tools/lint.ts validates the deterministic ones.
Workflow Routing
| Trigger | Workflow |
|---|---|
| "create a justfile", "set up just", "add a recipe/module" | Workflows/CreateJustfile.md |
| "migrate Makefile to just", "convert make to just" | Workflows/MigrateFromMake.md |
| "check/lint this justfile", "is this justfile correct" | Workflows/CheckJustfile.md |
When to Use Just vs Make
| Scenario | Tool |
|---|---|
| Project task automation (build, test, deploy, lint) | just |
| Cross-platform command runner | just |
Actual file-based build dependencies (compile .c → .o) | make |
| Legacy projects already deep in make | make (or migrate) |
Common Mistakes — do NOT do these
Patterns the model often generates incorrectly. Check output against this list.
| WRONG | RIGHT |
|---|---|
justfile (lowercase) | Justfile (capital J) |
mod docker (bare) | mod docker '.justfiles/docker.just' |
Module at docker.just or just/docker.just | Module at .justfiles/docker.just |
default: | _default: (underscore required) |
@just --list | @just --list --unsorted (module) or --unsorted --list-submodules (root with modules) |
env("NAME", "val") | env_var_or_default("NAME", "val") |
| Module file without the three-line header | Every file gets the full header |
Module file without its own _default recipe | Every file gets its own _default |
Module named after a tool (psql.just) | Module named after a concern (db.just) |
Tests in docker.just because they run in a container | Tests in test.just — classify by purpose, not implementation |
| Root recipe duplicates module logic | Root shortcut delegates: build: docker-build |
Ad-hoc names (run-tests, do-lint) | Standard names: test, lint, build, dev, fmt, check |
Relative paths in module recipes (bash tests/run.sh) | Use source_directory() for absolute paths |
Mandatory Rules — apply to EVERY file you create or edit
1. The root file MUST be named Justfile (capital J). 2. EVERY file (root and every .just module) MUST start with this three-line header:
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := trueUse set dotenv-load := false where appropriate, but the line must always be present. 3. EVERY file MUST have _default as its first recipe:
# List all available recipes
_default:
@just --list --unsortedThe root Justfile with modules uses @just --list --unsorted --list-submodules. Module files use @just --list --unsorted (no --list-submodules). 4. Section order in every file: variables → mod imports → recipes. 5. Module files MUST live at .justfiles/<name>.just — never just/, never beside the root. 6. Import modules with explicit paths: mod name '.justfiles/name.just' — never bare mod name. 7. Use env_var_or_default("NAME", "value") for variable defaults — never env(). 8. Every recipe gets a # doc comment on the line directly above it. 9. Parameterized recipes document each param: # param - description (default: value). 10. Private/helper recipes start with _. 11. Dependencies go on the definition line: build: _lint test. 12. Destructive recipes prompt for confirmation; the doc comment says "DESTRUCTIVE, prompts for confirmation". 13. Extract modules by domain concern, named after the concern (db.just) not the tool (psql.just). The root Justfile is a thin orchestrator: _default, shortcut recipes, and project-wide recipes like check/clean. 14. The root provides shortcut recipes for common workflows that delegate to modules, giving developers a flat namespace for everyday tasks. 15. Use the standard recipe vocabulary below as the public API. Never invent run-tests, do-lint, compile, format. 16. In modules, never use bare relative paths — module recipes run with the module's directory as CWD. Define root := source_directory() / ".." and reference files as {{root}}/tests/run.sh.
Standard Recipe Vocabulary
A developer should be able to run just test, just dev, or just check in any project without guessing. Use these exact names; include only the ones that apply.
| Recipe | Purpose | Include when |
|---|---|---|
dev | Start dev environment (server, watch, REPL) | Project has a dev loop |
test | Run the test suite | Always |
build | Build or compile | Project has a build step |
lint | Run linters | Linters configured |
fmt | Format code | Formatters configured |
check | Run ALL quality gates (check: lint test) | Always |
clean | Remove build artifacts, caches, generated files | Project produces output |
check is the meta-recipe — depend on the applicable gates and add format checks (cargo fmt --check, ruff format --check) as appropriate.
Namespacing by Concern
Group by domain, not tool. Classify by purpose: a test that runs in Docker is a testing recipe (test.just), not a Docker recipe. A migration that uses kubectl is a database recipe (db.just).
| Concern | Module | Typical recipes |
|---|---|---|
| Development | dev.just | build, test, lint, fmt, bench |
| Testing | test.just | run, list, watch, coverage |
| Containers | docker.just | build, push, run, compose-up |
| CI/CD | ci.just | lint, deploy, release |
| Database | db.just | migrate, seed, reset, dump, restore |
| Infrastructure | infra.just | plan, apply, destroy |
| Kubernetes | k8s.just | apply, diff, rollback, logs |
| Documentation | docs.just | build, serve, publish |
Single-concern projects (e.g. a Go/Rust project with only build/test/lint/fmt) use one dev.just; the root still stays thin. Modules are self-contained: own variables, own _default, no cross-module recipe dependencies.
Templates & References
- Templates (root + module, copy-and-adapt):
Templates.md - just language reference (variables, args, deps, conditionals, attributes, functions, install):
References/Syntax.md - Recipe fragments by project type (Terraform, Go, Python, Docker, Azure, Ansible):
References/Patterns.md - Makefile → just migration guide:
References/MakeMigration.md
Linting
After creating or editing ANY Justfile or .just module, run the structural lint and fix every failure:
bun Tools/lint.ts <project-dir>It checks file naming, the three-line header, _default as first recipe, the --unsorted/--list-submodules flags, env_var_or_default() usage, doc comments on all recipes, explicit module import paths, and section order. Any FAIL is a bug — fix and re-run until clean. The lint cannot judge concern-based naming, self-containment, or standard-vocabulary use — verify those by inspection (see Workflows/CheckJustfile.md).
Gotchas
- Each recipe line runs in a separate shell by default —
cd fooon one line andlson the next runslsin the original directory. Use a shebang recipe for multi-line scripts. - `set dotenv-load` loads `.env` from the justfile directory, not the invocation directory — running
justfrom a subdirectory loads the parent's.env. Useset dotenv-pathto override. - Backtick variables evaluate at parse time, every invocation —
git_hash := \git rev-parse HEAD\`runs git on everyjust` call, even for unrelated recipes. Slow on large repos; move inside the recipe if not needed globally. - Recipe arguments don't shell-quote automatically —
just deploy "my server"passes two args. Useset positional-argumentswith"$@", or wrap as{{quote(target)}}. - `set shell := ["bash", "-c"]` breaks `set -euo pipefail` semantics because each line is its own
-cinvocation —pipefailonly applies within that line. Use shebang recipes for proper fail-fast scripts. - `just --list` hides `_`-prefixed and `[private]` recipes but they're still callable — obscurity, not access control.
- Cross-platform `[macos]`/`[linux]` attributes silently skip the recipe on other OSes — running
just installon Windows when only[linux]/[macos]variants exist exits 0 with no error, which looks like success. - The lint validates structure, not behavior — a recipe can pass every check and still run the wrong command. Run
just --dry-run <recipe>to verify expansion.
Makefile → Justfile Migration Guide
Lookup table and per-feature notes for converting a Makefile. The procedure is in Workflows/MigrateFromMake.md; the target Justfile must follow the house conventions in SKILL.md.
Syntax Translation Table
| Make | Just | Notes |
|---|---|---|
.PHONY: target | (not needed) | just doesn't track files |
default: help / first target | _default: recipe | First recipe is the default; house style requires _default |
@command | @command | Same — suppress echo |
$(VAR) or ${VAR} | {{var}} | Variable interpolation |
$$VAR | $VAR | Shell variable (no double-dollar escape) |
VAR := value | var := "value" | Strings must be quoted |
VAR ?= default | var := env_var_or_default("VAR", "default") | Env fallback (house style) |
$(shell cmd) | ` cmd ` | Backtick evaluation (parse-time — see gotchas) |
export VAR | export var := "value" | Or set export globally |
target: dep1 dep2 | target: dep1 dep2 | Same syntax |
.DEFAULT_GOAL := help | Put _default first | |
include file.mk | import 'file.just' or mod | |
| Tab indentation | Spaces or tabs | Consistent within file (header pins shell) |
ifeq / endif | if expr { } else { } | Inline conditionals |
$(MAKEFILE_LIST) | (not needed) | just --list is built-in |
Key Differences
1. No double-dollar escaping
In Make, $$ passes a literal $ to the shell. In just, use $ directly:
# Make
target:
echo $$HOME
for f in $$files; do echo $$f; done# Just
target:
echo $HOME
for f in $files; do echo $f; done2. Each line is a separate shell
Like make, each line runs in its own shell — but just makes multi-line scripts easy via shebang recipes:
# Make — awkward line continuation
target:
@if [ -f file ]; then \
echo "found"; \
else \
echo "missing"; \
fi# Just — shebang recipe (runs as a single script)
target:
#!/usr/bin/env bash
if [ -f file ]; then
echo "found"
else
echo "missing"
fi3. String quoting
Just variables must be quoted strings; Make variables are unquoted:
# Make
VERSION := 1.0.0
IMAGE := myapp# Just
version := "1.0.0"
image := "myapp"4. Self-documenting help
Make needs a grep/awk hack; just has it built in. Comments above recipes become --list descriptions:
# Make
help: ## Show help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "%-20s %s\n", $$1, $$2}'# Just
# List all available recipes
_default:
@just --list --unsorted5. Environment variables
Make uses $$VAR in recipes; just uses $VAR directly and supports dotenv via the house header (set dotenv-load := true):
# Make
init:
@terraform init \
-backend-config="subscription_id=$$TF_VAR_BACKEND_SUBSCRIPTION_ID"# Just (header already sets dotenv-load)
init:
terraform init \
-backend-config="subscription_id=$TF_VAR_BACKEND_SUBSCRIPTION_ID"6. Arguments
Make doesn't natively support recipe arguments; just does:
# Make — hacky workaround
recreate:
@terraform apply -replace "azurerm_linux_virtual_machine.azxdev01"# Just — parameterized
# resource - the Terraform address to replace
recreate resource:
terraform apply -replace "{{resource}}"
# Usage: just recreate azurerm_linux_virtual_machine.azxdev01Coexistence Strategy
During migration, keep both files — Makefile and Justfile don't conflict. Migrate one concern at a time, verify each recipe with just --dry-run <recipe>, then remove the corresponding target from the Makefile. Delete the Makefile only once every recipe works and Tools/lint.ts passes.
Justfile Patterns by Project Type
Recipe content for common stacks. These show what recipes a domain needs — drop them into the appropriate .justfiles/<concern>.just module and apply the house conventions from SKILL.md (three-line header, _default first, env_var_or_default, doc comments). Each complete example below already carries the header so it passes Tools/lint.ts; the fragments under "Common Patterns" are snippets to compose into a module.
Terraform / Infrastructure (.justfiles/infra.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Initialize Terraform with backend config
init:
terraform init \
-backend-config="subscription_id=$TF_VAR_BACKEND_SUBSCRIPTION_ID" \
-backend-config="resource_group_name=$TF_VAR_BACKEND_RESOURCE_GROUP_NAME" \
-backend-config="storage_account_name=$TF_VAR_BACKEND_STORAGE_ACCOUNT_NAME" \
-backend-config="container_name=$TF_VAR_BACKEND_CONTAINER_NAME" \
-backend-config="key=$TF_VAR_BACKEND_KEY"
# Validate Terraform configuration
validate:
terraform validate
# Format Terraform files
fmt:
terraform fmt -recursive
# Generate and review a Terraform plan
plan: validate
terraform plan
# Apply Terraform changes
apply:
terraform apply
# Show Terraform outputs
outputs:
terraform output
# Recreate a specific resource
# resource - the Terraform address to replace
recreate resource:
terraform apply -replace "{{resource}}"
# Run Checkov security scan
checkov:
checkov --directory .
# Destroy Terraform resources — DESTRUCTIVE, prompts for confirmation
[confirm("Are you sure you want to destroy resources?")]
destroy:
terraform destroyGo Project (.justfiles/dev.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := false
# --- Variables ---
version := `git describe --tags --always --dirty 2>/dev/null || echo "dev"`
ldflags := "-ldflags \"-X main.version=" + version + "\""
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Build the binary
build:
go build {{ldflags}} -o bin/app ./cmd/app
# Run all tests
test:
go test -v -race ./...
# Run linters
lint:
golangci-lint run
# Format code
fmt:
go fmt ./...
goimports -w .
# Remove build artifacts
clean:
rm -rf bin/ dist/Python Project (.justfiles/dev.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Install dependencies
install:
uv sync
# Run tests
# args - extra pytest arguments (default: "")
test *args="":
uv run pytest -v {{args}}
# Run linters
lint:
uv run ruff check .
# Format code
fmt:
uv run ruff format .
# Type check
typecheck:
uv run mypy src/
# Remove caches and build output
clean:
rm -rf .pytest_cache .ruff_cache __pycache__ .mypy_cache dist/Docker / Container (.justfiles/docker.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := false
# --- Variables ---
image := env_var_or_default("IMAGE", "ghcr.io/user/app")
tag := `git describe --tags --always --dirty 2>/dev/null || echo "latest"`
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Build the image
build:
docker build -t {{image}}:{{tag}} -t {{image}}:latest .
# Push the image
push:
docker push {{image}}:{{tag}}
docker push {{image}}:latest
# Build a multi-arch image and push
buildx:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag {{image}}:{{tag}} \
--tag {{image}}:latest \
--push .Azure / Bastion SSH Tunneling (.justfiles/infra.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
# --- Variables ---
vm_ip := env_var_or_default("VM_IP", "10.0.2.4")
vm_port := env_var_or_default("VM_PORT", "50022")
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Open an SSH tunnel via Azure Bastion
tunnel:
az network bastion tunnel \
--name "$ARM_BASTION_NAME" \
--resource-group "$ARM_RESOURCE_GROUP" \
--target-ip-address {{vm_ip}} \
--resource-port 22 \
--port {{vm_port}} &
# Kill the tunnel process
kill-tunnel:
#!/usr/bin/env bash
if [ -f .tunnel.pid ]; then
kill "$(cat .tunnel.pid)" && rm -f .tunnel.pid
echo "Tunnel killed"
else
echo "No tunnel process found"
fiAnsible (.justfiles/infra.just)
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
# --- Variables ---
playbook := env_var_or_default("PLAYBOOK", "local.yml")
inventory := env_var_or_default("INVENTORY", "inventory/hosts.ini")
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Run the playbook
# args - extra ansible-playbook arguments (default: "")
play *args="":
ansible-playbook -i {{inventory}} {{playbook}} {{args}}
# Dry run (check mode)
check:
ansible-playbook -i {{inventory}} {{playbook}} --check --diff
# List inventory hosts
hosts:
ansible-inventory -i {{inventory}} --listCommon Patterns (snippets to compose)
Grouped recipes
[group("ci")]
ci-lint:
...
[group("deploy")]
deploy-production:
...Confirmation for dangerous operations
# Destroy everything — DESTRUCTIVE, prompts for confirmation
[confirm("This will destroy ALL resources. Continue?")]
destroy:
terraform destroyHidden helper recipes
# Ensure required tools are installed
[private]
_ensure-tools:
#!/usr/bin/env bash
for cmd in terraform az jq; do
command -v "$cmd" >/dev/null || { echo "Missing: $cmd"; exit 1; }
done
# Plan, guarded by the tool check
plan: _ensure-tools
terraform planJust Language Reference
The just language: recipes, variables, settings, arguments, dependencies, conditionals, attributes, functions, and installation. Style-neutral syntax — apply the house conventions in SKILL.md (header, _default, env_var_or_default, .justfiles/ modules) when assembling a real Justfile.
Recipe = Target
Recipes are the core unit — a named set of commands:
recipe-name:
command1
command2- Indentation MUST be consistent (the three-line header pins
set shell). - Each line runs in a separate shell by default — use a shebang recipe for multi-line scripts.
- No
.PHONYneeded — just doesn't track file timestamps.
Variables
# Assignment
version := "1.0.0"
# Backtick evaluation (runs command, captures stdout — at parse time, every invocation)
git_hash := `git rev-parse --short HEAD`
# Environment variable with fallback (house style — never bare env())
home := env_var_or_default("HOME", "/root")
# Export to recipe commands as an env var
export DATABASE_URL := "postgres://localhost/mydb"Settings
The three-line house header pins the most important ones. Other useful settings:
set positional-arguments # Pass recipe args as $1, $2, ...
set export # Export all variables as env vars
set dotenv-path := "..." # Override which .env file dotenv-load readsRecipe Arguments
# Required argument
deploy target:
echo "Deploying to {{target}}"
# Default value
greet name="World":
echo "Hello {{name}}"
# Variadic (one or more)
test +targets:
go test {{targets}}
# Variadic (zero or more)
lint *flags:
eslint {{flags}} src/Dependencies
# Run 'build' before 'test'
test: build
cargo test
# Pass arguments to a dependency
push: (deploy "production")
# Multiple dependencies
all: clean build test lintConditionals
# Ternary-style assignment
rust_target := if os() == "macos" { "aarch64-apple-darwin" } else { "x86_64-unknown-linux-gnu" }
# Inside a recipe
check:
if [ -f .env ]; then echo "Found .env"; fiPlatform-Specific Recipes
[linux]
install:
sudo apt install ripgrep
[macos]
install:
brew install ripgrep
[windows]
install:
choco install ripgrepGotcha: on an OS with no matching variant, just install exits 0 silently — looks like success.Recipe Attributes
[private] # Hidden from --list (still callable)
[no-cd] # Don't cd to justfile directory
[confirm] # Ask confirmation before running
[confirm("Deploy to production?")] # Custom confirmation prompt
[no-exit-message] # Suppress error message on failure
[group("deploy")] # Group in --list output
[doc("Run the full test suite")] # Custom doc stringShebang Recipes (multi-line scripts)
When a recipe must run as one script instead of line-by-line (the fix for the separate-shell and pipefail gotchas):
process-data:
#!/usr/bin/env python3
import json
with open("data.json") as f:
data = json.load(f)
print(f"Found {len(data)} records")Self-Documenting Help
The _default recipe runs when you type just with no args. Comments above recipes become their --list descriptions:
# List all available recipes
_default:
@just --list --unsorted
# Initialize Terraform with backend config
init:
terraform initImports and Modules
# Import another justfile (merged into the namespace)
import 'ci.just'
# Module (namespaced) — house style requires the explicit path
mod deploy '.justfiles/deploy.just'
# Usage: just deploy::productionUseful Functions
| Function | Purpose |
|---|---|
os() | Current OS (linux, macos, windows) |
arch() | CPU architecture (x86_64, aarch64) |
env_var_or_default('KEY', 'default') | Env var with fallback (house style) |
invocation_directory() | Directory where just was called from |
justfile_directory() | Directory containing the justfile |
source_directory() | Directory of the current file (use in modules) |
join(a, b) | Join path components |
parent_directory(path) | Parent of path |
file_name(path) | Filename component |
without_extension(path) | Remove file extension |
uppercase(s) / lowercase(s) | Case conversion |
replace(s, from, to) | String replacement |
trim(s) | Trim whitespace |
quote(s) | Shell-quote a string |
sha256_file(path) | SHA-256 hash of a file |
shell(cmd, args...) | Execute command, capture output |
Installation
# macOS
brew install just
# Cargo
cargo install just
# Pre-built binaries
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
# Shell completions
just --completions zsh > ~/.zsh/completions/_just
just --completions bash > /etc/bash_completion.d/justJustfile Templates
Copy-and-adapt starting points that satisfy the house conventions in SKILL.md. After adapting, run bun Tools/lint.ts <dir> and fix any failures.
Root Justfile (thin orchestrator)
The root holds project-wide variables, module imports, and shortcut recipes that delegate to modules. Extract recipes into modules by concern even for small projects.
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
# --- Variables ---
app_name := env_var_or_default("APP_NAME", "myapp")
# --- Modules ---
mod docker '.justfiles/docker.just'
mod test '.justfiles/test.just'
mod ci '.justfiles/ci.just'
# --- Recipes ---
# List all available recipes
_default:
@just --list --unsorted --list-submodules
# Start the development environment (shortcut)
dev: docker-dev
# Run tests (shortcut)
test *filter="":
just -f "{{justfile()}}" test run {{filter}}
# Build the project (shortcut)
build: docker-build
# Run linters (shortcut)
lint: ci-lint
# Run all quality gates
check: lint test
# Tear down everything — DESTRUCTIVE, prompts for confirmation
destroy:
@echo "This will destroy everything. Continue? [y/N]" && read ans && [ "$ans" = "y" ]
echo "Destroying..."Module file (.justfiles/<name>.just)
Modules use the same three-line header and their own _default. Module recipes run from the module's directory, so build absolute paths with source_directory() / ".." instead of bare relative paths.
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := false
# --- Variables ---
root := source_directory() / ".."
registry := env_var_or_default("REGISTRY", "ghcr.io/myorg")
image := env_var_or_default("IMAGE", "myapp")
tag := env_var_or_default("TAG", "latest")
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Build the Docker image
# target - build stage to target (default: production)
build target="production":
docker build --target {{target}} -t {{registry}}/{{image}}:{{tag}} .
# Push the image to the registry
push: build
docker push {{registry}}/{{image}}:{{tag}}
# Run the container locally
# args - additional docker run arguments (default: "")
run *args="":
docker run --rm {{args}} {{registry}}/{{image}}:{{tag}}Single-concern project (one dev.just)
For a Go/Rust/Python project with only build/test/lint/fmt, use a single dev.just module and keep the root thin with shortcuts.
# .justfiles/dev.just
#!/usr/bin/env just --justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := false
# --- Variables ---
root := source_directory() / ".."
# --- Recipes ---
# List recipes in this module
_default:
@just --list --unsorted
# Run the test suite
test *args="":
cargo test {{args}}
# Run linters
lint:
cargo clippy -- -D warnings
# Format code
fmt:
cargo fmt#!/usr/bin/env bun
/**
* lint.ts — structural validator for Justfiles and `.justfiles/<name>.just` modules.
*
* Validates the house conventions documented in ../SKILL.md. Part of the `justfile`
* skill in the julianobarbosa/claude-code-skills repository.
*
* Usage: bun Tools/lint.ts [directory] (directory defaults to ".")
* Exit: 0 if every check passes, 1 if any check fails.
*/
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
let checks = 0;
let errors = 0;
function pass(label: string): void {
checks++;
console.log(` ${GREEN}PASS${RESET} ${label}`);
}
function fail(label: string, detail = ""): void {
checks++;
errors++;
console.log(` ${RED}FAIL${RESET} ${label}${detail ? ` — ${detail}` : ""}`);
}
/** Return the recipe name if `line` is a recipe definition, else null. */
function recipeName(line: string): string | null {
// Recipe defs are unindented, not comments/settings/mods/variables.
if (line === "" || /^\s/.test(line)) return null;
if (line.startsWith("#") || line.startsWith("set ") || line.startsWith("mod ")) return null;
if (/^[A-Za-z_][\w-]*\s*:=/.test(line)) return null; // variable assignment
// name, optional args/deps, then a single ':' that is not ':='
const m = line.match(/^([A-Za-z_][\w-]*)(?:[ (][^:]*)?:(?!=)/);
return m ? m[1] : null;
}
/** Validate one Justfile or module file. */
function checkFile(file: string, isRoot: boolean, hasModules: boolean): void {
const label = isRoot ? basename(file) : `.justfiles/${basename(file)}`;
console.log(`\n${label}`);
const raw = readFileSync(file, "utf8");
const lines = raw.split("\n");
// 1. Three-line header.
const header = [
"#!/usr/bin/env just --justfile",
'set shell := ["bash", "-euo", "pipefail", "-c"]',
];
lines[0] === header[0] ? pass("Shebang line") : fail("Shebang line", `got: ${lines[0] ?? "<empty>"}`);
lines[1] === header[1] ? pass("set shell") : fail("set shell", `got: ${lines[1] ?? "<empty>"}`);
/^set dotenv-load := (true|false)$/.test(lines[2] ?? "")
? pass("set dotenv-load")
: fail("set dotenv-load", `got: ${lines[2] ?? "<empty>"}`);
// 2. _default is the first recipe.
let firstRecipe: string | null = null;
for (const line of lines) {
const name = recipeName(line);
if (name) {
firstRecipe = name;
break;
}
}
firstRecipe === "_default"
? pass("_default is first recipe")
: fail("_default is first recipe", `first recipe: ${firstRecipe ?? "<none>"}`);
// 3. --unsorted present.
raw.includes("--unsorted") ? pass("--unsorted flag present") : fail("--unsorted flag present");
// 4. --list-submodules only on root-with-modules.
if (isRoot && hasModules) {
raw.includes("--list-submodules")
? pass("--list-submodules flag (root with modules)")
: fail("--list-submodules flag (root with modules)", "_default should use --list-submodules");
}
if (!isRoot && raw.includes("--list-submodules")) {
fail("No --list-submodules in module", "module files must not use --list-submodules");
}
// 5. No bare env() — must be env_var_or_default()/env_var().
const bareEnv = [...raw.matchAll(/\benv\([^)]*\)/g)]
.map((m) => m[0])
.filter((s) => !s.startsWith("env_var"));
bareEnv.length === 0
? pass("No bare env() calls")
: fail("No bare env() calls", `found: ${bareEnv.join(", ")}`);
// 6. Every recipe has a doc comment directly above it.
const missingDocs: string[] = [];
for (let i = 0; i < lines.length; i++) {
const name = recipeName(lines[i]);
if (!name) continue;
const prev = (lines[i - 1] ?? "").trim();
if (!prev.startsWith("#")) missingDocs.push(name);
}
missingDocs.length === 0
? pass("Doc comments on all recipes")
: fail("Doc comments on all recipes", `missing on: ${missingDocs.join(", ")}`);
if (!isRoot) return;
// 7. Module imports use explicit quoted paths (root only).
const bareMods = lines
.filter((l) => l.startsWith("mod "))
.filter((l) => !/'[^']+\.just'/.test(l) && !/"[^"]+\.just"/.test(l));
if (hasModules) {
bareMods.length === 0
? pass("Module imports use explicit paths")
: fail("Module imports use explicit paths", `bare: ${bareMods.join(", ")}`);
}
// 8. Section order: variables → mod imports → recipes (root only).
let lastVar = -1;
let firstMod = Infinity;
let lastMod = -1;
let firstRec = Infinity;
lines.forEach((line, idx) => {
if (/^[A-Za-z_][\w-]*\s*:=/.test(line)) lastVar = idx;
if (line.startsWith("mod ")) {
firstMod = Math.min(firstMod, idx);
lastMod = idx;
}
if (recipeName(line)) firstRec = Math.min(firstRec, idx);
});
const orderOk =
!(lastVar > -1 && firstMod < Infinity && lastVar > firstMod) &&
!(lastVar > -1 && firstRec < Infinity && lastVar > firstRec) &&
!(lastMod > -1 && firstRec < Infinity && lastMod > firstRec);
orderOk
? pass("Section order (variables → mods → recipes)")
: fail("Section order (variables → mods → recipes)", "sections are out of order");
}
function main(): void {
const dir = process.argv[2] ?? ".";
// Locate the root file by its real on-disk name (reliable even on
// case-insensitive filesystems, unlike a bare `-f Justfile` test).
const entries = existsSync(dir) ? readdirSync(dir) : [];
const hasCapital = entries.includes("Justfile");
const hasLower = entries.includes("justfile");
if (!hasCapital && !hasLower) {
fail("Root file exists", `no Justfile found in ${dir}`);
console.log(`\n${checks} checks, ${errors} errors`);
process.exit(1);
}
const root = join(dir, hasCapital ? "Justfile" : "justfile");
hasCapital
? pass("Root file named Justfile (capital J)")
: fail("Root file naming", "found lowercase 'justfile', must be 'Justfile'");
// Wrong module locations.
if (existsSync(join(dir, "just")) && statSync(join(dir, "just")).isDirectory()) {
fail("No modules in just/", "modules belong in .justfiles/");
}
for (const e of entries) {
if (e.endsWith(".just")) fail("No root-level .just files", `found ${e} — modules belong in .justfiles/`);
}
// Collect modules.
const modDir = join(dir, ".justfiles");
const modules =
existsSync(modDir) && statSync(modDir).isDirectory()
? readdirSync(modDir)
.filter((f) => f.endsWith(".just"))
.sort()
.map((f) => join(modDir, f))
: [];
const hasModules = modules.length > 0;
checkFile(root, true, hasModules);
for (const m of modules) checkFile(m, false, hasModules);
console.log(`\n${checks} checks, ${errors} errors`);
process.exit(errors === 0 ? 0 : 1);
}
main();
Workflow: CheckJustfile
Validate an existing Justfile against the house conventions — automated structural lint plus the judgment-call checklist the linter cannot cover.
1. Structural lint (automated)
bun Tools/lint.ts <project-dir>Checks: file naming (Justfile capital J, no root-level .just, no just/ dir), the three-line header, _default as the first recipe, --unsorted (and --list-submodules only on the root with modules), env_var_or_default() (no bare env()), doc comments on every recipe, explicit module import paths, and section order. Every FAIL is a bug — fix and re-run until the summary reports 0 errors.
Requires bun. The validator reads the directory listing to check the exact filename, so thecapital-J(Justfilevsjustfile) check is reliable on every platform, including
case-insensitive macOS filesystems.
2. Parse check (automated)
Confirm the root and every module parse:
just --justfile <project-dir>/Justfile --summary >/dev/null && echo "root OK"3. Manual checklist (judgment calls)
The linter validates structure, not intent. Verify by inspection:
- [ ] Recipes organized into modules by domain concern (purpose, not implementation tool).
- [ ] Modules named after concerns, not tools (tests-in-Docker →
test.just, notdocker.just). - [ ] Root with modules has shortcut recipes for common workflows (flat namespace for daily tasks).
- [ ] Modules are self-contained — no cross-module recipe dependencies.
- [ ] Module recipes use
source_directory()paths, not bare relative paths. - [ ] Standard recipe names used where applicable (
dev,test,build,lint,fmt,check,clean). - [ ] Param docs present:
# param - desc (default: val)on parameterized recipes. - [ ] Destructive recipes prompt for confirmation and the doc comment says "DESTRUCTIVE, prompts for confirmation".
- [ ] Dependencies on the recipe definition line, not buried in the body.
4. Behavior spot-check (optional)
A recipe can pass every structural check and still run the wrong command. For risky recipes, confirm the expansion without executing:
just --justfile <project-dir>/Justfile --dry-run <recipe>Workflow: CreateJustfile
Create a new Justfile (and its .just modules) for a project, following the house conventions.
Steps
1. Identify the concerns. List the task domains the project needs (dev, test, docker, ci, db, infra, k8s, docs). Map each to a module from the namespacing table in SKILL.md. Single-concern projects use one dev.just.
2. Pick the standard recipes. From the vocabulary table in SKILL.md, select the public API (dev, test, build, lint, fmt, check, clean) — include only those that apply. Skip build for a project with no build step; skip test for a static site with no tests.
3. Scaffold the root `Justfile` from the root template in Templates.md:
- Three-line header (
set dotenv-load := trueunless the project has no.env). - Project-wide variables via
env_var_or_default(...). - One
mod name '.justfiles/name.just'per concern. _defaultfirst, using@just --list --unsorted --list-submodules(root with modules).- Shortcut recipes delegating to modules (
build: docker-build), pluscheck: lint test.
4. Scaffold each module at .justfiles/<name>.just from the module template in Templates.md:
- Same three-line header, own
_defaultwith@just --list --unsorted(no--list-submodules). - Section order: variables → (mods, rare in modules) → recipes.
root := source_directory() / ".."for any path to a project file.- Doc comment above every recipe;
# param - desc (default: val)for parameters. [confirm(...)]or an inline prompt on destructive recipes; note "DESTRUCTIVE, prompts for
confirmation" in the doc comment.
5. Pull recipe content from References/Patterns.md for the project's stack (Terraform, Go, Python, Docker, Azure, Ansible), adapting each fragment into the house structure.
6. Lint and fix.
bun Tools/lint.ts <project-dir>Fix every FAIL and re-run until clean.
7. Verify expansion of a couple of recipes without executing them:
just --justfile <project-dir>/Justfile --dry-run <recipe>8. Manual checklist (judgment calls the linter can't make) — run Workflows/CheckJustfile.md.
Workflow: MigrateFromMake
Convert an existing Makefile into a Justfile that follows the house conventions.
Full translation table and per-feature notes: References/MakeMigration.md. This workflow is the procedure; that reference is the lookup.
Steps
1. Read the Makefile. Understand every target, variable, dependency, and .PHONY declaration.
2. Group targets into concerns. Don't translate 1:1 into a flat file. Map targets onto modules by domain (see the namespacing table in SKILL.md): build/test/lint/fmt → dev.just or test.just, container targets → docker.just, deploy/release → ci.just, etc.
3. Create the root `Justfile` from Templates.md with the three-line header, mod imports, and shortcut recipes. Translate .DEFAULT_GOAL / first-target-is-default into _default.
4. Translate each target into a module recipe, applying the table in References/MakeMigration.md:
- Drop
.PHONY(just doesn't track files). $(VAR)/${VAR}→{{var}};$$VAR→$VAR(no double-dollar escaping).VAR := value→var := "value"(quote strings);VAR ?= def→var := env_var_or_default("VAR", "def").$(shell cmd)→ `cmd(but mind the parse-time gotcha — seeSKILL.md`).include file.mk→import 'file.just'or amod.- Multi-line
\-continued shell blocks → shebang recipes.
5. Rename to the standard vocabulary. Map ad-hoc target names to dev/test/build/lint/ fmt/check/clean where they match. Add check: lint test.
6. Add a doc comment above every recipe (Make's grep/awk help hack is replaced by just --list), and [confirm(...)] on destructive recipes.
7. Lint and fix.
bun Tools/lint.ts <project-dir>8. Verify each recipe with just --dry-run <recipe> before deleting the Makefile. During migration both files can coexist (Makefile vs Justfile don't conflict) — migrate and verify one concern at a time, then remove the Makefile.