Skip to content

SimonVerhoeven/command-entrypoints

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

67 Commits
 
 
 
 

Command runners in 2026

Table of Contents

This article explores the landscape of command runners in 2026, comparing Make, Just, Task (Taskfile), Mise, JBang, and Gradle from a senior Java engineer’s perspective. It covers the why, what, and how of command automation, with real-world scenarios, performance comparisons, and practical recommendations for choosing the right tool for your project.


Every development team faces the same challenge: How do we make our projects easy to work with? Whether you’re onboarding a new engineer, setting up CI/CD pipelines, or just trying to remember how to run integration tests after a week away from a project, the cognitive load adds up. Command runners provide a solution by offering a consistent, self-documenting interface to all your project’s operations.

Given the rising usage of AI, command runners become even more valuable. A single entry point gives human and AI agents the same view of how to operate a project. Rather than an LLM guessing at ad‑hoc shell commands, it can list available tasks and run your verified workflows. This way, we can reduce risks, improve the reproducibility, and make automation safer for teams working at scale.

In this article, we’ll explore why command runners matter, trace their evolution from Make to modern alternatives, and provide practical guidance on choosing and implementing the right tool for your projects.

Imagine a new developer joins your Spring Boot microservices project.
To get started locally, they need to:

  1. Run docker-compose up to start infrastructure (Postgres, Redis, Kafka)

  2. Wait for services to be ready

  3. Run database migrations

  4. Generate jOOQ classes from the database schema

  5. Generate API client code from OpenAPI specs

  6. Start the application with the correct profile

  7. Optionally seed test data

Without a command runner, this knowledge might live in README files, wiki pages, senior developers' heads, or scattered shell scripts.
The new developer wastes days piecing together "how do I actually run this thing?"

Without a command runner, onboarding involves:

  • Reading through README files, hoping they’re up-to-date

  • Discovering hidden dependencies through trial and error

  • Debugging Docker Compose configuration issues

  • Asking teammates, "How do I actually run this?"

  • Wrestling with inconsistent script locations and naming

  • Manually coordinating the startup sequence of multiple services

  • Troubleshooting environment-specific issues that "work on my machine"

With a command runner, the friction disappears:

  • Run just --list or mise tasks to see all available operations

  • Run just dev, and everything starts in the correct order

  • Start exploring the running application

A command runner provides a self-documenting interface to your project’s operations:

$ just --list
Available recipes:
    clean              # Clean build artifacts
    test               # Run all tests
    dev                # Start local development environment
    db-migrate DB      # Run database migrations
    db-seed DB         # Seed database with test data
    generate           # Generate code (jOOQ, OpenAPI clients)
    deploy ENV         # Deploy to environment (dev, staging, prod)

Now the new developer knows exactly what’s possible and how to do it.
They don’t need to hunt through documentation, ask teammates basic questions, or rely on so-called tribal knowledge.

Think of command runners as the friendly interface to your project.
They don’t replace your other tools, they orchestrate them:

Developer → command runner → Build tools (Gradle/Maven)
                           → Containers (Docker/Docker Compose)
                           → Databases (Flyway/Liquibase)
                           → Infrastructure (Terraform/kubectl)
                           → Custom scripts

Modern projects use diverse tooling across different domains.
A command runner provides a consistent interface with clear documentation, dependency management, parameter handling, and cross-platform compatibility.

Scripts are often the first step in automation, which is a logical first step.
The real question is not “Should we use scripts?” but “When do scripts stop being enough?”

Use scripts when:

  • A task has more than one step and should be repeatable

  • You find yourself copy‑pasting the same commands across projects

  • You need to remember flags, environment variables, or ordering

  • You want to lower onboarding friction for new team members

Scripts are great because:

  • Fast to write: there is no ceremony nor tooling decisions

  • Flexible: the full power of your shell or language

  • Low overhead: there are no extra dependencies

But scripts start to become cumbersome when:

  • You have more than a handful of them and no discoverability

  • Tasks depend on other tasks, and the ordering starts to matter

  • You need consistent behaviour across macOS, Linux, and Windows

  • Parameter handling becomes inconsistent across scripts

  • Documentation drifts away from reality

At that point, scripts are still useful, but they should be orchestrated, not discovered.
This is where command runners shine: keep scripts for complexity, and provide a single, documented entry point for everything else.

I recommend a simple rule of thumb:

Use scripts for isolated tasks. Adopt a command runner once scripts become a system.

Raw shell scripts in a scripts/ directory seem simple, but they have significant drawbacks:

  • There’s no discoverability. How do you know what scripts exist?

  • Each script needs its own --help documentation.

  • Expressing dependencies between scripts can be a hassle.

  • Parameter handling is inconsistent.

  • Shell scripts don’t run natively on all platforms.

  • Each script becomes a standalone item, and changes often don’t cascade.

Command runners solve these problems with:

  • built-in discovery (just --list).

  • consistent parameter handling

  • clear dependency syntax (task-b: task-a)

  • cross-platform support, and centralised maintenance in one or a few well-known files.

Build tools can run arbitrary tasks, but they have significant overhead:

  • Startup time ranges from 1-3 seconds, even for "simple" tasks

  • Too much ceremony for simple operations

  • Build files can become cluttered with unrelated tasks

  • Often awkward for tasks outside their primary domain

  • Require a different mental model than "run a command"

Build tools and command runners serve different purposes, and understanding when to use each is key to an efficient workflow.

Build tools (Maven, Gradle, Cargo, dotnet CLI) are specialised for:

  • Compiling source code

  • Managing dependencies

  • Running tests tightly coupled to the build

  • Packaging applications

  • Publishing artifacts

