This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch fix/split-cli-dependencies
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 237cf0394505ada63aa8402b32affa0662c90a5e
Author: James Daugherty <[email protected]>
AuthorDate: Thu Jul 9 15:42:25 2026 -0400

    Move grailsCli related gradle configuration to a single gradle plugin
---
 .../commandLine/gradleBuild/gradlePlugins.adoc     |   1 +
 grails-gradle/plugins/build.gradle                 |   6 +
 .../plugin/commands/GrailsCliGradlePlugin.groovy   | 448 +++++++++++++++++++++
 .../gradle/plugin/core/GrailsGradlePlugin.groovy   | 399 +-----------------
 .../gradle/plugin/util/ClasspathUtils.groovy       |  60 +++
 .../gradle/plugin/web/GrailsWebGradlePlugin.groovy |   6 +-
 6 files changed, 523 insertions(+), 397 deletions(-)

diff --git a/grails-doc/src/en/guide/commandLine/gradleBuild/gradlePlugins.adoc 
b/grails-doc/src/en/guide/commandLine/gradleBuild/gradlePlugins.adoc
index 8a94c3f56e..dd4e109f63 100644
--- a/grails-doc/src/en/guide/commandLine/gradleBuild/gradlePlugins.adoc
+++ b/grails-doc/src/en/guide/commandLine/gradleBuild/gradlePlugins.adoc
@@ -55,6 +55,7 @@ Many of these are built-in plugins provided by Gradle or 
third-party plugins. Th
 * `org.apache.grails.gradle.grails-gsp` - Adds support for compiling Groovy 
Server Pages (GSP)
 * `org.apache.grails.gradle.grails-gson` - Adds support for compiling JSON 
Views (GSON)
 * `org.apache.grails.gradle.grails-markup` - Adds support for compiling Markup 
Views (XML)
+* `org.apache.grails.gradle.grails-cli` - Configures the Grails CLI tier (the 
`grailsCli` configurations with automatic `-cli` companion discovery, command 
tasks, and the console/shell tasks); applied automatically by the application 
and plugin plugins
 * `org.apache.grails.gradle.grails-plugin` - Marks the project as a Grails 
plugin
 * `org.apache.grails.gradle.grails-plugin-cli` - Publishes a Grails plugin's 
CLI commands as a companion `-cli` artifact, keeping command code off 
application runtime classpaths
 * `org.apache.grails.gradle.grails-web` - Adds the web URL mappings report to 
the Gradle project
diff --git a/grails-gradle/plugins/build.gradle 
b/grails-gradle/plugins/build.gradle
index ae5645cde3..e609cb11c4 100644
--- a/grails-gradle/plugins/build.gradle
+++ b/grails-gradle/plugins/build.gradle
@@ -119,6 +119,12 @@ gradlePlugin {
             id = 'org.apache.grails.gradle.grails-plugin'
             implementationClass = 
'org.grails.gradle.plugin.core.GrailsPluginGradlePlugin'
         }
