diff --git a/README.md b/README.md
index a40b9edb0d..7ac14b3b3f 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
 [![Gradle Plugin](https://img.shields.io/gradle-plugin-portal/v/com.diffplug.spotless?color=blue&label=gradle%20plugin)](plugin-gradle)
 [![Maven Plugin](https://img.shields.io/maven-central/v/com.diffplug.spotless/spotless-maven-plugin?color=blue&label=maven%20plugin)](plugin-maven)
 [![SBT Plugin](https://img.shields.io/badge/sbt%20plugin-0.1.3-blue)](https://github.com/moznion/sbt-spotless)
+[![CLI](https://img.shields.io/badge/cli-0.0.1-blue)](cli)
 
 Spotless can format <antlr | c | c# | c++ | css | flow | graphql | groovy | html | java | javascript | json | jsx | kotlin | less | license headers | markdown | objective-c | protobuf | python | scala | scss | shell | sql | typeScript | vue | yaml | anything> using <gradle | maven | sbt | anything>.
 
@@ -41,6 +42,13 @@ user@machine repo % mvn spotless:check
 ```
 
 ## [❇️ Spotless for SBT (external for now)](https://github.com/moznion/sbt-spotless)
+
+## [❇️ Spotless Command Line Interface (CLI)](cli)
+
+```console
+user@machine repo % spotless --target '**/src/**/*.java' license-header --header='/* Myself $YEAR */' google-java-format
+```
+
 ## [Other build systems](CONTRIBUTING.md#how-to-add-a-new-plugin-for-a-build-system)
 
 ## How it works (for potential contributors)
diff --git a/cli/CHANGES.md b/cli/CHANGES.md
new file mode 100644
index 0000000000..2eb62859e9
--- /dev/null
+++ b/cli/CHANGES.md
@@ -0,0 +1,8 @@
+# spotless-cli releases
+
+We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format
+
+## [Unreleased]
+
+## [0.0.1] - 2024-11-06
+Anchor version number.
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000000..12d0f2fb5d
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,25 @@
+# <img align="left" src="../_images/spotless_logo.png"> Spotless Command Line Interface CLI
+*Keep your code Spotless with Gradle*
+
+<!---freshmark shields
+output = [
+  link(shield('Changelog', 'changelog', '{{versionLast}}', 'blue'), 'CHANGES.md'),
+  '',
+  link(shield('OS Win', 'OS', 'Windows', 'blueviolet'), 'README.md'),
+  link(shield('OS Linux', 'OS', 'Linux', 'blueviolet'), 'README.md'),
+  link(shield('OS macOS', 'OS', 'macOS', 'blueviolet'), 'README.md'),
+  ].join('\n');
+-->
+[![Changelog](https://img.shields.io/badge/changelog-0.0.1-blue.svg)](CHANGES.md)
+
+[![OS Win](https://img.shields.io/badge/OS-Windows-blueviolet.svg)](README.md)
+[![OS Linux](https://img.shields.io/badge/OS-Linux-blueviolet.svg)](README.md)
+[![OS macOS](https://img.shields.io/badge/OS-macOS-blueviolet.svg)](README.md)
+<!---freshmark /shields -->
+
+`spotless` is a command line interface (CLI) for the [spotless code formatter](../README.md).
+It intends to be a simple alternative to its siblings: the plugins for [gradle](../plugin-gradle/README.md), [maven](../plugin-maven/README.md)
+and others. 
+
+- TODO: add usage and examples
+- TBD: can usage be generated automatically e.g. via freshmark?
diff --git a/cli/build.gradle b/cli/build.gradle
new file mode 100644
index 0000000000..4135d56481
--- /dev/null
+++ b/cli/build.gradle
@@ -0,0 +1,193 @@
+plugins {
+	id 'org.graalvm.buildtools.native'
+	id 'com.gradleup.shadow'
+}
+apply from: rootProject.file('gradle/changelog.gradle')
+ext.artifactId = project.artifactIdGradle
+version = spotlessChangelog.versionNext
+apply plugin: 'java-library'
+apply plugin: 'application'
+apply from: rootProject.file('gradle/java-setup.gradle')
+apply from: rootProject.file('gradle/spotless-freshmark.gradle')
+
+dependencies {
+	// todo, unify with plugin-gradle/build.gradle -- BEGIN
+	if (version.endsWith('-SNAPSHOT') || (rootProject.spotlessChangelog.versionNext == rootProject.spotlessChangelog.versionLast)) {
+		api projects.lib
+		api projects.libExtra
+	} else {
+		api "com.diffplug.spotless:spotless-lib:${rootProject.spotlessChangelog.versionLast}"
+		api "com.diffplug.spotless:spotless-lib-extra:${rootProject.spotlessChangelog.versionLast}"
+	}
+	implementation "com.diffplug.durian:durian-core:${VER_DURIAN}"
+	implementation "com.diffplug.durian:durian-io:${VER_DURIAN}"
+	implementation "com.diffplug.durian:durian-collect:${VER_DURIAN}"
+	implementation "org.eclipse.jgit:org.eclipse.jgit:${VER_JGIT}"
+
+	testImplementation projects.testlib
+	testImplementation "org.junit.jupiter:junit-jupiter:${VER_JUNIT}"
+	testImplementation "org.assertj:assertj-core:${VER_ASSERTJ}"
+	testImplementation "com.diffplug.durian:durian-testlib:${VER_DURIAN}"
+	testImplementation 'org.owasp.encoder:encoder:1.3.1'
+	testRuntimeOnly "org.junit.platform:junit-platform-launcher"
+	// todo, unify with plugin-gradle/build.gradle -- END
+
+	implementation "info.picocli:picocli:${VER_PICOCLI}"
+	annotationProcessor "info.picocli:picocli-codegen:${VER_PICOCLI}"
+}
+
+dependencies {
+	[
+		'com.google.googlejavaformat:google-java-format:1.24.0'
+	].each {
+		implementation it
+	}
+}
+
+apply from: rootProject.file('gradle/special-tests.gradle')
+tasks.withType(Test).configureEach {
+	testLogging.showStandardStreams = true
+}
+
+compileJava {
+	// options for picocli codegen
+	// https://github.com/remkop/picocli/tree/main/picocli-codegen#222-other-options
+	options.compilerArgs += [
+		"-Aproject=${project.group}/${project.name}",
+		"-Aother.resource.bundles=application",
+		// patterns require double-escaping (one escape is removed by groovy, the other one is needed in the resulting json file)
+		"-Aother.resource.patterns=.*\\\\.properties,.*\\\\.json,.*\\\\.js"
+	]
+}
+
+tasks.withType(org.graalvm.buildtools.gradle.tasks.GenerateResourcesConfigFile).configureEach {
+	notCompatibleWithConfigurationCache('https://github.com/britter/maven-plugin-development/issues/8')
+}
+tasks.withType(org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask).configureEach {
+	notCompatibleWithConfigurationCache('https://github.com/britter/maven-plugin-development/issues/8')
+}
+
+tasks.withType(ProcessResources).configureEach(new ApplicationPropertiesProcessResourcesAction(project.version))
+
+class ApplicationPropertiesProcessResourcesAction implements Action<ProcessResources> {
+
+	private final String cliVersion
+
+	ApplicationPropertiesProcessResourcesAction(String cliVersion) {
+		this.cliVersion = cliVersion
+	}
+
+	@Override
+	void execute(ProcessResources processResources) {
+		def localCliVersion = cliVersion // prevent issues with decorated closure
+		processResources.filesMatching("application.properties") {
+			filter(
+					org.apache.tools.ant.filters.ReplaceTokens,
+					tokens: [
+						'cli.version': localCliVersion
+					]
+					)
+		}
+	}
+}
+
+application {
+	mainClass = 'com.diffplug.spotless.cli.SpotlessCLI'
+	applicationName = 'spotless'
+	archivesBaseName = 'spotless-cli'
+}
+
+
+def nativeCompileMetaDir = project.layout.buildDirectory.dir('nativeCompile/src/main/resources/native-image/' + project.group + '/' + project.name)
+
+// use tasks 'nativeCompile' and 'nativeRun' to compile and run the native image
+graalvmNative {
+	agent {
+		enabled = !project.hasProperty('skipGraalAgent') // we would love to make this dynamic, but it's not possible
+		defaultMode = "standard"
+		metadataCopy {
+			inputTaskNames.add('test')
+			inputTaskNames.add('testNpm')
+			mergeWithExisting = false
+			outputDirectories.add(nativeCompileMetaDir.get().asFile.path)
+		}
+		tasksToInstrumentPredicate = new java.util.function.Predicate<Task>() {
+					@Override
+					boolean test(Task task) {
+						//						if (project.hasProperty('agent')) {
+						println ("Instrumenting task: " + task.name + " " + task.name == 'test' + "proj: " + task.project.hasProperty('agent'))
+						return task.name == 'test' || task.name == 'testNpm'
+						//						}
+						//						return false
+					}
+				}
+	}
+	binaries {
+		main {
+			imageName = 'spotless'
+			mainClass = 'com.diffplug.spotless.cli.SpotlessCLI'
+			sharedLibrary = false
+			useFatJar = true // use shadowJar as input to have same classpath
+
+			// optimizations, see https://www.graalvm.org/latest/reference-manual/native-image/optimizations-and-performance/
+			//buildArgs.add('-O3') // on production builds
+
+			// the following options are required for GJF
+			// see: <https://github.com/google/google-java-format/issues/894#issuecomment-1430408909>
+			buildArgs.add('-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED')
+			buildArgs.add('-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED')
+			buildArgs.add('-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED')
+			buildArgs.add('-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED')
+			buildArgs.add('-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED')
+
+			buildArgs.add('--initialize-at-build-time=com.sun.tools.javac.file.Locations')
+
+			buildArgs.add('-H:IncludeResourceBundles=com.sun.tools.javac.resources.compiler')
+			buildArgs.add('-H:IncludeResourceBundles=com.sun.tools.javac.resources.javac')
+		}
+	}
+}
+
+
+tasks.named('metadataCopy') {
+	dependsOn('test', 'testNpm')
+}
+
+tasks.named('nativeCompile') {
+	dependsOn('shadowJar')
+	classpathJar = tasks.shadowJar.archiveFile.get().asFile
+}
+
+
+tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) {
+	dependsOn('metadataCopy') // produces graalvm agent info
+	from(nativeCompileMetaDir.get().asFile.path) {
+		into('META-INF/native-image/' + project.group + '/' + project.name)
+	}
+}
+
+gradle.taskGraph.whenReady { TaskExecutionGraph graph ->
+	//	println "Graph: " + graph.allTasks*.name
+	if (graph.hasTask(':cli:nativeCompile') || graph.hasTask(':cli:metadataCopy') || graph.hasTask(':cli:shadowJar')) {
+		// enable graalvm agent using property here instead of command line `-Pagent=standard`
+		// this collects information about reflective access and resources used by the application (e.g. GJF)
+		project.ext.agent = 'standard'
+	}
+}
+
+tasks.withType(Test).configureEach {
+	if (it.name == 'test' || it.name == 'testNpm') {
+		it.outputs.dir(nativeCompileMetaDir)
+		if (project.hasProperty('agent')) {
+			it.inputs.property('agent', project.property('agent')) // make sure to re-run tests if agent changes
+		}
+	}
+	if (it.name == 'testCliProcess' || it.name == 'testCliProcessNpm') {
+		it.dependsOn('shadowJar')
+		it.systemProperty 'spotless.cli.shadowJar', tasks.shadowJar.archiveFile.get().asFile
+	}
+	if (it.name == 'testCliNative' || it.name == 'testCliNativeNpm') {
+		it.dependsOn('nativeCompile')
+		it.systemProperty 'spotless.cli.nativeImage', tasks.nativeCompile.outputFile.get().asFile
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/SpotlessAction.java b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessAction.java
new file mode 100644
index 0000000000..4216fd98bd
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessAction.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.FormatterStep;
+
+public interface SpotlessAction extends SpotlessCommand {
+	Integer executeSpotlessAction(@Nonnull List<FormatterStep> formatterSteps);
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/SpotlessActionContextProvider.java b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessActionContextProvider.java
new file mode 100644
index 0000000000..974cb9dd12
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessActionContextProvider.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.cli.core.SpotlessCommandLineStream;
+
+public interface SpotlessActionContextProvider {
+
+	SpotlessActionContext spotlessActionContext(SpotlessCommandLineStream commandLineStream);
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java
new file mode 100644
index 0000000000..80e551711b
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCLI.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.nio.charset.Charset;
+import java.nio.file.Path;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.Formatter;
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.LineEnding;
+import com.diffplug.spotless.LintState;
+import com.diffplug.spotless.ThrowingEx;
+import com.diffplug.spotless.cli.core.FileResolver;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.cli.core.SpotlessCommandLineStream;
+import com.diffplug.spotless.cli.core.TargetFileTypeInferer;
+import com.diffplug.spotless.cli.core.TargetResolver;
+import com.diffplug.spotless.cli.execution.SpotlessExecutionStrategy;
+import com.diffplug.spotless.cli.help.OptionConstants;
+import com.diffplug.spotless.cli.steps.GoogleJavaFormat;
+import com.diffplug.spotless.cli.steps.LicenseHeader;
+import com.diffplug.spotless.cli.steps.Prettier;
+import com.diffplug.spotless.cli.version.SpotlessCLIVersionProvider;
+
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "spotless", mixinStandardHelpOptions = true, versionProvider = SpotlessCLIVersionProvider.class, description = "Runs spotless", synopsisSubcommandLabel = "[FORMATTING_STEPS]", commandListHeading = "%nAvailable formatting steps:%n", subcommandsRepeatable = true, subcommands = {
+		LicenseHeader.class,
+		GoogleJavaFormat.class,
+		Prettier.class})
+public class SpotlessCLI implements SpotlessAction, SpotlessCommand, SpotlessActionContextProvider {
+
+	@CommandLine.Spec
+	CommandLine.Model.CommandSpec spec; // injected by picocli
+
+	@CommandLine.Option(names = {"--mode", "-m"}, defaultValue = "APPLY", description = "The mode to run spotless in." + OptionConstants.VALID_AND_DEFAULT_VALUES_SUFFIX)
+	SpotlessMode spotlessMode;
+
+	@CommandLine.Option(names = {"--basedir"}, hidden = true, description = "The base directory to run spotless in. Intended for testing purposes only.")
+	Path baseDir;
+
+	@CommandLine.Option(names = {"--target", "-t"}, description = "The target files to format.")
+	public List<String> targets;
+
+	@CommandLine.Option(names = {"--encoding", "-e"}, defaultValue = "UTF-8", description = "The encoding of the files to format." + OptionConstants.DEFAULT_VALUE_SUFFIX)
+	public Charset encoding;
+
+	@CommandLine.Option(names = {"--line-ending", "-l"}, defaultValue = "UNIX", description = "The line ending of the files to format." + OptionConstants.VALID_AND_DEFAULT_VALUES_SUFFIX)
+	public LineEnding lineEnding;
+
+	@Override
+	public Integer executeSpotlessAction(@Nonnull List<FormatterStep> formatterSteps) {
+		validateTargets();
+		TargetResolver targetResolver = targetResolver();
+
+		try (Formatter formatter = Formatter.builder()
+				.lineEndingsPolicy(lineEnding.createPolicy())
+				.encoding(encoding)
+				.steps(formatterSteps)
+				.build()) {
+
+			ResultType resultType = targetResolver.resolveTargets()
+					.parallel()
+					.peek(path -> System.out.printf("%s: formatting %s%n", Thread.currentThread().getName(), path))
+					.map(path -> ThrowingEx.get(() -> new Result(path, LintState.of(formatter, path.toFile())))) // TODO handle suppressions, see SpotlessTaskImpl
+					.map(result -> this.handleResult(formatter, result))
+					.reduce(ResultType.CLEAN, ResultType::combineWith);
+			return spotlessMode.translateResultTypeToExitCode(resultType);
+		}
+	}
+
+	private void validateTargets() {
+		if (targets == null || targets.isEmpty()) { // cannot use `required = true` because of the subcommands
+			throw new CommandLine.ParameterException(spec.commandLine(), "Error: Missing required argument (specify one of these): (--target=<targets> | -t)");
+		}
+	}
+
+	private ResultType handleResult(Formatter formatter, Result result) {
+		if (result.lintState.isClean()) {
+			//			System.out.println("File is clean: " + result.target.toFile().getName());
+			return ResultType.CLEAN;
+		}
+		if (result.lintState.getDirtyState().didNotConverge()) {
+			System.err.println("File did not converge: " + result.target.toFile().getName()); // TODO: where to print the output to?
+			return ResultType.DID_NOT_CONVERGE;
+		}
+		return this.spotlessMode.handleResult(formatter, result);
+	}
+
+	private TargetResolver targetResolver() {
+		return new TargetResolver(baseDir(), targets);
+	}
+
+	private Path baseDir() {
+		return baseDir == null ? Path.of(System.getProperty("user.dir")) : baseDir;
+	}
+
+	@Override
+	public SpotlessActionContext spotlessActionContext(SpotlessCommandLineStream commandLineStream) {
+		validateTargets();
+		TargetResolver targetResolver = targetResolver();
+		TargetFileTypeInferer targetFileTypeInferer = new TargetFileTypeInferer(targetResolver);
+		return SpotlessActionContext.builder()
+				.targetFileType(targetFileTypeInferer.inferTargetFileType())
+				.fileResolver(new FileResolver(baseDir()))
+				.commandLineStream(commandLineStream)
+				.build();
+	}
+
+	public static void main(String... args) {
+		if (args.length == 0) {
+			//		args = new String[]{"--version"};
+			//					args = new String[]{"license-header", "--header-file", "CHANGES.md", "--delimiter-for", "java", "license-header", "--header", "abc"};
+
+			//			args = new String[]{"--mode=CHECK", "--target", "src/poc/java/**/*.java", "--encoding=UTF-8", "license-header", "--header", "abc", "license-header", "--header-file", "TestHeader.txt"};
+			args = new String[]{"--basedir", "cli", "--target", "src/poc/java/**/*.java", "--encoding=UTF-8", "google-java-format"};
+			//			args = new String[]{"--version"};
+		}
+		int exitCode = createCommandLine(createInstance())
+				.execute(args);
+		System.exit(exitCode);
+	}
+
+	static SpotlessCLI createInstance() {
+		return new SpotlessCLI();
+	}
+
+	static CommandLine createCommandLine(SpotlessCLI spotlessCLI) {
+		return new CommandLine(spotlessCLI)
+				.setExecutionStrategy(new SpotlessExecutionStrategy())
+				.setCaseInsensitiveEnumValuesAllowed(true);
+	}
+
+	private enum SpotlessMode {
+		CHECK {
+			@Override
+			ResultType handleResult(Formatter formatter, Result result) {
+				if (result.lintState.isHasLints()) {
+					result.lintState.asStringOneLine(result.target.toFile(), formatter);
+				} else {
+					System.out.println(String.format("%s is violating formatting rules.", result.target));
+				}
+				return ResultType.DIRTY;
+			}
+
+			@Override
+			Integer translateResultTypeToExitCode(ResultType resultType) {
+				if (resultType == ResultType.CLEAN) {
+					return 0;
+				}
+				if (resultType == ResultType.DIRTY) {
+					return 1;
+				}
+				if (resultType == ResultType.DID_NOT_CONVERGE) {
+					return -1;
+				}
+				throw new IllegalStateException("Unexpected result type: " + resultType);
+			}
+		},
+		APPLY {
+			@Override
+			ResultType handleResult(Formatter formatter, Result result) {
+				if (result.lintState.isHasLints()) {
+					// something went wrong, we should not apply the changes
+					System.err.println("File has lints: " + result.target.toFile().getName());
+					System.err.println("lint:\n" + result.lintState.asStringDetailed(result.target.toFile(), formatter));
+					return ResultType.DIRTY;
+				}
+				ThrowingEx.run(() -> result.lintState.getDirtyState().writeCanonicalTo(result.target.toFile()));
+				return ResultType.CLEAN;
+			}
+
+			@Override
+			Integer translateResultTypeToExitCode(ResultType resultType) {
+				if (resultType == ResultType.CLEAN) {
+					return 0;
+				}
+				if (resultType == ResultType.DIRTY) {
+					return 0;
+				}
+				if (resultType == ResultType.DID_NOT_CONVERGE) {
+					return -1;
+				}
+				throw new IllegalStateException("Unexpected result type: " + resultType);
+			}
+		};
+
+		abstract ResultType handleResult(Formatter formatter, Result result);
+
+		abstract Integer translateResultTypeToExitCode(ResultType resultType);
+
+	}
+
+	private enum ResultType {
+		CLEAN, DIRTY, DID_NOT_CONVERGE;
+
+		ResultType combineWith(ResultType other) {
+			if (this == other) {
+				return this;
+			}
+			if (this == DID_NOT_CONVERGE || other == DID_NOT_CONVERGE) {
+				return DID_NOT_CONVERGE;
+			}
+			if (this == DIRTY || other == DIRTY) {
+				return DIRTY;
+			}
+			throw new IllegalStateException("Unexpected combination of result types: " + this + " and " + other);
+		}
+	}
+
+	private static final class Result {
+		private final Path target;
+		private final LintState lintState;
+
+		public Result(Path target, LintState lintState) {
+			this.target = target;
+			this.lintState = lintState;
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCommand.java b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCommand.java
new file mode 100644
index 0000000000..603bdb0df3
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/SpotlessCommand.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+public interface SpotlessCommand {}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/ChecksumCalculator.java b/cli/src/main/java/com/diffplug/spotless/cli/core/ChecksumCalculator.java
new file mode 100644
index 0000000000..652b277201
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/ChecksumCalculator.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import com.diffplug.common.hash.Hashing;
+import com.diffplug.spotless.ThrowingEx;
+import com.diffplug.spotless.cli.SpotlessAction;
+import com.diffplug.spotless.cli.steps.SpotlessCLIFormatterStep;
+
+import picocli.CommandLine;
+
+public class ChecksumCalculator {
+
+	public String calculateChecksum(SpotlessCLIFormatterStep step) {
+		try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+			writeObjectDataTo(step, out);
+			return toHashedHexBytes(out.toByteArray());
+		} catch (Exception e) {
+			throw ThrowingEx.asRuntime(e);
+		}
+	}
+
+	private void writeObjectDataTo(Object object, OutputStream outputStream) {
+		ThrowingEx.run(() -> outputStream.write(object.getClass().getName().getBytes(StandardCharsets.UTF_8)));
+		options(object)
+				.map(Object::toString)
+				.map(str -> str.getBytes(StandardCharsets.UTF_8))
+				.forEachOrdered(bytes -> ThrowingEx.run(() -> outputStream.write(bytes)));
+
+	}
+
+	public String calculateChecksum(SpotlessCommandLineStream commandLineStream) {
+		try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+
+			calculateChecksumOfActions(commandLineStream.actions(), out);
+
+			calculateChecksumOfSteps(commandLineStream.formatterSteps(), out);
+			return toHashedHexBytes(out.toByteArray());
+		} catch (Exception e) {
+			throw ThrowingEx.asRuntime(e);
+		}
+	}
+
+	private void calculateChecksumOfSteps(Stream<SpotlessCLIFormatterStep> spotlessCLIFormatterStepStream, ByteArrayOutputStream out) {
+		spotlessCLIFormatterStepStream.forEachOrdered(step -> writeObjectDataTo(step, out));
+	}
+
+	private void calculateChecksumOfActions(Stream<SpotlessAction> actions, ByteArrayOutputStream out) {
+		actions.forEachOrdered(action -> writeObjectDataTo(action, out));
+	}
+
+	private static Stream<Object> options(Object step) {
+		List<Class<?>> classHierarchy = classHierarchy(step);
+		return classHierarchy.stream()
+				.flatMap(clazz -> Arrays.stream(clazz.getDeclaredFields()))
+				.flatMap(field -> expandOptionField(field, step))
+				.map(FieldOnObject::getValue)
+				.filter(Objects::nonNull);
+	}
+
+	private static List<Class<?>> classHierarchy(Object obj) {
+		List<Class<?>> hierarchy = new ArrayList<>();
+		Class<?> clazz = obj.getClass();
+		while (clazz != null) {
+			hierarchy.add(clazz);
+			clazz = clazz.getSuperclass();
+		}
+		return hierarchy;
+	}
+
+	private static Stream<FieldOnObject> expandOptionField(Field field, Object obj) {
+		if (field.isAnnotationPresent(CommandLine.Option.class) || field.isAnnotationPresent(CommandLine.Parameters.class)) {
+			return Stream.of(new FieldOnObject(field, obj));
+		}
+		if (field.isAnnotationPresent(CommandLine.ArgGroup.class)) {
+			Object fieldValue = new FieldOnObject(field, obj).getValue();
+			return Arrays.stream(fieldValue.getClass().getDeclaredFields())
+					.flatMap(subField -> expandOptionField(subField, fieldValue));
+		}
+		return Stream.empty(); // nothing to expand
+	}
+
+	private static String toHashedHexBytes(byte[] bytes) {
+		byte[] hash = Hashing.murmur3_128().hashBytes(bytes).asBytes();
+		StringBuilder builder = new StringBuilder();
+		for (byte b : hash) {
+			builder.append(String.format("%02x", b));
+		}
+		return builder.toString();
+	}
+
+	private static class FieldOnObject {
+		private final Field field;
+		private final Object obj;
+
+		FieldOnObject(Field field, Object obj) {
+			this.field = field;
+			this.obj = obj;
+		}
+
+		Object getValue() {
+			ThrowingEx.run(() -> field.setAccessible(true));
+			return ThrowingEx.get(() -> field.get(obj));
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/CliJarProvisioner.java b/cli/src/main/java/com/diffplug/spotless/cli/core/CliJarProvisioner.java
new file mode 100644
index 0000000000..413f586369
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/CliJarProvisioner.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+import com.diffplug.spotless.JarState;
+import com.diffplug.spotless.Provisioner;
+
+public class CliJarProvisioner implements Provisioner {
+
+	public static final CliJarProvisioner INSTANCE = new CliJarProvisioner();
+
+	public static final File OWN_JAR = createSentinelFile();
+
+	public CliJarProvisioner() {
+		JarState.setOverrideClassLoader(getClass().getClassLoader()); // use the classloader of this class
+		// TODO (simschla, 11.11.2024): THIS IS A HACK, replace with proper solution
+	}
+
+	private static File createSentinelFile() {
+		try {
+			File file = File.createTempFile("spotless-cli", ".jar");
+			Files.write(file.toPath(), List.of("@@@@PLACEHOLDER_FOR_OWN_JAR@@@@"), StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING);
+			file.deleteOnExit();
+			return file;
+		} catch (Exception e) {
+			throw new RuntimeException("Could not create sentinel file", e);
+		}
+	}
+
+	@Override
+	public Set<File> provisionWithTransitives(boolean withTransitives, Collection<String> mavenCoordinates) {
+		return Set.of(OWN_JAR);
+	}
+
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/ExecutionLayout.java b/cli/src/main/java/com/diffplug/spotless/cli/core/ExecutionLayout.java
new file mode 100644
index 0000000000..bf170296b2
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/ExecutionLayout.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.diffplug.spotless.cli.steps.BuildDirGloballyReusable;
+import com.diffplug.spotless.cli.steps.SpotlessCLIFormatterStep;
+
+public class ExecutionLayout {
+
+	private final FileResolver fileResolver;
+	private final SpotlessCommandLineStream commandLineStream;
+	private final ChecksumCalculator checksumCalculator;
+
+	private ExecutionLayout(@Nonnull FileResolver fileResolver, @Nonnull SpotlessCommandLineStream commandLineStream) {
+		this.fileResolver = Objects.requireNonNull(fileResolver);
+		this.commandLineStream = Objects.requireNonNull(commandLineStream);
+		this.checksumCalculator = new ChecksumCalculator();
+	}
+
+	public static ExecutionLayout create(@Nonnull FileResolver fileResolver, @Nonnull SpotlessCommandLineStream commandLineStream) {
+		return new ExecutionLayout(fileResolver, commandLineStream);
+	}
+
+	public Optional<Path> find(@Nullable Path searchPath) {
+		if (searchPath == null) {
+			return Optional.empty();
+		}
+		Path found = fileResolver.resolvePath(searchPath);
+		if (found.toFile().canRead()) {
+			return Optional.of(found);
+		}
+		if (searchPath.toFile().canRead()) {
+			return Optional.of(searchPath);
+		}
+		return Optional.empty();
+	}
+
+	public Path baseDir() {
+		return fileResolver.baseDir();
+	}
+
+	public Path buildDir() {
+		// gradle?
+		if (isGradleDirectory()) {
+			return gradleBuildDir();
+		}
+		if (isMavenDirectory()) {
+			return mavenBuildDir();
+		}
+		return tempBuildDir();
+	}
+
+	private boolean isGradleDirectory() {
+		return List.of("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts").stream()
+				.map(Paths::get)
+				.map(this::find)
+				.anyMatch(Optional::isPresent);
+	}
+
+	private Path gradleBuildDir() {
+		return fileResolver.resolvePath(Paths.get("build", "spotless-cli"));
+	}
+
+	private boolean isMavenDirectory() {
+		return List.of("pom.xml").stream()
+				.map(Paths::get)
+				.map(this::find)
+				.anyMatch(Optional::isPresent);
+	}
+
+	private Path mavenBuildDir() {
+		return fileResolver.resolvePath(Paths.get("target", "spotless-cli"));
+	}
+
+	private Path tempBuildDir() {
+		String tmpDir = System.getProperty("java.io.tmpdir");
+		return Path.of(tmpDir, "spotless-cli");
+	}
+
+	public Path buildDirFor(@Nonnull SpotlessCLIFormatterStep step) {
+		Objects.requireNonNull(step);
+		Path buildDir = buildDir();
+		String checksum = checksumCalculator.calculateChecksum(step);
+		if (step instanceof BuildDirGloballyReusable) {
+			return buildDir.resolve(checksum);
+		}
+		String commandLineChecksum = checksumCalculator.calculateChecksum(commandLineStream);
+		return buildDir.resolve(checksum + "-" + commandLineChecksum);
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/FilePathUtil.java b/cli/src/main/java/com/diffplug/spotless/cli/core/FilePathUtil.java
new file mode 100644
index 0000000000..e83d691ff2
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/FilePathUtil.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public final class FilePathUtil {
+
+	private FilePathUtil() {
+		// no instance
+	}
+
+	public static File asFile(Path path) {
+		return path == null ? null : path.toFile();
+	}
+
+	public static List<File> asFiles(List<Path> paths) {
+		return paths == null ? null : paths.stream().map(Path::toFile).collect(Collectors.toList());
+	}
+
+	public static List<Boolean> assertDirectoryExists(File... files) {
+		return assertDirectoryExists(Arrays.asList(files));
+	}
+
+	public static List<Boolean> assertDirectoryExists(List<File> files) {
+		return files.stream().map(f -> f != null && f.mkdirs()).collect(Collectors.toList());
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/FileResolver.java b/cli/src/main/java/com/diffplug/spotless/cli/core/FileResolver.java
new file mode 100644
index 0000000000..6f4c453a89
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/FileResolver.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+
+public class FileResolver {
+	private final Path baseDir;
+
+	public FileResolver(@Nonnull Path baseDir) {
+		this.baseDir = Objects.requireNonNull(baseDir);
+	}
+
+	Path baseDir() {
+		return baseDir;
+	}
+
+	public File resolveFile(File file) {
+		return resolvePath(file.toPath()).toFile();
+	}
+
+	public Path resolvePath(Path path) {
+		if (path.isAbsolute()) {
+			return path;
+		}
+		return baseDir.resolve(path);
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessActionContext.java b/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessActionContext.java
new file mode 100644
index 0000000000..894a73c919
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessActionContext.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.Provisioner;
+
+public class SpotlessActionContext {
+
+	private final TargetFileTypeInferer.TargetFileType targetFileType;
+	private final FileResolver fileResolver;
+	private final ExecutionLayout executionLayout;
+
+	private SpotlessActionContext(@Nonnull TargetFileTypeInferer.TargetFileType targetFileType, @Nonnull FileResolver fileResolver, @Nonnull SpotlessCommandLineStream commandLineStream) {
+		this.targetFileType = Objects.requireNonNull(targetFileType);
+		this.fileResolver = Objects.requireNonNull(fileResolver);
+		this.executionLayout = ExecutionLayout.create(fileResolver, Objects.requireNonNull(commandLineStream));
+	}
+
+	@Nonnull
+	public TargetFileTypeInferer.TargetFileType targetFileType() {
+		return targetFileType;
+	}
+
+	public File resolveFile(File file) {
+		return fileResolver.resolveFile(file);
+	}
+
+	public Path resolvePath(Path path) {
+		return fileResolver.resolvePath(path);
+	}
+
+	public Provisioner provisioner() {
+		return CliJarProvisioner.INSTANCE;
+	}
+
+	public ExecutionLayout executionLayout() {
+		return executionLayout;
+	}
+
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	public static class Builder {
+		private TargetFileTypeInferer.TargetFileType targetFileType;
+		private FileResolver fileResolver;
+		private SpotlessCommandLineStream commandLineStream;
+
+		public Builder targetFileType(TargetFileTypeInferer.TargetFileType targetFileType) {
+			this.targetFileType = targetFileType;
+			return this;
+		}
+
+		public Builder fileResolver(FileResolver fileResolver) {
+			this.fileResolver = fileResolver;
+			return this;
+		}
+
+		public Builder commandLineStream(SpotlessCommandLineStream commandLineStream) {
+			this.commandLineStream = commandLineStream;
+			return this;
+		}
+
+		public SpotlessActionContext build() {
+			return new SpotlessActionContext(targetFileType, fileResolver, commandLineStream);
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessCommandLineStream.java b/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessCommandLineStream.java
new file mode 100644
index 0000000000..7303e4f3b8
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/SpotlessCommandLineStream.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.util.stream.Stream;
+
+import com.diffplug.spotless.cli.SpotlessAction;
+import com.diffplug.spotless.cli.SpotlessActionContextProvider;
+import com.diffplug.spotless.cli.steps.SpotlessCLIFormatterStep;
+
+import picocli.CommandLine;
+
+public interface SpotlessCommandLineStream { // todo turn into an interface
+
+	static SpotlessCommandLineStream of(CommandLine.ParseResult parseResult) {
+		return new DefaultSpotlessCommandLineStream(parseResult);
+	}
+
+	Stream<SpotlessCLIFormatterStep> formatterSteps();
+
+	Stream<SpotlessActionContextProvider> contextProviders();
+
+	Stream<SpotlessAction> actions();
+
+	class DefaultSpotlessCommandLineStream implements SpotlessCommandLineStream {
+
+		private final CommandLine.ParseResult parseResult;
+
+		private DefaultSpotlessCommandLineStream(CommandLine.ParseResult parseResult) {
+			this.parseResult = parseResult;
+		}
+
+		@Override
+		public Stream<SpotlessCLIFormatterStep> formatterSteps() {
+			return parseResult.asCommandLineList().stream()
+					.map(CommandLine::getCommand)
+					.filter(command -> command instanceof SpotlessCLIFormatterStep)
+					.map(SpotlessCLIFormatterStep.class::cast);
+		}
+
+		@Override
+		public Stream<SpotlessActionContextProvider> contextProviders() {
+			return parseResult.asCommandLineList().stream()
+					.map(CommandLine::getCommand)
+					.filter(command -> command instanceof SpotlessActionContextProvider)
+					.map(SpotlessActionContextProvider.class::cast);
+		}
+
+		@Override
+		public Stream<SpotlessAction> actions() {
+			return parseResult.asCommandLineList().stream()
+					.map(CommandLine::getCommand)
+					.filter(command -> command instanceof SpotlessAction)
+					.map(SpotlessAction.class::cast);
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/TargetFileTypeInferer.java b/cli/src/main/java/com/diffplug/spotless/cli/core/TargetFileTypeInferer.java
new file mode 100644
index 0000000000..18b32986d6
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/TargetFileTypeInferer.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+
+public class TargetFileTypeInferer {
+
+	private final TargetResolver targetResolver;
+
+	public TargetFileTypeInferer(TargetResolver targetResolver) {
+		this.targetResolver = Objects.requireNonNull(targetResolver);
+	}
+
+	public TargetFileType inferTargetFileType() {
+		return targetResolver.resolveTargets()
+				.limit(5) // only check the first n files
+				.map(this::inferFileType)
+				.reduce(this::reduceFileType)
+				.orElseGet(TargetFileType::unknown);
+	}
+
+	private TargetFileType reduceFileType(TargetFileType fileType1, TargetFileType fileType2) {
+		if (Objects.equals(fileType1, fileType2)) {
+			return fileType1;
+		}
+		return TargetFileType.unknown();
+	}
+
+	private TargetFileType inferFileType(@Nonnull java.nio.file.Path path) {
+		String fileName = path.getFileName().toString();
+		int lastDotIndex = fileName.lastIndexOf('.');
+		if (lastDotIndex == -1) {
+			return TargetFileType.unknown();
+		}
+		String fileExtension = fileName.substring(lastDotIndex + 1);
+		return new TargetFileType(fileExtension);
+	}
+
+	public final static class TargetFileType {
+		private final String fileExtension;
+
+		private TargetFileType(String fileExtension) {
+			this.fileExtension = fileExtension;
+		}
+
+		public String fileExtension() {
+			return fileExtension;
+		}
+
+		public FileType fileType() {
+			return FileType.fromFileExtension(fileExtension);
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (this == o)
+				return true;
+			if (o == null || getClass() != o.getClass())
+				return false;
+			TargetFileType that = (TargetFileType) o;
+			return Objects.equals(fileExtension, that.fileExtension);
+		}
+
+		@Override
+		public int hashCode() {
+			return Objects.hashCode(fileExtension);
+		}
+
+		static TargetFileType unknown() {
+			return new TargetFileType(null);
+		}
+
+		static TargetFileType fromExtension(String fileExtension) {
+			return new TargetFileType(fileExtension);
+		}
+	}
+
+	public enum FileType {
+		JAVA, CPP, ANTLR4("g4"), GROOVY, PROTOBUF("proto"), KOTLIN("kt"), UNDETERMINED("");
+
+		private final String fileExtensionOverride;
+
+		FileType() {
+			this.fileExtensionOverride = null;
+		}
+
+		FileType(String fileExtensionOverride) {
+			this.fileExtensionOverride = fileExtensionOverride;
+		}
+
+		public String fileExtension() {
+			return fileExtensionOverride == null ? name().toLowerCase() : fileExtensionOverride;
+		}
+
+		public static FileType fromFileExtension(String fileExtension) {
+			if (fileExtension == null || fileExtension.isEmpty()) {
+				return UNDETERMINED;
+			}
+			return Arrays.stream(values())
+					.filter(fileType -> fileType.fileExtension().equalsIgnoreCase(fileExtension))
+					.findFirst()
+					.orElse(UNDETERMINED);
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/core/TargetResolver.java b/cli/src/main/java/com/diffplug/spotless/cli/core/TargetResolver.java
new file mode 100644
index 0000000000..4977b77d8f
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/core/TargetResolver.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import static java.util.function.Predicate.not;
+
+import java.io.File;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.ThrowingEx;
+
+public class TargetResolver {
+
+	private final List<String> targets;
+
+	private final FileResolver fileResolver;
+
+	public TargetResolver(@Nonnull Path baseDir, @Nonnull List<String> targets) {
+		this.fileResolver = new FileResolver(baseDir);
+		this.targets = Objects.requireNonNull(targets);
+	}
+
+	public Stream<Path> resolveTargets() {
+		return targets.parallelStream()
+				.map(this::resolveTarget)
+				.reduce(Stream::concat) // beware! when using flatmap, the stream goes to sequential
+				.orElse(Stream.empty());
+	}
+
+	private Stream<Path> resolveTarget(String target) {
+
+		final boolean isGlob = target.contains("*") || target.contains("?");
+		System.out.println("isGlob: " + isGlob + " target: " + target);
+
+		if (isGlob) {
+			return resolveGlob(target);
+		}
+		Path targetPath = fileResolver.resolvePath(Path.of(target));
+		if (Files.isReadable(targetPath)) {
+			return Stream.of(targetPath);
+		}
+		if (Files.isDirectory(targetPath)) {
+			return resolveDir(targetPath);
+		}
+		// TODO log warn?
+		return Stream.empty();
+	}
+
+	private Stream<Path> resolveDir(Path startDir) {
+		List<Path> collected = new ArrayList<>();
+		ThrowingEx.run(() -> Files.walkFileTree(startDir,
+				EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+				Integer.MAX_VALUE,
+				new SimpleFileVisitor<>() {
+					@Override
+					public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
+						collected.add(file);
+						return FileVisitResult.CONTINUE;
+					}
+				}));
+		return collected.parallelStream();
+	}
+
+	private Stream<Path> resolveGlob(String glob) {
+		Path startDir;
+		String globPart;
+		// if the glob is absolute, we need to split the glob into its parts and use all parts except glob chars '*', '**', and '?'
+		String[] parts = glob.split("\\Q" + File.separator + "\\E");
+		List<String> startDirParts = Stream.of(parts)
+				.takeWhile(not(TargetResolver::isGlobPathPart))
+				.collect(Collectors.toList());
+
+		startDir = Path.of(glob.startsWith(File.separator) ? File.separator : fileResolver.baseDir().toString(), startDirParts.toArray(String[]::new));
+		globPart = Stream.of(parts)
+				.skip(startDirParts.size())
+				.collect(Collectors.joining(File.separator));
+
+		PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + globPart);
+		List<Path> collected = new ArrayList<>();
+		ThrowingEx.run(() -> Files.walkFileTree(startDir,
+				EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+				Integer.MAX_VALUE,
+				new SimpleFileVisitor<>() {
+					@Override
+					public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
+						Path relativeFile = startDir.relativize(file);
+						if (matcher.matches(relativeFile)) {
+							System.out.println("Matched: " + file);
+							collected.add(file);
+						}
+						return FileVisitResult.CONTINUE;
+					}
+				}));
+		return collected.parallelStream()
+				.map(Path::normalize);
+		//				.map(Path::toAbsolutePath);
+	}
+
+	private static boolean isGlobPathPart(String part) {
+		return part.contains("*") || part.contains("?") || part.matches(".*\\[.*].*") || part.matches(".*\\{.*}.*");
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/execution/SpotlessExecutionStrategy.java b/cli/src/main/java/com/diffplug/spotless/cli/execution/SpotlessExecutionStrategy.java
new file mode 100644
index 0000000000..081c86aed8
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/execution/SpotlessExecutionStrategy.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.execution;
+
+import static picocli.CommandLine.executeHelpRequest;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.cli.core.SpotlessCommandLineStream;
+
+import picocli.CommandLine;
+
+public class SpotlessExecutionStrategy implements CommandLine.IExecutionStrategy {
+
+	public int execute(CommandLine.ParseResult parseResult) throws CommandLine.ExecutionException {
+		Integer helpResult = executeHelpRequest(parseResult);
+		if (helpResult != null) {
+			return helpResult;
+		}
+		return runSpotlessActions(SpotlessCommandLineStream.of(parseResult));
+	}
+
+	private Integer runSpotlessActions(SpotlessCommandLineStream commandLineStream) {
+		// 1. prepare context
+		SpotlessActionContext context = provideSpotlessActionContext(commandLineStream);
+
+		// 2. run setup (for combining steps handled as subcommands)
+		List<FormatterStep> steps = prepareFormatterSteps(commandLineStream, context);
+
+		// 3. run spotless steps
+		return executeSpotlessAction(commandLineStream, steps);
+	}
+
+	private SpotlessActionContext provideSpotlessActionContext(SpotlessCommandLineStream commandLineStream) {
+		return commandLineStream.contextProviders()
+				.findFirst()
+				.map(provider -> provider.spotlessActionContext(commandLineStream))
+				.orElseThrow(() -> new IllegalStateException("No SpotlessActionContextProvider found"));
+	}
+
+	private List<FormatterStep> prepareFormatterSteps(SpotlessCommandLineStream commandLineStream, SpotlessActionContext context) {
+		return commandLineStream.formatterSteps()
+				.flatMap(step -> step.prepareFormatterSteps(context).stream())
+				.collect(Collectors.toList());
+	}
+
+	private Integer executeSpotlessAction(SpotlessCommandLineStream commandLineStream, List<FormatterStep> steps) {
+		return commandLineStream.actions()
+				.findFirst()
+				.map(spotlessAction -> spotlessAction.executeSpotlessAction(steps))
+				.orElse(-1);
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/help/OptionConstants.java b/cli/src/main/java/com/diffplug/spotless/cli/help/OptionConstants.java
new file mode 100644
index 0000000000..0070917469
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/help/OptionConstants.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.help;
+
+public final class OptionConstants {
+
+	public static final String NEW_LINE = "%n  ";
+
+	public static final String VALID_VALUES_SUFFIX = NEW_LINE + "One of: ${COMPLETION-CANDIDATES}";
+
+	public static final String DEFAULT_VALUE_SUFFIX = NEW_LINE + "(default: ${DEFAULT-VALUE})";
+
+	public static final String VALID_AND_DEFAULT_VALUES_SUFFIX = VALID_VALUES_SUFFIX + DEFAULT_VALUE_SUFFIX;
+
+	private OptionConstants() {
+		// no instance
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/BuildDirGloballyReusable.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/BuildDirGloballyReusable.java
new file mode 100644
index 0000000000..117adf558e
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/BuildDirGloballyReusable.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+public interface BuildDirGloballyReusable {}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/GoogleJavaFormat.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/GoogleJavaFormat.java
new file mode 100644
index 0000000000..63ee56773f
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/GoogleJavaFormat.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.util.List;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.cli.help.OptionConstants;
+import com.diffplug.spotless.java.GoogleJavaFormatStep;
+
+import picocli.CommandLine;
+
+@CommandLine.Command(name = "google-java-format", description = "Runs google java format")
+public class GoogleJavaFormat extends SpotlessFormatterStep {
+
+	@CommandLine.Option(names = {"--style", "-s"}, required = false, defaultValue = "GOOGLE", description = "The style to use for the google java format." + OptionConstants.VALID_AND_DEFAULT_VALUES_SUFFIX)
+	Style style;
+
+	@CommandLine.Option(names = {"--reflow-long-strings", "-r"}, required = false, defaultValue = "false", description = "Reflow long strings." + OptionConstants.DEFAULT_VALUE_SUFFIX)
+	boolean reflowLongStrings;
+
+	@CommandLine.Option(names = {"--reorder-imports", "-i"}, required = false, defaultValue = "false", description = "Reorder imports." + OptionConstants.DEFAULT_VALUE_SUFFIX)
+	boolean reorderImports;
+
+	@CommandLine.Option(names = {"--format-javadoc", "-j"}, required = false, defaultValue = "true", description = "Format javadoc." + OptionConstants.DEFAULT_VALUE_SUFFIX)
+	boolean formatJavadoc;
+
+	@Override
+	public List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context) {
+		return List.of(GoogleJavaFormatStep.create(
+				GoogleJavaFormatStep.defaultGroupArtifact(),
+				GoogleJavaFormatStep.defaultVersion(),
+				style.name(),
+				context.provisioner(),
+				reflowLongStrings,
+				reorderImports,
+				formatJavadoc));
+	}
+
+	public enum Style {
+		AOSP, GOOGLE
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/LicenseHeader.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/LicenseHeader.java
new file mode 100644
index 0000000000..cfdcab452b
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/LicenseHeader.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.ThrowingEx;
+import com.diffplug.spotless.antlr4.Antlr4Defaults;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.cli.core.TargetFileTypeInferer;
+import com.diffplug.spotless.cli.help.OptionConstants;
+import com.diffplug.spotless.cpp.CppDefaults;
+import com.diffplug.spotless.generic.LicenseHeaderStep;
+import com.diffplug.spotless.kotlin.KotlinConstants;
+import com.diffplug.spotless.protobuf.ProtobufConstants;
+
+import picocli.CommandLine;
+
+@CommandLine.Command(name = "license-header", description = "Runs license header")
+public class LicenseHeader extends SpotlessFormatterStep {
+
+	@CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
+	LicenseHeaderSourceOption licenseHeaderSourceOption;
+
+	@CommandLine.Option(names = {"--delimiter", "-d"}, required = false, description = "The delimiter to use for the license header. If not provided, the delimiter will be guessed based on the first few files we find. Otherwise, 'java' will be assumed.")
+	String delimiter;
+
+	static class LicenseHeaderSourceOption {
+		@CommandLine.Option(names = {"--header", "-H"}, required = true, description = "The license header content to apply. May contain @|YELLOW $YEAR|@ as placeholder.")
+		String header;
+		@CommandLine.Option(names = {"--header-file", "-f"}, required = true, description = "The license header content in a file to apply.%n  May contain @|YELLOW $YEAR|@ as placeholder.")
+		File headerFile;
+	}
+
+	@CommandLine.Option(names = {"--year-mode", "-m"}, required = false, defaultValue = "PRESERVE", description = "How and if the year in the copyright header should be updated." + OptionConstants.VALID_AND_DEFAULT_VALUES_SUFFIX)
+	LicenseHeaderStep.YearMode yearMode;
+
+	@CommandLine.Option(names = {"--year-separator", "-Y"}, required = false, defaultValue = LicenseHeaderStep.DEFAULT_YEAR_DELIMITER, description = "The separator to use for the year range in the license header." + OptionConstants.DEFAULT_VALUE_SUFFIX)
+	String yearSeparator;
+
+	@CommandLine.Option(names = {"--skip-lines-matching", "-s"}, required = false, description = "Skip lines matching the given regex pattern before inserting the licence header.")
+	String skipLinesMatching;
+
+	@CommandLine.Option(names = {"--content-pattern", "-c"}, required = false, description = "The pattern to match the content of the file before inserting the licence header. (If the file content does not match the pattern, the header will not be inserted/updated.)")
+	String contentPattern;
+
+	@Nonnull
+	@Override
+	public List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context) {
+		FormatterStep licenseHeaderStep = LicenseHeaderStep.headerDelimiter(headerSource(context), delimiter(context.targetFileType()))
+				.withYearMode(yearMode)
+				.withYearSeparator(yearSeparator)
+				.withSkipLinesMatching(skipLinesMatching)
+				.withContentPattern(contentPattern)
+				.build();
+		return List.of(licenseHeaderStep);
+	}
+
+	private ThrowingEx.Supplier<String> headerSource(SpotlessActionContext context) {
+		if (licenseHeaderSourceOption.header != null) {
+			return () -> licenseHeaderSourceOption.header;
+		} else {
+			return () -> ThrowingEx.get(() -> Files.readString(context.resolveFile(licenseHeaderSourceOption.headerFile).toPath()));
+		}
+	}
+
+	private String delimiter(TargetFileTypeInferer.TargetFileType inferredFileType) {
+		if (delimiter != null) {
+			return delimiter;
+		} else {
+			return inferredDelimiterType(inferredFileType);
+		}
+	}
+
+	private String inferredDelimiterType(TargetFileTypeInferer.TargetFileType inferredFileType) {
+		switch (inferredFileType.fileType()) {
+		case JAVA:
+			// fall through
+		case GROOVY:
+			return LicenseHeaderStep.DEFAULT_JAVA_HEADER_DELIMITER;
+		case CPP:
+			return CppDefaults.DELIMITER_EXPR;
+		case ANTLR4:
+			return Antlr4Defaults.licenseHeaderDelimiter();
+		case PROTOBUF:
+			return ProtobufConstants.LICENSE_HEADER_DELIMITER;
+		case KOTLIN:
+			return KotlinConstants.LICENSE_HEADER_DELIMITER;
+		default:
+			return LicenseHeaderStep.DEFAULT_JAVA_HEADER_DELIMITER;
+		}
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/OptionDefaultUse.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/OptionDefaultUse.java
new file mode 100644
index 0000000000..5b9ef69fb6
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/OptionDefaultUse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.util.function.Supplier;
+
+import javax.annotation.Nullable;
+
+import com.diffplug.common.base.Suppliers;
+
+public class OptionDefaultUse<T> {
+
+	@Nullable
+	private final T obj;
+
+	private OptionDefaultUse(@Nullable T obj) {
+		this.obj = obj;
+	}
+
+	public static <T> OptionDefaultUse<T> use(@Nullable T obj) {
+		return new OptionDefaultUse<>(obj);
+	}
+
+	public T orIfNullGet(Supplier<T> supplier) {
+		return obj != null ? obj : supplier.get();
+	}
+
+	public T orIfNull(T other) {
+		return orIfNullGet(Suppliers.ofInstance(other));
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/Prettier.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/Prettier.java
new file mode 100644
index 0000000000..b10ae4f8c0
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/Prettier.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import static com.diffplug.spotless.cli.core.FilePathUtil.asFile;
+import static com.diffplug.spotless.cli.core.FilePathUtil.asFiles;
+import static com.diffplug.spotless.cli.core.FilePathUtil.assertDirectoryExists;
+import static com.diffplug.spotless.cli.steps.OptionDefaultUse.use;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.core.ExecutionLayout;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+import com.diffplug.spotless.npm.NpmPathResolver;
+import com.diffplug.spotless.npm.PrettierConfig;
+import com.diffplug.spotless.npm.PrettierFormatterStep;
+
+import picocli.CommandLine;
+
+@CommandLine.Command(name = "prettier", description = "Runs prettier")
+public class Prettier extends SpotlessFormatterStep {
+
+	@CommandLine.Option(names = {"--dev-dependency", "-D"}, description = "The devDependencies to use for Prettier.")
+	Map<String, String> devDependencies;
+
+	@CommandLine.Option(names = {"--cache-dir", "-C"}, description = "The directory to use for caching Prettier.")
+	Path cacheDir;
+
+	@CommandLine.Option(names = {"--npm-exec", "-n"}, description = "The explicit path to the npm executable.")
+	Path explicitNpmExecutable;
+
+	@CommandLine.Option(names = {"--node-exec", "-N"}, description = "The explicit path to the node executable.")
+	Path explicitNodeExecutable;
+
+	@CommandLine.Option(names = {"--npmrc-file", "-R"}, description = "The explicit path to the .npmrc file.")
+	Path explicitNpmrcFile;
+
+	@CommandLine.Option(names = {"--additional-npmrc-location", "-A"}, description = "Additional locations to search for .npmrc files.")
+	List<Path> additionalNpmrcLocations;
+
+	@CommandLine.Option(names = {"--prettier-config-path", "-P"}, description = "The path to the Prettier configuration file.")
+	Path prettierConfigPath;
+
+	@CommandLine.Option(names = {"--prettier-config-option", "-c"}, description = "The Prettier configuration options.")
+	Map<String, String> prettierConfigOptions;
+
+	@Nonnull
+	@Override
+	public List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context) {
+		FormatterStep prettierFormatterStep = builder(context)
+				.withDevDependencies(devDependencies())
+				.withCacheDir(cacheDir)
+				.withExplicitNpmExecutable(explicitNpmExecutable)
+				.withExplicitNodeExecutable(explicitNodeExecutable)
+				.withExplicitNpmrcFile(explicitNpmrcFile)
+				.withAdditionalNpmrcLocations(additionalNpmrcLocations())
+				.withPrettierConfigOptions(prettierConfigOptions())
+				.withPrettierConfigPath(prettierConfigPath)
+				.build();
+
+		//		return List.of(adapt(prettierFormatterStep));
+
+		return List.of(prettierFormatterStep);
+	}
+
+	private Map<String, Object> prettierConfigOptions() {
+		if (prettierConfigOptions == null) {
+			return Collections.emptyMap();
+		}
+		Map<String, Object> normalized = new LinkedHashMap<>();
+		prettierConfigOptions.forEach((key, value) -> {
+			if (value == null) {
+				normalized.put(key, null);
+			} else {
+				normalized.put(key, normalizePrettierOption(key, value));
+			}
+		});
+		return normalized;
+	}
+
+	private Object normalizePrettierOption(String key, String value) {
+		if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) {
+			return Boolean.parseBoolean(value);
+		}
+		try {
+			return Integer.parseInt(value);
+		} catch (NumberFormatException e) {
+			return value;
+		}
+	}
+
+	private Map<String, String> devDependencies() {
+		return use(devDependencies).orIfNullGet(PrettierFormatterStep::defaultDevDependencies);
+	}
+
+	private List<Path> additionalNpmrcLocations() {
+		return use(additionalNpmrcLocations).orIfNullGet(Collections::emptyList);
+	}
+
+	private PrettierFormatterStepBuilder builder(@Nonnull SpotlessActionContext context) {
+		return new PrettierFormatterStepBuilder(context);
+	}
+
+	private class PrettierFormatterStepBuilder {
+
+		@Nonnull
+		private final SpotlessActionContext context;
+
+		private Map<String, String> devDependencies;
+
+		private Path cacheDir = null;
+
+		// npmPathResolver
+		private Path explicitNpmExecutable;
+
+		private Path explicitNodeExecutable;
+
+		private Path explicitNpmrcFile;
+
+		private List<Path> additionalNpmrcLocations;
+
+		// prettierConfig
+
+		private Map<String, Object> prettierConfigOptions;
+
+		private Path prettierConfigPath;
+
+		private PrettierFormatterStepBuilder(@Nonnull SpotlessActionContext context) {
+			this.context = Objects.requireNonNull(context);
+		}
+
+		public PrettierFormatterStepBuilder withDevDependencies(Map<String, String> devDependencies) {
+			this.devDependencies = devDependencies;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withCacheDir(Path cacheDir) {
+			this.cacheDir = cacheDir;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withExplicitNpmExecutable(Path explicitNpmExecutable) {
+			this.explicitNpmExecutable = explicitNpmExecutable;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withExplicitNodeExecutable(Path explicitNodeExecutable) {
+			this.explicitNodeExecutable = explicitNodeExecutable;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withExplicitNpmrcFile(Path explicitNpmrcFile) {
+			this.explicitNpmrcFile = explicitNpmrcFile;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withAdditionalNpmrcLocations(List<Path> additionalNpmrcLocations) {
+			this.additionalNpmrcLocations = additionalNpmrcLocations;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withPrettierConfigOptions(Map<String, Object> prettierConfigOptions) {
+			this.prettierConfigOptions = prettierConfigOptions;
+			return this;
+		}
+
+		public PrettierFormatterStepBuilder withPrettierConfigPath(Path prettierConfigPath) {
+			this.prettierConfigPath = prettierConfigPath;
+			return this;
+		}
+
+		public FormatterStep build() {
+			ExecutionLayout layout = context.executionLayout();
+			File projectDirFile = asFile(layout.find(Path.of("package.json")) // project dir
+					.map(Path::getParent)
+					.orElseGet(layout::baseDir));
+			File buildDirFile = asFile(layout.buildDirFor(Prettier.this));
+			File cacheDirFile = asFile(cacheDir);
+			assertDirectoryExists(projectDirFile, buildDirFile, cacheDirFile);
+			FormatterStep step = PrettierFormatterStep.create(
+					use(devDependencies).orIfNullGet(PrettierFormatterStep::defaultDevDependencies),
+					context.provisioner(),
+					projectDirFile,
+					buildDirFile,
+					cacheDirFile,
+					new NpmPathResolver(
+							asFile(explicitNpmExecutable),
+							asFile(explicitNodeExecutable),
+							asFile(explicitNpmrcFile),
+							asFiles(additionalNpmrcLocations)),
+					new PrettierConfig(
+							asFile(prettierConfigPath != null ? layout.find(prettierConfigPath).orElseThrow() : null),
+							prettierConfigOptions));
+			return step;
+		}
+
+	}
+
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessCLIFormatterStep.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessCLIFormatterStep.java
new file mode 100644
index 0000000000..26d75cd0dd
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessCLIFormatterStep.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.SpotlessCommand;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+
+public interface SpotlessCLIFormatterStep extends SpotlessCommand {
+
+	@Nonnull
+	List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context);
+
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessFormatterStep.java b/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessFormatterStep.java
new file mode 100644
index 0000000000..fdd40fedf4
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/steps/SpotlessFormatterStep.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.core.SpotlessActionContext;
+
+import picocli.CommandLine;
+
+@CommandLine.Command(mixinStandardHelpOptions = true)
+public abstract class SpotlessFormatterStep implements SpotlessCLIFormatterStep {
+
+	@Nonnull
+	@Override
+	public List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context) {
+		return prepareFormatterSteps();
+	}
+
+	protected List<FormatterStep> prepareFormatterSteps() {
+		throw new IllegalStateException("This method must be overridden or not be called");
+	}
+}
diff --git a/cli/src/main/java/com/diffplug/spotless/cli/version/SpotlessCLIVersionProvider.java b/cli/src/main/java/com/diffplug/spotless/cli/version/SpotlessCLIVersionProvider.java
new file mode 100644
index 0000000000..32dad8b32d
--- /dev/null
+++ b/cli/src/main/java/com/diffplug/spotless/cli/version/SpotlessCLIVersionProvider.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.version;
+
+import java.util.Properties;
+
+import picocli.CommandLine;
+
+public class SpotlessCLIVersionProvider implements CommandLine.IVersionProvider {
+
+	@Override
+	public String[] getVersion() throws Exception {
+		// load application.properties
+		Properties properties = new Properties();
+		properties.load(getClass().getResourceAsStream("/application.properties"));
+		String version = properties.getProperty("cli.version");
+		return new String[]{"Spotless CLI version " + version};
+	}
+}
diff --git a/cli/src/main/resources/application.properties b/cli/src/main/resources/application.properties
new file mode 100644
index 0000000000..6dc79bfbd7
--- /dev/null
+++ b/cli/src/main/resources/application.properties
@@ -0,0 +1 @@
+cli.version=@cli.version@
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/CLIIntegrationHarness.java b/cli/src/test/java/com/diffplug/spotless/cli/CLIIntegrationHarness.java
new file mode 100644
index 0000000000..02ec270a8c
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/CLIIntegrationHarness.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.BeforeEach;
+
+import com.diffplug.spotless.ResourceHarness;
+import com.diffplug.spotless.tag.CliNativeNpmTest;
+import com.diffplug.spotless.tag.CliNativeTest;
+import com.diffplug.spotless.tag.CliProcessNpmTest;
+import com.diffplug.spotless.tag.CliProcessTest;
+
+public abstract class CLIIntegrationHarness extends ResourceHarness {
+
+	/**
+	 * Each test gets its own temp folder, and we create a gradle
+	 * build there and run it.
+	 * <p>
+	 * Because those test folders don't have a .gitattributes file,
+	 * git (on windows) will default to \r\n. So now if you read a
+	 * test file from the spotless test resources, and compare it
+	 * to a build result, the line endings won't match.
+	 * <p>
+	 * By sticking this .gitattributes file into the test directory,
+	 * we ensure that the default Spotless line endings policy of
+	 * GIT_ATTRIBUTES will use \n, so that tests match the test
+	 * resources on win and linux.
+	 */
+	@BeforeEach
+	void gitAttributes() throws IOException {
+		setFile(".gitattributes").toContent("* text eol=lf");
+	}
+
+	protected SpotlessCLIRunner cliRunner() {
+		return createRunnerForTag()
+				.withWorkingDir(rootFolder());
+	}
+
+	private SpotlessCLIRunner createRunnerForTag() {
+		CliProcessTest cliProcessTest = getClass().getAnnotation(CliProcessTest.class);
+		CliProcessNpmTest cliProcessNpmTest = getClass().getAnnotation(CliProcessNpmTest.class);
+		if (cliProcessTest != null || cliProcessNpmTest != null) {
+			return SpotlessCLIRunner.createExternalProcess();
+		}
+		CliNativeTest cliNativeTest = getClass().getAnnotation(CliNativeTest.class);
+		CliNativeNpmTest cliNativeNpmTest = getClass().getAnnotation(CliNativeNpmTest.class);
+		if (cliNativeTest != null || cliNativeNpmTest != null) {
+			return SpotlessCLIRunner.createNative();
+		}
+		return SpotlessCLIRunner.create();
+	}
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIHelpAndVersionTest.java b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIHelpAndVersionTest.java
new file mode 100644
index 0000000000..f352bfa747
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIHelpAndVersionTest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+public class SpotlessCLIHelpAndVersionTest extends CLIIntegrationHarness {
+
+	@Test
+	void testHelp() {
+		SpotlessCLIRunner.Result result = cliRunner().withOption("--help").run();
+		assertThat(result.stdOut()).contains("Usage: spotless");
+	}
+
+	@Test
+	void testVersion() {
+		SpotlessCLIRunner.Result result = cliRunner().withOption("--version").run();
+		assertThat(result.stdOut()).contains("Spotless CLI version");
+	}
+
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunner.java b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunner.java
new file mode 100644
index 0000000000..1a57901271
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunner.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import com.diffplug.spotless.cli.steps.SpotlessCLIFormatterStep;
+
+import picocli.CommandLine;
+
+public abstract class SpotlessCLIRunner {
+
+	private File workingDir = new File(".");
+
+	private final List<String> args = new ArrayList<>();
+
+	public static SpotlessCLIRunner create() {
+		return new SpotlessCLIRunnerInSameThread();
+	}
+
+	public static SpotlessCLIRunner createExternalProcess() {
+		return new SpotlessCLIRunnerInExternalJavaProcess();
+	}
+
+	public static SpotlessCLIRunner createNative() {
+		return new SpotlessCLIRunnerInNativeExternalProcess();
+	}
+
+	public SpotlessCLIRunner withWorkingDir(@NotNull File workingDir) {
+		this.workingDir = Objects.requireNonNull(workingDir);
+		return this;
+	}
+
+	protected File workingDir() {
+		return workingDir;
+	}
+
+	public SpotlessCLIRunner withOption(@NotNull String option) {
+		args.add(Objects.requireNonNull(option));
+		return this;
+	}
+
+	public SpotlessCLIRunner withOption(@NotNull String option, @NotNull String value) {
+		args.add(String.format("%s=%s", Objects.requireNonNull(option), Objects.requireNonNull(value)));
+		return this;
+	}
+
+	public SpotlessCLIRunner withTargets(String... targets) {
+		for (String target : targets) {
+			withOption("--target", target);
+		}
+		return this;
+	}
+
+	public SpotlessCLIRunner withStep(@NotNull String stepName) {
+		args.add(Objects.requireNonNull(stepName));
+		return this;
+	}
+
+	public SpotlessCLIRunner withStep(@NotNull Class<? extends SpotlessCLIFormatterStep> stepClass) {
+		String stepName = determineStepName(stepClass);
+		return withStep(stepName);
+	}
+
+	private String determineStepName(Class<? extends SpotlessCLIFormatterStep> stepClass) {
+		CommandLine.Command annotation = stepClass.getAnnotation(CommandLine.Command.class);
+		if (annotation == null) {
+			throw new IllegalArgumentException("Step class must be annotated with @CommandLine.Command");
+		}
+		return annotation.name();
+	}
+
+	public Result run() {
+		Result result = executeCommand(args);
+		if (result.executionException() != null) {
+			throwRuntimeException("Error while executing Spotless CLI command", result);
+		}
+		if (result.exitCode == null || result.exitCode != 0) {
+			throwRuntimeException("Spotless CLI command failed with exit code " + result.exitCode, result);
+		}
+		return result;
+	}
+
+	public Result runAndFail() {
+		Result result = executeCommand(args);
+		if (result.executionException() != null) {
+			throwRuntimeException("Error while executing Spotless CLI command", result);
+		}
+		if (result.exitCode == null || result.exitCode == 0) {
+			throwRuntimeException("Spotless CLI command should have failed but exited with code " + result.exitCode, result);
+		}
+		return result;
+	}
+
+	private void throwRuntimeException(String message, Result result) {
+		StringBuilder sb = new StringBuilder(message)
+				.append("\nExit code: ").append(result.exitCode()).append("\n")
+				.append("\n--- Standard output: ---\n").append(result.stdOut()).append("\n------------------------\n")
+				.append("\n--- Standard error: ---\n").append(result.stdErr()).append("\n------------------------\n");
+
+		if (result.executionException() != null) {
+			throw new RuntimeException(sb.toString(), result.executionException());
+		}
+		throw new RuntimeException(sb.toString());
+	}
+
+	protected abstract Result executeCommand(List<String> args);
+
+	public static class Result {
+
+		private final Integer exitCode;
+		private final String stdOut;
+		private final String stdErr;
+		private final Exception executionException;
+
+		protected Result(@Nullable Integer exitCode, @Nullable Exception executionException, @NotNull String stdOut, @NotNull String stdErr) {
+			this.exitCode = exitCode;
+			this.executionException = executionException;
+			this.stdOut = Objects.requireNonNull(stdOut);
+			this.stdErr = Objects.requireNonNull(stdErr);
+		}
+
+		public Integer exitCode() {
+			return exitCode;
+		}
+
+		public String stdOut() {
+			return stdOut;
+		}
+
+		public String stdErr() {
+			return stdErr;
+		}
+
+		public Exception executionException() {
+			return executionException;
+		}
+	}
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInExternalJavaProcess.java b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInExternalJavaProcess.java
new file mode 100644
index 0000000000..486077b7d5
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInExternalJavaProcess.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.diffplug.spotless.ProcessRunner;
+import com.diffplug.spotless.ThrowingEx;
+
+public class SpotlessCLIRunnerInExternalJavaProcess extends SpotlessCLIRunner {
+
+	private static final String SPOTLESS_CLI_SHADOW_JAR_SYSPROP = "spotless.cli.shadowJar";
+
+	public SpotlessCLIRunnerInExternalJavaProcess() {
+		super();
+		if (System.getProperty(SPOTLESS_CLI_SHADOW_JAR_SYSPROP) == null) {
+			throw new IllegalStateException("spotless.cli.shadowJar system property must be set to the path of the shadow jar");
+		}
+	}
+
+	protected Result executeCommand(List<String> args) {
+		try (ProcessRunner runner = new ProcessRunner()) {
+
+			ProcessRunner.Result pResult = ThrowingEx.get(() -> runner.exec(
+					workingDir(),
+					System.getenv(),
+					null,
+					processArgs(args)));
+
+			return new Result(pResult.exitCode(), null, pResult.stdOutUtf8(), pResult.stdErrUtf8());
+		}
+	}
+
+	private List<String> processArgs(List<String> args) {
+		List<String> processArgs = new ArrayList<>();
+		processArgs.add(currentJavaExecutable());
+		processArgs.add("-jar");
+		String jarPath = System.getProperty(SPOTLESS_CLI_SHADOW_JAR_SYSPROP);
+		processArgs.add(jarPath);
+
+		//		processArgs.add(SpotlessCLI.class.getProtectionDomain().getCodeSource().getLocation().getPath());
+
+		processArgs.addAll(args);
+		return processArgs;
+	}
+
+	private String currentJavaExecutable() {
+		return ProcessHandle.current().info().command().orElse("java");
+	}
+
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInNativeExternalProcess.java b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInNativeExternalProcess.java
new file mode 100644
index 0000000000..414f7bdcb8
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInNativeExternalProcess.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.diffplug.spotless.ProcessRunner;
+import com.diffplug.spotless.ThrowingEx;
+
+public class SpotlessCLIRunnerInNativeExternalProcess extends SpotlessCLIRunner {
+
+	private static final String SPOTLESS_CLI_NATIVE_IMAGE_SYSPROP = "spotless.cli.nativeImage";
+
+	public SpotlessCLIRunnerInNativeExternalProcess() {
+		super();
+		if (System.getProperty(SPOTLESS_CLI_NATIVE_IMAGE_SYSPROP) == null) {
+			throw new IllegalStateException(SPOTLESS_CLI_NATIVE_IMAGE_SYSPROP + " system property must be set to the path of the native binary");
+		}
+		System.out.println("SpotlessCLIRunnerInNativeExternalProcess: " + System.getProperty(SPOTLESS_CLI_NATIVE_IMAGE_SYSPROP));
+	}
+
+	protected Result executeCommand(List<String> args) {
+		try (ProcessRunner runner = new ProcessRunner()) {
+
+			ProcessRunner.Result pResult = ThrowingEx.get(() -> runner.exec(
+					workingDir(),
+					System.getenv(),
+					null,
+					processArgs(args)));
+
+			return new Result(pResult.exitCode(), null, pResult.stdOutUtf8(), pResult.stdErrUtf8());
+		}
+	}
+
+	private List<String> processArgs(List<String> args) {
+		List<String> processArgs = new ArrayList<>();
+		processArgs.add(System.getProperty(SPOTLESS_CLI_NATIVE_IMAGE_SYSPROP));
+		//		processArgs.add(SpotlessCLI.class.getProtectionDomain().getCodeSource().getLocation().getPath());
+
+		processArgs.addAll(args);
+		return processArgs;
+	}
+
+	private String currentJavaExecutable() {
+		return ProcessHandle.current().info().command().orElse("java");
+	}
+
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInSameThread.java b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInSameThread.java
new file mode 100644
index 0000000000..071434d538
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/SpotlessCLIRunnerInSameThread.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.List;
+
+import picocli.CommandLine;
+
+public class SpotlessCLIRunnerInSameThread extends SpotlessCLIRunner {
+
+	protected Result executeCommand(List<String> args) {
+		SpotlessCLI cli = SpotlessCLI.createInstance();
+		CommandLine commandLine = SpotlessCLI.createCommandLine(cli);
+
+		StringWriter out = new StringWriter();
+		StringWriter err = new StringWriter();
+
+		try (PrintWriter outWriter = new PrintWriter(out);
+				PrintWriter errWriter = new PrintWriter(err)) {
+			commandLine.setOut(outWriter);
+			commandLine.setErr(errWriter);
+			Exception executionException = null;
+			Integer exitCode = null;
+			try {
+				exitCode = commandLine.execute(argsWithBaseDir(args));
+			} catch (Exception e) {
+				executionException = e;
+			}
+
+			// finalize
+			outWriter.flush();
+			errWriter.flush();
+			return new Result(exitCode, executionException, out.toString(), err.toString());
+		}
+	}
+
+	private String[] argsWithBaseDir(List<String> args) {
+		// prepend the base dir
+		String[] argsWithBaseDir = new String[args.size() + 2];
+		argsWithBaseDir[0] = "--basedir";
+		argsWithBaseDir[1] = workingDir().getAbsolutePath();
+		System.arraycopy(args.toArray(new String[0]), 0, argsWithBaseDir, 2, args.size());
+		return argsWithBaseDir;
+	}
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/core/ChecksumCalculatorTest.java b/cli/src/test/java/com/diffplug/spotless/cli/core/ChecksumCalculatorTest.java
new file mode 100644
index 0000000000..4195940fcc
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/core/ChecksumCalculatorTest.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Stream;
+
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.FormatterStep;
+import com.diffplug.spotless.cli.SpotlessAction;
+import com.diffplug.spotless.cli.SpotlessActionContextProvider;
+import com.diffplug.spotless.cli.steps.SpotlessCLIFormatterStep;
+import com.diffplug.spotless.cli.steps.SpotlessFormatterStep;
+
+import picocli.CommandLine;
+
+class ChecksumCalculatorTest {
+
+	private ChecksumCalculator checksumCalculator = new ChecksumCalculator();
+
+	@Test
+	void itCalculatesAChecksumForStep() {
+		Step step = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+
+		String checksum = checksumCalculator.calculateChecksum(step);
+
+		assertThat(checksum).isNotNull();
+	}
+
+	@Test
+	void itCalculatesDifferentChecksumsForSteps() {
+		Step step1 = step(randomPath(), randomString(), argGroup(randomString(), null), List.of(randomPath(), randomPath()));
+		Step step2 = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+
+		String checksum1 = checksumCalculator.calculateChecksum(step1);
+		String checksum2 = checksumCalculator.calculateChecksum(step2);
+
+		assertThat(checksum1).isNotEqualTo(checksum2);
+	}
+
+	@Test
+	void itRecalculatesSameChecksumsForStep() {
+		Step step = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+
+		String checksum1 = checksumCalculator.calculateChecksum(step);
+		String checksum2 = checksumCalculator.calculateChecksum(step);
+
+		assertThat(checksum1).isEqualTo(checksum2);
+	}
+
+	@Test
+	void itCalculatesAChecksumForCommandLineStream() {
+		Step step = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Action action = action(randomPath());
+		SpotlessCommandLineStream commandLineStream = commandLine(action, step);
+
+		String checksum = checksumCalculator.calculateChecksum(commandLineStream);
+
+		assertThat(checksum).isNotNull();
+	}
+
+	@Test
+	void itCalculatesDifferentChecksumForDifferentCommandLineStreamDueToAction() {
+		Step step = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Action action1 = action(randomPath());
+		Action action2 = action(randomPath());
+		SpotlessCommandLineStream commandLineStream1 = commandLine(action1, step);
+		SpotlessCommandLineStream commandLineStream2 = commandLine(action2, step);
+
+		String checksum1 = checksumCalculator.calculateChecksum(commandLineStream1);
+		String checksum2 = checksumCalculator.calculateChecksum(commandLineStream2);
+
+		assertThat(checksum1).isNotEqualTo(checksum2);
+	}
+
+	@Test
+	void itCalculatesDifferentChecksumForDifferentCommandLineStreamDueToSteps() {
+		Step step1 = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Step step2 = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Action action = action(randomPath());
+		SpotlessCommandLineStream commandLineStream1 = commandLine(action, step1);
+		SpotlessCommandLineStream commandLineStream2 = commandLine(action, step2);
+
+		String checksum1 = checksumCalculator.calculateChecksum(commandLineStream1);
+		String checksum2 = checksumCalculator.calculateChecksum(commandLineStream2);
+
+		assertThat(checksum1).isNotEqualTo(checksum2);
+	}
+
+	@Test
+	void itCalculatesDifferentChecksumForDifferentCommandLineStreamDueToStepOrder() {
+		Step step1 = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Step step2 = step(randomPath(), randomString(), argGroup(null, randomByteArray()), List.of(randomPath(), randomPath()));
+		Action action = action(randomPath());
+		SpotlessCommandLineStream commandLineStream1 = commandLine(action, step1, step2);
+		SpotlessCommandLineStream commandLineStream2 = commandLine(action, step2, step1);
+
+		String checksum1 = checksumCalculator.calculateChecksum(commandLineStream1);
+		String checksum2 = checksumCalculator.calculateChecksum(commandLineStream2);
+
+		assertThat(checksum1).isNotEqualTo(checksum2);
+	}
+
+	@Test
+	void itDoesSomething2() {
+		System.out.println("Hello");
+	}
+
+	private static Step step(Path test1, String test2, StepArgGroup argGroup, List<Path> parameters) {
+		Step step = new Step();
+		step.test1 = test1;
+		step.test2 = test2;
+		step.argGroup = argGroup;
+		step.parameters = parameters;
+		return step;
+	}
+
+	private static StepArgGroup argGroup(String test3, byte[] test4) {
+		StepArgGroup argGroup = new StepArgGroup();
+		argGroup.test3 = test3;
+		argGroup.test4 = test4;
+		return argGroup;
+	}
+
+	private static Path randomPath() {
+		return Path.of(randomString());
+	}
+
+	private static byte[] randomByteArray() {
+		return randomString().getBytes(StandardCharsets.UTF_8);
+	}
+
+	private static String randomString() {
+		return Long.toHexString(ThreadLocalRandom.current().nextLong());
+	}
+
+	static class Step extends SpotlessFormatterStep {
+
+		@CommandLine.Option(names = "--test1")
+		Path test1;
+
+		@CommandLine.Option(names = "--test2")
+		String test2;
+
+		@CommandLine.ArgGroup(exclusive = true)
+		StepArgGroup argGroup;
+
+		@CommandLine.Parameters
+		List<Path> parameters;
+
+		@NotNull
+		@Override
+		public List<FormatterStep> prepareFormatterSteps(SpotlessActionContext context) {
+			return List.of();
+		}
+	}
+
+	static class StepArgGroup {
+		@CommandLine.Option(names = "--test3")
+		String test3;
+
+		@CommandLine.Option(names = "--test4")
+		byte[] test4;
+	}
+
+	private static Action action(Path baseDir) {
+		Action action = new Action();
+		action.baseDir = baseDir;
+		return action;
+	}
+
+	@CommandLine.Command(name = "action")
+	static class Action implements SpotlessAction {
+		@CommandLine.Option(names = {"--basedir"})
+		Path baseDir;
+
+		@Override
+		public Integer executeSpotlessAction(@NotNull List<FormatterStep> formatterSteps) {
+			return 0;
+		}
+	}
+
+	private static SpotlessCommandLineStream commandLine(SpotlessAction action, SpotlessFormatterStep... steps) {
+		return new FixedCommandLineStream(Arrays.asList(steps), List.of(action));
+	}
+
+	static class FixedCommandLineStream implements SpotlessCommandLineStream {
+		private final List<SpotlessCLIFormatterStep> formatterSteps;
+		private final List<SpotlessAction> actions;
+
+		FixedCommandLineStream(List<SpotlessCLIFormatterStep> formatterSteps, List<SpotlessAction> actions) {
+			this.formatterSteps = formatterSteps;
+			this.actions = actions;
+		}
+
+		@Override
+		public Stream<SpotlessCLIFormatterStep> formatterSteps() {
+			return formatterSteps.stream();
+		}
+
+		@Override
+		public Stream<SpotlessAction> actions() {
+			return actions.stream();
+		}
+
+		@Override
+		public Stream<SpotlessActionContextProvider> contextProviders() {
+			return Stream.empty();
+		}
+	}
+
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatJavaProcessTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatJavaProcessTest.java
new file mode 100644
index 0000000000..939b3b395f
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatJavaProcessTest.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import com.diffplug.spotless.tag.CliProcessTest;
+
+@CliProcessTest
+public class GoogleJavaFormatJavaProcessTest extends GoogleJavaFormatTest {}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatNativeProcessTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatNativeProcessTest.java
new file mode 100644
index 0000000000..1aea50d8dd
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatNativeProcessTest.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import com.diffplug.spotless.tag.CliNativeTest;
+
+@CliNativeTest
+public class GoogleJavaFormatNativeProcessTest extends GoogleJavaFormatTest {
+
+	// TODO include the correct google-java-format class to be available in native
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatTest.java
new file mode 100644
index 0000000000..9cada7c590
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/GoogleJavaFormatTest.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.cli.CLIIntegrationHarness;
+import com.diffplug.spotless.cli.SpotlessCLIRunner;
+
+public class GoogleJavaFormatTest extends CLIIntegrationHarness {
+
+	@Test
+	void formattingWithGoogleJavaFormatWorks() throws IOException {
+		setFile("Java.java").toResource("java/googlejavaformat/JavaCodeUnformatted.test");
+
+		SpotlessCLIRunner.Result result = cliRunner().withTargets("*.java").withStep(GoogleJavaFormat.class).run();
+
+		System.out.println(result.stdOut());
+		System.out.println("-------");
+		System.out.println(result.stdErr());
+		assertFile("Java.java").sameAsResource("java/googlejavaformat/JavaCodeFormatted.test");
+	}
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderJavaProcessTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderJavaProcessTest.java
new file mode 100644
index 0000000000..b6fa1f0bf1
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderJavaProcessTest.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import com.diffplug.spotless.tag.CliProcessTest;
+
+@CliProcessTest
+public class LicenseHeaderJavaProcessTest extends LicenseHeaderTest {}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderNativeProcessTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderNativeProcessTest.java
new file mode 100644
index 0000000000..e9659d24d4
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderNativeProcessTest.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import com.diffplug.spotless.tag.CliNativeTest;
+
+@CliNativeTest
+public class LicenseHeaderNativeProcessTest extends LicenseHeaderTest {}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderTest.java
new file mode 100644
index 0000000000..69702caa11
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/LicenseHeaderTest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDate;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.cli.CLIIntegrationHarness;
+import com.diffplug.spotless.cli.SpotlessCLIRunner;
+import com.diffplug.spotless.generic.LicenseHeaderStep;
+
+public class LicenseHeaderTest extends CLIIntegrationHarness {
+
+	@Test
+	void assertHeaderMustBeSpecified() {
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("**/*.java")
+				.withStep(LicenseHeader.class)
+				.runAndFail();
+
+		assertThat(result.stdErr())
+				.containsPattern(".*Missing required.*header.*");
+	}
+
+	@Test
+	void assertHeaderIsApplied() {
+		setFile("TestFile.java").toContent("public class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License */")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertHeaderFileIsApplied() {
+		setFile("TestFile.java").toContent("public class TestFile {}");
+		setFile("header.txt").toContent("/* License */");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header-file", "header.txt")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertDelimiterIsApplied() {
+		setFile("TestFile.java").toContent("/* keep me */\npublic class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License */")
+				.withOption("--delimiter", "\\/\\* keep me")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License */\n/* keep me */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertYearModeIsApplied() {
+		setFile("TestFile.java").toContent("/* License (c) 2022 */\npublic class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License (c) $YEAR */")
+				.withOption("--year-mode", LicenseHeaderStep.YearMode.UPDATE_TO_TODAY.toString())
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License (c) 2022-" + LocalDate.now().getYear() + " */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertYearSeparatorIsApplied() {
+		setFile("TestFile.java").toContent("/* License (c) 2022...2023 */\npublic class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License (c) $YEAR */")
+				.withOption("--year-mode", LicenseHeaderStep.YearMode.UPDATE_TO_TODAY.toString())
+
+				.withOption("--year-separator", "...")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License (c) 2022..." + LocalDate.now().getYear() + " */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertSkipLinesMatchingIsApplied() {
+		setFile("TestFile.java").toContent("/* skip me */\npublic class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License */")
+				.withOption("--skip-lines-matching", ".*skip me.*")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* skip me */\n/* License */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertPreserveModeIsApplied() {
+		setFile("TestFile.java").toContent("/* License (c) 2022 */\npublic class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License (c) $YEAR */")
+				.withOption("--year-mode", LicenseHeaderStep.YearMode.PRESERVE.toString())
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License (c) 2022 */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertContentPatternIsAppliedIfMatching() {
+		setFile("TestFile.java").toContent("public class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License */")
+				.withOption("--content-pattern", ".*TestFile.*")
+				.run();
+
+		assertFile("TestFile.java").hasContent("/* License */\npublic class TestFile {}");
+	}
+
+	@Test
+	void assertContentPatternIsNotAppliedIfNotMatching() {
+		setFile("TestFile.java").toContent("public class TestFile {}");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("TestFile.java")
+				.withStep(LicenseHeader.class)
+				.withOption("--header", "/* License */")
+				.withOption("--content-pattern", ".*NonExistent.*")
+				.run();
+
+		assertFile("TestFile.java").hasContent("public class TestFile {}");
+	}
+}
diff --git a/cli/src/test/java/com/diffplug/spotless/cli/steps/PrettierTest.java b/cli/src/test/java/com/diffplug/spotless/cli/steps/PrettierTest.java
new file mode 100644
index 0000000000..2fea1ef9df
--- /dev/null
+++ b/cli/src/test/java/com/diffplug/spotless/cli/steps/PrettierTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.cli.steps;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Test;
+
+import com.diffplug.spotless.cli.CLIIntegrationHarness;
+import com.diffplug.spotless.cli.SpotlessCLIRunner;
+import com.diffplug.spotless.tag.NpmTest;
+
+@NpmTest
+public class PrettierTest extends CLIIntegrationHarness {
+
+	// TODO
+
+	@Test
+	void itRunsPrettierForTsFilesWithOptions() throws IOException {
+		setFile("test.ts").toResource("npm/prettier/config/typescript.dirty");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("test.ts")
+				.withStep(Prettier.class)
+				.withOption("--prettier-config-option", "printWidth=20")
+				.withOption("--prettier-config-option", "parser=typescript")
+				.run();
+
+		assertFile("test.ts").sameAsResource("npm/prettier/config/typescript.configfile_prettier_2.clean");
+	}
+
+	@Test
+	void itRunsPrettierForTsFilesWithOptionFile() throws Exception {
+		setFile(".prettierrc.yml").toResource("npm/prettier/config/.prettierrc.yml");
+		setFile("test.ts").toResource("npm/prettier/config/typescript.dirty");
+
+		SpotlessCLIRunner.Result result = cliRunner()
+				.withTargets("test.ts")
+				.withStep(Prettier.class)
+				.withOption("--prettier-config-path", ".prettierrc.yml")
+				.run();
+
+		assertFile("test.ts").sameAsResource("npm/prettier/config/typescript.configfile_prettier_2.clean");
+	}
+
+}
diff --git a/gradle.properties b/gradle.properties
index 3ed73f7fa1..9a17f782fb 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -32,4 +32,5 @@ VER_JGIT=6.10.0.202406032230-r
 VER_JUNIT=5.11.4
 VER_ASSERTJ=3.27.3
 VER_MOCKITO=5.15.2
-VER_SELFIE=2.4.2
\ No newline at end of file
+VER_SELFIE=2.4.2
+VER_PICOCLI=4.7.6
diff --git a/gradle/changelog.gradle b/gradle/changelog.gradle
index 994e3558dc..9e412795a8 100644
--- a/gradle/changelog.gradle
+++ b/gradle/changelog.gradle
@@ -6,6 +6,9 @@ if (project.name == 'plugin-gradle') {
 } else if (project.name == 'plugin-maven') {
 	kind = 'maven'
 	releaseTitle = 'Maven Plugin'
+} else if (project.name == 'cli') {
+	kind = 'cli'
+	releaseTitle = 'Spotless CLI'
 } else {
 	assert project == rootProject
 	kind = 'lib'
diff --git a/gradle/special-tests.gradle b/gradle/special-tests.gradle
index 650c04b84b..eff60c4bfb 100644
--- a/gradle/special-tests.gradle
+++ b/gradle/special-tests.gradle
@@ -7,7 +7,11 @@ def special = [
 	'clang',
 	'gofmt',
 	'npm',
-	'shfmt'
+	'shfmt',
+	'cliProcess',
+	'cliProcessNpm',
+	'cliNative',
+	'cliNativeNpm'
 ]
 
 boolean isCiServer = System.getenv().containsKey("CI")
diff --git a/lib/src/main/java/com/diffplug/spotless/JarState.java b/lib/src/main/java/com/diffplug/spotless/JarState.java
index 8680932b9e..561cb8c02b 100644
--- a/lib/src/main/java/com/diffplug/spotless/JarState.java
+++ b/lib/src/main/java/com/diffplug/spotless/JarState.java
@@ -37,6 +37,13 @@
  * catch changes in a SNAPSHOT version.
  */
 public final class JarState implements Serializable {
+
+	private static ClassLoader OVERRIDE_CLASS_LOADER = null;
+
+	public static void setOverrideClassLoader(ClassLoader overrideClassLoader) {
+		OVERRIDE_CLASS_LOADER = overrideClassLoader;
+	}
+
 	/** A lazily evaluated JarState, which becomes a set of files when serialized. */
 	public static class Promised implements Serializable {
 		private static final long serialVersionUID = 1L;
@@ -133,6 +140,9 @@ URL[] jarUrls() {
 	 * The lifetime of the underlying cacheloader is controlled by {@link SpotlessCache}.
 	 */
 	public ClassLoader getClassLoader() {
+		if (OVERRIDE_CLASS_LOADER != null) {
+			return OVERRIDE_CLASS_LOADER;
+		}
 		return SpotlessCache.instance().classloader(this);
 	}
 
@@ -145,6 +155,9 @@ public ClassLoader getClassLoader() {
 	 * The lifetime of the underlying cacheloader is controlled by {@link SpotlessCache}.
 	 */
 	public ClassLoader getClassLoader(Serializable key) {
+		if (OVERRIDE_CLASS_LOADER != null) {
+			return OVERRIDE_CLASS_LOADER;
+		}
 		return SpotlessCache.instance().classloader(key, this);
 	}
 }
diff --git a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java
index 941c1c376f..94a4106e37 100644
--- a/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java
+++ b/lib/src/main/java/com/diffplug/spotless/generic/LicenseHeaderStep.java
@@ -190,7 +190,7 @@ private String sanitizePattern(@Nullable String pattern) {
 	}
 
 	private static final String DEFAULT_NAME_PREFIX = LicenseHeaderStep.class.getName();
-	private static final String DEFAULT_YEAR_DELIMITER = "-";
+	public static final String DEFAULT_YEAR_DELIMITER = "-";
 	private static final List<String> YEAR_TOKENS = Arrays.asList("$YEAR", "$today.year");
 
 	private static final SerializableFileFilter UNSUPPORTED_JVM_FILES_FILTER = SerializableFileFilter.skipFilesNamed(
diff --git a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java
index 27a1002df5..d75ee8d37d 100644
--- a/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java
+++ b/lib/src/main/java/com/diffplug/spotless/npm/PrettierFormatterStep.java
@@ -17,12 +17,16 @@
 
 import static java.util.Objects.requireNonNull;
 
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
+import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.util.Base64;
 import java.util.Collections;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
 
 import javax.annotation.Nonnull;
 
@@ -57,7 +61,30 @@ public static FormatterStep create(Map<String, String> devDependencies, Provisio
 		requireNonNull(buildDir);
 		return FormatterStep.createLazy(NAME,
 				() -> new State(NAME, devDependencies, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig),
-				State::createFormatterFunc);
+				PrettierFormatterStep::cachedStateToFormatterFunc);
+	}
+
+	// TODO (simschla, 21.11.2024): this is a hack for the POC
+	// problem is, that the function is instantiated multiple times for cli call, which
+	// results in concurrent initialization of the node_modules dir and starting multiple
+	// server instances.
+	// I'm not sure if this is intended/expected or if it is a bug, will have to check with the team.
+	// For now, I will cache the formatter function based on the state, so that it is only initialized once.
+	private static final ConcurrentHashMap<String, FormatterFunc> CACHED_FORMATTERS = new ConcurrentHashMap<>();
+
+	public static FormatterFunc cachedStateToFormatterFunc(State state) {
+		String serializedState = serializeToBase64(state);
+		return CACHED_FORMATTERS.computeIfAbsent(serializedState, key -> state.createFormatterFunc());
+	}
+
+	private static String serializeToBase64(State state) {
+		try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+			ObjectOutputStream oos = new ObjectOutputStream(baos);
+			oos.writeObject(state);
+			return Base64.getEncoder().encodeToString(baos.toByteArray());
+		} catch (IOException e) {
+			throw ThrowingEx.asRuntime(e);
+		}
 	}
 
 	private static class State extends NpmFormatterStepStateBase implements Serializable {
diff --git a/settings.gradle b/settings.gradle
index 2ca7c46cf4..71574372c1 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -23,6 +23,10 @@ plugins {
 	id 'com.gradle.develocity' version '3.19.1'
 	// https://github.com/equodev/equo-ide/blob/main/plugin-gradle/CHANGELOG.md
 	id 'dev.equo.ide' version '1.7.8' apply false
+	// https://github.com/graalvm/native-build-tools/releases
+	id 'org.graalvm.buildtools.native' version '0.10.2' apply false
+	// https://github.com/GradleUp/shadow/releases
+	id 'com.gradleup.shadow' version '8.3.5' apply false
 }
 
 dependencyResolutionManagement {
@@ -76,6 +80,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
 
 rootProject.name = 'spotless'
 
+include 'cli'		// command-line interface
 include 'lib'		// reusable library with no dependencies
 include 'testlib'	// library for sharing test infrastructure between the projects below
 
@@ -99,3 +104,4 @@ def getStartProperty(java.lang.String name) {
 if (System.getenv('SPOTLESS_EXCLUDE_MAVEN') != 'true' && getStartProperty('SPOTLESS_EXCLUDE_MAVEN') != 'true') {
 	include 'plugin-maven'	// maven-specific glue code
 }
+
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeNpmTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeNpmTest.java
new file mode 100644
index 0000000000..37d777eb3b
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeNpmTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("cliNativeNpm")
+public @interface CliNativeNpmTest {}
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeTest.java
new file mode 100644
index 0000000000..d35473cc5b
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/CliNativeTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("cliNative")
+public @interface CliNativeTest {}
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessNpmTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessNpmTest.java
new file mode 100644
index 0000000000..beec995592
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessNpmTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("cliProcessNpm")
+public @interface CliProcessNpmTest {}
diff --git a/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessTest.java b/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessTest.java
new file mode 100644
index 0000000000..0973fe0a2b
--- /dev/null
+++ b/testlib/src/main/java/com/diffplug/spotless/tag/CliProcessTest.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021-2024 DiffPlug
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.diffplug.spotless.tag;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+@Tag("cliProcess")
+public @interface CliProcessTest {}