Command runners (Just, Task, Make) excel at:

  • Orchestrating multiple external tools

  • Setting up and tearing down development environments

  • Running tasks unrelated to building (deployment, database operations, data processing)

  • Providing a discoverable interface to project operations

  • Simple, fast task execution without build tool overhead

The hybrid approach works best: Use your build tool for what it does well (building and testing), and use a command runner to orchestrate everything else.

Command runners aren’t just for developers:

  • Data analysts might use them to orchestrate Excel spreadsheet processing, data transformations, or report generation

  • DevOps teams use them to provision infrastructure

  • Technical writers might use them for documentation builds and publishing workflows

  • Anyone who needs to automate sequences of commands benefits from the discoverability and consistency that command runners provide.

Use build tools for tasks when:

  • Tasks are tightly coupled to the build (running tests, packaging)

  • You want to minimise tooling in your stack

  • Tasks benefit from your build tool’s incremental build features

Use dedicated command runners when:

  • Orchestrating multiple external tools

  • Setting up and tearing down environments

  • Running tasks unrelated to building (deployment, data operations)

  • Startup time matters

  • Simplicity and discoverability matter

  • Working with non-developers who need to run standardised workflows

The beauty is that you don’t have to choose just one tool. In practice, many successful teams combine command runners to leverage their respective strengths. We can use a lightweight command runner such as Just or Task as the primary interface, while delegating complex logic to JBang or similar tools in your language ecosystem.

This approach gives you the best of both worlds: simple, readable task definitions for everyday operations, and the full power of your favourite, well-known programming language when you need it. Your justfile or Taskfile.yml remains clean and approachable, while complex tasks benefit from type safety, testing, and proper error handling.

We could, for example, do the following:

justfile:

# Simple task orchestration
clean:
    ./gradlew clean

test:
    ./gradlew test

# Complex tasks delegate to JBang
db-clone env:
    ./scripts/db-tasks.java clone-{{env}}

data-migration source target:
    ./scripts/data-migration.java --source {{source}} --target {{target}}

generate-test-data count:
    ./scripts/generate-data.java --records {{count}}

# Best of both worlds: Just orchestrates, JBang handles complexity
setup-local: clean
    docker-compose up -d
    ./scripts/db-tasks.java migrate
    ./scripts/generate-data.java --records 1000
    ./gradlew bootRun

This gives you:

  • ✅ Simple, readable task definitions in Just

  • ✅ Complex logic in type-safe, testable JBang scripts

  • ✅ Clear separation of concerns

  • ✅ Easy discoverability (just --list shows everything)

  • ✅ The right tool for each type of task

The key is to keep clear boundaries: use your command runner for orchestration and simple operations, and delegate the rest to programming languages, such as when tasks require conditional logic, error handling, or specific libraries.


Command runners reduce friction across multiple dimensions:

Standardising workflows: they ensure consistency across your team:

  • Everyone uses the same commands instead of developer-specific approaches

  • Important steps can’t be accidentally skipped

  • Environments remain consistent across machines

  • Best practices are encoded in tasks

  • Onboarding becomes faster

  • Tribal knowledge is captured in executable form

Reducing context switching: Developers don’t need to remember whether to use npm run, ./gradlew, docker-compose, or a custom script. One consistent interface (just <task> or task <task>) reduces cognitive load and keeps you in the flow.

CI/CD integration: Your CI/CD pipeline can use the same commands as local development, ensuring parity between environments. If it works locally, it works in CI:

# .github/workflows/ci.yml
jobs:
  test:
    steps:
      - run: just test-all
  deploy:
    steps:
      - run: just deploy staging

Documentation that stays current: README files tend to get outdated as soon as they’re written, but a justfile is executable documentation. If the task doesn’t work, it gets fixed immediately because it blocks actual work.

Enabling the team: Product managers can trigger demo environment setups, QA can reset test databases, or support teams can generate diagnostic reports. All this without deep technical knowledge.

Project-specific automation: Every project has unique tasks such as batch jobs, maintenance operations, reports, and troubleshooting. These do not belong in your build tool, but they need to be runnable and documented. Command runners provide a home for these one-off but important operations.

Now that we understand why we need command runners, let’s look at where they came from.

In 1976, Stuart Feldman created Make as a build tool for C programs. Over the decades, it became widely adopted for general-purpose command running beyond its original scope. Make’s design decisions (tab-based syntax, file-centric workflow, implicit rules) were well-suited for C compilation in the 1970s. However, modern development workflows involving Docker, microservices, and cloud infrastructure have different needs.

Modern languages developed specialised build tools: Maven and Gradle for Java, Cargo for Rust, dotnet CLI for .NET, and Go’s built-in tooling. These tools excel at compilation and dependency management, but can add unnecessary overhead for simple task orchestration, such as starting containers or running migrations.

This created space for a new generation of purpose-built command runners. Just (2016), Task/Taskfile (2017), and JBang (2020) were designed specifically for task orchestration rather than building software. Each brings modern conveniences like improved syntax, cross-platform support, and clearer error messages.


This comparison focuses on the most relevant options for Java development environments:

  1. Make - A build automation tool with widespread adoption for command running

  2. Just - Purpose-built command runner with modern syntax

  3. Task (Taskfile) - YAML-based command runner built with Go.

  4. Mise - Polyglot tool manager and integrated command runner

  5. JBang - Java-based scripting tool for task automation

  6. Gradle - Build automation system with extensible task support

Additionally, JShell, npm scripts, and Maven exec will be briefly covered as they are sometimes used for similar purposes.


Before we start exploring each tool’s syntax and capabilities, I recommend installing them so you can experiment and follow along with the examples.

Unix/Linux/macOS: Usually pre-installed

Windows:

# Option 1: Download standalone binary
# Visit: http://gnuwin32.sourceforge.net/packages/make.htm
# Download and add to your PATH

# Option 2: Use WSL (Windows Subsystem for Linux)
# Make comes pre-installed in most WSL distributions

# Option 3: Using winget (if already installed)
winget install GnuWin32.Make

# Option 4: Using Chocolatey (if already installed)
choco install make

Download pre-built binary:

Using package managers (alternative):

brew install just           # macOS (requires Homebrew)
winget install Casey.Just   # Windows (requires winget)
scoop install just          # Windows (requires Scoop)
cargo install just          # Any OS (requires Rust toolchain)

Download pre-built binary:

Installation script (Linux/macOS):

sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d

Using package managers (alternative):

brew install go-task        # macOS (requires Homebrew)
winget install Task.Task    # Windows (requires winget)
scoop install task          # Windows (requires Scoop)
choco install go-task       # Windows (requires Chocolatey)

Download pre-built binary:

Installation script (Linux/macOS):

curl https://mise.run | sh

Using package managers (alternative):

brew install mise           # macOS (requires Homebrew)
winget install jdx.mise     # Windows (requires winget)
scoop install mise          # Windows (requires Scoop)
choco install mise          # Windows (requires Chocolatey)

Download and install:

Installation script (Linux/macOS):

curl -Ls https://sh.jbang.dev | bash -s - app setup

Using package managers (alternative):

brew install jbangdev/tap/jbang  # macOS (requires Homebrew)
winget install jbangdev.jbang    # Windows (requires winget)
scoop install jbang              # Windows (requires Scoop)
choco install jbang              # Windows (requires Chocolatey)

Using SDKMan (alternative, if already installed):

sdk install jbang

If not already in your project:

Download and install:

Using SDKMan (alternative, if already installed):

sdk install gradle

All platforms: Pre-installed with JDK 9 and later

No separate installation is needed; you merely need to ensure that you have JDK 9+ installed and run jshell from the command line.

Note: Many Java projects use the Gradle Wrapper (gradlew/gradlew.bat), which requires no separate installation.


The choice of a command runner often comes down to developer experience: how intuitive the syntax is, how quickly you can get started, and how easy it is to maintain over time. In this section, we’ll examine the syntax and practical usability of each tool through equivalent examples.

Each example implements the same set of commands:

  • cleaning build artifacts

  • running tests

  • deploying to environments

  • running integration tests

So that we can compare the approaches directly, and see how each tool handles:

  • Variable declarations and interpolation

  • Parameter passing and default values

  • Task dependencies

  • Cross-platform compatibility

  • Error messages and discoverability

A useful way to read the examples is to separate what you can express from how it feels to maintain. Most tools can run the same underlying commands, and the differences show up in how discoverable tasks are for new joiners, how parameters are passed, how dependencies are modelled, and how much syntax you have to carry around as the task set grows.

Make is the historical baseline: a build tool by origin, but still widely used as a general-purpose entry point because it’s available on all Unix-like systems and "good enough" for many teams. In this comparison, we treat Make as the reference point for trade-offs: powerful and familiar, but with older ergonomics (tabs, escaping rules, and environment-variable-driven parameters).

.PHONY: clean test deploy

# Variables must be set like this
ENVIRONMENT ?= dev

clean:
	@echo "Cleaning build artifacts"
	./gradlew clean
	rm -rf build/

test:
	@echo "Running tests"
	./gradlew test

# Rerun deploy if the deploy script changes
deploy: test ./scripts/deploy.sh
	@echo "Deploying to $(ENVIRONMENT)"
	./scripts/deploy.sh $(ENVIRONMENT)

# Parameters must be passed as environment variables
integration-test:
	@echo "Running integration tests against $(SERVICE)"
	./gradlew :$(SERVICE):integrationTest

Pros:

  • ✅ Universal: every Unix system has it

  • ✅ Widely known in the software engineering community

  • ✅ Simple for basic tasks

  • ✅ File dependency tracking (it can trigger task reruns when script files change, though this is less critical for most command-running scenarios)

Cons:

  • Tabs vs. spaces: Strict tab requirement for command lines can cause syntax errors

  • Parameter passing: Relies on environment variables rather than function-style parameters

  • Platform dependency: Windows requires additional tooling (WSL, Cygwin, or GNU Make for Windows)

  • Error messages: Can be difficult to interpret for newcomers

  • Syntax: Special characters (@, $, $@, $<) and escaping rules have a learning curve

Just is a purpose-built command runner that optimises for readability and a smooth day-to-day developer experience. It’s a good fit when you want a small, fast, self-documenting "front door" to a project, without dragging operational concerns into your build tool.

# justfile
# AI Hint: This file is Model Context Protocol (MCP) optimised.
# AI agents should use `just --list` to discover available tools.

set dotenv-load := true

# Set the variables
environment := env('ENVIRONMENT', 'dev')
service     := env('SERVICE', 'core-api')

# Sets the fallback recipe (Supported in version 1.43.0+)
[default]
list:
    @just --list

# Clean build artifacts
clean:
    @echo "Cleaning build artifacts"
    ./gradlew clean
    rm -rf build/

# Run tests
test:
    @echo "Running tests"
    ./gradlew test

# [confirm] attribute provides a safety guard for human and agentic workflows.
# Credentials are injected via a secure wrapper to avoid .env exposure.
deploy env=environment: test
    @echo "Fetching ephemeral credentials and deploying to {{env}}..."
    infisical run -- ./scripts/deploy.sh {{env}}

# Per-recipe environment variables using the '$' parameter prefix
check-status $LOG_LEVEL="warn":
    @echo "Checking status for {{service}} with LOG_LEVEL=$LOG_LEVEL..."
    ./scripts/check.sh

