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

jamesfredley pushed a commit to branch feat/8.0.x-legacy-command-compat
in repository https://gitbox.apache.org/repos/asf/grails-core.git


The following commit(s) were added to 
refs/heads/feat/8.0.x-legacy-command-compat by this push:
     new d1fe4956a6 Isolate Grails 7 command compat into execution-only 
grails-core-cli-legacy
d1fe4956a6 is described below

commit d1fe4956a6d59ed6ac68a71bafe2020d813b11f6
Author: James Fredley <[email protected]>
AuthorDate: Sun Jul 19 11:44:35 2026 -0400

    Isolate Grails 7 command compat into execution-only grails-core-cli-legacy
    
    The backwards-compatibility layer that keeps unchanged Grails 7 command
    plugins (grails.dev.commands.ApplicationCommand) working on Grails 8 was
    restored inside grails-core-cli, which put the deprecated contract on the
    compile ABI of every plugin that depends on the CLI tier. Move the 
deprecated
    types, the adapter, and the legacy loader into a new published subproject
    grails-core-cli-legacy and provision it execution-only.
    
    grails-core-cli now exposes a neutral SPI - ApplicationCommandProvider,
    ApplicationCommandRegistrar, and ApplicationCommandTargetAware - and
    ApplicationContextCommandRegistry discovers providers through
    META-INF/grails-cli.factories (dual-classloader, class-identity 
deduplicated,
    failure isolated) with modern commands winning name collisions. The legacy
    module ships LegacyApplicationCommandProvider, which performs the deprecated
    dual-classloader load, adapter-wrap, and one-time deprecation warning.
    grails-core-cli retains zero references to grails.dev.commands.
    
    The Grails Gradle plugin adds a non-resolvable grailsCliLegacy bucket that
    feeds only grailsCliClasspath and the test runtime classpaths - never any
    compile classpath - and is auto-provisioned by default via cliAutoProvision,
    so compatibility stays on by default with zero user action. A plugin that
    still authors legacy commands compiles against grails-core-cli-legacy
    explicitly.
    
    Assisted-by: claude-code:claude-opus-4-8
---
 gradle/publish-root-config.gradle                  |  1 +
 .../GrailsApplicationContextCommandRunner.groovy   | 12 +--
 ...railsApplicationContextCommandRunnerSpec.groovy |  6 +-
 grails-core-cli-legacy/build.gradle                | 67 +++++++++++++++++
 .../grails/dev/commands/ApplicationCommand.groovy  |  0
 .../grails/dev/commands/ExecutionContext.groovy    |  0
 .../dev/commands/GrailsApplicationCommand.groovy   |  0
 .../dev/commands/io/FileSystemInteraction.groovy   |  0
 .../commands/io/FileSystemInteractionImpl.groovy   |  0
 .../dev/commands/template/TemplateException.groovy |  0
 .../dev/commands/template/TemplateRenderer.groovy  |  0
 .../commands/template/TemplateRendererImpl.groovy  |  0
 .../compat/LegacyApplicationCommandAdapter.groovy  |  6 +-
 .../compat/LegacyApplicationCommandProvider.groovy | 69 +++++++++++++++++
 .../main/resources/META-INF/grails-cli.factories   | 16 ++++
 .../cli/LegacyCommandRegistryLoadingSpec.groovy    | 63 ++++++++++++++--
 .../LegacyApplicationCommandAdapterSpec.groovy     |  2 +-
 ...re.groovy => ApplicationCommandProvider.groovy} | 12 ++-
 ...e.groovy => ApplicationCommandRegistrar.groovy} |  9 ++-
 ...groovy => ApplicationCommandTargetAware.groovy} |  9 ++-
 .../cli/ApplicationContextCommandRegistry.groovy   | 55 ++++++--------
 .../compiler/CommandFactoriesTransformation.groovy | 14 +++-
 .../core/cli/ApplicationCommandProviderSpec.groovy | 86 ++++++++++++++++++++++
 .../CommandFactoriesTransformationSpec.groovy      | 16 ++++
 grails-doc/src/en/guide/introduction/whatsNew.adoc |  9 ++-
 .../src/en/guide/upgrading/upgrading80x.adoc       | 18 ++++-
 .../plugin/commands/GrailsCliGradlePlugin.groovy   | 26 ++++++-
 .../plugin/commands/CliAutoDiscoverySpec.groovy    | 28 ++++++-
 .../cli-companion-autodiscovery/app/build.gradle   | 24 ++++++
 .../legacy-command-plugin/build.gradle             |  4 +-
 .../legacy-commands-plugin/build.gradle            |  4 +-
 ...egacyCommandCompatibilityIntegrationSpec.groovy | 10 +--
 settings.gradle                                    |  1 +
 33 files changed, 485 insertions(+), 82 deletions(-)

