A lightweight, type-safe command framework for Minecraft server plugins, supporting both Paper (Bukkit) and Velocity proxy platforms.
- Platform Abstraction: Core module is platform-agnostic — adapters for Paper and Velocity provided
- Annotation-Based Commands: Declare commands with
@Command,@Subcommand,@Permission,@Cooldown,@Async - Type-Safe Parameters: Automatic argument parsing with
ParameterResolverregistry (String, int, long, double, boolean, enums, greedy strings, and Paper types) - Low-Boilerplate UX:
@Arg,@PlayerOnly,@ConsoleOnly,@OnlinePlayer,@GreedyString,@OptionalArg,@Syntax, batch registration, and message presets - Declarative Validation:
@Range,@Min,@Max,@Length,@Regexannotations for automatic parameter validation - CLI-Style Flags:
@Flag("silent") boolean silentand@Flag("reason") String reasonfor flag-based arguments - Pipeline Architecture: Dispatch pipeline with guard stage (permission, sender, cooldown) and execution stage (parse + invoke + interceptors)
- Rich Interceptors:
RichCommandInterceptorreceives resolved parameters inbefore(ctx, params)for advanced middleware - Async Support: Mark routes with
@Asyncto run the command body off-thread while guards/parsing stay on the platform thread - Rate Limiting & Throttling: Built-in token-bucket rate limiting and input sanitization
- Tab Completion: Automatic suggestion engine based on route tree, parameter types, and flags
- Adventure Components:
CommandActor.sendMessage(Component)supported in core with platform-native delivery - Type-Safe Actor:
actor.unwrap(Player.class)returns the native platform entity without casting - Subcommand Groups:
@CommandGroup("moderator")on inner classes for type-safe command hierarchies - Advanced Help:
PaginatedHelpCommandwith pagination, grouping, and syntax highlighting - Parse Caching: Optional
CachedCommandParameterParserfor high-traffic commands - Metrics:
InMemoryCommandMetricstracks dispatch counts, error rates, and duration statistics per route - Production Ready: Thread-safe actor caches, debounced messages, safe logging with truncation, input sanitization, configurable overlays
| Module | Description |
|---|---|
command-core |
Core framework — dispatcher, routing, parsing, pipeline, rate limiting, cooldowns |
command-annotations |
Annotation scanning (@Command, @Subcommand, etc.) and method binding |
command-paper |
Paper/Bukkit adapter — Brigadier lifecycle + legacy Bukkit command map |
command-velocity |
Velocity proxy adapter — SimpleCommand + RawCommand bridges |
examples |
Sample plugins for Paper and Velocity |
benchmarks |
JMH benchmarks |
Current stable release: v1.3.0.
Add the JitPack repository to your build.gradle.kts:
repositories {
mavenCentral()
maven("https://jitpack.io")
maven("https://repo.papermc.io/repository/maven-public/")
}Add the dependencies you need:
dependencies {
// Paper plugin
implementation("com.github.HanielCota.CommandFramework:command-paper:v1.3.0")
// Velocity plugin
implementation("com.github.HanielCota.CommandFramework:command-velocity:v1.3.0")
// Or individual modules
implementation("com.github.HanielCota.CommandFramework:command-core:v1.3.0")
implementation("com.github.HanielCota.CommandFramework:command-annotations:v1.3.0")
}For snapshots from main, replace v1.3.0 with main-SNAPSHOT.
JitPack builds Git tags on demand, so a newly published tag can take a few
minutes on its first dependency resolution.
- Java: 21+
- Paper API: 1.21.11+
- Velocity API: 3.5.0-SNAPSHOT+
import org.bukkit.entity.Player;
public final class MyPlugin extends JavaPlugin {
private PaperCommandFramework commands;
@Override
public void onEnable() {
commands = PaperCommandFramework.builder(this)
.messageProvider(CommandMessages.portugueseBrazil())
.build();
commands.registerAnnotated(new MyCommands(), new AdminCommands());
// Or, for no-arg command classes:
// commands.registerPackage("com.example.commands");
}
@Override
public void onDisable() {
if (commands != null) commands.shutdown();
}
}
@Command("kit")
public class MyCommands {
@Default
@PlayerOnly
public void onDefault(CommandActor actor) {
actor.sendWarning("Use /kit give <player>");
}
@Subcommand("give")
@Permission("kit.give")
@Cooldown(value = 3, unit = TimeUnit.SECONDS)
@Syntax("/kit give <player> <item> [amount]")
public void onGive(CommandActor actor,
@OnlinePlayer @Arg("player") Player target,
Material item,
@Arg(value = "amount", defaultValue = "1") int amount) {
actor.sendSuccess("Sent " + amount + "x " + item.name() + " to " + target.getName());
// void methods automatically return CommandResult.success()
}
@Subcommand("heal")
@Permission("kit.heal")
@PlayerOnly
public void onHeal(Player player) {
// Receive Player directly — no casting needed!
player.setHealth(player.getMaxHealth());
player.sendMessage("§aYou have been healed!");
}
@Subcommand("delete")
@Permission("kit.admin")
public CommandResult onDelete(CommandActor actor, String target) {
// Return CommandResult explicitly when you need error handling
if (target.isBlank()) {
return CommandResult.failure(CommandStatus.INVALID_USAGE, "Player name required");
}
actor.sendMessage("Kit deleted for " + target);
return CommandResult.success();
}
}Use the semantic aliases when they make command intent clearer:
@Subcommand("broadcast")
@Permission("admin.broadcast")
public void broadcast(CommandActor actor, @GreedyString String message) {
actor.sendSuccess("Broadcast sent.");
}
@Subcommand("page")
public void page(CommandActor actor, @OptionalArg("1") int page) {
actor.sendMessage("Page " + page);
}
@Subcommand("give")
@Syntax("/kit give <player> <item> [amount]")
public void give(CommandActor actor,
@OnlinePlayer @Arg("player") Player target,
Material item,
@Arg(value = "amount", defaultValue = "1") int amount) {
actor.sendSuccess("Kit sent.");
}Platform adapters also register annotated player resolvers:
@Subcommand("tp")
@PlayerOnly
public void tp(Player sender, @OnlinePlayer Player target) {
sender.teleport(target);
}Player without @OnlinePlayer injects the command sender. @OnlinePlayer Player
consumes a player-name argument and provides player-name tab completion.
On Paper, built-in resolvers also cover World, cached OfflinePlayer,
Material, NamespacedKey, and Enchantment.
@Syntax overrides the generated usage shown after parse failures and in help.
Use @Arg when the Java parameter name is not available at runtime or when a
default value should live beside the parameter.
Platform adapters support explicit instances, batches, no-arg command classes, and package scanning:
commands.registerAnnotated(new KitCommands());
commands.registerAnnotated(new KitCommands(), new AdminCommands());
commands.registerClasses(KitCommands.class, AdminCommands.class); // no-arg constructors required
commands.registerPackage("com.example.commands"); // scans @Command classesPrefer explicit instances when commands need constructor dependencies.
registerPackage is intended for plugin startup and rejects blank package names.
@Plugin(id = "my-plugin", name = "My Plugin", version = "1.0.0")
public class MyVelocityPlugin {
private final VelocityCommandFramework<MyVelocityPlugin> commands;
@Inject
public MyVelocityPlugin(ProxyServer server, Logger logger) {
this.commands = VelocityCommandFramework.builder(server, this).build();
commands.registerAnnotated(new MyCommands());
}
}The framework provides type-safe tab-completion with @Suggestions:
@Subcommand("msg")
public void onMsg(CommandActor actor, @Suggestions("players") String target, String message) {
// 'target' suggests online player names
}
@Subcommand("warp")
public void onWarp(CommandActor actor, @Suggestions("worlds") String world) {
// 'world' suggests world names (Paper)
}Built-in providers: players, worlds (Paper), servers (Velocity).
Enums suggest their constants automatically without annotation.
Register custom providers:
PaperCommandFramework.builder(this)
.suggestionProvider("kits", ctx -> List.of("starter", "vip", "admin"))
.build();Use @Flag for toggle-style and value-style flags:
@Subcommand("ban")
@Permission("moderator.ban")
public void ban(
CommandActor actor,
String player,
@Flag("silent") boolean silent,
@Flag("reason") String reason
) {
// /ban player --silent --reason "spam"
// boolean flags default to false
// String flags consume the next token as value
}Flag parameters are declared after positional parameters in Java method signatures, but users may type flags before or after positional arguments. Flags automatically suggest themselves in tab-completion (--silent, -s).
Validate parameters without manual checks:
@Subcommand("give")
public void give(
CommandActor actor,
@Range(min = 1, max = 64) int amount,
@Length(min = 3, max = 16) String item
) {
// Validation runs automatically before method invocation
// Failed validation returns CommandStatus.INVALID_USAGE
}
@Subcommand("warp")
public void warp(
@Min(0) @Max(100) int x,
@Min(0) @Max(100) int y,
@Min(0) @Max(100) int z
) {
// Numeric bounds enforced automatically
}Supported validators:
| Annotation | Types | Description |
|---|---|---|
@Range(min, max) |
Numeric | Inclusive range check |
@Min(value) |
Numeric | Minimum value (inclusive) |
@Max(value) |
Numeric | Maximum value (inclusive) |
@Length(min, max) |
String | String length bounds |
@Regex("pattern") |
String | Regex pattern matching |
Access the underlying platform entity without casting:
@Subcommand("heal")
@OnlyPlayer
public void heal(CommandActor actor) {
// Paper: get native Player without casting
Player player = actor.unwrap(Player.class);
player.setHealth(player.getMaxHealth());
}unwrap(Class) delegates to platform adapters. as(Class) is also available for casting the actor wrapper itself.
Create type-safe command hierarchies with @CommandGroup:
@Command("admin")
public class AdminCommands {
@Subcommand("ban")
public void ban(CommandActor actor, String player) { }
@CommandGroup("moderator")
public class ModeratorGroup {
@Subcommand("kick")
public void kick(CommandActor actor, String player) { }
// Resolves to: /admin moderator kick <player>
}
}Groups can be nested arbitrarily deep. Each inner class annotated with @CommandGroup prefixes its methods with the group path.
Paginated help with automatic grouping:
// Show 8 routes per page
CommandRoute helpRoute = PaginatedHelpCommand.create(
"help", dispatcher, messageProvider, 8
);
dispatcher.register(helpRoute);PaginatedHelpCommand filters by permission, groups by category, and supports pagination via /help <page>.
Access resolved parameters in interceptors:
public class AuditInterceptor implements RichCommandInterceptor {
@Override
public CommandResult before(CommandContext context, List<ParsedParameter<?>> parameters) {
// Log or validate based on parsed parameter values
for (ParsedParameter<?> param : parameters) {
logger.info("Param " + param.parameter().name() + " = " + param.value());
}
return CommandResult.success();
}
}RichCommandInterceptor extends CommandInterceptor and receives parsed parameters before execution. Regular CommandInterceptor continues to work unchanged.
Retrieve parsed parameters by name and type:
public CommandResult execute(CommandContext context, List<ParsedParameter<?>> parameters) {
// Type-safe parameter retrieval
Optional<Integer> amount = context.get("amount", Integer.class);
String player = context.require("player", String.class);
// Alternative: access via parsedParameter()
Optional<ParsedParameter<?>> param = context.parsedParameter("amount");
}get() returns Optional<T> when present and type-compatible. require() throws IllegalArgumentException if absent or incompatible.
Send Adventure Component directly from the core API:
Component component = MiniMessage.miniMessage().deserialize("<green>Hello!</green>");
actor.sendMessage(component); // Works on both Paper and VelocityThe default implementation serializes to plain text for platforms without native Adventure support. Paper and Velocity adapters deliver components natively.
Track command usage with built-in metrics:
InMemoryCommandMetrics metrics = new InMemoryCommandMetrics();
CommandDispatcher dispatcher = CommandDispatcher.builder()
.metrics(metrics)
.build();
// After dispatching commands:
InMemoryCommandMetrics.CommandStats stats = metrics.stats("kit give");
long total = stats.totalDispatches();
double errorRate = stats.errorRate();
Duration avg = stats.averageDuration();- Paper: Most Bukkit API calls must run on the main thread. When using
@Async, route resolution, guards, parsing, and before-interceptors stay on the command thread; only the command executor body is scheduled on the configured async executor. The Paper adapter automatically schedulessendMessageand after-interceptors back to the main thread. Other API calls (Player, World, Inventory) must be scheduled manually by the command executor. - Velocity: The proxy API is generally thread-safe.
@Asyncis safe for most operations.
Note on Registration: Route registration (register(), unregister()) is synchronized but should happen during plugin startup, not concurrently with command dispatch.
- Input Sanitization: Automatically strips control characters and null bytes (
\0). Limits max arguments (32) and argument length (128) by default. - Safe Logging: Log text is cleaned of control characters and truncated to 1024 characters to prevent log injection.
- Locale Safety: All string normalizations use
Locale.ROOTto avoid Turkish locale bugs (ivsı).
The framework provides two help command implementations:
CommandRoute helpRoute = AutoHelpCommand.create("help", dispatcher, messageProvider);
dispatcher.register(helpRoute);Lists all available routes filtered by permission and sender requirements.
CommandRoute helpRoute = PaginatedHelpCommand.create(
"help", dispatcher, messageProvider, 8 /* routes per page */
);
dispatcher.register(helpRoute);Features pagination, permission filtering, and syntax display. Users navigate with /help <page>.
The CommandMessageProvider interface includes noDescription() for internationalized fallback text when a route has no description.
Clone an existing route to create a modified variant:
CommandRoute original = CommandRoute.builder("warp", executor).build();
CommandRoute modified = CommandRoute.Builder.copyFrom(original)
.permission("warp.use")
.build();Override route settings at runtime via CommandConfiguration:
CommandDispatcher dispatcher = CommandDispatcher.builder()
.configuration(myYamlConfig)
.build();Supported overrides: permission, cooldown, aliases, description, syntax, async.
Enable caching for high-traffic commands to avoid re-parsing identical arguments:
CommandDispatcher dispatcher = CommandDispatcher.builder()
.parameterCache(true)
.build();Uses an internal bounded cache with 10k entries and 5-minute TTL. The cache only stores scalar/enum parse results; routes with platform objects or other unsafe parameter types bypass caching automatically.
Track command usage with built-in metrics:
InMemoryCommandMetrics metrics = new InMemoryCommandMetrics();
CommandDispatcher dispatcher = CommandDispatcher.builder()
.metrics(metrics)
.build();
// Query statistics
InMemoryCommandMetrics.CommandStats stats = metrics.stats("kit give");
System.out.println("Total: " + stats.totalDispatches());
System.out.println("Error rate: " + stats.errorRate());
System.out.println("Avg duration: " + stats.averageDuration());Both Paper and Velocity adapters support MiniMessage out of the box:
actor.sendMessage("<green>Hello <yellow>%s</yellow>!".formatted(playerName));MiniMessage tags are parsed automatically. If MiniMessage parsing fails, the adapter falls back to legacy color codes (§a, &a).
For advanced use (hover events, click events, translatable components), use Component directly on platform adapters.
All default framework messages use MiniMessage tags for rich formatting.
./gradlew buildThe build runs tests, Javadoc, shadow JAR tasks, JaCoCo reports, and Spotless
format checks. Spotless is pinned to UNIX line endings; .editorconfig and
.gitattributes also enforce LF so Windows and Linux checkouts behave the same.
MIT License — see LICENSE for details.