# Use the [script] attribute, which was stabilised in late 2025 (Version 1.44.0)
[script("python3")]
analyze-logs:
    import os
    # Exported variables or global Just variables are accessible via os.getenv
    print(f"Analyzing logs for {os.getenv('SERVICE')}")

# Integration tests
integration-test:
    @echo "Targeting service: {{service}}"
    ./gradlew :{{service}}:integrationTest

Pros:

  • Modern syntax: No tab requirements, spaces work naturally

  • Parameters: Function-style parameters with default values

  • Error messages: Clear and actionable feedback

  • Cross-platform: Single binary works on Windows, macOS, Linux

  • Variables: Straightforward {{variable}} interpolation syntax

  • Listing: Built-in just --list shows all available tasks

Cons:

  • Installation required: It’s not available by default; it needs a separate installation

  • No file dependencies: Always executes tasks without checking file timestamps (though Task addresses this if needed)

  • Smaller ecosystem: Fewer online examples and community resources than Make

  • Additional tool: Adds another dependency to your project

Task sits somewhere between a lightweight runner and a workflow engine: it keeps a simple mental model, but adds practical features for larger task suites (descriptions, structured variables, includes, and skipping work when inputs haven’t changed). If your team already lives in YAML for CI and infrastructure, Task often feels like a natural extension.

# Taskfile.yml
version: '3'

vars:
  ENVIRONMENT: '{{.ENVIRONMENT | default "dev"}}'
  GRADLE: ./gradlew

tasks:
  clean:
    desc: Clean build artifacts
    cmds:
      - echo "Cleaning build artifacts"
      - '{{.GRADLE}} clean'
      - rm -rf build/

  test:
    desc: Run tests
    cmds:
      - echo "Running tests"
      - '{{.GRADLE}} test'

  deploy:
    desc: Deploy to environment
    deps: [test]
    if: '{{ne.ENV "prod" | or (env "CI")}}'
    vars:
      ENV: '{{.ENV | default.ENVIRONMENT}}'
    # Only redeploy if script changes
    sources:
      - ./scripts/deploy.sh
    cmds:
      - echo "Deploying to {{.ENV}}"
      - infisical run -- ./scripts/deploy.sh {{.ENV}}

  integration-test:
    desc: Run integration tests for a service
    requires:
      vars:
        - name: SERVICE
    cmds:
      - echo "Running integration tests for {{.SERVICE}}"
      - '{{.GRADLE}} :{{.SERVICE}}:integrationTest'

  dev:
    desc: Start local development environment
    deps: [clean]
    cmds:
      - docker-compose up -d postgres redis
      - '{{.GRADLE}} bootRun'

  default:
    desc: List all available tasks
    cmds:
      - task --list

Pros:

  • YAML-based: A familiar format for most developers

  • Rich features: File watching, status checks, includes, and more

  • Cross-platform: Handles shell differences across operating systems

  • File dependencies: Can check file timestamps and skip tasks when sources haven’t changed

  • Good documentation: Well-maintained with clear examples

  • Powerful: Variables, templating, dynamic tasks

Cons:

  • YAML verbosity: More structure required compared to Just for simple tasks

  • Installation required: A separate binary installation needed

  • YAML challenges: Indentation and quoting can be error-prone

  • Complexity: Feature-rich nature may be more than needed for simple projects

In 2026, the industry is consolidating around fewer, more powerful tools. The "works on my machine" problem has shifted from locally installed software, thanks to tools such as Docker, to developer toolchains themselves. While many developers have spent years juggling nvm for Node, pyenv for Python, sdkman for Java, and direnv for secrets, Mise (formerly rtx) has emerged as the "one tool to rule them all." It is a polyglot tool version manager, environment manager, and task runner all bundled into a single, high-performance Rust binary.

Mise’s most compelling argument is that it eliminates "tooling sprawl." Instead of having four different configuration files to set up a microservice, you have one mise.toml that ensures the correct JDK, Node version, and database credentials are all "en place" the moment you cd into the directory.

# mise.toml
[tools]
java = "temurin-21"        # Replaces sdkman/jenv
node = "22"                # Replaces nvm
terraform = "1.7"          # Replaces tfenv

[env]
# Replaces direnv/dotenv
DATABASE_URL = "postgresql://localhost:5432/myapp"
# 2026: Native support for encrypted secrets via sops
_.file = ".env.secrets.json"

[tasks.dev]
description = "Start local development environment"
# Tasks automatically run with the tools and env defined above
run = """
docker-compose up -d
./gradlew bootRun
"""

Pros:

  • Low overhead: Unlike asdf, which uses "shims" (adding ~120ms lag), Mise modifies the PATH directly, making tool execution instantaneous.

  • Integrated lifecycle: It manages tool installation, environment variables, and task execution in a single workflow. If a developer runs a task, Mise installs any missing JDKs or runtimes automatically.

  • Security-first: Includes native integration with sops and age for managing encrypted secrets within your repository.

  • Parallel execution: Mise tasks can run in parallel by default, which is a significant speedup for monorepos.

  • Ecosystem: ability to leverage a large set of tools via its multiple backends

Cons:

  • Learning curve: The "hierarchical configuration" can be confusing for teams new to the tool.

  • Windows nuances: Although it supports Windows, features like automatic PATH modification require PowerShell profile setup, and WSL2 is recommended for the best experience.

JBang is a different kind of solution: instead of declaring tasks in a DSL, you’re writing Java (or Java-adjacent) code that runs like a script. This becomes attractive when tasks stop being pure orchestration and start needing real logic, libraries, tests, and a polished CLI. This does come at the cost of more ceremony and JVM startup overhead.

///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS info.picocli:picocli:4.7.6