+        grailsCli {
+            displayName = 'Grails CLI Gradle Plugin'
+            description = 'Configures the Grails CLI tier: the grailsCli 
configurations with automatic -cli companion discovery, command tasks, and the 
console/shell tasks'
+            id = 'org.apache.grails.gradle.grails-cli'
+            implementationClass = 
'org.grails.gradle.plugin.commands.GrailsCliGradlePlugin'
+        }
         grailsPluginCli {
             displayName = 'Grails Plugin CLI Artifact Gradle Plugin'
             description = 'Declares a Grails plugin\'s CLI commands as a 
companion -cli artifact, keeping command code off application runtime 
classpaths'
diff --git 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
new file mode 100644
index 0000000000..131d2f7c91
--- /dev/null
+++ 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy
@@ -0,0 +1,448 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 org.grails.gradle.plugin.commands
+
+import java.util.jar.JarFile
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectProvider
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.Dependency
+import org.gradle.api.artifacts.DependencySet
+import org.gradle.api.artifacts.component.ProjectComponentIdentifier
+import org.gradle.api.artifacts.result.ResolvedArtifactResult
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.JavaExec
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+
+import grails.util.Environment
+import grails.util.GrailsNameUtils
+import org.grails.gradle.plugin.core.GrailsExtension
+import org.grails.gradle.plugin.core.GrailsGradlePlugin
+import org.grails.gradle.plugin.util.ClasspathUtils
+import org.grails.gradle.plugin.util.SourceSets
+import org.grails.io.support.FactoriesLoaderSupport
+import org.grails.build.parsing.CommandLineParser
+
+/**
+ * Configures the CLI tier of a Grails application or plugin project: the 
{@code grailsCli}
+ * configurations (with automatic discovery of companion {@code -cli} 
artifacts advertised by the
+ * dependency graph), the per-command tasks, the generic {@code 
runCommand}/{@code runScript}
+ * tasks, and the interactive {@code console}/{@code shell} tasks. Applied 
automatically by the
+ * Grails Gradle plugin.
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class GrailsCliGradlePlugin implements Plugin<Project> {
+
+    public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 
'org.apache.grails.core.cli.ApplicationCommand'
+
+    /**
+     * The dependency bucket carrying CLI-only dependencies (Grails commands 
and their libraries):
+     * compile-visible so `grails-app/commands` sources compile against the 
cli-only contract, on
+     * the command-runner classpath, but never on `runtimeClasspath`, 
`bootRun`, or packaged
+     * artifacts.
+     */
+    public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli'
+
+    /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the 
command-runner tasks */
+    public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 
'grailsCliClasspath'
+
+    /**
+     * Set this project property to {@code false} to stop the plugin from 
auto-provisioning the
+     * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to
+     * {@code grails { cliAutoProvision = false }}.
+     */
+    public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 
'grailsCliAutoProvision'
+
+    /** The internal probe configuration used to detect companion `-cli` 
modules */
+    public static final String GRAILS_CLI_DETECT_CONFIGURATION = 
'grailsCliDetect'
+
+    @Override
+    void apply(Project project) {
+        // self-sufficient when applied standalone: the auto-provisioning 
behavior is configured
+        // through the `grails` extension, normally registered by the Grails 
Gradle plugin
+        if (project.extensions.findByName('grails') == null) {
+            project.extensions.create('grails', GrailsExtension, project)
+        }
+
+        configureGrailsCliConfiguration(project)
+
+        configureConsoleTask(project)
+
+        configureApplicationCommands(project)
+
+        configureRunScript(project)
+
+        configureRunCommand(project)
+    }
+
+
+    /**
+     * Registers the `grailsCli` dependency bucket and its resolvable 
`grailsCliClasspath` view.
+     * `grailsCli` carries the CLI tier — command companion artifacts 
(`<artifactId>-cli`) and the
+     * libraries they need. It extends the compile classpaths (the same wiring 
`compileOnly` uses)
+     * so `grails-app/commands` sources compile against the cli-only contract, 
while staying off
+     * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and 
`bootWar`.
+     */
+    protected void configureGrailsCliConfiguration(Project project) {
+        ConfigurationContainer configurations = project.configurations
+        if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) {
+            return
+        }
+
+        Configuration grailsCli = 
configurations.create(GRAILS_CLI_CONFIGURATION)
+        grailsCli.canBeResolved = false
+        grailsCli.canBeConsumed = false
+        grailsCli.description = 'CLI-only dependencies (Grails commands and 
the libraries they need); compile-visible and on the command-runner classpath, 
but never on runtimeClasspath, bootRun, or packaged artifacts.'
+
+        // compile visibility for grails-app/commands sources plus the TEST 
classpaths (tests
+        // exercise commands inside the test JVM), while the main 
runtimeClasspath — and therefore
+        // bootRun, bootJar, and bootWar — never sees the cli tier; matching 
configurations that
+        // appear later (e.g. the integrationTest pair) are included as they 
are created
+        configurations.matching { Configuration it ->
+            it.name in ['compileClasspath', 'testCompileClasspath', 
'testRuntimeClasspath',
+                        'integrationTestCompileClasspath', 
'integrationTestRuntimeClasspath']
+        }.configureEach { Configuration it ->
+            it.extendsFrom(grailsCli)
+        }
+
+        Configuration grailsCliClasspath = 
configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+        grailsCliClasspath.extendsFrom(grailsCli)
+        grailsCliClasspath.canBeResolved = true
+        grailsCliClasspath.canBeConsumed = false
+        grailsCliClasspath.description = 'Resolvable view of grailsCli used by 
the command-runner tasks.'
+
+        Configuration grailsCliDetect = 
configurations.create(GRAILS_CLI_DETECT_CONFIGURATION)
+        grailsCliDetect.canBeResolved = true
+        grailsCliDetect.canBeConsumed = false
+        grailsCliDetect.visible = false
+        grailsCliDetect.description = 'Internal probe used to discover 
companion -cli artifacts advertised by dependencies.'
+        for (String bucket : ['api', 'implementation', 'runtimeOnly']) {
+            configurations.matching { Configuration it -> it.name == bucket 
}.configureEach { Configuration it ->
+                grailsCliDetect.extendsFrom(it)
+            }
+        }
+
+        // computed when the configuration is first resolved, so every 
dependency (and the
+        // extension configuration) declared by the build script is visible
+        grailsCli.withDependencies { DependencySet dependencies ->
+            autoProvisionCliDependencies(project, dependencies)
+        }
+    }
+
+
+    /**
+     * Auto-provisions the CLI tier onto {@code grailsCli}: the command 
contract and runner, plus
+     * every companion {@code -cli} artifact advertised by a dependency of the 
application. A
+     * module advertises its companion through the {@code Grails-Cli-Artifact} 
manifest attribute
+     * of its runtime jar (stamped by the framework's cli-artifact build 
convention; third-party
+     * plugins set it on their jar task). Discovery walks the full dependency 
graph (including
+     * transitive plugins) through a lenient resolution of an internal probe 
configuration.
+     * Disable with {@code grails { cliAutoProvision = false }}.
+     */
+    @CompileDynamic
+    protected void autoProvisionCliDependencies(Project project, DependencySet 
dependencies) {
+        GrailsExtension grails = project.extensions.getByType(GrailsExtension)
+        if (!grails.cliAutoProvision.get()) {
+            return
+        }
+
+        // command authoring and execution work out of the box: the command 
contract + the runner
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli'))
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-console'))
+
+        Configuration probe = 
project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION)
+        Set<String> companions = [] as Set
+        def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) 
}.artifacts
+        for (ResolvedArtifactResult artifact : lenientArtifacts.artifacts) {
+            String companion = findAdvertisedCliArtifact(project, artifact)
+            if (companion) {
+                companions.add(companion)
+            }
+        }
+
+        for (String companion : companions) {
+            List<String> coordinate = companion.tokenize(':')
+            if (coordinate.size() != 2) {
+                project.logger.warn('Ignoring malformed Grails-Cli-Artifact 
value [{}] found in the dependencies of project {}', companion, project.name)
+                continue
+            }
+            boolean alreadyDeclared = dependencies.any { Dependency existing ->
+                existing.group == coordinate[0] && existing.name == 
coordinate[1]
+            }
+            if (!alreadyDeclared) {
+                project.logger.info('Detected cli companion artifact {}, 
adding it to the {} configuration of project {}',
+                        companion, GRAILS_CLI_CONFIGURATION, project.name)
+                dependencies.add(project.dependencies.create(companion))
+            }
+        }
+    }
+
+
+    /**
+     * Returns the companion cli coordinate ({@code group:artifactId}) 
advertised by the given
+     * resolved artifact, or {@code null}. For artifacts produced by a project 
of the same build
+     * the (possibly not yet built) jar is not read — the coordinate comes 
from the project's
+     * {@code cliArtifactId} property exported by the cli-artifact convention.
+     */
+    @CompileDynamic
+    protected String findAdvertisedCliArtifact(Project project, 
ResolvedArtifactResult artifact) {
+        def componentIdentifier = artifact.id.componentIdentifier
+        if (componentIdentifier instanceof ProjectComponentIdentifier) {
+            Project target = 
project.rootProject.findProject(((ProjectComponentIdentifier) 
componentIdentifier).projectPath)
+            def cliArtifactId = target?.findProperty('cliArtifactId')
+            return cliArtifactId ? "${target.group}:${cliArtifactId}" as 
String : null
+        }
+
+        File file = artifact.file
+        if (file == null || !file.isFile() || !file.name.endsWith('.jar')) {
+            return null
+        }
+        try (JarFile jarFile = new JarFile(file)) {
+            return 
jarFile.manifest?.mainAttributes?.getValue('Grails-Cli-Artifact')
+        }
+        catch (IOException ignored) {
+            return null
+        }
+    }
+
+
+    @CompileDynamic
+    protected void configureApplicationCommands(Project project) {
+        def applicationContextCommands = 
FactoriesLoaderSupport.loadFactoryNames(APPLICATION_CONTEXT_COMMAND_CLASS,
+                FactoriesLoaderSupport.classLoader, 
FactoriesLoaderSupport.CLI_FACTORIES_RESOURCE_LOCATION)
+        project.afterEvaluate {
+            FileCollection fileCollection = 
ClasspathUtils.buildClasspath(project, project.configurations.runtimeClasspath, 
project.configurations.grailsCliClasspath)
+            // commands come from the buildscript classloader (legacy 
placement) and from the
+            // project's cli tier, so auto-provisioned command artifacts 
register their tasks
+            // without any buildscript classpath entry
+            Set<String> commandClassNames = new LinkedHashSet<String>()
+            if (applicationContextCommands) {
+                commandClassNames.addAll(applicationContextCommands as List)
+            }
+            GrailsExtension grails = 
project.extensions.getByType(GrailsExtension)
+            if (grails.cliAutoProvision.get()) {
+                
commandClassNames.addAll(loadCommandNamesFromCliClasspath(project))
+            }
+            for (ctxCommand in commandClassNames) {
+                String taskName = 
GrailsNameUtils.getLogicalPropertyName(ctxCommand, 'Command')
+                String commandName = 
GrailsNameUtils.getScriptName(GrailsNameUtils.getLogicalName(ctxCommand, 
'Command'))
+                if (!project.tasks.names.contains(taskName)) {
+                    project.tasks.register(taskName, 
ApplicationContextCommandTask).configure {
+                        it.classpath = fileCollection
+                        it.command = commandName
+                        it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
+                        List<Object> args = []
+                        def otherArgs = project.findProperty('args')
+                        if (otherArgs) {
+                            
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
+                        }
+
+                        def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
+
+                        it.doFirst {
+                            args << appClassProvider.get()
+                            it.args(args)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+
+    /**
+     * Loads command class names from the {@code 
META-INF/grails-cli.factories} files of the
+     * resolved {@code grailsCliClasspath} jars, so per-command Gradle tasks 
(e.g. {@code dbmUpdate})
+     * are registered for auto-provisioned cli artifacts. Resolution is 
lenient and jars that are
+     * not built yet (project dependencies within a composite) are skipped — 
the generic
+     * {@code runCommand} task can always execute those commands regardless.
+     */
+    @CompileDynamic
+    protected Collection<String> loadCommandNamesFromCliClasspath(Project 
project) {
+        Set<String> names = new LinkedHashSet<String>()
+        Configuration cliClasspath = 
project.configurations.findByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+        if (cliClasspath == null) {
+            return names
+        }
+        for (File file : cliClasspath.incoming.artifactView { it.lenient(true) 
}.files) {
+            if (!file.isFile() || !file.name.endsWith('.jar')) {
+                continue
+            }
+            try (JarFile jarFile = new JarFile(file)) {
+                def entry = 
jarFile.getEntry(FactoriesLoaderSupport.CLI_FACTORIES_RESOURCE_LOCATION)
+                if (entry == null) {
+                    continue
+                }
+                Properties properties = new Properties()
+                jarFile.getInputStream(entry).withCloseable { input ->
+                    properties.load(input)
+                }
+                String factoryNames = 
properties.getProperty(APPLICATION_CONTEXT_COMMAND_CLASS)
+                if (factoryNames) {
+                    names.addAll(factoryNames.split(',').toList()*.trim())
+                }
+            }
+            catch (IOException ignored) {
+                // unreadable jar — skip
+            }
+        }
+        names
+    }
+
+
+    protected void configureConsoleTask(Project project) {
+        TaskContainer tasks = project.tasks
+        if (!project.configurations.names.contains('console')) {
+            if (!tasks.names.contains('findMainClass')) {
+                project.logger.info('Project {} does not contain the 
findMainClass task so the console & shell tasks will not be created.', 
project.name)
+                return
+            }
+
+            NamedDomainObjectProvider<Configuration> consoleConfiguration = 
project.configurations.register('console')
+            createConsoleTask(project, tasks, consoleConfiguration)
+            createShellTask(project, tasks, consoleConfiguration)
+        }
+    }
+
+
+    @CompileDynamic
+    protected TaskProvider<JavaExec> createConsoleTask(Project project, 
TaskContainer tasks, NamedDomainObjectProvider<Configuration> configuration) {
+        def consoleTask = tasks.register('console', JavaExec)
+        project.afterEvaluate {
+            consoleTask.configure {
+                it.dependsOn(tasks.named('classes'), 
tasks.named('findMainClass'))
+                // grails-console arrives through the auto-provisioned 
grailsCli tier; the
+                // `console` configuration remains for additional console-only 
dependencies
+                it.classpath = project.sourceSets.main.runtimeClasspath + 
configuration.get() +
+                        
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+                it.mainClass.set('grails.ui.console.GrailsSwingConsole')
+
+                def appClass = GrailsGradlePlugin.getMainClassProvider(project)
+
+                it.doFirst {
+                    it.args(appClass.get())
+                }
+            }
+        }
+        consoleTask
+    }
+
+
+    @CompileDynamic
+    protected TaskProvider<JavaExec> createShellTask(Project project, 
TaskContainer tasks, NamedDomainObjectProvider<Configuration> configuration) {
+        def shellTask = tasks.register('shell', JavaExec)
+        project.afterEvaluate {
+            shellTask.configure {
+                it.dependsOn(tasks.named('classes'), 
tasks.named('findMainClass'))
+                // grails-console arrives through the auto-provisioned 
grailsCli tier; the
+                // `console` configuration remains for additional console-only 
dependencies
+                it.classpath = project.sourceSets.main.runtimeClasspath + 
configuration.get() +
+                        
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+                it.mainClass.set('grails.ui.shell.GrailsShell')
+                it.standardInput = System.in
+
+                def appClass = GrailsGradlePlugin.getMainClassProvider(project)
+
+                it.doFirst {
+                    it.args(appClass.get())
+                }
+            }
+        }
+        shellTask
+    }
+
+
+    @CompileDynamic
+    protected void configureRunScript(Project project) {
+        if (!project.tasks.names.contains('runScript')) {
+            def runTask = project.tasks.register('runScript', 
ApplicationContextScriptTask)
+            project.afterEvaluate {
+                runTask.configure {
+                    SourceSet mainSourceSet = 
SourceSets.findMainSourceSet(project)
+                    it.classpath = mainSourceSet.runtimeClasspath + 
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+                    it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
+
+                    // devtools' automatic restart mechanism uses a 
specialized classloader setup, which can interfere
+                    // with Grails' plugin management and bean wiring when 
running CLI scripts via Gradle
+                    it.systemProperty('spring.devtools.restart.enabled', 
'false')
+
+                    List<Object> args = []
+                    def otherArgs = project.findProperty('args')
+                    if (otherArgs) {
+                        
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
+                    }
+
+                    def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
+
+                    it.doFirst {
+                        args << appClassProvider.get()
+                        it.args(args)
+                    }
+                }
+            }
+        }
+    }
+
+
+    @CompileDynamic
+    protected void configureRunCommand(Project project) {
+        if (!project.tasks.names.contains('runCommand')) {
+            def runTask = project.tasks.register('runCommand', 
ApplicationContextCommandTask)
+            project.afterEvaluate {
+                runTask.configure {
+                    SourceSet mainSourceSet = 
SourceSets.findMainSourceSet(project)
+                    it.classpath = mainSourceSet.runtimeClasspath + 
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+                    it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
+
+                    // devtools' automatic restart mechanism uses a 
specialized classloader setup, which can interfere
+                    // with Grails' plugin management and bean wiring when 
running CLI commands via Gradle
+                    it.systemProperty('spring.devtools.restart.enabled', 
'false')
+
+                    List<Object> args = []
+                    def otherArgs = project.findProperty('args')
+                    if (otherArgs) {
+                        
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
+                    }
+
+                    def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
+
+                    it.doFirst {
+                        args << appClassProvider.get()
+                        it.args(args)
+                    }
+
+                }
+            }
+        }
+    }
+
+
+
+}
diff --git 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
index 41bf62f627..705bfa9675 100644
--- 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
+++ 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy
@@ -18,7 +18,6 @@
  */
 package org.grails.gradle.plugin.core
 
-import java.util.jar.JarFile
 import java.util.zip.ZipFile
 
 import grails.util.BuildSettings
@@ -33,17 +32,13 @@ import org.apache.tools.ant.filters.ReplaceTokens
 import org.gradle.api.DefaultTask
 import org.gradle.api.GradleException
 import org.gradle.api.JavaVersion
-import org.gradle.api.NamedDomainObjectProvider
 import org.gradle.api.Plugin
 import org.gradle.api.Project
 import org.gradle.api.Task
 import org.gradle.api.artifacts.Configuration
-import org.gradle.api.artifacts.ConfigurationContainer
 import org.gradle.api.artifacts.Dependency
 import org.gradle.api.artifacts.DependencyResolveDetails
 import org.gradle.api.artifacts.DependencySet
-import org.gradle.api.artifacts.result.ResolvedArtifactResult
-import org.gradle.api.artifacts.component.ProjectComponentIdentifier
 import org.gradle.api.artifacts.ModuleDependency
 import org.gradle.api.attributes.AttributeMatchingStrategy
 import org.gradle.api.attributes.Category
@@ -57,7 +52,6 @@ import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.AbstractCopyTask
 import org.gradle.api.tasks.JavaExec
 import org.gradle.api.tasks.SourceSet
-import org.gradle.api.tasks.SourceSetOutput
 import org.gradle.api.tasks.TaskContainer
 import org.gradle.api.tasks.TaskProvider
 import org.gradle.api.tasks.compile.GroovyCompile
@@ -66,17 +60,14 @@ import org.gradle.jvm.toolchain.JavaToolchainService
 import org.gradle.language.jvm.tasks.ProcessResources
 import org.gradle.process.JavaForkOptions
 import org.gradle.tooling.provider.model.ToolingModelBuilderRegistry
-import org.grails.build.parsing.CommandLineParser
 import org.grails.gradle.plugin.bom.BomPropertyOverridesPlugin
-import org.grails.gradle.plugin.commands.ApplicationContextCommandTask
-import org.grails.gradle.plugin.commands.ApplicationContextScriptTask
+import org.grails.gradle.plugin.commands.GrailsCliGradlePlugin
 import org.grails.gradle.plugin.exploded.ExplodedCompatibilityRule
 import org.grails.gradle.plugin.exploded.ExplodedDisambiguationRule
 import org.grails.gradle.plugin.exploded.GrailsExplodedPlugin
 import org.grails.gradle.plugin.model.GrailsClasspathToolingModelBuilder
 import org.grails.gradle.plugin.run.FindMainClassTask
 import org.grails.gradle.plugin.util.SourceSets
-import org.grails.io.support.FactoriesLoaderSupport
 import org.springframework.boot.gradle.dsl.SpringBootExtension
 import org.springframework.boot.gradle.plugin.ResolveMainClassName
 import org.springframework.boot.gradle.plugin.SpringBootPlugin
@@ -92,30 +83,9 @@ import javax.inject.Inject
 @CompileStatic
 class GrailsGradlePlugin implements Plugin<Project> {
 
-    public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 
'org.apache.grails.core.cli.ApplicationCommand'
     private static final String CLI_PID_FILE_PROPERTY = 'grails.cli.pid.file'
     private static final String RUN_APP_PID_FILE_NAME = 'run-app.pid'
 
-    /**
-     * The dependency bucket carrying CLI-only dependencies (Grails commands 
and their libraries):
-     * compile-visible so `grails-app/commands` sources compile against the 
cli-only contract, on
-     * the command-runner classpath, but never on `runtimeClasspath`, 
`bootRun`, or packaged
-     * artifacts.
-     */
-    public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli'
-
-    /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the 
command-runner tasks */
-    public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 
'grailsCliClasspath'
-
-    /**
-     * Set this project property to {@code false} to stop the plugin from 
auto-provisioning the
-     * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to
-     * {@code grails { cliAutoProvision = false }}.
-     */
-    public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 
'grailsCliAutoProvision'
-
-    /** The internal probe configuration used to detect companion `-cli` 
modules */
-    public static final String GRAILS_CLI_DETECT_CONFIGURATION = 
'grailsCliDetect'
 
     List<Class<Plugin>> basePluginClasses = [IntegrationTestGradlePlugin] as 
List<Class<Plugin>>
     List<String> excludedGrailsAppSourceDirs = ['migrations', 'assets']
@@ -165,9 +135,9 @@ class GrailsGradlePlugin implements Plugin<Project> {
 
         configureAssetCompilation(project)
 
-        configureConsoleTask(project)
-
-        configureGrailsCliConfiguration(project)
+        // the CLI tier — the grailsCli configurations, 
command/console/shell/script tasks, and
+        // cli companion discovery — lives in its own plugin
+        project.pluginManager.apply(GrailsCliGradlePlugin)
 
         configureForkSettings(project, grailsVersion)
 
@@ -177,16 +147,10 @@ class GrailsGradlePlugin implements Plugin<Project> {
 
         configureGrailsSourceDirs(project)
 
-        configureApplicationCommands(project)
-
         project.gradle.projectsEvaluated {
             createBuildPropertiesTask(project)
         }
 
-        configureRunScript(project)
-
-        configureRunCommand(project)
-
         configureGroovyCompiler(project)
 
         configureMatchingExplodedRules(project)
@@ -766,221 +730,12 @@ ${importStatements}
         }
     }
 
-    /**
-     * Registers the `grailsCli` dependency bucket and its resolvable 
`grailsCliClasspath` view.
-     * `grailsCli` carries the CLI tier — command companion artifacts 
(`<artifactId>-cli`) and the
-     * libraries they need. It extends the compile classpaths (the same wiring 
`compileOnly` uses)
-     * so `grails-app/commands` sources compile against the cli-only contract, 
while staying off
-     * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and 
`bootWar`.
-     */
-    protected void configureGrailsCliConfiguration(Project project) {
-        ConfigurationContainer configurations = project.configurations
-        if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) {
-            return
-        }
-
-        Configuration grailsCli = 
configurations.create(GRAILS_CLI_CONFIGURATION)
-        grailsCli.canBeResolved = false
-        grailsCli.canBeConsumed = false
-        grailsCli.description = 'CLI-only dependencies (Grails commands and 
the libraries they need); compile-visible and on the command-runner classpath, 
but never on runtimeClasspath, bootRun, or packaged artifacts.'
-
-        // compile visibility for grails-app/commands sources plus the TEST 
classpaths (tests
-        // exercise commands inside the test JVM), while the main 
runtimeClasspath — and therefore
-        // bootRun, bootJar, and bootWar — never sees the cli tier; matching 
configurations that
-        // appear later (e.g. the integrationTest pair) are included as they 
are created
-        configurations.matching { Configuration it ->
-            it.name in ['compileClasspath', 'testCompileClasspath', 
'testRuntimeClasspath',
-                        'integrationTestCompileClasspath', 
'integrationTestRuntimeClasspath']
-        }.configureEach { Configuration it ->
-            it.extendsFrom(grailsCli)
-        }
-
-        Configuration grailsCliClasspath = 
configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-        grailsCliClasspath.extendsFrom(grailsCli)
-        grailsCliClasspath.canBeResolved = true
-        grailsCliClasspath.canBeConsumed = false
-        grailsCliClasspath.description = 'Resolvable view of grailsCli used by 
the command-runner tasks.'
-
-        Configuration grailsCliDetect = 
configurations.create(GRAILS_CLI_DETECT_CONFIGURATION)
-        grailsCliDetect.canBeResolved = true
-        grailsCliDetect.canBeConsumed = false
-        grailsCliDetect.visible = false
-        grailsCliDetect.description = 'Internal probe used to discover 
companion -cli artifacts advertised by dependencies.'
-        for (String bucket : ['api', 'implementation', 'runtimeOnly']) {
-            configurations.matching { Configuration it -> it.name == bucket 
}.configureEach { Configuration it ->
-                grailsCliDetect.extendsFrom(it)
-            }
-        }
-
-        // computed when the configuration is first resolved, so every 
dependency (and the
-        // extension configuration) declared by the build script is visible
-        grailsCli.withDependencies { DependencySet dependencies ->
-            autoProvisionCliDependencies(project, dependencies)
-        }
-    }
-
-    /**
-     * Auto-provisions the CLI tier onto {@code grailsCli}: the command 
contract and runner, plus
-     * every companion {@code -cli} artifact advertised by a dependency of the 
application. A
-     * module advertises its companion through the {@code Grails-Cli-Artifact} 
manifest attribute
-     * of its runtime jar (stamped by the framework's cli-artifact build 
convention; third-party
-     * plugins set it on their jar task). Discovery walks the full dependency 
graph (including
-     * transitive plugins) through a lenient resolution of an internal probe 
configuration.
-     * Disable with {@code grails { cliAutoProvision = false }}.
-     */
-    @CompileDynamic
-    protected void autoProvisionCliDependencies(Project project, DependencySet 
dependencies) {
-        GrailsExtension grails = project.extensions.getByType(GrailsExtension)
-        if (!grails.cliAutoProvision.get()) {
-            return
-        }
-
-        // command authoring and execution work out of the box: the command 
contract + the runner
-        
dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli'))
-        
dependencies.add(project.dependencies.create('org.apache.grails:grails-console'))
-
-        Configuration probe = 
project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION)
-        Set<String> companions = [] as Set
-        def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) 
}.artifacts
-        for (ResolvedArtifactResult artifact : lenientArtifacts.artifacts) {
-            String companion = findAdvertisedCliArtifact(project, artifact)
-            if (companion) {
-                companions.add(companion)
-            }
-        }
-
-        for (String companion : companions) {
-            List<String> coordinate = companion.tokenize(':')
-            if (coordinate.size() != 2) {
-                project.logger.warn('Ignoring malformed Grails-Cli-Artifact 
value [{}] found in the dependencies of project {}', companion, project.name)
-                continue
-            }
-            boolean alreadyDeclared = dependencies.any { Dependency existing ->
-                existing.group == coordinate[0] && existing.name == 
coordinate[1]
-            }
-            if (!alreadyDeclared) {
-                project.logger.info('Detected cli companion artifact {}, 
adding it to the {} configuration of project {}',
-                        companion, GRAILS_CLI_CONFIGURATION, project.name)
-                dependencies.add(project.dependencies.create(companion))
-            }
-        }
-    }
-
-    /**
-     * Returns the companion cli coordinate ({@code group:artifactId}) 
advertised by the given
-     * resolved artifact, or {@code null}. For artifacts produced by a project 
of the same build
-     * the (possibly not yet built) jar is not read — the coordinate comes 
from the project's
-     * {@code cliArtifactId} property exported by the cli-artifact convention.
-     */
-    @CompileDynamic
-    protected String findAdvertisedCliArtifact(Project project, 
ResolvedArtifactResult artifact) {
-        def componentIdentifier = artifact.id.componentIdentifier
-        if (componentIdentifier instanceof ProjectComponentIdentifier) {
-            Project target = 
project.rootProject.findProject(((ProjectComponentIdentifier) 
componentIdentifier).projectPath)
-            def cliArtifactId = target?.findProperty('cliArtifactId')
-            return cliArtifactId ? "${target.group}:${cliArtifactId}" as 
String : null
-        }
-
-        File file = artifact.file
-        if (file == null || !file.isFile() || !file.name.endsWith('.jar')) {
-            return null
-        }
-        try (JarFile jarFile = new JarFile(file)) {
-            return 
jarFile.manifest?.mainAttributes?.getValue('Grails-Cli-Artifact')
-        }
-        catch (IOException ignored) {
-            return null
-        }
-    }
-
     protected GrailsExtension registerGrailsExtension(Project project) {
         if (project.extensions.findByName('grails') == null) {
             project.extensions.create('grails', GrailsExtension, project)
         }
     }
 
-    @CompileDynamic
-    protected void configureApplicationCommands(Project project) {
-        def applicationContextCommands = 
FactoriesLoaderSupport.loadFactoryNames(APPLICATION_CONTEXT_COMMAND_CLASS,
-                FactoriesLoaderSupport.classLoader, 
FactoriesLoaderSupport.CLI_FACTORIES_RESOURCE_LOCATION)
-        project.afterEvaluate {
-            FileCollection fileCollection = buildClasspath(project, 
project.configurations.runtimeClasspath, 
project.configurations.grailsCliClasspath)
-            // commands come from the buildscript classloader (legacy 
placement) and from the
-            // project's cli tier, so auto-provisioned command artifacts 
register their tasks
-            // without any buildscript classpath entry
-            Set<String> commandClassNames = new LinkedHashSet<String>()
-            if (applicationContextCommands) {
-                commandClassNames.addAll(applicationContextCommands as List)
-            }
-            GrailsExtension grails = 
project.extensions.getByType(GrailsExtension)
-            if (grails.cliAutoProvision.get()) {
-                
commandClassNames.addAll(loadCommandNamesFromCliClasspath(project))
-            }
-            for (ctxCommand in commandClassNames) {
-                String taskName = 
GrailsNameUtils.getLogicalPropertyName(ctxCommand, 'Command')
-                String commandName = 
GrailsNameUtils.getScriptName(GrailsNameUtils.getLogicalName(ctxCommand, 
'Command'))
-                if (!project.tasks.names.contains(taskName)) {
-                    project.tasks.register(taskName, 
ApplicationContextCommandTask).configure {
-                        it.classpath = fileCollection
-                        it.command = commandName
-                        it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
-                        List<Object> args = []
-                        def otherArgs = project.findProperty('args')
-                        if (otherArgs) {
-                            
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
-                        }
-
-                        def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
-
-                        it.doFirst {
-                            args << appClassProvider.get()
-                            it.args(args)
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * Loads command class names from the {@code 
META-INF/grails-cli.factories} files of the
-     * resolved {@code grailsCliClasspath} jars, so per-command Gradle tasks 
(e.g. {@code dbmUpdate})
-     * are registered for auto-provisioned cli artifacts. Resolution is 
lenient and jars that are
-     * not built yet (project dependencies within a composite) are skipped — 
the generic
-     * {@code runCommand} task can always execute those commands regardless.
-     */
-    @CompileDynamic
-    protected Collection<String> loadCommandNamesFromCliClasspath(Project 
project) {
-        Set<String> names = new LinkedHashSet<String>()
-        Configuration cliClasspath = 
project.configurations.findByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-        if (cliClasspath == null) {
-            return names
-        }
-        for (File file : cliClasspath.incoming.artifactView { it.lenient(true) 
}.files) {
-            if (!file.isFile() || !file.name.endsWith('.jar')) {
-                continue
-            }
-            try (JarFile jarFile = new JarFile(file)) {
-                def entry = 
jarFile.getEntry(FactoriesLoaderSupport.CLI_FACTORIES_RESOURCE_LOCATION)
-                if (entry == null) {
-                    continue
-                }
-                Properties properties = new Properties()
-                jarFile.getInputStream(entry).withCloseable { input ->
-                    properties.load(input)
-                }
-                String factoryNames = 
properties.getProperty(APPLICATION_CONTEXT_COMMAND_CLASS)
-                if (factoryNames) {
-                    names.addAll(factoryNames.split(',').toList()*.trim())
-                }
-            }
-            catch (IOException ignored) {
-                // unreadable jar — skip
-            }
-        }
-        names
-    }
-
     @CompileDynamic
     protected void configureGrailsSourceDirs(Project project) {
         project.sourceSets {
@@ -1217,65 +972,6 @@ ${importStatements}
         return JavaVersion.current().majorVersion.toInteger()
     }
 
-    protected void configureConsoleTask(Project project) {
-        TaskContainer tasks = project.tasks
-        if (!project.configurations.names.contains('console')) {
-            if (!tasks.names.contains('findMainClass')) {
-                project.logger.info('Project {} does not contain the 
findMainClass task so the console & shell tasks will not be created.', 
project.name)
-                return
-            }
-
-            NamedDomainObjectProvider<Configuration> consoleConfiguration = 
project.configurations.register('console')
-            createConsoleTask(project, tasks, consoleConfiguration)
-            createShellTask(project, tasks, consoleConfiguration)
-        }
-    }
-
-    @CompileDynamic
-    protected TaskProvider<JavaExec> createConsoleTask(Project project, 
TaskContainer tasks, NamedDomainObjectProvider<Configuration> configuration) {
-        def consoleTask = tasks.register('console', JavaExec)
-        project.afterEvaluate {
-            consoleTask.configure {
-                it.dependsOn(tasks.named('classes'), 
tasks.named('findMainClass'))
-                // grails-console arrives through the auto-provisioned 
grailsCli tier; the
-                // `console` configuration remains for additional console-only 
dependencies
-                it.classpath = project.sourceSets.main.runtimeClasspath + 
configuration.get() +
-                        
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-                it.mainClass.set('grails.ui.console.GrailsSwingConsole')
-
-                def appClass = GrailsGradlePlugin.getMainClassProvider(project)
-
-                it.doFirst {
-                    it.args(appClass.get())
-                }
-            }
-        }
-        consoleTask
-    }
-
-    @CompileDynamic
-    protected TaskProvider<JavaExec> createShellTask(Project project, 
TaskContainer tasks, NamedDomainObjectProvider<Configuration> configuration) {
-        def shellTask = tasks.register('shell', JavaExec)
-        project.afterEvaluate {
-            shellTask.configure {
-                it.dependsOn(tasks.named('classes'), 
tasks.named('findMainClass'))
-                // grails-console arrives through the auto-provisioned 
grailsCli tier; the
-                // `console` configuration remains for additional console-only 
dependencies
-                it.classpath = project.sourceSets.main.runtimeClasspath + 
configuration.get() +
-                        
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-                it.mainClass.set('grails.ui.shell.GrailsShell')
-                it.standardInput = System.in
-
-                def appClass = GrailsGradlePlugin.getMainClassProvider(project)
-
-                it.doFirst {
-                    it.args(appClass.get())
-                }
-            }
-        }
-        shellTask
-    }
-
     @CompileDynamic
     protected void registerFindMainClassTask(Project project) {
         TaskContainer taskContainer = project.tasks
@@ -1443,93 +1139,6 @@ ${importStatements}
         native2asciiTask
     }
 
-    @CompileDynamic
-    protected void configureRunScript(Project project) {
-        if (!project.tasks.names.contains('runScript')) {
-            def runTask = project.tasks.register('runScript', 
ApplicationContextScriptTask)
-            project.afterEvaluate {
-                runTask.configure {
-                    SourceSet mainSourceSet = 
SourceSets.findMainSourceSet(project)
-                    it.classpath = mainSourceSet.runtimeClasspath + 
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-                    it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
-
-                    // devtools' automatic restart mechanism uses a 
specialized classloader setup, which can interfere
-                    // with Grails' plugin management and bean wiring when 
running CLI scripts via Gradle
-                    it.systemProperty('spring.devtools.restart.enabled', 
'false')
-
-                    List<Object> args = []
-                    def otherArgs = project.findProperty('args')
-                    if (otherArgs) {
-                        
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
-                    }
-
-                    def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
-
-                    it.doFirst {
-                        args << appClassProvider.get()
-                        it.args(args)
-                    }
-                }
-            }
-        }
-    }
-
-    @CompileDynamic
-    protected void configureRunCommand(Project project) {
-        if (!project.tasks.names.contains('runCommand')) {
-            def runTask = project.tasks.register('runCommand', 
ApplicationContextCommandTask)
-            project.afterEvaluate {
-                runTask.configure {
-                    SourceSet mainSourceSet = 
SourceSets.findMainSourceSet(project)
-                    it.classpath = mainSourceSet.runtimeClasspath + 
project.configurations.getByName(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-                    it.systemProperty(Environment.KEY, 
System.getProperty(Environment.KEY, Environment.DEVELOPMENT.getName()))
-
-                    // devtools' automatic restart mechanism uses a 
specialized classloader setup, which can interfere
-                    // with Grails' plugin management and bean wiring when 
running CLI commands via Gradle
-                    it.systemProperty('spring.devtools.restart.enabled', 
'false')
-
-                    List<Object> args = []
-                    def otherArgs = project.findProperty('args')
-                    if (otherArgs) {
-                        
args.addAll(CommandLineParser.translateCommandline(otherArgs as String))
-                    }
-
-                    def appClassProvider = 
GrailsGradlePlugin.getMainClassProvider(project)
-
-                    it.doFirst {
-                        args << appClassProvider.get()
-                        it.args(args)
-                    }
-
-                }
-            }
-        }
-    }
-
-    protected FileCollection resolveClassesDirs(SourceSetOutput output, 
Project project) {
-        output?.classesDirs ?: 
project.files(project.layout.buildDirectory.dir('classes/main'))
-    }
-
-    protected FileCollection buildClasspath(Project project, Configuration... 
configurations) {
-        SourceSet mainSourceSet = SourceSets.findMainSourceSet(project)
-        SourceSetOutput output = mainSourceSet?.output
-        FileCollection mainFiles = resolveClassesDirs(output, project)
-        FileCollection fileCollection = 
project.files(project.layout.buildDirectory.dir('resources/main'), 
project.layout.buildDirectory.dir('gsp-classes')) + mainFiles
-        configurations.each {
-            fileCollection = fileCollection + it.filter({ File file -> 
!file.name.startsWith('spring-boot-devtools') })
-        }
-        fileCollection
-    }
-
-    protected FileCollection buildClasspath(Project project, String... 
configurationNames) {
-        buildClasspath(
-                project,
-                configurationNames.collect {
-                    project.configurations.named(it).getOrNull()
-                }.findAll(/* remove nulls */) as Configuration[]
-        )
-    }
-
     @CompileStatic
     private static final class OnlyOneGrailsPlugin {
 
diff --git 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/util/ClasspathUtils.groovy
 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/util/ClasspathUtils.groovy
new file mode 100644
index 0000000000..8ba9c2d130
--- /dev/null
+++ 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/util/ClasspathUtils.groovy
@@ -0,0 +1,60 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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
+ *
+ *    https://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 org.grails.gradle.plugin.util
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetOutput
+
+/**
+ * Builds the classpaths used by the Grails command and reporting tasks.
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class ClasspathUtils {
+
+    static FileCollection resolveClassesDirs(SourceSetOutput output, Project 
project) {
+        output?.classesDirs ?: 
project.files(project.layout.buildDirectory.dir('classes/main'))
+    }
+
+    static FileCollection buildClasspath(Project project, Configuration... 
configurations) {
+        SourceSet mainSourceSet = SourceSets.findMainSourceSet(project)
+        SourceSetOutput output = mainSourceSet?.output
+        FileCollection mainFiles = resolveClassesDirs(output, project)
+        FileCollection fileCollection = 
project.files(project.layout.buildDirectory.dir('resources/main'), 
project.layout.buildDirectory.dir('gsp-classes')) + mainFiles
+        configurations.each {
+            fileCollection = fileCollection + it.filter({ File file -> 
!file.name.startsWith('spring-boot-devtools') })
+        }
+        fileCollection
+    }
+
+    static FileCollection buildClasspath(Project project, String... 
configurationNames) {
+        buildClasspath(
+                project,
+                configurationNames.collect {
+                    project.configurations.named(it).getOrNull()
+                }.findAll(/* remove nulls */) as Configuration[]
+        )
+    }
+}
diff --git 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/web/GrailsWebGradlePlugin.groovy
 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/web/GrailsWebGradlePlugin.groovy
index af661e45c7..9898cd37b4 100644
--- 
a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/web/GrailsWebGradlePlugin.groovy
+++ 
b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/web/GrailsWebGradlePlugin.groovy
@@ -27,6 +27,8 @@ import org.gradle.process.CommandLineArgumentProvider
 import org.gradle.tooling.provider.model.ToolingModelBuilderRegistry
 
 import grails.util.Environment
+import org.grails.gradle.plugin.commands.GrailsCliGradlePlugin
+import org.grails.gradle.plugin.util.ClasspathUtils
 import org.grails.gradle.plugin.commands.ApplicationContextCommandTask
 import org.grails.gradle.plugin.core.GrailsGradlePlugin
 
@@ -60,9 +62,9 @@ class GrailsWebGradlePlugin extends GrailsGradlePlugin {
 
     private void registerUrlMappingsTask(Project project) {
         project.tasks.register(URL_MAPPINGS_REPORT, 
ApplicationContextCommandTask) { task ->
-            task.classpath = buildClasspath(
+            task.classpath = ClasspathUtils.buildClasspath(
                     project,
-                    'runtimeClasspath', 'console'
+                    'runtimeClasspath', 
GrailsCliGradlePlugin.GRAILS_CLI_CLASSPATH_CONFIGURATION
             )
             task.systemProperty(
                     Environment.KEY,

Reply via email to