diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java
new file mode 100644
index 00000000..9933b5bf
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Command.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.shell.context.InteractionMode;
+
+/**
+ * Annotation marking a method to be a candicate for a shell command target.
+ *
+ * @author Janne Valkealahti
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.METHOD })
+@Documented
+public @interface Command {
+
+ /**
+ * Define command as an array. Given that command should be
+ * {@code command1 sub1} it can be defined as:
+ *
+ *
+ *
+ * Values are split and trimmed meaning spaces doesn't matter.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level mappings inherit this primary
+ * command to use it as a prefix.
+ *
+ *
+ *
+ * @return the command as an array
+ */
+ String[] command() default {};
+
+ /**
+ * Define alias as an array. Given that alias should be
+ * {@code alias1 sub1} it can be defined as:
+ *
+ *
+ *
+ * Values are split and trimmed meaning spaces doesn't matter.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level mappings inherit this primary
+ * alias to use it as a prefix.
+ *
+ *
+ *
+ * @return the aliases as an array
+ */
+ String[] alias() default {};
+
+ /**
+ * Define a command group.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level group inherit this primary
+ * group. Can be overridden on method-level.
+ *
+ * @return the command group
+ */
+ String group() default "";
+
+ /**
+ * Define a command description.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level descriptions inherit this primary
+ * field. Can be overridden on method-level.
+ *
+ * @return the command description
+ */
+ String description() default "";
+
+ /**
+ * Define command to be hidden.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level mappings inherit this primary
+ * hidden field.
+ *
+ *
+ *
+ * @return true if command should be hidden
+ */
+ boolean hidden() default false;
+
+ /**
+ * Define interaction mode for a command as a hint when command should be
+ * available. For example presense of some commands doesn't make sense if shell
+ * is running as non-interactive mode and vice versa.
+ *
+ *
+ * Supported at the type level as well as at the method level!
+ * When used at the type level, all method-level mappings inherit this primary
+ * field.
+ *
+ * Type is an array to be able to indicate that default don't have anyting defined.
+ *
+ * @return interaction modes
+ */
+ InteractionMode[] interactionMode() default {};
+
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java
new file mode 100644
index 00000000..bf137fbb
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/CommandScan.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.context.annotation.Import;
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.shell.command.annotation.support.CommandScanRegistrar;
+
+/**
+ * Configures the base packages used when scanning for {@link Command @Comamnd}
+ * classes. One of {@link #basePackageClasses()}, {@link #basePackages()} or its
+ * alias {@link #value()} may be specified to define specific packages to scan.
+ * If specific packages are not defined scanning will occur from the package of
+ * the class with this annotation.
+ *
+ * @author Janne Valkealahti
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Import(CommandScanRegistrar.class)
+@EnableCommand
+public @interface CommandScan {
+
+ /**
+ * Alias for the {@link #basePackages()} attribute. Allows for more concise
+ * annotation declarations e.g.: {@code @CommandScan("org.my.pkg")} instead of
+ * {@code @CommandScan(basePackages="org.my.pkg")}.
+ *
+ * @return the base packages to scan
+ */
+ @AliasFor("basePackages")
+ String[] value() default {};
+
+ /**
+ * Base packages to scan for commands. {@link #value()} is an alias for (and
+ * mutually exclusive with) this attribute.
+ *
+ * Use {@link #basePackageClasses()} for a type-safe alternative to String-based
+ * package names.
+ *
+ * @return the base packages to scan
+ */
+ @AliasFor("value")
+ String[] basePackages() default {};
+
+ /**
+ * Type-safe alternative to {@link #basePackages()} for specifying the packages
+ * to scan for commands. The package of each class specified will be scanned.
+ *
+ * Consider creating a special no-op marker class or interface in each package
+ * that serves no purpose other than being referenced by this attribute.
+ *
+ * @return classes from the base packages to scan
+ */
+ Class>[] basePackageClasses() default {};
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java
new file mode 100644
index 00000000..36dd66ff
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/EnableCommand.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.context.annotation.Import;
+import org.springframework.shell.command.annotation.support.EnableCommandRegistrar;
+
+/**
+ * Enable support for {@link Command @Command} annotated classes.
+ * {@code @Command} classes can be registered directly on this annotation.
+ *
+ * @author Janne Valkealahti
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Import(EnableCommandRegistrar.class)
+public @interface EnableCommand {
+
+ /**
+ * Defines candicate classes for shell commands.
+ *
+ * @return candidate classes for shell commands
+ */
+ Class>[] value() default {};
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java
index 4c0a5ca4..87630fc2 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/ExceptionResolver.java
@@ -21,8 +21,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
-import org.springframework.aot.hint.annotation.Reflective;
-
/**
* Annotation for handling exceptions in specific command classes and/or its methods.
*
@@ -31,7 +29,6 @@ import org.springframework.aot.hint.annotation.Reflective;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
-@Reflective
public @interface ExceptionResolver {
/**
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java
new file mode 100644
index 00000000..409542aa
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/Option.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.shell.command.CommandRegistration.OptionArity;
+
+/**
+ * Annotation marking a method parameter to be a candicate for an option.
+ *
+ * @author Janne Valkealahti
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+@Documented
+public @interface Option {
+
+ /**
+ * Long names of an option. There can be multiple names where first is primary
+ * one and other are aliases.
+ *
+ * @return Option long names, defaults to empty.
+ */
+ String[] longNames() default {};
+
+ /**
+ * Short names of an option. There can be multiple names where first is primary
+ * one and other are aliases.
+ *
+ * @return Option short names, defaults to empty.
+ */
+ char[] shortNames() default {};
+
+ /**
+ * Mark option required.
+ *
+ * @return true if option is required, defaults to false.
+ */
+ boolean required() default false;
+
+ /**
+ * Define option default value.
+ *
+ * @return default value
+ */
+ String defaultValue() default "";
+
+ /**
+ * Return a short description of the option.
+ *
+ * @return description of the option
+ */
+ String description() default "";
+
+ /**
+ * Define option arity.
+ *
+ * @return option arity
+ */
+ OptionArity arity() default OptionArity.NONE;
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java
new file mode 100644
index 00000000..ebbc272f
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/OptionValues.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation marking a method parameter which completion proposals should be
+ * used.
+ *
+ * @author Janne Valkealahti
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+@Documented
+public @interface OptionValues {
+
+ /**
+ * Reference to a bean name
+ * @return a bean name
+ */
+ String ref() default "";
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java
new file mode 100644
index 00000000..f857ceff
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandAnnotationUtils.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation.support;
+
+import java.util.stream.Stream;
+
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.lang.Nullable;
+import org.springframework.shell.command.annotation.Command;
+import org.springframework.shell.context.InteractionMode;
+import org.springframework.util.StringUtils;
+
+/**
+ * Utilities to merge {@link Command} annotations using opinionated logic. In
+ * this class {@code left} is meant for annotation on a class level and
+ * {@code right} annotation on a method level. Class level is meant to provide
+ * defaults and every field may have its own logic.
+ *
+ * @author Janne Valkealahti
+ */
+class CommandAnnotationUtils {
+
+ private final static String COMMAND = "command";
+ private final static String ALIAS = "alias";
+ private final static String HIDDEN = "hidden";
+ private final static String GROUP = "group";
+ private final static String DESCRIPTION = "description";
+ private final static String INTERACTION_MODE = "interactionMode";
+
+ /**
+ * Deduce {@link Command#hidden()} from annotations.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced boolean for hidden field
+ */
+ static boolean deduceHidden(MergedAnnotation> left, MergedAnnotation> right) {
+ Boolean def = right.getDefaultValue(HIDDEN, Boolean.class).orElse(null);
+
+ boolean l = left.getBoolean(HIDDEN);
+ boolean r = right.getBoolean(HIDDEN);
+
+ if (def != null) {
+ if (def != r) {
+ l = r;
+ }
+ }
+ else {
+ l = r;
+ }
+
+ return l;
+ }
+
+ /**
+ * Deduce {@link Command#command()} from annotations. Command array is supposed
+ * to contain commands without leading or trailing white spaces, so strip, split
+ * and assume that class level defines prefix for array.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced boolean for command field
+ */
+ static String[] deduceCommand(MergedAnnotation> left, MergedAnnotation> right) {
+ return deduceStringArray(COMMAND, left, right);
+ }
+
+ /**
+ * Deduce {@link Command#alias()} from annotations. Alias array is supposed
+ * to contain commands without leading or trailing white spaces, so strip, split
+ * and assume that class level defines prefix for array.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced boolean for alias field
+ */
+ static String[] deduceAlias(MergedAnnotation> left, MergedAnnotation> right) {
+ return deduceStringArray(ALIAS, left, right);
+ }
+
+ /**
+ * Deduce {@link Command#group()} from annotations. Right side overrides if it
+ * has value.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced String for group field
+ */
+ static String deduceGroup(MergedAnnotation> left, MergedAnnotation> right) {
+ return deduceStringRightOverrides(GROUP, left, right);
+ }
+
+ /**
+ * Deduce {@link Command#description()} from annotations. Right side overrides if it
+ * has value.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced String for description field
+ */
+ static String deduceDescription(MergedAnnotation> left, MergedAnnotation> right) {
+ return deduceStringRightOverrides(DESCRIPTION, left, right);
+ }
+
+ /**
+ * Deduce {@link Command#interactionMode()} from annotations. Right side overrides if.
+ * Returns {@code null} if nothing defined.
+ *
+ * @param left the left side annotation
+ * @param right the right side annotation
+ * @return deduced InteractionMode for interaction mode field
+ */
+ static @Nullable InteractionMode deduceInteractionMode(MergedAnnotation> left, MergedAnnotation> right) {
+ InteractionMode mode = null;
+ InteractionMode[] l = left.getEnumArray(INTERACTION_MODE, InteractionMode.class);
+ for (InteractionMode m : l) {
+ if (InteractionMode.ALL == m) {
+ mode = m;
+ break;
+ }
+ else {
+ mode = m;
+ }
+ }
+ InteractionMode[] r = right.getEnumArray(INTERACTION_MODE, InteractionMode.class);
+ for (InteractionMode m : r) {
+ if (InteractionMode.ALL == m) {
+ mode = m;
+ break;
+ }
+ else {
+ mode = m;
+ break;
+ }
+ }
+ return mode;
+ }
+
+
+ private static String[] deduceStringArray(String field, MergedAnnotation> left, MergedAnnotation> right) {
+ return Stream.of(left.getStringArray(field), right.getStringArray(field))
+ .flatMap(commands -> Stream.of(commands))
+ .flatMap(command -> Stream.of(command.split(" ")))
+ .filter(command -> StringUtils.hasText(command))
+ .map(command -> command.strip())
+ .toArray(String[]::new);
+ }
+
+ private static String deduceStringRightOverrides(String field, MergedAnnotation> left, MergedAnnotation> right) {
+ String r = right.getString(field);
+ if (StringUtils.hasText(r)) {
+ return r;
+ }
+ return left.getString(field);
+ }
+
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java
new file mode 100644
index 00000000..c71a0aa9
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationBeanRegistrar.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation.support;
+
+import java.lang.reflect.Method;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.HierarchicalBeanFactory;
+import org.springframework.beans.factory.ListableBeanFactory;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.core.MethodIntrospector;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.annotation.MergedAnnotations;
+import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
+import org.springframework.shell.command.annotation.Command;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ReflectionUtils.MethodFilter;
+
+/**
+ * Delegate used by {@link EnableCommandRegistrar} and
+ * {@link CommandScanRegistrar} to register a bean definition(s) for a
+ * {@link Command @Command} class.
+ *
+ * @author Janne Valkealahti
+ */
+public final class CommandRegistrationBeanRegistrar {
+
+ private final BeanDefinitionRegistry registry;
+ private final BeanFactory beanFactory;
+ private static final MethodFilter COMMAND_METHODS = method ->
+ AnnotatedElementUtils.hasAnnotation(method, Command.class);
+
+ public CommandRegistrationBeanRegistrar(BeanDefinitionRegistry registry) {
+ this.registry = registry;
+ this.beanFactory = (BeanFactory) this.registry;
+ }
+
+ public void register(Class> type) {
+ MergedAnnotation annotation = MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY)
+ .get(Command.class);
+ register(type, annotation);
+ }
+
+ void register(Class> type, MergedAnnotation annotation) {
+ String name = type.getName();
+ if (!containsBeanDefinition(name)) {
+ registerCommandClassBeanDefinition(name, type, annotation);
+ }
+ scanMethods(type, name, annotation);
+ }
+
+ void scanMethods(Class> type, String containerBean, MergedAnnotation classAnnotation) {
+ Set methods = MethodIntrospector.selectMethods(type, COMMAND_METHODS);
+ methods.forEach(m -> {
+ String name = type.getName();
+ String methodName = m.getName();
+ Class>[] methodParameterTypes = m.getParameterTypes();
+ String postfix = Stream.of(methodParameterTypes).map(clazz -> ClassUtils.getShortName(clazz))
+ .collect(Collectors.joining());
+ name = name + "/" + methodName + postfix;
+
+ if (!containsBeanDefinition(name)) {
+ registerCommandMethodBeanDefinition(type, name, containerBean, methodName, methodParameterTypes);
+ }
+ });
+
+ }
+
+ private void registerCommandClassBeanDefinition(String beanName, Class> type,
+ MergedAnnotation annotation) {
+ Assert.state(annotation.isPresent(), () -> "No " + Command.class.getSimpleName()
+ + " annotation found on '" + type.getName() + "'.");
+ this.registry.registerBeanDefinition(beanName, createCommandClassBeanDefinition(type));
+ }
+
+ private void registerCommandMethodBeanDefinition(Class> commandBeanType, String commandBeanName, String containerBean, String methodName,
+ Class>[] methodParameterTypes) {
+ this.registry.registerBeanDefinition(commandBeanName,
+ createCommandMethodBeanDefinition(commandBeanType, containerBean, methodName, methodParameterTypes));
+ }
+
+ private BeanDefinition createCommandClassBeanDefinition(Class> type) {
+ RootBeanDefinition definition = new RootBeanDefinition(type);
+ return definition;
+ }
+
+ private BeanDefinition createCommandMethodBeanDefinition(Class> commandBeanType, String commandBeanName,
+ String commandMethodName, Class>[] commandMethodParameters) {
+ RootBeanDefinition definition = new RootBeanDefinition(CommandRegistrationFactoryBean.class);
+ definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_TYPE, commandBeanType);
+ definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_BEAN_NAME, commandBeanName);
+ definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_NAME, commandMethodName);
+ definition.getPropertyValues().add(CommandRegistrationFactoryBean.COMMAND_METHOD_PARAMETERS, commandMethodParameters);
+ return definition;
+ }
+
+ private boolean containsBeanDefinition(String name) {
+ return containsBeanDefinition(this.beanFactory, name);
+ }
+
+ private boolean containsBeanDefinition(BeanFactory beanFactory, String name) {
+ if (beanFactory instanceof ListableBeanFactory listableBeanFactory
+ && listableBeanFactory.containsBeanDefinition(name)) {
+ return true;
+ }
+ if (beanFactory instanceof HierarchicalBeanFactory hierarchicalBeanFactory) {
+ return containsBeanDefinition(hierarchicalBeanFactory.getParentBeanFactory(), name);
+ }
+ return false;
+ }
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java
new file mode 100644
index 00000000..fe53b434
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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.springframework.shell.command.annotation.support;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.core.DefaultParameterNameDiscoverer;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.annotation.MergedAnnotations;
+import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
+import org.springframework.messaging.handler.invocation.InvocableHandlerMethod;
+import org.springframework.shell.Utils;
+import org.springframework.shell.command.CommandExceptionResolver;
+import org.springframework.shell.command.CommandHandlingResult;
+import org.springframework.shell.command.CommandRegistration;
+import org.springframework.shell.command.CommandRegistration.Builder;
+import org.springframework.shell.command.CommandRegistration.OptionArity;
+import org.springframework.shell.command.CommandRegistration.OptionSpec;
+import org.springframework.shell.command.annotation.Command;
+import org.springframework.shell.command.annotation.ExceptionResolverMethodResolver;
+import org.springframework.shell.command.annotation.Option;
+import org.springframework.shell.command.annotation.OptionValues;
+import org.springframework.shell.command.invocation.InvocableShellMethod;
+import org.springframework.shell.completion.CompletionProvider;
+import org.springframework.shell.context.InteractionMode;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.ReflectionUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Factory bean used in {@link CommandRegistrationBeanRegistrar} to build
+ * instance of {@link CommandRegistration}. Main logic of constructing
+ * {@link CommandRegistration} out from annotated command target is
+ * in this factory.
+ *
+ * This factory needs a name of a {@code commandBeanName} which is a name of bean
+ * hosting command methods, {@code commandBeanType} which is type of a bean,
+ * {@code commandMethodName} which is a name of {@link Method} in a bean,
+ * {@code commandMethodParameters} for method parameter types and optionally
+ * {@code BuilderSupplier} if context provides pre-configured builder.
+ *
+ * This is internal class and not meant for generic use.
+ *
+ * @author Janne Valkealahti
+ */
+class CommandRegistrationFactoryBean implements FactoryBean, ApplicationContextAware, InitializingBean {
+
+ private final Logger log = LoggerFactory.getLogger(CommandRegistrationFactoryBean.class);
+ public static final String COMMAND_BEAN_TYPE = "commandBeanType";
+ public static final String COMMAND_BEAN_NAME = "commandBeanName";
+ public static final String COMMAND_METHOD_NAME = "commandMethodName";
+ public static final String COMMAND_METHOD_PARAMETERS = "commandMethodParameters";
+
+ private ObjectProvider supplier;
+ private ApplicationContext applicationContext;
+ private Object commandBean;
+ private Class> commandBeanType;
+ private String commandBeanName;
+ private String commandMethodName;
+ private Class>[] commandMethodParameters;
+
+ @Override
+ public CommandRegistration getObject() throws Exception {
+ CommandRegistration registration = buildRegistration();
+ return registration;
+ }
+
+ @Override
+ public Class> getObjectType() {
+ return CommandRegistration.class;
+ }
+
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ this.applicationContext = applicationContext;
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ this.commandBean = applicationContext.getBean(commandBeanName);
+ this.supplier = applicationContext.getBeanProvider(CommandRegistration.BuilderSupplier.class);
+ }
+
+ public void setCommandBeanType(Class> commandBeanType) {
+ this.commandBeanType = commandBeanType;
+ }
+
+ public void setCommandBeanName(String commandBeanName) {
+ this.commandBeanName = commandBeanName;
+ }
+
+ public void setCommandMethodName(String commandMethodName) {
+ this.commandMethodName = commandMethodName;
+ }
+
+ public void setCommandMethodParameters(Class>[] commandMethodParameters) {
+ this.commandMethodParameters = commandMethodParameters;
+ }
+
+ private CommandRegistration.Builder getBuilder() {
+ return supplier.getIfAvailable(() -> () -> CommandRegistration.builder()).get();
+ }
+
+ private CommandRegistration buildRegistration() {
+ Method method = ReflectionUtils.findMethod(commandBeanType, commandMethodName, commandMethodParameters);
+ MergedAnnotation classAnn = MergedAnnotations.from(commandBeanType, SearchStrategy.TYPE_HIERARCHY)
+ .get(Command.class);
+ MergedAnnotation methodAnn = MergedAnnotations.from(method, SearchStrategy.TYPE_HIERARCHY)
+ .get(Command.class);
+
+ Builder builder = getBuilder();
+
+ // command
+ String[] deduceCommand = CommandAnnotationUtils.deduceCommand(classAnn, methodAnn);
+ if (deduceCommand.length == 0) {
+ deduceCommand = new String[] { Utils.unCamelify(method.getName()) };
+ }
+ builder.command(deduceCommand);
+
+ // group
+ String deduceGroup = CommandAnnotationUtils.deduceGroup(classAnn, methodAnn);
+ builder.group(deduceGroup);
+
+ // hidden
+ boolean deduceHidden = CommandAnnotationUtils.deduceHidden(classAnn, methodAnn);
+ builder.hidden(deduceHidden);
+
+ // description
+ String deduceDescription = CommandAnnotationUtils.deduceDescription(classAnn, methodAnn);
+ builder.description(deduceDescription);
+
+ // interaction mode
+ InteractionMode deduceInteractionMode = CommandAnnotationUtils.deduceInteractionMode(classAnn, methodAnn);
+ builder.interactionMode(deduceInteractionMode);
+
+ // alias
+ String[] deduceAlias = CommandAnnotationUtils.deduceAlias(classAnn, methodAnn);
+ if (deduceAlias.length > 0) {
+ builder.withAlias().command(deduceAlias);
+ }
+
+ // target
+ builder.withTarget().method(commandBean, method);
+
+ // options
+ InvocableHandlerMethod ihm = new InvocableHandlerMethod(commandBean, method);
+ for (MethodParameter mp : ihm.getMethodParameters()) {
+ onCommandParameter(mp, builder);
+ }
+
+ // error handling
+ ExceptionResolverMethodResolver exceptionResolverMethodResolver = new ExceptionResolverMethodResolver(commandBean.getClass());
+ MethodCommandExceptionResolver methodCommandExceptionResolver = new MethodCommandExceptionResolver();
+ methodCommandExceptionResolver.bean = commandBean;
+ methodCommandExceptionResolver.exceptionResolverMethodResolver = exceptionResolverMethodResolver;
+ builder.withErrorHandling().resolver(methodCommandExceptionResolver);
+
+ CommandRegistration registration = builder.build();
+ return registration;
+ }
+
+ private void onCommandParameter(MethodParameter mp, Builder builder) {
+
+ MergedAnnotation