import picocli.CommandLine;
import picocli.CommandLine.*;

import java.io.File;
import java.util.concurrent.Callable;

@Command(
    name = "task",
    mixinStandardHelpOptions = true,
    version = "task 1.0",
    description = "Task runner equivalent for Taskfile.yml",
    subcommands = {
        TaskApp.Clean.class,
        TaskApp.Test.class,
        TaskApp.Deploy.class,
        TaskApp.IntegrationTest.class,
        TaskApp.Dev.class,
        TaskApp.ListTasks.class
    }
)
public class TaskApp implements Callable<Integer> {

    public static void main(String[] args) {
        int exit = new CommandLine(new TaskApp())
            .setCaseInsensitiveEnumValuesAllowed(true)
            .execute(args);
        System.exit(exit);
    }

    @Override
    public Integer call() {
        // Default: list all available tasks
        return new CommandLine(new ListTasks()).execute();
    }

    static String envOrDefault(String key, String def) {
        String val = System.getenv(key);
        return (val == null || val.isBlank()) ? def : val;
    }

    static String gradle() {
        return envOrDefault("GRADLE", "./gradlew");
    }

    static int run(String command) throws Exception {
        ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", command);
        pb.inheritIO();
        Process p = pb.start();
        return p.waitFor();
    }

    @Command(name = "clean", description = "Clean build artifacts")
    static class Clean implements Callable<Integer> {
        @Override
        public Integer call() throws Exception {
            System.out.println("Cleaning build artifacts");
            int rc1 = run(gradle() + " clean");
            if (rc1 != 0) return rc1;
            return run("rm -rf build/");
        }
    }

    @Command(name = "test", description = "Run tests")
    static class Test implements Callable<Integer> {
        @Override
        public Integer call() throws Exception {
            System.out.println("Running tests");
            return run(gradle() + " test");
        }
    }

    @Command(name = "deploy", description = "Deploy to environment")
    static class Deploy implements Callable<Integer> {

        @Option(names = {"-e", "--env"}, description = "Target environment (default: ${DEFAULT-VALUE})")
        String env = envOrDefault("ENVIRONMENT", "dev");

        @Option(names = {"--skip-test"}, description = "Skip test dependency")
        boolean skipTest;

        @Override
        public Integer call() throws Exception {
            // Mimic condition: if ENV != prod OR CI is set
            String ci = System.getenv("CI");
            boolean allow = !"prod".equalsIgnoreCase(env) || (ci != null && !ci.isBlank());
            if (!allow) {
                System.err.println("Deploy blocked: ENV=prod and CI is not set.");
                return 2;
            }

            // Dependency: test
            if (!skipTest) {
                int rc = new CommandLine(new Test()).execute();
                if (rc != 0) return rc;
            }

            System.out.println("Deploying to " + env);
            return run("infisical run -- ./scripts/deploy.sh " + env);
        }
    }

    @Command(name = "integration-test", description = "Run integration tests for a service")
    static class IntegrationTest implements Callable<Integer> {

        @Option(names = {"-s", "--service"}, required = true, description = "Service name")
        String service;

        @Override
        public Integer call() throws Exception {
            System.out.println("Running integration tests for " + service);
            return run(gradle() + " :" + service + ":integrationTest");
        }
    }

    @Command(name = "dev", description = "Start local development environment")
    static class Dev implements Callable<Integer> {
        @Override
        public Integer call() throws Exception {
            // Dependency: clean
            int rc = new CommandLine(new Clean()).execute();
            if (rc != 0) return rc;

            int rc1 = run("docker-compose up -d postgres redis");
            if (rc1 != 0) return rc1;
            return run(gradle() + " bootRun");
        }
    }

    @Command(name = "default", description = "List all available tasks")
    static class ListTasks implements Callable<Integer> {
        @Override
        public Integer call() {
            new CommandLine(new TaskApp()).usage(System.out);
            return 0;
        }
    }
}

Pros:

  • Pure Java: Leverage existing Java knowledge and skills

  • No build tool required: JBang handles compilation and dependency management

  • Dependency management: Declare dependencies inline with //DEPS

  • CLI frameworks: Integrate with Picocli for sophisticated command-line interfaces

  • Type safety: Compile-time checking and IDE refactoring support

  • IDE support: Full autocomplete and debugging capabilities

  • Cross-platform: JVM ensures consistent behaviour across operating systems

  • Access to ecosystem: Use any Java library

  • Debugging: Can attach a debugger to running scripts

  • Testing: Write unit tests for task logic

Cons:

  • Installation required: JBang needs to be installed separately

  • Verbosity: More code required compared to shell-based alternatives for simple tasks

  • Startup time: JVM startup adds 200-800ms overhead (cached after first run)

  • Learning curve: Requires familiarity with CLI frameworks like Picocli

  • More ceremony: Additional boilerplate for simple command execution

  • Heavyweight for simple tasks: Full programming language may be excessive for basic orchestration

  • No built-in file watching: Would need to implement manually if tracking file changes is needed

Gradle can act as a command entrypoint, especially in Java projects where it’s often used. The trade-off is that you’re running a build system to do non-build actions: you gain IDE support and incremental tracking, but you also pay configuration/startup overhead, and you risk turning the build into a grab-bag of operational workflows.

// build.gradle.kts
tasks.register("deploy") {
    description = "Deploy to environment"
    group = "deployment"

    dependsOn("test")

    // Track deploy script as input (rerun when it changes)
    inputs.file("./scripts/deploy.sh")

    doLast {
        val env = System.getenv("ENVIRONMENT") ?: "dev"
        println("Deploying to $env")
        exec {
            commandLine("./scripts/deploy.sh", env)
        }
    }
}

