From 5e4f3f7128d348289bd1dadc86700ae8b7bf1645 Mon Sep 17 00:00:00 2001 From: Elys Rivero Date: Mon, 15 Jun 2026 11:37:15 +0200 Subject: [PATCH 1/3] Add global command prefix support Signed-off-by: Elys Rivero --- .../autoconfigure/SpringShellProperties.java | 10 +++ .../support/CommandFactoryBean.java | 11 +++- .../support/CommandFactoryBeanTests.java | 64 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/SpringShellProperties.java b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/SpringShellProperties.java index 2bc6f3aad..9d2619bf6 100644 --- a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/SpringShellProperties.java +++ b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/SpringShellProperties.java @@ -245,6 +245,8 @@ public void setEnabled(boolean enabled) { public static class Command { + private String prefix = ""; + private HelpCommand help = new HelpCommand(); private ClearCommand clear = new ClearCommand(); @@ -255,6 +257,14 @@ public static class Command { private VersionCommand version = new VersionCommand(); + public String getPrefix() { + return prefix; + } + + public void setPrefix(String prefix) { + this.prefix = prefix; + } + public void setHelp(HelpCommand help) { this.help = help; } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java index 30ac91785..ed7766d0a 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java @@ -18,6 +18,7 @@ import jakarta.validation.Validator; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeansException; import org.springframework.beans.factory.FactoryBean; @@ -91,7 +92,7 @@ public Command getObject() { } // get command metadata - String name = groupPrefix + (groupPrefix.isEmpty() ? "" : " ") + String.join(" ", command.name()); + var name = resolveName(groupPrefix, command); name = name.isEmpty() ? Utils.unCamelify(this.method.getName()) : name; String description = command.description(); description = description.isEmpty() ? "N/A" : description; @@ -138,6 +139,14 @@ public Command getObject() { return methodInvokerCommandAdapter; } + private String resolveName(String groupPrefix, org.springframework.shell.core.command.annotation.Command command) { + String effectivePrefix = groupPrefix; + if (effectivePrefix.isEmpty()) { + effectivePrefix = this.applicationContext.getEnvironment().getProperty("spring.shell.command.prefix", ""); + } + return effectivePrefix + (effectivePrefix.isEmpty() ? "" : " ") + String.join(" ", command.name()); + } + private List getCommandOptions() { List commandOptions = new ArrayList<>(); for (Parameter parameter : this.method.getParameters()) { diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java index dfae79d8d..814402e94 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java @@ -25,6 +25,7 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.env.Environment; import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.converter.GenericConverter; @@ -48,10 +49,17 @@ class CommandFactoryBeanTests { private static ApplicationContext mockApplicationContext() { + return mockApplicationContext(""); + } + + private static ApplicationContext mockApplicationContext(String globalPrefix) { ApplicationContext context = mock(ApplicationContext.class); when(context.getBeansOfType(Converter.class)).thenReturn(Collections.emptyMap()); when(context.getBeansOfType(GenericConverter.class)).thenReturn(Collections.emptyMap()); when(context.getBeansOfType(ConverterFactory.class)).thenReturn(Collections.emptyMap()); + Environment environment = mock(Environment.class); + when(environment.getProperty("spring.shell.command.prefix", "")).thenReturn(globalPrefix); + when(context.getEnvironment()).thenReturn(environment); return context; } @@ -181,6 +189,62 @@ private static void runConvertCommandAndAssert(ApplicationContext context) throw assertThat(ConverterTarget.lastSeen.text).isEqualTo("hello"); } + @Test + void globalPrefixAppliedWhenNoCommandGroupPrefix() throws Exception { + ApplicationContext context = mockApplicationContext("app"); + when(context.getBean(NoGroupCommands.class)).thenReturn(new NoGroupCommands()); + Method method = Arrays.stream(NoGroupCommands.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("ping")) + .findFirst() + .orElseThrow(); + CommandFactoryBean factory = new CommandFactoryBean(method); + factory.setApplicationContext(context); + + org.springframework.shell.core.command.Command result = factory.getObject(); + + assertEquals("app ping", result.getName()); + } + + @Test + void commandGroupPrefixTakesPrecedenceOverGlobalPrefix() throws Exception { + ApplicationContext context = mockApplicationContext("global"); + when(context.getBean(GreetingCommands.class)).thenReturn(new GreetingCommands()); + Method method = Arrays.stream(GreetingCommands.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("hi")) + .findFirst() + .orElseThrow(); + CommandFactoryBean factory = new CommandFactoryBean(method); + factory.setApplicationContext(context); + + org.springframework.shell.core.command.Command result = factory.getObject(); + + assertEquals("greeting hi", result.getName()); + } + + @Test + void noPrefixesLeaveNameUnchanged() throws Exception { + ApplicationContext context = mockApplicationContext(""); + when(context.getBean(NoGroupCommands.class)).thenReturn(new NoGroupCommands()); + Method method = Arrays.stream(NoGroupCommands.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("ping")) + .findFirst() + .orElseThrow(); + CommandFactoryBean factory = new CommandFactoryBean(method); + factory.setApplicationContext(context); + + org.springframework.shell.core.command.Command result = factory.getObject(); + + assertEquals("ping", result.getName()); + } + + static class NoGroupCommands { + + @Command(name = "ping") + public void ping() { + } + + } + static class Message { String text; From 6d93ffb510f0997b87e6104245565b94b012849b Mon Sep 17 00:00:00 2001 From: Elys Rivero Date: Mon, 15 Jun 2026 11:52:27 +0200 Subject: [PATCH 2/3] Add global command prefix support Signed-off-by: Elys Rivero --- .../support/CommandFactoryBean.java | 62 +++++++++---------- .../support/CommandFactoryBeanTests.java | 6 +- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java index ed7766d0a..d3e002967 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.springframework.shell.core.command.annotation.support; import jakarta.validation.Validator; @@ -102,8 +103,7 @@ public Command getObject() { String simpleName = Utils.splitCamelCase(declaringClass.getSimpleName()); if (!simpleName.endsWith(" Commands")) { group = simpleName + " Commands"; - } - else { + } else { group = simpleName; } @@ -119,7 +119,7 @@ public Command getObject() { ConfigurableConversionService configurableConversionService = getConfigurableConversionService(); AvailabilityProvider availabilityProviderBean = getAvailabilityProvider(availabilityProviderBeanName); ExitStatusExceptionMapper exitStatusExceptionMapperBean = getExitStatusExceptionMapper( - exitStatusExceptionMapperBeanName); + exitStatusExceptionMapperBeanName); Validator validator = getValidator(); CompletionProvider completionProvider = getCompletionProvider(completionProviderBeanName); List commandOptions = getCommandOptions(); @@ -127,7 +127,7 @@ public Command getObject() { // create command adapter MethodInvokerCommandAdapter methodInvokerCommandAdapter = new MethodInvokerCommandAdapter(name, description, - group, help, hidden, this.method, targetObject, configurableConversionService, validator); + group, help, hidden, this.method, targetObject, configurableConversionService, validator); methodInvokerCommandAdapter.setAliases(Arrays.stream(aliases).toList()); methodInvokerCommandAdapter.setOptions(commandOptions); methodInvokerCommandAdapter.setArguments(commandArguments); @@ -140,11 +140,16 @@ public Command getObject() { } private String resolveName(String groupPrefix, org.springframework.shell.core.command.annotation.Command command) { - String effectivePrefix = groupPrefix; - if (effectivePrefix.isEmpty()) { - effectivePrefix = this.applicationContext.getEnvironment().getProperty("spring.shell.command.prefix", ""); + String globalPrefix = this.applicationContext.getEnvironment().getProperty("spring.shell.command.prefix", ""); + List parts = new ArrayList<>(); + if (!globalPrefix.isEmpty()) { + parts.add(globalPrefix); + } + if (!groupPrefix.isEmpty()) { + parts.add(groupPrefix); } - return effectivePrefix + (effectivePrefix.isEmpty() ? "" : " ") + String.join(" ", command.name()); + parts.add(String.join(" ", command.name())); + return String.join(" ", parts); } private List getCommandOptions() { @@ -199,11 +204,10 @@ private CompletionProvider getCompletionProvider(String completionProviderBeanNa if (!completionProviderBeanName.isEmpty()) { try { completionProvider = this.applicationContext.getBean(completionProviderBeanName, - CompletionProvider.class); - } - catch (BeansException e) { + CompletionProvider.class); + } catch (BeansException e) { log.debug("No CompletionProvider bean found with name '" + completionProviderBeanName - + "', using default completion provider."); + + "', using default completion provider."); } } return completionProvider; @@ -212,8 +216,7 @@ private CompletionProvider getCompletionProvider(String completionProviderBeanNa private Validator getValidator() { try { return this.applicationContext.getBean(Validator.class); - } - catch (BeansException e) { + } catch (BeansException e) { log.debug("No Validator bean found, using default validator."); return Utils.defaultValidator(); } @@ -222,10 +225,9 @@ private Validator getValidator() { private @Nullable ExitStatusExceptionMapper getExitStatusExceptionMapper(String exitStatusExceptionMapper) { try { return this.applicationContext.getBean(exitStatusExceptionMapper, ExitStatusExceptionMapper.class); - } - catch (BeansException e) { + } catch (BeansException e) { log.debug("No ExitStatusExceptionMapper bean found with name '" + exitStatusExceptionMapper - + "', using default exception mapping strategy."); + + "', using default exception mapping strategy."); return null; } } @@ -233,10 +235,9 @@ private Validator getValidator() { private AvailabilityProvider getAvailabilityProvider(String availabilityProvider) { try { return this.applicationContext.getBean(availabilityProvider, AvailabilityProvider.class); - } - catch (BeansException e) { + } catch (BeansException e) { log.debug("No AvailabilityProvider bean found with name '" + availabilityProvider - + "', using always available provider."); + + "', using always available provider."); return AvailabilityProvider.alwaysAvailable(); } } @@ -244,8 +245,7 @@ private AvailabilityProvider getAvailabilityProvider(String availabilityProvider private ConfigurableConversionService getConfigurableConversionService() { try { return this.applicationContext.getBean(ConfigurableConversionService.class); - } - catch (BeansException e) { + } catch (BeansException e) { log.debug("No ConfigurableConversionService bean found, using a default conversion service."); DefaultConversionService conversionService = new DefaultConversionService(); registerConverterBeans(conversionService); @@ -264,22 +264,21 @@ private void registerConverterBeans(ConfigurableConversionService conversionServ .forEach((name, converter) -> addConverter(conversionService, name, converter)); } - @SuppressWarnings({ "rawtypes", "unchecked" }) + @SuppressWarnings({"rawtypes", "unchecked"}) private void addConverter(ConfigurableConversionService conversionService, String beanName, Converter converter) { ResolvableType type = beanType(beanName, converter).as(Converter.class); Class source = type.getGeneric(0).resolve(); Class target = type.getGeneric(1).resolve(); if (source != null && target != null) { conversionService.addConverter(source, target, converter); - } - else { + } else { conversionService.addConverter(converter); } } private ResolvableType beanType(String beanName, Object bean) { if (this.applicationContext instanceof ConfigurableApplicationContext cac - && cac.getBeanFactory().containsBeanDefinition(beanName)) { + && cac.getBeanFactory().containsBeanDefinition(beanName)) { return cac.getBeanFactory().getMergedBeanDefinition(beanName).getResolvableType(); } return ResolvableType.forClass(bean.getClass()); @@ -288,13 +287,12 @@ private ResolvableType beanType(String beanName, Object bean) { private Object getTagetObject(Class declaringClass) { try { return this.applicationContext.getBean(declaringClass); - } - catch (NoSuchBeanDefinitionException e) { + } catch (NoSuchBeanDefinitionException e) { String errorMessage = """ - Unable to create command for method '%s' because no bean of type '%s' is defined in the application context. - Ensure that the declaring class is annotated with a Spring stereotype annotation (e.g., @Component) or - is otherwise registered as a bean in the application context. - """ + Unable to create command for method '%s' because no bean of type '%s' is defined in the application context. + Ensure that the declaring class is annotated with a Spring stereotype annotation (e.g., @Component) or + is otherwise registered as a bean in the application context. + """ .formatted(this.method.getName(), declaringClass.getName()); throw new CommandCreationException(errorMessage, e); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java index 814402e94..994abc714 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java @@ -206,8 +206,8 @@ void globalPrefixAppliedWhenNoCommandGroupPrefix() throws Exception { } @Test - void commandGroupPrefixTakesPrecedenceOverGlobalPrefix() throws Exception { - ApplicationContext context = mockApplicationContext("global"); + void globalAndCommandGroupPrefixesAreChained() throws Exception { + ApplicationContext context = mockApplicationContext("shell"); when(context.getBean(GreetingCommands.class)).thenReturn(new GreetingCommands()); Method method = Arrays.stream(GreetingCommands.class.getDeclaredMethods()) .filter(m -> m.getName().equals("hi")) @@ -218,7 +218,7 @@ void commandGroupPrefixTakesPrecedenceOverGlobalPrefix() throws Exception { org.springframework.shell.core.command.Command result = factory.getObject(); - assertEquals("greeting hi", result.getName()); + assertEquals("shell greeting hi", result.getName()); } @Test From 9e9c680eea5a3243eedaa83df8185d5c302c25c6 Mon Sep 17 00:00:00 2001 From: Elys Rivero Date: Mon, 15 Jun 2026 11:54:58 +0200 Subject: [PATCH 3/3] Add global command prefix support Signed-off-by: Elys Rivero --- .../support/CommandFactoryBean.java | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java index d3e002967..82dcd77d3 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.core.command.annotation.support; import jakarta.validation.Validator; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeansException; import org.springframework.beans.factory.FactoryBean; @@ -103,7 +101,8 @@ public Command getObject() { String simpleName = Utils.splitCamelCase(declaringClass.getSimpleName()); if (!simpleName.endsWith(" Commands")) { group = simpleName + " Commands"; - } else { + } + else { group = simpleName; } @@ -119,7 +118,7 @@ public Command getObject() { ConfigurableConversionService configurableConversionService = getConfigurableConversionService(); AvailabilityProvider availabilityProviderBean = getAvailabilityProvider(availabilityProviderBeanName); ExitStatusExceptionMapper exitStatusExceptionMapperBean = getExitStatusExceptionMapper( - exitStatusExceptionMapperBeanName); + exitStatusExceptionMapperBeanName); Validator validator = getValidator(); CompletionProvider completionProvider = getCompletionProvider(completionProviderBeanName); List commandOptions = getCommandOptions(); @@ -127,7 +126,7 @@ public Command getObject() { // create command adapter MethodInvokerCommandAdapter methodInvokerCommandAdapter = new MethodInvokerCommandAdapter(name, description, - group, help, hidden, this.method, targetObject, configurableConversionService, validator); + group, help, hidden, this.method, targetObject, configurableConversionService, validator); methodInvokerCommandAdapter.setAliases(Arrays.stream(aliases).toList()); methodInvokerCommandAdapter.setOptions(commandOptions); methodInvokerCommandAdapter.setArguments(commandArguments); @@ -204,10 +203,11 @@ private CompletionProvider getCompletionProvider(String completionProviderBeanNa if (!completionProviderBeanName.isEmpty()) { try { completionProvider = this.applicationContext.getBean(completionProviderBeanName, - CompletionProvider.class); - } catch (BeansException e) { + CompletionProvider.class); + } + catch (BeansException e) { log.debug("No CompletionProvider bean found with name '" + completionProviderBeanName - + "', using default completion provider."); + + "', using default completion provider."); } } return completionProvider; @@ -216,7 +216,8 @@ private CompletionProvider getCompletionProvider(String completionProviderBeanNa private Validator getValidator() { try { return this.applicationContext.getBean(Validator.class); - } catch (BeansException e) { + } + catch (BeansException e) { log.debug("No Validator bean found, using default validator."); return Utils.defaultValidator(); } @@ -225,9 +226,10 @@ private Validator getValidator() { private @Nullable ExitStatusExceptionMapper getExitStatusExceptionMapper(String exitStatusExceptionMapper) { try { return this.applicationContext.getBean(exitStatusExceptionMapper, ExitStatusExceptionMapper.class); - } catch (BeansException e) { + } + catch (BeansException e) { log.debug("No ExitStatusExceptionMapper bean found with name '" + exitStatusExceptionMapper - + "', using default exception mapping strategy."); + + "', using default exception mapping strategy."); return null; } } @@ -235,9 +237,10 @@ private Validator getValidator() { private AvailabilityProvider getAvailabilityProvider(String availabilityProvider) { try { return this.applicationContext.getBean(availabilityProvider, AvailabilityProvider.class); - } catch (BeansException e) { + } + catch (BeansException e) { log.debug("No AvailabilityProvider bean found with name '" + availabilityProvider - + "', using always available provider."); + + "', using always available provider."); return AvailabilityProvider.alwaysAvailable(); } } @@ -245,7 +248,8 @@ private AvailabilityProvider getAvailabilityProvider(String availabilityProvider private ConfigurableConversionService getConfigurableConversionService() { try { return this.applicationContext.getBean(ConfigurableConversionService.class); - } catch (BeansException e) { + } + catch (BeansException e) { log.debug("No ConfigurableConversionService bean found, using a default conversion service."); DefaultConversionService conversionService = new DefaultConversionService(); registerConverterBeans(conversionService); @@ -264,21 +268,22 @@ private void registerConverterBeans(ConfigurableConversionService conversionServ .forEach((name, converter) -> addConverter(conversionService, name, converter)); } - @SuppressWarnings({"rawtypes", "unchecked"}) + @SuppressWarnings({ "rawtypes", "unchecked" }) private void addConverter(ConfigurableConversionService conversionService, String beanName, Converter converter) { ResolvableType type = beanType(beanName, converter).as(Converter.class); Class source = type.getGeneric(0).resolve(); Class target = type.getGeneric(1).resolve(); if (source != null && target != null) { conversionService.addConverter(source, target, converter); - } else { + } + else { conversionService.addConverter(converter); } } private ResolvableType beanType(String beanName, Object bean) { if (this.applicationContext instanceof ConfigurableApplicationContext cac - && cac.getBeanFactory().containsBeanDefinition(beanName)) { + && cac.getBeanFactory().containsBeanDefinition(beanName)) { return cac.getBeanFactory().getMergedBeanDefinition(beanName).getResolvableType(); } return ResolvableType.forClass(bean.getClass()); @@ -287,12 +292,13 @@ private ResolvableType beanType(String beanName, Object bean) { private Object getTagetObject(Class declaringClass) { try { return this.applicationContext.getBean(declaringClass); - } catch (NoSuchBeanDefinitionException e) { + } + catch (NoSuchBeanDefinitionException e) { String errorMessage = """ - Unable to create command for method '%s' because no bean of type '%s' is defined in the application context. - Ensure that the declaring class is annotated with a Spring stereotype annotation (e.g., @Component) or - is otherwise registered as a bean in the application context. - """ + Unable to create command for method '%s' because no bean of type '%s' is defined in the application context. + Ensure that the declaring class is annotated with a Spring stereotype annotation (e.g., @Component) or + is otherwise registered as a bean in the application context. + """ .formatted(this.method.getName(), declaringClass.getName()); throw new CommandCreationException(errorMessage, e); }