diff --git a/gradle/publish-root-config.gradle 
b/gradle/publish-root-config.gradle
index 17f2cede7b..78b1c0cf11 100644
--- a/gradle/publish-root-config.gradle
+++ b/gradle/publish-root-config.gradle
@@ -43,6 +43,7 @@ def publishedProjects = [
         'grails-controllers',
         'grails-converters',
         'grails-core',
+        'grails-core-cli-legacy',
         'grails-databinding',
         'grails-databinding-core',
         'grails-datasource',
diff --git 
a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
 
b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
index 92ebb447c7..5fc9801f18 100644
--- 
a/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
+++ 
b/grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy
@@ -24,7 +24,7 @@ import 
org.springframework.context.ConfigurableApplicationContext
 import grails.config.Settings
 import org.apache.grails.core.cli.ApplicationContextCommandRegistry
 import org.apache.grails.core.cli.ExecutionContext
-import org.apache.grails.core.cli.LegacyApplicationCommandAware
+import org.apache.grails.core.cli.ApplicationCommandTargetAware
 import grails.ui.support.DevelopmentGrailsApplication
 import org.grails.build.parsing.CommandLine
 import org.grails.build.parsing.CommandLineParser
@@ -121,17 +121,17 @@ class GrailsApplicationContextCommandRunner extends 
DevelopmentGrailsApplication
     /**
      * Resolves the object that Spring should autowire and inspect for the 
{@code skipBootstrap}
      * property. For a command loaded through the deprecated Grails 7 
compatibility layer the
-     * registry returns a {@link LegacyApplicationCommandAware} adapter; the 
real legacy command it
-     * wraps - not the adapter - is what must be autowired and queried, 
because the adapter forwards
+     * registry returns an {@link ApplicationCommandTargetAware} adapter; the 
target it wraps - not
+     * the adapter - is what must be autowired and queried, because the 
adapter forwards
      * {@code applicationContext} and {@code handle} to that same instance. 
Any other command is
      * returned unchanged.
      *
      * @param command the command resolved from the registry
-     * @return the legacy target when {@code command} is a {@link 
LegacyApplicationCommandAware}, otherwise {@code command}
+     * @return the adapter target when {@code command} is {@link 
ApplicationCommandTargetAware}, otherwise {@code command}
      */
     static Object resolveAutowireTarget(Object command) {
-        command instanceof LegacyApplicationCommandAware ?
-            ((LegacyApplicationCommandAware) command).legacyCommand : command
+        command instanceof ApplicationCommandTargetAware ?
+            ((ApplicationCommandTargetAware) command).target : command
     }
 
     /**
diff --git 
a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
 
b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
index 7e9202f176..1d177e928b 100644
--- 
a/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
+++ 
b/grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy
@@ -16,7 +16,7 @@
  */
 package grails.ui.command
 
-import org.apache.grails.core.cli.LegacyApplicationCommandAware
+import org.apache.grails.core.cli.ApplicationCommandTargetAware
 import spock.lang.Specification
 import spock.lang.Unroll
 
@@ -138,8 +138,8 @@ class GrailsApplicationContextCommandRunnerSpec extends 
Specification {
     def "resolveAutowireTarget unwraps a legacy command adapter"() {
         given:
         Object legacyCommand = new Object()
-        LegacyApplicationCommandAware adapter = Stub() {
-            getLegacyCommand() >> legacyCommand
+        ApplicationCommandTargetAware adapter = Stub() {
+            getTarget() >> legacyCommand
         }
 
         when:
diff --git a/grails-core-cli-legacy/build.gradle 
b/grails-core-cli-legacy/build.gradle
new file mode 100644
index 0000000000..cdf1ace8e9
--- /dev/null
+++ b/grails-core-cli-legacy/build.gradle
@@ -0,0 +1,67 @@
+/*
+ *  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.
+ */
+
+plugins {
+    id 'groovy'
+    id 'java-library'
+    id 'project-report'
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.dependency-validator'
+    id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.publish'
+    id 'org.apache.grails.buildsrc.sbom'
+    id 'org.apache.grails.buildsrc.vulnerability-scan'
+    id 'org.apache.grails.gradle.grails-code-style'
+    id 'org.apache.grails.gradle.grails-cli-library'
+    id 'org.apache.grails.gradle.grails-jacoco'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+dependencies {
+    implementation platform(project(':grails-bom'))
+
+    api project(':grails-core')
+    api(project(':grails-core')) {
+        capabilities {
+            requireCapability('org.apache.grails:grails-core-cli')
+        }
+    }
+    api 'org.apache.groovy:groovy'
+    implementation 'org.apache.groovy:groovy-templates'
+
+    testImplementation 'org.apache.groovy:groovy-test-junit5'
+    testImplementation 'org.junit.jupiter:junit-jupiter-api'
+    testImplementation 'org.junit.platform:junit-platform-suite'
+    testImplementation 'org.spockframework:spock-core'
+    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
+    testImplementation 'ch.qos.logback:logback-classic'
+    testRuntimeOnly 'net.bytebuddy:byte-buddy'
+    testRuntimeOnly 'org.objenesis:objenesis'
+}
+
+tasks.named('jar', Jar).configure {
+    manifest.attributes('Automatic-Module-Name': 
'org.apache.grails.core.cli.legacy')
+}
+
+apply {
+    from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
+    from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+}
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/ApplicationCommand.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/ApplicationCommand.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/ExecutionContext.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/ExecutionContext.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/GrailsApplicationCommand.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/io/FileSystemInteraction.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateException.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateException.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateRenderer.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateRenderer.groovy
diff --git 
a/grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
 
b/grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
similarity index 100%
rename from 
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
 
b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
similarity index 94%
rename from 
grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
rename to 
grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
index b572d46898..2480e0fd92 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
+++ 
b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy
@@ -21,8 +21,8 @@ package org.apache.grails.core.cli.compat
 import groovy.transform.CompileStatic
 
 import org.apache.grails.core.cli.ApplicationCommand
+import org.apache.grails.core.cli.ApplicationCommandTargetAware
 import org.apache.grails.core.cli.ExecutionContext
-import org.apache.grails.core.cli.LegacyApplicationCommandAware
 import org.springframework.context.ConfigurableApplicationContext
 
 /**
@@ -30,7 +30,7 @@ import 
org.springframework.context.ConfigurableApplicationContext
  */
 @SuppressWarnings('deprecation')
 @CompileStatic
-class LegacyApplicationCommandAdapter implements ApplicationCommand, 
LegacyApplicationCommandAware {
+class LegacyApplicationCommandAdapter implements ApplicationCommand, 
ApplicationCommandTargetAware {
 
     private final grails.dev.commands.ApplicationCommand legacyCommand
 
@@ -39,7 +39,7 @@ class LegacyApplicationCommandAdapter implements 
ApplicationCommand, LegacyAppli
     }
 
     @Override
-    Object getLegacyCommand() {
+    Object getTarget() {
         legacyCommand
     }
 
diff --git 
a/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy
 
b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy
new file mode 100644
index 0000000000..f9f5a14bb4
--- /dev/null
+++ 
b/grails-core-cli-legacy/src/main/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandProvider.groovy
@@ -0,0 +1,69 @@
+/*
+ *  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.apache.grails.core.cli.compat
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import grails.dev.commands.ApplicationCommand as LegacyApplicationCommand
+import org.apache.grails.core.cli.ApplicationCommand
+import org.apache.grails.core.cli.ApplicationCommandProvider
+import org.apache.grails.core.cli.ApplicationCommandRegistrar
+import org.grails.core.io.support.GrailsFactoriesLoader
+import org.grails.io.support.FactoriesLoaderSupport
+
+/**
+ * Loads commands implemented against the deprecated Grails 7 command contract.
+ */
+@Slf4j
+@CompileStatic
+class LegacyApplicationCommandProvider implements ApplicationCommandProvider {
+
+    private boolean warningLogged
+
+    @Override
+    @SuppressWarnings('deprecation')
+    void contributeCommands(
+        ClassLoader registryClassLoader,
+        ClassLoader contextClassLoader,
+        ApplicationCommandRegistrar registrar) {
+        Set<Class<? extends LegacyApplicationCommand>> legacyClasses = new 
LinkedHashSet<>()
+        legacyClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
+                LegacyApplicationCommand, registryClassLoader, 
FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION))
+        if (contextClassLoader != null && contextClassLoader != 
registryClassLoader) {
+            legacyClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
+                    LegacyApplicationCommand, contextClassLoader, 
FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION))
+        }
+
+        for (Class<? extends LegacyApplicationCommand> legacyClass : 
legacyClasses) {
+            try {
+                LegacyApplicationCommand legacyCommand = 
legacyClass.getDeclaredConstructor().newInstance()
+                ApplicationCommand command = new 
LegacyApplicationCommandAdapter(legacyCommand)
+                if (registrar.register(command) && !warningLogged) {
+                    log.warn("Command '{}' from a Grails 7 plugin was loaded 
through the deprecated grails.dev.commands compatibility layer. Ask the plugin 
author to migrate to the org.apache.grails.core.cli command API and publish a 
-cli companion artifact; this compatibility path will be removed in a future 
major release.", command.name)
+                    warningLogged = true
+                }
+            }
+            catch (Throwable e) {
+                log.warn("Failed to load a Grails 7 legacy command from class 
'{}' through the deprecated grails.dev.commands compatibility layer; skipping 
it.",
+                        legacyClass?.name, e)
+            }
+        }
+    }
+}
diff --git 
a/grails-core-cli-legacy/src/main/resources/META-INF/grails-cli.factories 
b/grails-core-cli-legacy/src/main/resources/META-INF/grails-cli.factories
new file mode 100644
index 0000000000..929762413d
--- /dev/null
+++ b/grails-core-cli-legacy/src/main/resources/META-INF/grails-cli.factories
@@ -0,0 +1,16 @@
+# 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
+#
+#     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.
+
+org.apache.grails.core.cli.ApplicationCommandProvider=org.apache.grails.core.cli.compat.LegacyApplicationCommandProvider
diff --git 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
 
b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
similarity index 74%
rename from 
grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
rename to 
grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
index 321633a65b..ca314e7a95 100644
--- 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
+++ 
b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy
@@ -18,9 +18,14 @@
  */
 package org.apache.grails.core.cli
 
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
 import grails.dev.commands.ExecutionContext as LegacyExecutionContext
 import org.apache.grails.core.cli.compat.LegacyApplicationCommandAdapter
+import org.apache.grails.core.cli.compat.LegacyApplicationCommandProvider
 import org.grails.build.parsing.CommandLine
+import org.slf4j.LoggerFactory
 import org.springframework.context.ConfigurableApplicationContext
 import spock.lang.Specification
 import spock.lang.TempDir
@@ -32,10 +37,13 @@ class LegacyCommandRegistryLoadingSpec extends 
Specification {
 
     private ClassLoader originalContextClassLoader
     private URLClassLoader factoryClassLoader
+    private Logger providerLogger
+    private ListAppender<ILoggingEvent> providerAppender
 
     def cleanup() {
         Thread.currentThread().contextClassLoader = originalContextClassLoader
         factoryClassLoader?.close()
+        providerLogger?.detachAppender(providerAppender)
     }
 
     def "discovers adapts and executes a command registered under the legacy 
factory key"() {
@@ -49,10 +57,10 @@ class LegacyCommandRegistryLoadingSpec extends 
Specification {
 
         then:
         command instanceof LegacyApplicationCommandAdapter
-        command instanceof LegacyApplicationCommandAware
+        command instanceof ApplicationCommandTargetAware
         LegacyApplicationCommandAdapter adapter = command as 
LegacyApplicationCommandAdapter
-        LegacyRegistryTestCommand legacyCommand = 
((LegacyApplicationCommandAware) command).legacyCommand as 
LegacyRegistryTestCommand
-        adapter.legacyCommand.is(legacyCommand)
+        LegacyRegistryTestCommand legacyCommand = 
((ApplicationCommandTargetAware) command).target as LegacyRegistryTestCommand
+        adapter.target.is(legacyCommand)
 
         when:
         adapter.applicationContext = applicationContext
@@ -76,10 +84,10 @@ class LegacyCommandRegistryLoadingSpec extends 
Specification {
 
         then:
         command instanceof LegacyApplicationCommandAdapter
-        command instanceof LegacyApplicationCommandAware
+        command instanceof ApplicationCommandTargetAware
         LegacyApplicationCommandAdapter adapter = command as 
LegacyApplicationCommandAdapter
-        LegacyRegistryGrailsApplicationTestCommand legacyCommand = 
((LegacyApplicationCommandAware) command).legacyCommand as 
LegacyRegistryGrailsApplicationTestCommand
-        adapter.legacyCommand.is(legacyCommand)
+        LegacyRegistryGrailsApplicationTestCommand legacyCommand = 
((ApplicationCommandTargetAware) command).target as 
LegacyRegistryGrailsApplicationTestCommand
+        adapter.target.is(legacyCommand)
 
         when:
         boolean result = adapter.handle(new ExecutionContext(commandLine))
@@ -121,12 +129,53 @@ class LegacyCommandRegistryLoadingSpec extends 
Specification {
 
         and:
         ApplicationCommand legacyCommand = 
registry.findCommand('legacy-registry-command')
-        legacyCommand instanceof LegacyApplicationCommandAware
+        legacyCommand instanceof ApplicationCommandTargetAware
 
         and:
         registry.findCommand('config-report') instanceof ConfigReportCommand
     }
 
+    def "does not warn when every legacy command collides with a modern 
command"() {
+        given:
+        attachProviderAppender()
+        useFactoryResources(
+                
'grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.LegacyRegistryCollisionCommand',
+                
'org.apache.grails.core.cli.ApplicationCommand=org.apache.grails.core.cli.ConfigReportCommand')
+
+        when:
+        new ApplicationContextCommandRegistry()
+
+        then:
+        deprecationWarnings.empty
+    }
+
+    def "warns once when multiple legacy commands are installed"() {
+        given:
+        attachProviderAppender()
+        
useFactoryResources('grails.dev.commands.ApplicationCommand=org.apache.grails.core.cli.LegacyRegistryTestCommand,org.apache.grails.core.cli.LegacyRegistryGrailsApplicationTestCommand')
+
+        when:
+        ApplicationContextCommandRegistry registry = new 
ApplicationContextCommandRegistry()
+
+        then:
+        registry.findCommand('legacy-registry-command') != null
+        registry.findCommand('legacy-registry-grails-application-command') != 
null
+        deprecationWarnings.size() == 1
+    }
+
+    private void attachProviderAppender() {
+        providerLogger = 
LoggerFactory.getLogger(LegacyApplicationCommandProvider) as Logger
+        providerAppender = new ListAppender<>()
+        providerAppender.start()
+        providerLogger.addAppender(providerAppender)
+    }
+
+    private List<ILoggingEvent> getDeprecationWarnings() {
+        providerAppender.list.findAll { ILoggingEvent event ->
+            event.formattedMessage.contains('deprecated grails.dev.commands 
compatibility layer')
+        }
+    }
+
     private void useFactoryResources(String legacyFactories, String 
cliFactories = null) {
         File metaInf = new File(tempDir, 'META-INF')
         assert metaInf.mkdirs()
diff --git 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
 
b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
similarity index 98%
rename from 
grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
rename to 
grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
index 6627d1f8a6..8229762c6b 100644
--- 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
+++ 
b/grails-core-cli-legacy/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy
@@ -42,7 +42,7 @@ class LegacyApplicationCommandAdapterSpec extends 
Specification {
         adapter.name == 'legacy-command'
         adapter.description == 'Legacy command description'
         adapter.applicationContext.is(applicationContext)
-        adapter.legacyCommand.is(legacyCommand)
+        adapter.target.is(legacyCommand)
         result
         legacyCommand.applicationContext.is(applicationContext)
         legacyCommand.executionContext instanceof LegacyExecutionContext
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandProvider.groovy
similarity index 74%
copy from 
grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
copy to 
grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandProvider.groovy
index 87708ef6f3..e12e2f2a23 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
+++ 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandProvider.groovy
@@ -19,10 +19,14 @@
 package org.apache.grails.core.cli
 
 /**
- * Implemented by adapters that wrap a legacy {@code 
grails.dev.commands.ApplicationCommand}.
- * Lets a launcher unwrap the real command for Spring autowiring and property 
inspection.
+ * Contributes application commands discovered outside the primary command 
contract.
+ *
+ * @since 8.0
  */
-interface LegacyApplicationCommandAware {
+interface ApplicationCommandProvider {
 
-    Object getLegacyCommand()
+    void contributeCommands(
+        ClassLoader registryClassLoader,
+        ClassLoader contextClassLoader,
+        ApplicationCommandRegistrar registrar)
 }
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandRegistrar.groovy
similarity index 77%
copy from 
grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
copy to 
grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandRegistrar.groovy
index 87708ef6f3..dd36889959 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
+++ 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandRegistrar.groovy
@@ -19,10 +19,11 @@
 package org.apache.grails.core.cli
 
 /**
- * Implemented by adapters that wrap a legacy {@code 
grails.dev.commands.ApplicationCommand}.
- * Lets a launcher unwrap the real command for Spring autowiring and property 
inspection.
+ * Installs a contributed command unless its name is already registered.
+ *
+ * @since 8.0
  */
-interface LegacyApplicationCommandAware {
+interface ApplicationCommandRegistrar {
 
-    Object getLegacyCommand()
+    boolean register(ApplicationCommand command)
 }
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandTargetAware.groovy
similarity index 77%
rename from 
grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
rename to 
grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandTargetAware.groovy
index 87708ef6f3..b9936d2ee9 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy
+++ 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationCommandTargetAware.groovy
@@ -19,10 +19,11 @@
 package org.apache.grails.core.cli
 
 /**
- * Implemented by adapters that wrap a legacy {@code 
grails.dev.commands.ApplicationCommand}.
- * Lets a launcher unwrap the real command for Spring autowiring and property 
inspection.
+ * Implemented by command adapters whose underlying target should be autowired 
and inspected.
+ *
+ * @since 8.0
  */
-interface LegacyApplicationCommandAware {
+interface ApplicationCommandTargetAware {
 
-    Object getLegacyCommand()
+    Object getTarget()
 }
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
index 5007cb3e1d..93690c455d 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
+++ 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy
@@ -19,9 +19,7 @@ package org.apache.grails.core.cli
 import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
 
-import org.apache.grails.core.cli.compat.LegacyApplicationCommandAdapter
 import org.grails.core.io.support.GrailsFactoriesLoader
-import org.grails.io.support.FactoriesLoaderSupport
 
 /**
  * A registry of {@link org.apache.grails.core.cli.ApplicationCommand} 
instances
@@ -34,8 +32,6 @@ import org.grails.io.support.FactoriesLoaderSupport
 class ApplicationContextCommandRegistry {
 
     private final Map<String, ApplicationCommand> commands = [:]
-    private boolean legacyCommandWarningLogged
-
     ApplicationContextCommandRegistry() {
         ClassLoader registryClassLoader = 
ApplicationContextCommandRegistry.classLoader
         ClassLoader contextClassLoader = 
Thread.currentThread().contextClassLoader
@@ -51,7 +47,7 @@ class ApplicationContextCommandRegistry {
         // also used to pull any commands that are loaded from the gradle 
classpath. Only when it is a distinct
         // classloader: repeating the scan for the same classloader would 
re-instantiate every command (whose
         // constructor may have side effects) just to discard it on the 
name-collision check below.
-        if (contextClassLoader != registryClassLoader) {
+        if (contextClassLoader != null && contextClassLoader != 
registryClassLoader) {
             for (ApplicationCommand cmd : 
GrailsFactoriesLoader.loadFactories(ApplicationCommand,
                     contextClassLoader, 
GrailsFactoriesLoader.CLI_FACTORIES_RESOURCE_LOCATION)) {
                 if (!commands.containsKey(cmd.name)) {
@@ -60,44 +56,37 @@ class ApplicationContextCommandRegistry {
             }
         }
 
-        loadLegacyCommands(registryClassLoader, contextClassLoader)
+        loadCommandProviders(registryClassLoader, contextClassLoader)
     }
 
-    @SuppressWarnings('deprecation')
-    private void loadLegacyCommands(ClassLoader registryClassLoader, 
ClassLoader contextClassLoader) {
-        // Gather the legacy command classes from the registry classloader 
and, when it is a distinct
-        // classloader, the thread context classloader, de-duplicated by Class 
identity before any are
-        // instantiated. A child context classloader delegates to its parent, 
so it also reports the
-        // parent's grails.factories entries; de-duplicating by the resolved 
Class avoids instantiating a
-        // parent-visible legacy command twice (its constructor may have side 
effects) only to discard the
-        // duplicate on the name-collision check below.
-        Set<Class<? extends grails.dev.commands.ApplicationCommand>> 
legacyClasses = new LinkedHashSet<>()
-        legacyClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
-                grails.dev.commands.ApplicationCommand, registryClassLoader, 
FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION))
+    private void loadCommandProviders(ClassLoader registryClassLoader, 
ClassLoader contextClassLoader) {
+        Set<Class<? extends ApplicationCommandProvider>> providerClasses = new 
LinkedHashSet<>()
+        providerClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
+                ApplicationCommandProvider, registryClassLoader, 
GrailsFactoriesLoader.CLI_FACTORIES_RESOURCE_LOCATION))
         if (contextClassLoader != null && contextClassLoader != 
registryClassLoader) {
-            legacyClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
-                    grails.dev.commands.ApplicationCommand, 
contextClassLoader, FactoriesLoaderSupport.FACTORIES_RESOURCE_LOCATION))
+            providerClasses.addAll(GrailsFactoriesLoader.loadFactoryClasses(
+                    ApplicationCommandProvider, contextClassLoader, 
GrailsFactoriesLoader.CLI_FACTORIES_RESOURCE_LOCATION))
         }
-        // Instantiate each in isolation: a single stale Grails 7 command 
whose no-arg constructor (or
-        // getName()) throws under Grails 8 must be skipped with a warning, 
never abort the whole registry
-        // and take valid legacy and new-contract commands down with it.
-        for (Class<? extends grails.dev.commands.ApplicationCommand> 
legacyClass : legacyClasses) {
-            try {
-                grails.dev.commands.ApplicationCommand legacyCommand = 
legacyClass.getDeclaredConstructor().newInstance()
-                ApplicationCommand command = new 
LegacyApplicationCommandAdapter(legacyCommand)
+
+        ApplicationCommandRegistrar registrar = new 
ApplicationCommandRegistrar() {
+            @Override
+            boolean register(ApplicationCommand command) {
                 String name = command.name
                 if (commands.containsKey(name)) {
-                    continue
+                    return false
                 }
                 commands[name] = command
-                if (!legacyCommandWarningLogged) {
-                    log.warn("Command '{}' from a Grails 7 plugin was loaded 
through the deprecated grails.dev.commands compatibility layer. Ask the plugin 
author to migrate to the org.apache.grails.core.cli command API and publish a 
-cli companion artifact; this compatibility path will be removed in a future 
major release.", name)
-                    legacyCommandWarningLogged = true
-                }
+                true
+            }
+        }
+
+        for (Class<? extends ApplicationCommandProvider> providerClass : 
providerClasses) {
+            try {
+                ApplicationCommandProvider provider = 
providerClass.getDeclaredConstructor().newInstance()
+                provider.contributeCommands(registryClassLoader, 
contextClassLoader, registrar)
             }
             catch (Throwable e) {
-                log.warn("Failed to load a Grails 7 legacy command from class 
'{}' through the deprecated grails.dev.commands compatibility layer; skipping 
it.",
-                        legacyClass?.name, e)
+                log.warn("Failed to load application commands from provider 
'{}'; skipping it.", providerClass?.name, e)
             }
         }
     }
diff --git 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformation.groovy
 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformation.groovy
index 913736d12e..34f1fae390 100644
--- 
a/grails-core/src/cli/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformation.groovy
+++ 
b/grails-core/src/cli/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformation.groovy
@@ -55,6 +55,8 @@ class CommandFactoriesTransformation implements 
ASTTransformation, TransformWith
 
     public static final ClassNode APPLICATION_COMMAND_CLASS = 
ClassHelper.make('org.apache.grails.core.cli.ApplicationCommand')
 
+    public static final ClassNode APPLICATION_COMMAND_TARGET_AWARE_CLASS = 
ClassHelper.make('org.apache.grails.core.cli.ApplicationCommandTargetAware')
+
     /** The command registration file written into the compilation target 
directory */
     public static final String CLI_FACTORIES_LOCATION = 
'META-INF/grails-cli.factories'
 
@@ -94,8 +96,16 @@ class CommandFactoriesTransformation implements 
ASTTransformation, TransformWith
         File compilationTargetDirectory = 
GlobalGrailsClassInjectorTransformation.resolveCompilationTargetDirectory(source)
 
         for (ClassNode classNode : classes) {
-            FactoriesFileWriter.updateFactoriesWithType(classNode, 
APPLICATION_COMMAND_CLASS,
-                    compilationTargetDirectory, CLI_FACTORIES_LOCATION, 
SOURCE_CLI_FACTORIES_LOCATIONS)
+            if (shouldRegisterCommand(classNode)) {
+                FactoriesFileWriter.updateFactoriesWithType(classNode, 
APPLICATION_COMMAND_CLASS,
+                        compilationTargetDirectory, CLI_FACTORIES_LOCATION, 
SOURCE_CLI_FACTORIES_LOCATIONS)
+            }
+        }
+    }
+
+    protected static boolean shouldRegisterCommand(ClassNode classNode) {
+        !classNode.allInterfaces.any { ClassNode interfaceNode ->
+            interfaceNode.name == APPLICATION_COMMAND_TARGET_AWARE_CLASS.name
         }
     }
 
diff --git 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy
 
b/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy
new file mode 100644
index 0000000000..d72122205a
--- /dev/null
+++ 
b/grails-core/src/test/groovy/org/apache/grails/core/cli/ApplicationCommandProviderSpec.groovy
@@ -0,0 +1,86 @@
+/*
+ *  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.apache.grails.core.cli
+
+import java.nio.file.Path
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class ApplicationCommandProviderSpec extends Specification {
+
+    @TempDir
+    Path tempDir
+
+    private ClassLoader originalContextClassLoader
+    private URLClassLoader factoryClassLoader
+
+    def cleanup() {
+        Thread.currentThread().contextClassLoader = originalContextClassLoader
+        factoryClassLoader?.close()
+    }
+
+    def "continues loading providers when one provider constructor fails"() {
+        given:
+        File metaInf = new File(tempDir.toFile(), 'META-INF')
+        assert metaInf.mkdirs()
+        new File(metaInf, 'grails-cli.factories').text = 
"${ApplicationCommandProvider.name}=${ThrowingCommandProvider.name},${TestCommandProvider.name}"
+        originalContextClassLoader = Thread.currentThread().contextClassLoader
+        factoryClassLoader = new URLClassLoader([tempDir.toUri().toURL()] as 
URL[], getClass().classLoader)
+        Thread.currentThread().contextClassLoader = factoryClassLoader
+
+        when:
+        ApplicationCommand command = new 
ApplicationContextCommandRegistry().findCommand('provider-test')
+
+        then:
+        command instanceof ProviderTestCommand
+    }
+}
+
+class ThrowingCommandProvider implements ApplicationCommandProvider {
+
+    ThrowingCommandProvider() {
+        throw new RuntimeException('boom')
+    }
+
+    @Override
+    void contributeCommands(ClassLoader registryClassLoader, ClassLoader 
contextClassLoader, ApplicationCommandRegistrar registrar) {
+    }
+}
+
+class TestCommandProvider implements ApplicationCommandProvider {
+
+    @Override
+    void contributeCommands(ClassLoader registryClassLoader, ClassLoader 
contextClassLoader, ApplicationCommandRegistrar registrar) {
+        registrar.register(new ProviderTestCommand())
+    }
+}
+
+class ProviderTestCommand implements ApplicationCommand {
+
+    @Override
+    String getName() {
+        'provider-test'
+    }
+
+    @Override
+    boolean handle(ExecutionContext executionContext) {
+        true
+    }
+}
diff --git 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformationSpec.groovy
 
b/grails-core/src/test/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformationSpec.groovy
index e051b67b5c..8c1e03dfe5 100644
--- 
a/grails-core/src/test/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformationSpec.groovy
+++ 
b/grails-core/src/test/groovy/org/apache/grails/core/cli/compiler/CommandFactoriesTransformationSpec.groovy
@@ -18,6 +18,9 @@
  */
 package org.apache.grails.core.cli.compiler
 
+import org.codehaus.groovy.ast.ClassHelper
+
+import org.apache.grails.core.cli.ApplicationCommandTargetAware
 import spock.lang.Specification
 import spock.lang.Unroll
 
@@ -42,4 +45,17 @@ class CommandFactoriesTransformationSpec extends 
Specification {
         '/work/my-plugin/grails-app/commands/example/BackupCommand.groovy'     
 || false
         '/work/clipboard/src/climate/groovy/com/example/NotCliCommand.groovy'  
 || false
     }
+
+    def "does not register target-aware command adapters as command 
factories"() {
+        expect:
+        
!CommandFactoriesTransformation.shouldRegisterCommand(ClassHelper.make(TargetAwareTestAdapter))
+    }
+}
+
+class TargetAwareTestAdapter implements ApplicationCommandTargetAware {
+
+    @Override
+    Object getTarget() {
+        null
+    }
 }
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc 
b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index c1a139dc63..be33cb8f93 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -118,6 +118,11 @@ dependencies {
 The auto-provisioning can be disabled with `grails { cliAutoProvision = false 
}`, in which case
 `grailsCli` dependencies are declared explicitly.
 
+The plugin also provisions `grails-core-cli-legacy` into a separate 
execution-only
+`grailsCliLegacy` bucket. That bucket contributes to `grailsCliClasspath`, 
never to application or
+plugin compile classpaths. This keeps unchanged Grails 7 command plugins 
working by default with
+zero user action without leaking `grails.dev.commands.*` into the Grails 8 
command-authoring ABI.
+
 Plugin authors get the whole publishing side from the new
 `org.apache.grails.gradle.grails-plugin-cli` Gradle plugin: it creates the 
`cli` source set as a
 feature variant, wires the command contract onto its compile classpath, 
advertises the companion
@@ -133,7 +138,9 @@ commands is unaffected, and a command-bearing plugin that 
has not yet been rebui
 keeps its runtime functionality. Its legacy 
`grails.dev.commands.ApplicationCommand` registrations
 also continue to work through a deprecated compatibility layer, so those 
commands remain available
 without a re-release; publishing a companion `-cli` artifact is recommended 
when migrating to the
-Grails 8 command API. See the xref:upgrading#upgrading80x[upgrade guide] for 
the migration steps and
+Grails 8 command API. New commands should use `org.apache.grails.core.cli.*`. 
A plugin that still
+authors legacy commands must explicitly compile against 
`grails-core-cli-legacy`; consumers of an
+already-published legacy plugin need no declaration. See the 
xref:upgrading#upgrading80x[upgrade guide] for the migration steps and
 the full upgrade-impact breakdown.
 
 ==== GSP Tag Library Improvements
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc 
b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 42283884f8..75553e7600 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1655,6 +1655,12 @@ The `console` and `shell` tasks also draw from the 
provisioned cli tier, so the
 `build.gradle` is no longer needed and can be removed (the `console` 
configuration itself remains
 for additional console-only dependencies).
 
+The deprecated Grails 7 command contract is isolated in
+`org.apache.grails:grails-core-cli-legacy`. While `cliAutoProvision` is 
enabled, the plugin adds
+this artifact to the execution-only `grailsCliLegacy` bucket. 
`grailsCliClasspath` includes that
+bucket, but application and plugin compile classpaths do not. Existing Grails 
7 command plugins
+therefore keep working by default without exposing the deprecated API to new 
command sources.
+
 Disable the discovery with `grails { cliAutoProvision = false }` and declare 
the cli tier
 explicitly when full control over the command classpath is preferred:
 
@@ -1670,6 +1676,7 @@ dependencies {
     grailsCli 'org.apache.grails:grails-core-cli'
     grailsCli 'org.apache.grails:grails-console'
     grailsCli 'org.apache.grails:grails-data-hibernate7-dbmigration-cli'
+    grailsCliLegacy 'org.apache.grails:grails-core-cli-legacy'
 }
 ----
 
@@ -1699,10 +1706,12 @@ The renamed framework command packages are:
 
 ===== 35.1 Backwards Compatibility for Existing Command Plugins
 
-Grails 8 provides a deprecated compatibility layer for already-published 
Grails 7 command
+Grails 8 provides a deprecated, execution-only compatibility layer for 
already-published Grails 7 command
 plugins. Application commands created with `create-command` that implement
 `grails.dev.commands.ApplicationCommand` or 
`grails.dev.commands.GrailsApplicationCommand` remain
-available without a plugin re-release. The deprecated `grails.dev.commands.*` 
types are retained,
+available without a plugin re-release. The deprecated `grails.dev.commands.*` 
types are retained in
+`org.apache.grails:grails-core-cli-legacy`, which is provisioned by default 
only on the command
+runner classpath,
 and registrations in `META-INF/grails.factories` under
 `grails.dev.commands.ApplicationCommand` continue to be discovered at runtime.
 
@@ -1710,6 +1719,11 @@ Grails logs a one-time deprecation warning when it uses 
this compatibility layer
 to keep existing published plugins working while plugin authors migrate; it is 
deprecated and will
 be removed in a future major release.
 
+New and migrated commands should always compile against 
`org.apache.grails.core.cli.*` from
+`grails-core-cli`. A plugin that intentionally continues to author commands 
against the deprecated
+contract must declare `compileOnly 'org.apache.grails:grails-core-cli-legacy'` 
explicitly; normal
+consumers still need no declaration or other action.
+
 **What just works:**
 
 * Existing published Grails 7 application-command plugins continue to work on 
Grails 8 without a
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
index 9e4264304a..ea9cd30552 100644
--- 
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
@@ -77,6 +77,9 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
      */
     public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli'
 
+    /** Execution-only dependencies used by the command compatibility layer */
+    public static final String GRAILS_CLI_LEGACY_CONFIGURATION = 
'grailsCliLegacy'
+
     /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the 
command-runner tasks */
     public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 
'grailsCliClasspath'
 
@@ -127,6 +130,17 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
         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.'
 
+        Configuration grailsCliLegacy = 
configurations.create(GRAILS_CLI_LEGACY_CONFIGURATION)
+        grailsCliLegacy.canBeResolved = false
+        grailsCliLegacy.canBeConsumed = false
+        grailsCliLegacy.description = 'Execution-only compatibility 
dependencies used by the command runner; never compile-visible.'
+
+        configurations.matching { Configuration it ->
+            it.name in ['testRuntimeClasspath', 
'integrationTestRuntimeClasspath']
+        }.configureEach { Configuration it ->
+            it.extendsFrom(grailsCliLegacy)
+        }
+
         // 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
@@ -139,7 +153,7 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
         }
 
         Configuration grailsCliClasspath = 
configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION)
-        grailsCliClasspath.extendsFrom(grailsCli)
+        grailsCliClasspath.extendsFrom(grailsCli, grailsCliLegacy)
         grailsCliClasspath.canBeResolved = true
         grailsCliClasspath.canBeConsumed = false
         grailsCliClasspath.description = 'Resolvable view of grailsCli used by 
the command-runner tasks.'
@@ -160,6 +174,9 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
         grailsCli.withDependencies { DependencySet dependencies ->
             autoProvisionCliDependencies(project, dependencies)
         }
+        grailsCliLegacy.withDependencies { DependencySet dependencies ->
+            autoProvisionLegacyCliDependencies(project, dependencies)
+        }
     }
 
     /**
@@ -213,6 +230,13 @@ class GrailsCliGradlePlugin implements Plugin<Project> {
         }
     }
 
+    protected void autoProvisionLegacyCliDependencies(Project project, 
DependencySet dependencies) {
+        GrailsExtension grails = project.extensions.getByType(GrailsExtension)
+        if (grails.cliAutoProvision.get()) {
+            
dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli-legacy'))
+        }
+    }
+
     /**
      * Returns the {@link Dependency} on the companion cli artifact advertised 
by the given
      * resolved artifact, or {@code null} if it advertises none.
diff --git 
a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/commands/CliAutoDiscoverySpec.groovy
 
b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/commands/CliAutoDiscoverySpec.groovy
index 0e93ba3e04..91640eec1c 100644
--- 
a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/commands/CliAutoDiscoverySpec.groovy
+++ 
b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/commands/CliAutoDiscoverySpec.groovy
@@ -39,7 +39,7 @@ class CliAutoDiscoverySpec extends GradleSpecification {
         setupTestResourceProject('cli-companion-autodiscovery')
 
         when: 'the app resolves its grailsCli dependencies'
-        def result = executeTask(':app:inspectGrailsCli')
+        def result = executeTask(':app:inspectGrailsCli', 
[':app:inspectGrailsCliLegacy', ':app:inspectClasspathWiring'])
 
         then: 'the companion is a project dependency requesting the plugin cli 
capability'
         result.output.contains('GRAILSCLI_DEP: project path=:my-plugin 
capabilities=[org.example.test:my-plugin-cli]')
@@ -50,6 +50,17 @@ class CliAutoDiscoverySpec extends GradleSpecification {
         and: 'the command contract and runner are auto-provisioned as well'
         result.output.contains('GRAILSCLI_DEP: module 
org.apache.grails:grails-core-cli')
         result.output.contains('GRAILSCLI_DEP: module 
org.apache.grails:grails-console')
+
+        and: 'the legacy bridge is execution-only'
+        result.output.contains('GRAILSCLILEGACY_DEP: module 
org.apache.grails:grails-core-cli-legacy')
+        !result.output.contains('GRAILSCLI_DEP: module 
org.apache.grails:grails-core-cli-legacy')
+        result.output.contains('CLASSPATH_EXTENDS_GRAILSCLILEGACY=true')
+        result.output.contains('COMPILE_EXTENDS_GRAILSCLILEGACY=false')
+        result.output.contains('COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        
result.output.contains('INTEGRATION_TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_RUNTIME_HAS_GRAILSCLILEGACY=true')
+        
result.output.contains('INTEGRATION_TEST_RUNTIME_HAS_GRAILSCLILEGACY=true')
     }
 
     def "a companion with a customized artifactId is discovered and resolves 
through its advertised capability"() {
@@ -77,6 +88,13 @@ class CliAutoDiscoverySpec extends GradleSpecification {
         then: 'grailsCli extends the compile classpath but not the runtime 
classpath'
         result.output.contains('COMPILE_EXTENDS_GRAILSCLI=true')
         result.output.contains('RUNTIME_EXTENDS_GRAILSCLI=false')
+        result.output.contains('COMPILE_EXTENDS_GRAILSCLILEGACY=false')
+        result.output.contains('CLASSPATH_EXTENDS_GRAILSCLILEGACY=true')
+        result.output.contains('COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        
result.output.contains('INTEGRATION_TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_RUNTIME_HAS_GRAILSCLILEGACY=true')
+        
result.output.contains('INTEGRATION_TEST_RUNTIME_HAS_GRAILSCLILEGACY=true')
 
         and: 'no companion (-cli) jar reaches the resolved runtime classpath'
         !result.output.readLines().any { it.startsWith('RUNTIME_ARTIFACT:') && 
it.contains('-cli') }
@@ -87,9 +105,15 @@ class CliAutoDiscoverySpec extends GradleSpecification {
         setupTestResourceProject('cli-companion-autodiscovery')
 
         when:
-        def result = executeTask(':app:inspectGrailsCli', 
['-PgrailsCliAutoProvision=false'])
+        def result = executeTask(':app:inspectGrailsCli', 
[':app:inspectGrailsCliLegacy', ':app:inspectClasspathWiring', 
'-PgrailsCliAutoProvision=false'])
 
         then: 'nothing is provisioned onto grailsCli — neither the companion 
nor the contract/runner'
         !result.output.contains('GRAILSCLI_DEP:')
+        !result.output.contains('GRAILSCLILEGACY_DEP:')
+        result.output.contains('COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        
result.output.contains('INTEGRATION_TEST_COMPILE_HAS_GRAILSCLILEGACY=false')
+        result.output.contains('TEST_RUNTIME_HAS_GRAILSCLILEGACY=false')
+        
result.output.contains('INTEGRATION_TEST_RUNTIME_HAS_GRAILSCLILEGACY=false')
     }
 }
diff --git 
a/grails-gradle/plugins/src/test/resources/test-projects/cli-companion-autodiscovery/app/build.gradle
 
b/grails-gradle/plugins/src/test/resources/test-projects/cli-companion-autodiscovery/app/build.gradle
index 8294d50628..c07ff5e876 100644
--- 
a/grails-gradle/plugins/src/test/resources/test-projects/cli-companion-autodiscovery/app/build.gradle
+++ 
b/grails-gradle/plugins/src/test/resources/test-projects/cli-companion-autodiscovery/app/build.gradle
@@ -5,6 +5,10 @@ plugins {
     id 'org.apache.grails.gradle.grails-cli'
 }
 
+sourceSets {
+    integrationTest
+}
+
 dependencies {
     implementation project(':my-plugin')
     implementation project(':renamed-plugin')
@@ -30,8 +34,28 @@ tasks.register('inspectGrailsCli') {
 tasks.register('inspectClasspathWiring') {
     doLast {
         def extendsGrailsCli = { org.gradle.api.artifacts.Configuration c -> 
'grailsCli' in (c.hierarchy*.name as Set) }
+        def extendsGrailsCliLegacy = { org.gradle.api.artifacts.Configuration 
c -> 'grailsCliLegacy' in (c.hierarchy*.name as Set) }
+        def hasGrailsCliLegacy = { org.gradle.api.artifacts.Configuration c ->
+            c.allDependencies.any { it.group == 'org.apache.grails' && it.name 
== 'grails-core-cli-legacy' }
+        }
         println 
"COMPILE_EXTENDS_GRAILSCLI=${extendsGrailsCli(configurations.compileClasspath)}"
+        println 
"COMPILE_EXTENDS_GRAILSCLILEGACY=${extendsGrailsCliLegacy(configurations.compileClasspath)}"
         println 
"RUNTIME_EXTENDS_GRAILSCLI=${extendsGrailsCli(configurations.runtimeClasspath)}"
+        println 
"CLASSPATH_EXTENDS_GRAILSCLILEGACY=${extendsGrailsCliLegacy(configurations.grailsCliClasspath)}"
+        println 
"COMPILE_HAS_GRAILSCLILEGACY=${hasGrailsCliLegacy(configurations.compileClasspath)}"
+        println 
"TEST_COMPILE_HAS_GRAILSCLILEGACY=${hasGrailsCliLegacy(configurations.testCompileClasspath)}"
+        println 
"INTEGRATION_TEST_COMPILE_HAS_GRAILSCLILEGACY=${hasGrailsCliLegacy(configurations.integrationTestCompileClasspath)}"
+        println 
"TEST_RUNTIME_HAS_GRAILSCLILEGACY=${hasGrailsCliLegacy(configurations.testRuntimeClasspath)}"
+        println 
"INTEGRATION_TEST_RUNTIME_HAS_GRAILSCLILEGACY=${hasGrailsCliLegacy(configurations.integrationTestRuntimeClasspath)}"
+    }
+}
+
+tasks.register('inspectGrailsCliLegacy') {
+    def grailsCliLegacy = configurations.grailsCliLegacy
+    doLast {
+        grailsCliLegacy.dependencies.each { dep ->
+            println "GRAILSCLILEGACY_DEP: module ${dep.group}:${dep.name}"
+        }
     }
 }
 
diff --git 
a/grails-gradle/plugins/src/test/resources/test-projects/legacy-command-discovery/legacy-command-plugin/build.gradle
 
b/grails-gradle/plugins/src/test/resources/test-projects/legacy-command-discovery/legacy-command-plugin/build.gradle
index a27cd2b250..65acb306d8 100644
--- 
a/grails-gradle/plugins/src/test/resources/test-projects/legacy-command-discovery/legacy-command-plugin/build.gradle
+++ 
b/grails-gradle/plugins/src/test/resources/test-projects/legacy-command-discovery/legacy-command-plugin/build.gradle
@@ -4,11 +4,11 @@ plugins {
 
 configurations.configureEach {
     resolutionStrategy.dependencySubstitution {
-        substitute module('org.apache.grails:grails-core-cli') using 
project(':legacy-command-api')
+        substitute module('org.apache.grails:grails-core-cli-legacy') using 
project(':legacy-command-api')
     }
 }
 
 dependencies {
     implementation localGroovy()
-    compileOnly 'org.apache.grails:grails-core-cli'
+    compileOnly 'org.apache.grails:grails-core-cli-legacy'
 }
diff --git a/grails-test-examples/legacy-commands-plugin/build.gradle 
b/grails-test-examples/legacy-commands-plugin/build.gradle
index 08b64595a0..d57c0a1d9b 100644
--- a/grails-test-examples/legacy-commands-plugin/build.gradle
+++ b/grails-test-examples/legacy-commands-plugin/build.gradle
@@ -31,12 +31,12 @@ group = 'legacy.commands.plugin'
 dependencies {
     implementation platform(project(':grails-bom'))
 
-    // This fixture recompiles legacy command sources against Grails 8's 
grails-core-cli to
+    // This fixture recompiles legacy command sources against Grails 8's 
grails-core-cli-legacy to
     // validate discovery, adapter, registry, and runner wiring end-to-end. It 
does not
     // re-validate a pre-compiled Grails 7 binary's Groovy-trait ABI. That 
relies on Groovy's
     // stable trait encoding across 4->5 and could be strengthened later with 
a prebuilt Grails 7
     // fixture jar.
-    compileOnly 'org.apache.grails:grails-core-cli'
+    compileOnly 'org.apache.grails:grails-core-cli-legacy'
 }
 
 apply {
diff --git 
a/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
 
b/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
index c3fd9efe13..1ca798fae5 100644
--- 
a/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
+++ 
b/grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy
@@ -26,7 +26,7 @@ import grails.testing.mixin.integration.Integration
 import org.apache.grails.core.cli.ApplicationCommand
 import org.apache.grails.core.cli.ApplicationContextCommandRegistry
 import org.apache.grails.core.cli.ExecutionContext
-import org.apache.grails.core.cli.LegacyApplicationCommandAware
+import org.apache.grails.core.cli.ApplicationCommandTargetAware
 import org.grails.build.parsing.CommandLine
 import spock.lang.Specification
 
@@ -51,9 +51,9 @@ class LegacyCommandCompatibilityIntegrationSpec extends 
Specification {
 
         expect: 'both legacy factory registrations are discovered and adapted'
         applicationCommand != null
-        applicationCommand instanceof LegacyApplicationCommandAware
+        applicationCommand instanceof ApplicationCommandTargetAware
         grailsApplicationCommand != null
-        grailsApplicationCommand instanceof LegacyApplicationCommandAware
+        grailsApplicationCommand instanceof ApplicationCommandTargetAware
 
         when: 'the legacy ApplicationCommand runs through its Grails 8 adapter'
         applicationCommand.applicationContext = applicationContext
@@ -80,8 +80,8 @@ class LegacyCommandCompatibilityIntegrationSpec extends 
Specification {
     def "autowires the legacy command unwrapped from its Grails 8 adapter"() {
         given: 'a legacy command adapter and its wrapped target'
         ApplicationCommand applicationCommand = 
ApplicationContextCommandRegistry.instance.findCommand('hello-legacy-app')
-        LegacyApplicationCommandAware adapter = 
(LegacyApplicationCommandAware) applicationCommand
-        Object legacyCommand = adapter.legacyCommand
+        ApplicationCommandTargetAware adapter = 
(ApplicationCommandTargetAware) applicationCommand
+        Object legacyCommand = adapter.target
         ExecutionContext executionContext = new 
ExecutionContext(Mock(CommandLine))
         File applicationMarkerFile = new File(executionContext.baseDir, 
'hello-legacy-app.txt')
         applicationMarkerFile.delete()
diff --git a/settings.gradle b/settings.gradle
index 1b1889674b..2449ba89bf 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -115,6 +115,7 @@ include(
         'grails-controllers',
         'grails-converters',
         'grails-core',
+        'grails-core-cli-legacy',
         'grails-databinding-core',
         'grails-databinding',
         'grails-datasource',

Reply via email to