tasks.register("dev") {
    description = "Start local development environment"
    group = "development"

    dependsOn("clean")

    doLast {
        exec {
            commandLine("docker-compose", "up", "-d", "postgres", "redis")
        }
        exec {
            commandLine("./gradlew", "bootRun")
        }
    }
}

tasks.register("integration-test") {
    description = "Run integration tests for a service"

    doLast {
        val service = System.getenv("SERVICE")
            ?: throw GradleException("SERVICE environment variable is required")

        println("Running integration tests against $service")
        exec {
            commandLine("./gradlew", ":$service:integrationTest")
        }
    }
}

Pros:

  • IDE integration: Full support in IntelliJ, Eclipse, VS Code

  • Powerful: Full programming language (Kotlin/Groovy) available

  • Gradle daemon: Subsequent runs are significantly faster

  • File tracking: Can monitor input files and skip tasks when unchanged

Cons:

  • Startup time: Initial runs can be slow (1-3 seconds without daemon)

  • Build tool overhead: Full build system initialization for simple tasks

  • More verbose: Requires more code than dedicated command runners

  • Scope creep risk: Easy to add complexity beyond what’s needed

  • Different purpose: Designed for building software, not general task orchestration


JShell is Java’s REPL (introduced in Java 9). It’s not a command runner, but it can be a surprisingly effective "zero extra tooling" option when you want to script with plain JDK APIs and iterate interactively.

// tasks.jsh
void clean() throws Exception {
    System.out.println("Cleaning build artifacts");
    var process = new ProcessBuilder("./gradlew", "clean").start();
    process.waitFor();
    var deleteProcess = new ProcessBuilder("rm", "-rf", "build/").start();
    deleteProcess.waitFor();
}

void test() throws Exception {
    System.out.println("Running tests");
    var process = new ProcessBuilder("./gradlew", "test").start();
    process.waitFor();
}

void deploy(String environment) throws Exception {
    System.out.println("Deploying to " + environment);

    // Run tests first
    test();

    var process = new ProcessBuilder("./scripts/deploy.sh", environment).start();
    process.waitFor();
}

void integrationTest(String service) throws Exception {
    System.out.println("Running integration tests against " + service);
    var process = new ProcessBuilder("./gradlew", ":" + service + ":integrationTest").start();
    process.waitFor();
}

void dev() throws Exception {
    System.out.println("Start local development environment");

    // Clean first
    clean();

    // Start docker-compose
    var dockerProcess = new ProcessBuilder("docker-compose", "up", "-d", "postgres", "redis").start();
    dockerProcess.waitFor();

    // Start the application
    var bootProcess = new ProcessBuilder("./gradlew", "bootRun").start();
    bootProcess.waitFor();
}

Usage:

jshell tasks.jsh
jshell> clean()
jshell> deploy("staging")

Pros:

  • ✅ Pure Java: Uses standard JDK APIs; no extra dependencies

  • ✅ Scriptable: Easy to tweak and run ad‑hoc commands interactively

  • ✅ Portable: Works anywhere a JDK is available (no extra tooling)

  • ✅ Immediate feedback: REPL makes iterating on tasks fast

Cons:

  • ❌ No task graph: No built‑in dependencies or incremental task tracking

  • ❌ No file inputs/outputs: Must implement change detection manually

  • ❌ Limited IDE support: Less tooling support than Gradle/Kotlin DSL

  • ❌ Manual invocation: Users must remember to call functions in the REPL

  • ❌ Not designed for automation: Primarily a REPL, not a command runner


As a software engineer, you’ll likely work with teams where members use different operating systems. Cross-platform compatibility and ease of set-up across different environments are important considerations when choosing a command runner.

Tool Cross-Platform Support

Make

❌ Requires additional setup on Windows (WSL, Cygwin, or GNU Make for Windows)

Just

✅ Single binary, works identically on all platforms

Task

✅ Excellent support; written in Go, compiles to single binary, handles shell differences gracefully

Mise

✅ Native Rust binary; handles Windows paths via shims or activation

JBang

✅ Java’s "write once, run anywhere" promise (requires JDK)

Gradle

✅ Cross-platform by design (requires JVM)

Winner: Task and Just tie here, with a slight edge to Task for its more robust shell handling.


Beyond technical capabilities, the success of a command runner depends on how easily your team can adopt and maintain it. Consider the learning curve for new team members, the installation overhead, and the long-term maintainability of your task definitions.

Make:

  • ✅ Widely known across the industry

  • ✅ No installation needed on Unix systems

  • ❌ Tab sensitivity may cause errors, especially when initially starting

  • ❌ Syntax can be challenging for newcomers

Just:

  • ✅ Easy to learn in 10 minutes

  • ✅ Self-documenting with --list

  • ❌ Requires installation and buy-in

  • ❌ Less familiar to teams coming from traditional tooling

Task:

  • ✅ YAML is familiar to most developers

  • ✅ Good documentation and examples

  • ❌ YAML can become verbose for simple tasks

  • ❌ Additional tool to install and maintain

Mise:

  • ✅ High Return on Investment: Replaces multiple tool managers like nvm, pyenv, and sdkman

  • ✅ Performance: Near-zero overhead as it modifies PATH directly rather than using slow shims

  • ❌ Complexity: The hierarchical merging of configurations (global vs. local) requires a clear team strategy

  • ❌ Windows support: While much improved in 2026, it still requires more set-up than a simple binary like Just

JBang:

  • ✅ Leverages existing language knowledge (Java)

  • ✅ Can write tests for complex task logic

  • ❌ Higher barrier to entry than shell-based runners

  • ❌ Requires understanding of CLI frameworks for best results

