Skip to content

Commit 6cabff3

Browse files
committed
Add support to group commands at class level
Resolves #1266
1 parent a72c76e commit 6cabff3

File tree

8 files changed

+303
-26
lines changed

8 files changed

+303
-26
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.core.command.annotation;
17+
18+
import java.lang.annotation.Documented;
19+
import java.lang.annotation.ElementType;
20+
import java.lang.annotation.Retention;
21+
import java.lang.annotation.RetentionPolicy;
22+
import java.lang.annotation.Target;
23+
24+
import org.springframework.aot.hint.annotation.Reflective;
25+
import org.springframework.stereotype.Component;
26+
27+
/**
28+
* Annotation marking a class as a command group. Command groups are used to group related
29+
* commands together and provide a common name and prefix for the group.
30+
*
31+
* @author Mahmoud Ben Hassine
32+
* @since 4.0.2
33+
*/
34+
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@Target({ ElementType.TYPE })
37+
@Documented
38+
@Reflective
39+
@Component
40+
public @interface CommandGroup {
41+
42+
/**
43+
* The name of the command group.
44+
* @return the name of the command group
45+
*/
46+
String name() default "";
47+
48+
/**
49+
* The description of the command group.
50+
* @return the description of the command group
51+
*/
52+
String description() default "";
53+
54+
/**
55+
* The prefix of the command group.
56+
* @return the prefix of the command group
57+
*/
58+
String prefix() default "";
59+
60+
}

spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.springframework.shell.core.command.CommandCreationException;
4040
import org.springframework.shell.core.command.CommandOption;
4141
import org.springframework.shell.core.command.adapter.MethodInvokerCommandAdapter;
42+
import org.springframework.shell.core.command.annotation.CommandGroup;
4243
import org.springframework.shell.core.command.annotation.Option;
4344
import org.springframework.shell.core.command.availability.AvailabilityProvider;
4445
import org.springframework.shell.core.command.completion.DefaultCompletionProvider;
@@ -75,15 +76,24 @@ public Command getObject() {
7576
.get(org.springframework.shell.core.command.annotation.Command.class)
7677
.synthesize();
7778

79+
Class<?> declaringClass = this.method.getDeclaringClass();
80+
String groupName = "";
81+
String groupPrefix = "";
82+
if (declaringClass.isAnnotationPresent(CommandGroup.class)) {
83+
CommandGroup commandGroup = MergedAnnotations.from(declaringClass).get(CommandGroup.class).synthesize();
84+
groupName = commandGroup.name();
85+
groupPrefix = commandGroup.prefix();
86+
}
87+
7888
// get command metadata
79-
String name = String.join(" ", command.name());
89+
String name = groupPrefix + (groupPrefix.isEmpty() ? "" : " ") + String.join(" ", command.name());
8090
name = name.isEmpty() ? Utils.unCamelify(this.method.getName()) : name;
8191
String description = command.description();
8292
description = description.isEmpty() ? "N/A" : description;
8393
String help = command.help();
84-
String group = command.group();
94+
String group = !command.group().isEmpty() ? command.group() : groupName;
8595
if (group.isEmpty()) {
86-
String simpleName = Utils.splitCamelCase(this.method.getDeclaringClass().getSimpleName());
96+
String simpleName = Utils.splitCamelCase(declaringClass.getSimpleName());
8797
if (!simpleName.endsWith(" Commands")) {
8898
group = simpleName + " Commands";
8999
}
@@ -97,7 +107,6 @@ public Command getObject() {
97107
String availabilityProviderBeanName = command.availabilityProvider();
98108
String exitStatusExceptionMapperBeanName = command.exitStatusExceptionMapper();
99109
String completionProviderBeanName = command.completionProvider();
100-
Class<?> declaringClass = this.method.getDeclaringClass();
101110
log.debug("Creating command bean for method '%s' defined in class '%s' with name '%s'"
102111
.formatted(this.method.getName(), declaringClass.getName(), name));
103112
Object targetObject = getTagetObject(declaringClass);

spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
*/
1616
package org.springframework.shell.core.command.annotation.support;
1717

18+
import java.lang.reflect.Method;
19+
import java.util.Arrays;
1820
import java.util.List;
1921

2022
import org.junit.jupiter.api.Test;
2123
import org.springframework.context.ApplicationContext;
2224
import org.springframework.shell.core.command.CommandOption;
2325
import org.springframework.shell.core.command.annotation.Command;
26+
import org.springframework.shell.core.command.annotation.CommandGroup;
2427
import org.springframework.shell.core.command.annotation.Option;
2528

2629
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -61,6 +64,48 @@ void testOptionNames() {
6164
assertEquals(' ', options.get(3).shortName());
6265
}
6366

67+
@Test
68+
public void testCommandGroup() {
69+
// given
70+
ApplicationContext context = mock(ApplicationContext.class);
71+
when(context.getBean(GreetingCommands.class)).thenReturn(new GreetingCommands());
72+
Method[] declaredMethods = GreetingCommands.class.getDeclaredMethods();
73+
Method hiMethod = Arrays.stream(declaredMethods)
74+
.filter(m -> m.getName().equals("hi"))
75+
.findFirst()
76+
.orElseThrow();
77+
CommandFactoryBean commandFactoryBean = new CommandFactoryBean(hiMethod);
78+
commandFactoryBean.setApplicationContext(context);
79+
80+
// when
81+
org.springframework.shell.core.command.Command result = commandFactoryBean.getObject();
82+
83+
// then
84+
assertEquals("greeting hi", result.getName());
85+
assertEquals("Greeting Commands", result.getGroup());
86+
}
87+
88+
@Test
89+
public void testCommandGroupOverride() {
90+
// given
91+
ApplicationContext context = mock(ApplicationContext.class);
92+
when(context.getBean(GreetingCommands.class)).thenReturn(new GreetingCommands());
93+
Method[] declaredMethods = GreetingCommands.class.getDeclaredMethods();
94+
Method byeMethod = Arrays.stream(declaredMethods)
95+
.filter(m -> m.getName().equals("bye"))
96+
.findFirst()
97+
.orElseThrow();
98+
CommandFactoryBean commandFactoryBean = new CommandFactoryBean(byeMethod);
99+
commandFactoryBean.setApplicationContext(context);
100+
101+
// when
102+
org.springframework.shell.core.command.Command result = commandFactoryBean.getObject();
103+
104+
// then
105+
assertEquals("greeting bye", result.getName());
106+
assertEquals("Farewell Commands", result.getGroup());
107+
}
108+
64109
static class TestClass {
65110

66111
@Command(name = "hello")
@@ -72,4 +117,19 @@ public void helloMethod(@Option String myOption1, @Option(shortName = 'm') Strin
72117

73118
}
74119

120+
@CommandGroup(name = "Greeting Commands", prefix = "greeting")
121+
static class GreetingCommands {
122+
123+
@Command(name = "hi")
124+
public void hi() {
125+
// no-op
126+
}
127+
128+
@Command(name = "bye", group = "Farewell Commands")
129+
public void bye() {
130+
// no-op
131+
}
132+
133+
}
134+
75135
}

spring-shell-docs/modules/ROOT/pages/commands/organize.adoc

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,33 @@ Command myCommand() {
3535
});
3636
}
3737
----
38+
39+
Typically, related commands are defined in the same class (to easily share state between commands), and the group name and prefix are specified at the class level,
40+
which means that all commands defined in that class will belong to the same group. Here is an example of how to do that:
41+
42+
[source,java]
43+
----
44+
@CommandGroup(name = "Authentication Commands", prefix = "auth")
45+
public class AuthenticationCommands {
46+
47+
private boolean authenticated = false;
48+
49+
@Command(name = "login", description = "Log in to the system")
50+
public void login() {
51+
// Authentication logic here
52+
authenticated = true;
53+
System.out.println("Logged in successfully!");
54+
}
55+
56+
@Command(name = "logout", description = "Log out of the system")
57+
public void logout() {
58+
// Logout logic here
59+
authenticated = false;
60+
System.out.println("Logged out successfully!");
61+
}
62+
}
63+
----
64+
65+
In this example, both the `login` and `logout` commands belong to the `Authentication Commands` group, and they will be displayed together in the help screen under that group. The `prefix` attribute specifies a common prefix for all commands in that group, which can be used to invoke the commands (e.g., `auth login` and `auth logout`).
66+
67+
NOTE: If a command provides a `group` attribute, it will take precedence over the group specified at the class level. This allows you to override the group for specific commands if needed.

spring-shell-samples/spring-shell-sample-petclinic/src/main/java/org/springframework/shell/samples/petclinic/commands/PetCommands.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
import org.springframework.jdbc.core.simple.JdbcClient;
2626
import org.springframework.shell.core.command.CommandContext;
2727
import org.springframework.shell.core.command.annotation.Command;
28+
import org.springframework.shell.core.command.annotation.CommandGroup;
2829
import org.springframework.shell.core.command.annotation.Option;
2930
import org.springframework.shell.samples.petclinic.domain.Pet;
30-
import org.springframework.stereotype.Component;
3131

32-
@Component
32+
@CommandGroup(name = "Pets commands", description = "Commands to manage pets", prefix = "pets")
3333
public class PetCommands {
3434

3535
private final JdbcClient jdbcClient;
@@ -38,8 +38,7 @@ public PetCommands(JdbcClient jdbcClient) {
3838
this.jdbcClient = jdbcClient;
3939
}
4040

41-
@Command(name = { "pets", "list" }, description = "List pets", group = "Pets",
42-
help = "List pets in Pet Clinic. Usage: pets list")
41+
@Command(name = { "list" }, description = "List pets", help = "List pets in Pet Clinic. Usage: pets list")
4342
public void listPets(CommandContext commandContext) {
4443
List<@Nullable Pet> pets = jdbcClient.sql("SELECT id, name FROM PETS")
4544
.query(new DataClassRowMapper<>(Pet.class))
@@ -51,7 +50,7 @@ public void listPets(CommandContext commandContext) {
5150
writer.flush();
5251
}
5352

54-
@Command(name = { "pets", "info" }, description = "Show detail about a given pet", group = "Pets",
53+
@Command(name = { "info" }, description = "Show detail about a given pet",
5554
help = "Show the details about a given pet. Usage: pets info --petId=<id>")
5655
public void showPet(@Option(longName = "petId", description = "The pet ID", required = true) int id,
5756
CommandContext commandContext) {

spring-shell-samples/spring-shell-sample-secure-input/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ To run the application, use the following command:
1818
java -jar spring-shell-samples/spring-shell-sample-secure-input/target/secure-input.jar
1919
```
2020

21-
You should see a prompt where you can use the `change-password` command to securely input and change a password.
21+
You should see a prompt where you can login with `auth login`. This command will ask you to enter a username and password securely.
22+
23+
The sample application uses an in-memory user store with a single user `foo` with the password `bar`. You can use these credentials to log in.
24+
25+
After logging in, you can use the `auth change-password` command to securely change the password.
26+
27+
This sample also shows how to use the `AvailabilityProvider` API to restrict access to certain commands based on the user's authentication status.
28+
For example, the `auth change-password` command is only available when the user is authenticated.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2026-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.samples.secure.input;
17+
18+
import java.io.PrintWriter;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
22+
import org.springframework.context.annotation.Bean;
23+
import org.springframework.shell.core.InputReader;
24+
import org.springframework.shell.core.command.CommandContext;
25+
import org.springframework.shell.core.command.annotation.Command;
26+
import org.springframework.shell.core.command.annotation.CommandGroup;
27+
import org.springframework.shell.core.command.availability.Availability;
28+
import org.springframework.shell.core.command.availability.AvailabilityProvider;
29+
30+
@CommandGroup(name = "Authentication Commands", description = "Commands related to user authentication",
31+
prefix = "auth")
32+
public class AuthenticationCommands {
33+
34+
private String username;
35+
36+
private boolean authenticated = false;
37+
38+
// In-memory user store for demonstration purposes
39+
private Map<String, String> userStore = new HashMap<>() {
40+
{
41+
put("foo", "bar");
42+
}
43+
};
44+
45+
@Command(name = "login", description = "Log in to the system")
46+
public void login(CommandContext commandContext) {
47+
InputReader inputReader = commandContext.inputReader();
48+
PrintWriter outputWriter = commandContext.outputWriter();
49+
if (this.authenticated) {
50+
outputWriter.println("Already logged in.");
51+
return;
52+
}
53+
try {
54+
String username = inputReader.readInput("Username: ");
55+
String password = new String(inputReader.readPassword("Password: "));
56+
if (userStore.containsKey(username) && userStore.get(username).equals(password)) {
57+
outputWriter.println("Login successful.");
58+
this.authenticated = true;
59+
this.username = username;
60+
}
61+
else {
62+
outputWriter.println("Invalid credentials.");
63+
}
64+
}
65+
catch (Exception e) {
66+
outputWriter.println("Failed to login.");
67+
}
68+
}
69+
70+
@Command(name = "logout", description = "Log out of the system")
71+
public void logout() {
72+
if (!this.authenticated) {
73+
System.out.println("Not logged in.");
74+
return;
75+
}
76+
System.out.println("Logging out...");
77+
this.authenticated = false;
78+
this.username = null;
79+
}
80+
81+
@Command(name = "status", description = "Check authentication status")
82+
public String status() {
83+
return authenticated ? "Logged in as " + this.username : "Not logged in.";
84+
}
85+
86+
@Command(name = "change-password", description = "Change password", availabilityProvider = "authenticationProvider")
87+
public void changePassword(CommandContext commandContext) {
88+
InputReader inputReader = commandContext.inputReader();
89+
PrintWriter outputWriter = commandContext.outputWriter();
90+
try {
91+
char[] currentPassword = inputReader.readPassword("Enter current password: ");
92+
if (!userStore.containsKey(this.username)
93+
|| !userStore.get(this.username).equals(new String(currentPassword))) {
94+
outputWriter.println("Current password is incorrect.");
95+
return;
96+
}
97+
char[] chars = inputReader.readPassword("Enter new password: ");
98+
String newPassword = new String(chars);
99+
userStore.put(this.username, newPassword);
100+
outputWriter.println("Password successfully updated.");
101+
}
102+
catch (Exception e) {
103+
outputWriter.println("Failed to set password.");
104+
}
105+
}
106+
107+
@Bean
108+
public AvailabilityProvider authenticationProvider() {
109+
return () -> this.authenticated ? Availability.available()
110+
: Availability.unavailable("You must be logged in to use this command.");
111+
}
112+
113+
}

0 commit comments

Comments
 (0)