Gradle:

  • ✅ No additional installation if already using Gradle

  • ✅ Many teams are already familiar with the syntax and tooling

  • ❌ Tasks mixed with build configuration can reduce clarity

  • ❌ May encourage over-engineering simple operations

To quickly recapture the respective strengths and weaknesses of each tool:

Tool Advantages Disadvantages

Make

Widely available on Unix systems, widely known, simple for basic tasks, file dependency tracking for builds

Tab sensitivity, syntax learning curve, limited Windows support, error messages can be difficult for newcomers, environment variable-based parameters

Just

Modern clean syntax, function-style parameters, clear error messages, fast, cross-platform, easy to learn, self-documenting

Requires installation, no file dependency tracking, smaller community than Make, additional project dependency

Task

YAML-based, file watching and status checks, cross-platform shell handling, file timestamp tracking, well-documented, powerful features

YAML verbosity for simple tasks, installation required, YAML formatting sensitivity, may be feature-rich beyond simple needs

Mise

Unified tool/env/task manager, no shim lag, asdf-compatible, automatically installs required runtimes before task execution

Hierarchical configuration can be confusing, newer command ecosystem than Make & Just, and some features remain experimental

JBang

Pure Java, type safety, full ecosystem access, testable, debuggable, no build tool ceremony, cross-platform via JVM

JVM startup overhead, more verbose for simple tasks, installation required, CLI framework knowledge needed, heavyweight for basic orchestration

Gradle

Already present in many Java projects, full IDE support, powerful programming language, the daemon speeds up subsequent runs, and file tracking capabilities

Slow cold start, build tool overhead for simple tasks, scope creep risk, more verbose than dedicated command runners, designed for building, not orchestration


The best command runner depends on your project’s complexity, team composition, and specific needs. Rather than a one-size-fits-all answer, consider these practical guidelines that balance simplicity, power, and team productivity.

Use Just. It is simple, fast, and readable, with a low learning curve. Most teams can adopt it quickly, and the syntax remains approachable over time. A well‑maintained justfile reduces onboarding friction, which is especially valuable for small projects.

Standardise on Mise. It is the only tool that solves the "it works on my machine" problem by managing the runtimes (JDK, Node, Python) alongside the tasks in a single mise.toml. It effectively replaces the "mess" of asdf, direnv, and scattered shell scripts.

Use Task (Taskfile). You’ll appreciate:

  • File watching (task --watch)

  • Include files (share tasks across repos)

  • Status checks (skip unnecessary work)

  • Better variable handling for complex scenarios

Add JBang. Use it for tasks that require:

  • Database operations with JDBC

  • API integrations

  • Data transformation

  • Complex validation or business logic

  • Anything that benefits from type safety and testing

Use your build tool’s tasks. Why add another tool if your build tool can handle it? But be disciplined, don’t over-engineer.

  • Legacy projects where it’s already working

  • Teams that live in the terminal and love Unix tools

  • When you truly need file dependency tracking (though consider your build tool for this)

The rise of Just, Task, and JBang represents a recognition that build tools and command runners serve different purposes. Make was brilliant for its time, but it was designed as a C build tool in 1976 and later repurposed for general command running. Modern development workflows already leverage build tools (Maven, Gradle, Cargo, dotnet CLI). What we need is a lightweight, readable way to orchestrate common development tasks.

For most teams in 2026, Just hits the sweet spot: modern enough to feel good, simple enough to adopt quickly, and powerful enough to handle real-world scenarios. For teams needing more advanced features, Task provides them without the complexity of a full build system. And for tasks requiring complex logic, JBang (or equivalent in your language) lets you stay in your ecosystem with full type safety and tooling support.

Make remains relevant for its universality and file dependency tracking, but unless you’re maintaining legacy projects or working in environments where you can’t install tools, the modern alternatives offer a better developer experience.

Choose the right tool for the job.

  • Use Build tools (Gradle/Maven/Cargo/dotnet) for compilation and dependency management

  • Use Just/Task for simple task orchestration

  • Use JBang (or equivalent in your language) for tasks that need programming logic

  • Don’t try to make one tool do everything

Your justfile or Taskfile.yml should be the friendly entry point to your project; it’s the place where a new team member runs just --list or task --list and immediately sees how to build, test, and run the application. That clarity is worth far more than shaving 50ms off execution time or avoiding a simple tool installation.


Whether you’re an enthusiastic early adopter or a cautious sceptic, AI-assisted development is reshaping how teams interact with their codebases. This isn’t about choosing sides but rather about acknowledging reality. AI agents, coding assistants, and LLM-powered tools are already in use across development teams, and that usage will only increase. Our responsibility is to design systems that work well for both humans and the tools assisting them, without compromising the clarity and maintainability that make projects sustainable.

The good news: making your command runner "AI-ready" doesn’t require special AI-specific tooling or complexity. It means applying the same principles that have always made good software:

  • clear naming

  • structured metadata

  • discoverable interfaces

  • sensible security boundaries

A well-designed task file serves human developers and AI agents equally well.

Command runners empower both human developers and AI agents. Just as we learned that good API design makes integration easier, task files designed with machine discoverability in mind reduce friction for both agentic workflows and human teams. Rather than increasing complexity, it underscores the importance of always being deliberate about structure, naming, and metadata.

The Model Context Protocol is emerging as a standard interface connecting LLMs to local tools and data. Rather than forcing agents to parse raw shell scripts, which bloats context windows and invites syntax errors, certain MCP adapters, such as just-mcp and gotask-mcp, expose your recipes as structured, callable tools. Support is still uneven across ecosystems, so treat MCP integration as optional rather than foundational.

What this means practically:

  • Token efficiency: Agents interact with task names and schemas rather than reading entire script implementations

  • Structured discovery: Tools like just-mcp allow agents to query available commands (/just:list-recipes) without manual parsing

  • Bidirectional capabilities: Modern MCP servers support "sampling," where the task runner itself can query the LLM mid-execution, such as asking "Does this migration look safe?" before applying database changes

For example, the following # AI Hint signals to agents that tasks are discoverable via just --list:

# justfile
# AI Hint: This file is Model Context Protocol (MCP) optimised.
# AI agents should use `just --list` to discover available tools.

This approach keeps tasks human-readable while making them machine-discoverable, thus no separate "AI documentation" is needed.

As coding assistants like Cursor and GitHub Copilot become standard, repositories increasingly include llms.txt at the root. This is a lightweight "sitemap for AI" that directs agents to canonical documentation and entry points.

  • llms.txt: A concise Markdown file summarising the project structure and primary commands

  • llms-full.txt: A comprehensive export containing full documentation for deep context ingestion and retrieval-augmented generation (RAG)

Tools like Task have integrated VitePress plugins to auto-generate these files, ensuring AI context stays synchronised with actual implementation. This helps our task runner become the source of truth for both humans and agents.

The rise of AI-assisted development, where applications are built and modified through natural language prompts, most certainly introduces new security challenges. When agents execute tasks autonomously, proper isolation and access controls become mandatory, not optional.

Key guardrails for AI execution:

I’ve included some key guardrails to keep in mind when writing your files to help ensure safety.

Sandbox Isolation: Autonomous agents should never run with direct host access. For production-grade isolation, we can use micro-VMs like Firecracker or gVisor. For local development, lightweight jails like Bubblewrap restrict filesystem and network access:

# Example Bubblewrap jail for an AI agent (Linux)
bwrap --ro-bind /usr /usr \
      --ro-bind /lib /lib \
      --ro-bind /lib64 /lib64 \
      --dir /tmp \
      --unshare-all \
      --share-net \
      --bind $(pwd) /workspace \
      --chdir /workspace \
      ./agent-binary

Deterministic Contracts: Define "safe outputs" where agents can only produce specific artifacts (e.g., opening a pull request) while being blocked from high-risk actions like pushing directly to main:

# Example Safe-Outputs Policy (safe-outputs.yaml)
version: "2026.1"
agent_permissions:
  scope: "read-only"
  allowed_artifacts:
    - type: "pull_request"
      target_branch: "ai-proposals"
    - type: "issue_comment"
  blocked_actions:
    - "git_push_main"
    - "filesystem_write_global"

Identity-Aware Execution: Treat agents as distinct identities with least-privilege API keys. Link every action to a specific human operator for audit trails:

# Identity-aware execution via CLI wrapper
export AGENT_ID="codex-alpha-01"
export HUMAN_OPERATOR="simon.v"

# Link agent action to human orchestrator for audit trail
infisical run --env AGENT_SECRET -- \
  just deploy --identity-header "X-Agent: $AGENT_ID; X-Operator: $HUMAN_OPERATOR"

The bottom line: Make AI assistance productive while maintaining the safety boundaries required for production systems. Your task runner becomes the control plane for both human and agentic workflows: discoverable, auditable, and secure by design. Whether AI agents are executing tasks today or five years from now, the principles of good design remain the same: clarity, structure, and appropriate constraints.


Mise includes first‑class support for encrypted env files via SOPS + AGE, giving you a secure alternative to plain‑text .env files without adding workflow friction.

  • SOPS (Secrets OPerationS) is Mozilla’s encryption tool for structured config files. It lets you keep JSON, YAML, or TOML files encrypted in place while still being diffable and version‑controlled.

  • AGE is a modern, minimalist encryption tool designed as a simpler, safer alternative to GPG. It provides the key format that is used by SOPS for encryption and decryption.

With Mise, you can store encrypted .env.json, .env.yaml, or .env.toml files directly in the repo. Mise decrypts them automatically when tasks run, provided the correct AGE private key is available (via MISE_SOPS_AGE_KEY or a local key file).

The result is security without friction: developers get secrets injected automatically, and workflows stay fast. Mise uses a high‑performance decryptor by default (via rops), so secret loading feels instant in day‑to‑day work. You can also enable log redaction (redact = true in the env._.file config) to avoid accidental leakage in CI or shared terminals, which is an underrated but critical safety net for modern teams.


For file‑based secrets, SOPS is enough. But in enterprise environments, we often need to integrate with centralised vaults and cloud identity providers. That’s where fnox (Fort Knox) comes in: a separate CLI designed by the creator of Mise to manage secrets across providers like 1Password, Bitwarden, HashiCorp Vault, AWS Secrets Manager, and Azure Key Vault.

Mise and fnox are intentionally separate:

  • Mise focuses on fast local environment switching with aggressive caching

  • fnox handles real‑time, secure access to remote secret providers

This separation matters: it avoids the performance hit you’d get if every cd triggered network calls to a vault.


  • Identity‑aware decryption: Encrypt secrets for multiple recipients so each developer can use their own SSH or AGE key.

  • Unified interface: Run any task with secrets injected via fnox exec — <command>.

  • Environment profiles: Define separate secret sets for dev, staging, and prod in a version‑controlled fnox.toml without exposing the secrets themselves.

# Example fnox.toml for 1Password integration
[providers.op]
type = "onepassword"
vault = "Engineering"

[secrets]
DB_PASSWORD = { provider = "op", item = "Postgres", field = "password" }
STRIPE_KEY  = { provider = "op", item = "Stripe", field = "credential" }

This article is accompanied by a practical demonstration repository containing working examples of all the command runners discussed:

  • command-entrypoints - Working examples of Make, Just, Task, JBang, and Gradle command runners

About

No description, website, or topics provided.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published