A modern, lightweight, type-safe command framework for Minecraft server plugins.
Supports Paper (Bukkit) and Velocity proxy platforms with a unified, annotation-driven API.
| Feature | Description |
|---|---|
| 🏗️ Platform Abstraction | Core module is 100% platform-agnostic — adapters for Paper and Velocity provided |
| 📝 Annotation-Based Commands | Declare commands with @Command, @Subcommand, @Permission, @PermissionTemplate, @Cooldown, @Confirm, @Async |
| 🔒 Type-Safe Parameters | Automatic argument parsing with ParameterResolver registry (String, int, long, double, boolean, enums, greedy strings, and Paper types) |
| 🎯 Low-Boilerplate UX | @Arg, @Sender, @PlayerOnly, @ConsoleOnly, @OnlinePlayer, @TargetOrSelf, @PermissionForOther, @ParseError, @DisplayName, Optional<T>, @GreedyString, @DefaultValue, @Syntax, batch registration, and message presets |
| ♻️ Less Boilerplate | Composable annotations (meta-annotations), @AutoPermission for convention-based permission nodes, registerPackageOf(Class), .enumAlias() for external enums, reply-and-return CommandResult factories, framework.actorOf(...), and actor.sendDualMessage(...) |
| ✅ Declarative Validation | @Range, @Min, @Max, @Length, @NotBlank, @Regex annotations for automatic parameter validation |
| 🚩 CLI-Style Flags | @Flag("silent") boolean silent and @Flag("reason") String reason for flag-based arguments |
| ⚡ Pipeline Architecture | Dispatch pipeline with guard stage (static permission + sender) and execution stage (parse + dynamic guards + cooldown + invoke + interceptors) |
| 🔌 Rich Interceptors | RichCommandInterceptor receives resolved parameters in before(ctx, params) for advanced middleware |
| 🔄 Async Support | Mark routes with @Async to run the command body off-thread while guards/parsing stay on the platform thread |
| 🛡️ Centralized Exception Handling | Register type-specific handlers with onException(Class, CommandExceptionHandler) for clean error recovery |
| ⏱️ 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 | Native Component support in core API with platform-native delivery (CommandActor.sendMessage(Component)) |
| 🧑💻 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 | PaginatedHelpCommand with pagination, grouping, and syntax highlighting |
| 💾 Parse Caching | Optional CachedCommandParameterParser for high-traffic commands |
| 📊 Metrics | InMemoryCommandMetrics tracks dispatch counts, error rates, and duration statistics per route |
| 💉 Dependency Injection | Integrated DependencyRegistry for automatic injection into command constructors |
| 🔍 Fuzzy Matching | "Did you mean?" suggestions for root commands with typos |
| 🔒 Production Ready | Thread-safe atomic caches, debounced messages, safe logging, and optimized execution |
| Module | Description |
|---|---|
command-core |
Core framework — dispatcher, routing, parsing, pipeline, rate limiting, cooldowns, exception handling |
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 |
command-test |
Test harness — dispatch command lines against a fake actor and assert outcomes |
examples |
Sample plugins for Paper and Velocity |
benchmarks |
JMH benchmarks |
Current stable release: v3.2.1
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 (includes core + annotations)
implementation("com.github.HanielCota.CommandFramework:command-paper:v3.2.1")
// Velocity plugin (includes core + annotations)
implementation("com.github.HanielCota.CommandFramework:command-velocity:v3.2.1")
// Or individual modules
implementation("com.github.HanielCota.CommandFramework:command-core:v3.2.1")
implementation("com.github.HanielCota.CommandFramework:command-annotations:v3.2.1")
}For snapshots from main, replace v3.2.1 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())
.dependency(MyService.class, new MyService()) // Dependency Injection
.onException(IllegalStateException.class, (ctx, ex) -> {
ctx.actor().sendError("State error: " + ex.getMessage());
return CommandResult.invalidUsage(ex.getMessage());
})
.build();
// No-arg or DI-satisfied constructors
commands.registerPackageOf(MyCommands.class);
}
@Override
public void onDisable() {
if (commands != null) commands.shutdown();
}
}
@Command("kit")
public class MyCommands {
@DefaultSubcommand
@PlayerOnly
public void onDefault(CommandActor actor) {
actor.sendWarning("Use /kit give <player>");
}
@Subcommand("give")
@Permission("kit.give")
@Cooldown(duration = "3s") // Shorthand duration
@Syntax("/kit give <player> <item> [amount]")
public void onGive(CommandActor actor,
@OnlinePlayer @Arg("player") Player target,
Material item,
@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) { // Smart injection (no @Sender needed)
// 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.invalidUsage("Player name required");
}
actor.sendMessage("Kit deleted for " + target);
return CommandResult.success();
}
}@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());
}
}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, @DefaultValue("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,
@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.
Use @Arg Player when you want the built-in online-player argument resolver
without adding the more specific @OnlinePlayer marker:
@Subcommand("fly")
public void fly(Player sender, @Arg Player target) {
target.setAllowFlight(true);
}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 to name
a suggestion provider. Use @DefaultValue to supply a value for an omitted
argument.
Use Optional<T> for omitted arguments without reparsing a synthetic default:
@Subcommand("heal")
public void heal(Player sender, @OnlinePlayer Optional<Player> target) {
target.orElse(sender).setHealth(sender.getMaxHealth());
}@TargetOrSelf Player resolves an optional player argument that falls back to
the sender when omitted. Pair it with @PermissionForOther to require an extra
permission node only when the resolved target is not the sender:
@Subcommand("heal")
@Permission("essentials.heal")
@PermissionForOther("essentials.heal.others")
@PlayerOnly
public void heal(Player sender, @TargetOrSelf Player subject) {
// subject == sender when omitted; otherwise the named player.
// essentials.heal.others is enforced only when subject != sender.
subject.setHealth(subject.getMaxHealth());
}This collapses the common Essentials-style pattern — an optional player argument
plus a .others permission node, shared by /heal, /feed, /fly,
/gamemode, and similar commands — into two annotations, with no custom
resolver or guard. Route registration fails fast if @PermissionForOther is
declared without a @TargetOrSelf parameter.
Any concrete enum is parsed automatically, case-insensitively, with enum-value tab completion:
public enum TimeType {
DAY,
NIGHT
}
@Subcommand("time")
public void time(Player sender, TimeType time) {
sender.getWorld().setTime(time == TimeType.DAY ? 1000 : 13000);
}Use @DefaultValue("") World when an omitted world should mean the sender's
current world. Explicit world names still resolve with Bukkit.getWorld(name);
console senders fail with an invalid-sender parse error if the world is omitted:
@Subcommand("time")
@PlayerOnly
public void time(Player sender, TimeType time, @DefaultValue("") World world) {
world.setTime(time == TimeType.DAY ? 1000 : 13000);
}Use @PermissionTemplate when a permission depends on a parsed value:
@Subcommand("gamemode")
@PermissionTemplate("essentials.gamemode.{mode}")
public void gamemode(Player sender, GameMode mode) {
sender.setGameMode(mode);
}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 classes
commands.registerPackageOf(KitCommands.class); // same, refactor-safe — no string literalWhen command classes have constructor dependencies, pass an instance factory. The framework stays out of dependency injection while letting any container (Guice, Spring, a manual map, …) supply the instances:
commands.registerPackage("com.example.commands", clazz -> injector.getInstance(clazz));
commands.registerClasses(clazz -> injector.getInstance(clazz), KitCommands.class, AdminCommands.class);registerPackage is intended for plugin startup and rejects blank package names.
dispatcher.describeRoutes() returns a human-readable dump of every registered
route — roots, subcommands, parameters, permissions, sender restrictions, and
cooldowns — useful for debugging command registration during startup.
Framework annotations on a command type or method are resolved through meta-annotations, so a recurring cluster collapses into one custom annotation:
@PlayerOnly
@Cooldown(5)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ModerationAction {}@ModerationAction @Subcommand("ban") void ban(...) {}
@ModerationAction @Subcommand("kick") void kick(...) {}A directly present annotation always wins over one reached through a meta-annotation, so individual routes can still override.
@AutoPermission derives each route's permission node from the command path
(<prefix>.<path>), removing a repetitive @Permission from every method:
@Command("admin")
@AutoPermission // prefix defaults to "admin"
public final class AdminCommand {
@Subcommand("broadcast") void broadcast(...) {} // -> admin.broadcast
@Subcommand("players") void players(...) {} // -> admin.players
@Subcommand("reload")
@Permission("server.reload") // explicit node still wins
void reload(...) {}
}Pass @AutoPermission("myplugin.admin") to set an explicit prefix.
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,
@NotBlank @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 |
@NotBlank |
String | Requires at least one non-whitespace character |
@Regex("pattern") |
String | Regex pattern matching |
Normal positional parameters are required by default and fail with a missing
argument parse error when omitted. Use Optional<T> or @DefaultValue when
omission is intentional; @NotBlank Optional<String> still
allows omission but rejects a present blank value.
Give a parameter a custom message for when its value fails to parse with
@ParseError. The message is sent verbatim and supports the {input} and
{parameter} placeholders:
@Subcommand("setlevel")
public void setLevel(
CommandActor actor,
@ParseError("<red>'{input}' is not a valid level.</red>") int level
) {
// A non-numeric argument sends the custom message instead of the default
}Annotate enum constants with @DisplayName to control how they appear in
usage, error messages and tab-completion. The display name is accepted as
parse input, and the raw constant name keeps working:
public enum Weather {
@DisplayName("clear") SUN,
@DisplayName("rainy") RAIN
}Both /weather rainy and /weather RAIN resolve to Weather.RAIN. Permission
nodes continue to use the raw constant name, so they stay stable.
@DisplayName only works on enums you can annotate. For external enums like
Bukkit's GameMode you'd otherwise be stuck maintaining a parallel
Map<GameMode, String> plus a manual switch for aliases — and you'd lose
type-safety in the process. The platform builder exposes .enumAlias(constant, displayName, aliases…) so the framework's own enum resolver handles parsing,
tab-completion and the invalid-choice error text for any enum:
PaperCommandFramework framework = PaperCommandFramework.builder(this)
.enumAlias(GameMode.SURVIVAL, "sobrevivência", "s", "0")
.enumAlias(GameMode.CREATIVE, "criativo", "c", "1")
.enumAlias(GameMode.ADVENTURE, "aventura", "a", "2")
.enumAlias(GameMode.SPECTATOR, "espectador", "sp", "3")
.build();@Subcommand("set")
public void set(CommandActor actor, GameMode mode) {
// /gamemode set sobrevivência -> GameMode.SURVIVAL
// /gamemode set sp -> GameMode.SPECTATOR
// /gamemode set 0 -> GameMode.SURVIVAL
actor.unwrap(Player.class).setGameMode(mode);
}What the framework does for you:
- Parsing: the display name, the raw constant name (
SURVIVAL, case-insensitive) and every alias are all accepted. - Tab-completion: only the display name shows up — aliases are deliberately hidden to keep the suggestion list clean.
- Error text: the invalid-choice list uses the display names
(
sobrevivência, criativo, aventura, espectador). - Precedence: a builder-registered override replaces
@DisplayNamewhen both target the same constant. Use@DisplayNamefor display-only on enums you own; reach for.enumAlias(...)when you need parse aliases too (or when you don't control the enum). - Same API in Velocity:
VelocityCommandFramework.builder(...)exposes the identical method.TestCommandFrameworkalso has.enumAlias(...)for unit tests.
For advanced cases you can build an EnumDisplayRegistry directly and pass it
to a custom ParameterResolverRegistry, but the builder method covers the
common path.
Mark a destructive command with @Confirm. The first invocation only prompts
the actor; the command runs only when the same command and arguments are
re-issued within the window:
@Subcommand("reset")
@Confirm(value = 20, unit = TimeUnit.SECONDS)
public void reset(CommandActor actor) {
// First /world reset -> "run it again within 20s to confirm"
// Second /world reset (<20s) -> actually resets
}Confirmation is checked after parsing and guards but before the cooldown is claimed, so being prompted never consumes the actor's cooldown. Pending confirmations are tracked per actor, route and arguments.
Access the underlying platform entity without casting:
@Subcommand("heal")
@PlayerOnly
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.
The CommandActor passed to your handler is the sender. When you want to
notify someone else (the heal target, the player whose mode you changed, the
recipient of a /msg), don't fall back to
target.sendMessage(MiniMessage.miniMessage().deserialize(...)) — you'd lose
the framework's MiniMessage parsing, legacy color fallback and main-thread
scheduling. Use framework.actorOf(...) instead:
PaperCommandFramework framework = PaperCommandFramework.create(plugin);
@Subcommand("set")
public void set(CommandActor senderActor, @OnlinePlayer Player target, GameMode mode) {
target.setGameMode(mode);
CommandActor targetActor = framework.actorOf(target);
senderActor.sendSuccess("Modo de <yellow>" + target.getName() + "</yellow> alterado.");
targetActor.sendSuccess("Seu modo foi alterado para <yellow>" + mode + "</yellow>.");
}PaperCommandFramework.actorOf(CommandSender) returns a PaperCommandActor
already wired to the plugin, so:
- Strings parse as MiniMessage with automatic legacy (
§a,&a) fallback. - If the call lands on an async thread, delivery is rescheduled on the server main thread automatically — and skipped silently if the player logged off in the meantime.
- The actor exposes
unwrap(Player.class)like any other.
VelocityCommandFramework.actorOf(CommandSource) is the proxy equivalent.
Commands like /heal, /feed, /fly, /gamemode all repeat the same shape:
tell the sender what happened, and tell the target the same thing unless
sender is the target. The framework collapses it:
@Subcommand("set")
public void set(CommandActor sender, @OnlinePlayer Player target, GameMode mode) {
target.setGameMode(mode);
sender.sendDualMessage(
framework.actorOf(target),
"Modo de <yellow>" + target.getName() + "</yellow> alterado para " + mode + ".",
"Seu modo foi alterado para <yellow>" + mode + "</yellow> por " + sender.name() + ".");
}The target message is suppressed when target.uniqueId().equals(sender.uniqueId()),
so /gamemode set creative from a player and from console behave correctly
without manual UUID checks.
The pair actor.sendError(msg); return CommandResult.failure(...) is repeated
in every handler that rejects input, and forgetting the return is a real
source of bugs (the command keeps running silently). CommandResult exposes
overloads that send the message and return the matching result in a single
statement:
@Subcommand("set")
public CommandResult set(CommandActor actor, String input) {
GameMode mode = parseMode(input);
if (mode == null) {
return CommandResult.invalidUsage(actor, "Modo inválido: <white>" + input + "</white>.");
}
if (!actor.hasPermission("gamemode.set." + mode.name().toLowerCase())) {
return CommandResult.denied(actor, "Você não pode usar esse modo.");
}
try {
applyMode(actor, mode);
} catch (IllegalStateException ex) {
return CommandResult.internalError(actor, "Falha ao aplicar o modo. Tente novamente.");
}
return CommandResult.success();
}| Factory | Status | Sends as |
|---|---|---|
CommandResult.invalidUsage(actor, message) |
INVALID_USAGE |
red error |
CommandResult.denied(actor, message) |
NO_PERMISSION |
red error |
CommandResult.internalError(actor, message) |
ERROR |
red error |
CommandResult.cooldown(actor, message, remaining) |
COOLDOWN |
yellow warning |
Important — messages passed to these factories go directly to
CommandActor.sendError/sendWarning, so they bypass the dispatcher's
CommandMessageProvider (no i18n template lookup) and the ActorMessageDebouncer
(no automatic deduplication). Pre-format the message in the caller's language,
and call your own logger separately if internalError needs an audit trail.
These overloads exist alongside the original side-effect-free factories
(CommandResult.invalidUsage(detail), CommandResult.cooldown(remaining),
etc.) — those still work and are still the right choice when the message has
already been sent through some other channel.
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.
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.
You can also use ActorMessageSender for debounced component delivery:
ActorMessageSender sender = new ActorMessageSender(debouncer);
Component errorComponent = MiniMessage.miniMessage().deserialize("<red>Invalid input!</red>");
sender.send(actor, errorComponent);Routes can also have Component descriptions:
CommandRoute route = CommandRoute.builder("kit", executor)
.description(MiniMessage.miniMessage().deserialize("<yellow>Kit management commands</yellow>"))
.build();Register type-specific exception handlers for clean, centralized error recovery:
PaperCommandFramework.builder(this)
.onException(IllegalArgumentException.class, (ctx, ex) -> {
ctx.actor().sendError("Invalid argument: " + ex.getMessage());
return CommandResult.invalidUsage(ex.getMessage());
})
.onException(IllegalStateException.class, (ctx, ex) -> {
ctx.actor().sendError("State error: " + ex.getMessage());
return CommandResult.invalidUsage(ex.getMessage());
})
.onException(RuntimeException.class, (ctx, ex) -> {
ctx.actor().sendError("An unexpected error occurred.");
return CommandResult.failure(CommandStatus.ERROR, "Unexpected error");
})
.build();The framework performs hierarchical lookup: exact class → interfaces (BFS) → superclass → ...
This means a handler registered for RuntimeException will catch any runtime exception
that doesn't have a more specific handler.
Exception handlers are invoked in two places:
- Pipeline stage: catches exceptions during parameter parsing, validation, and execution
- Dispatcher runtime: catches unexpected runtime errors during dispatch
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>.
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.
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.
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();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ı).
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.
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.
The optional command-test module turns testing an annotated command into a
few fluent lines — no platform mocks required:
testImplementation("com.github.HanielCota.CommandFramework:command-test:v3.2.1")TestCommandFramework framework = TestCommandFramework.of(new KitCommand());
framework.as(TestActor.player("Notch").withPermission("kit.give"))
.run("kit give sword")
.assertSuccess()
.assertMessageContains("sword");
framework.as(TestActor.player("Notch"))
.run("kit give sword")
.assertDenied("kit.give");TestActor stands in for a player or the console with a controllable
permission set and records every message it receives. DispatchResult exposes
assertions such as assertSuccess, assertDenied(permission), assertNotFound,
assertInvalidUsage, assertInvalidSender, and assertMessageContains. Failed
assertions throw a plain AssertionError, so the module works with any test
runner.
command-test depends only on the platform-neutral core, so commands that use
platform types such as the Paper Player are out of scope for now.
Version 2.0.0 introduced the following breaking changes from the v1.x line. The
v3.0.0 release additionally removed duplicate annotation aliases and renamed
@Default to @DefaultSubcommand — see the CHANGELOG for that
migration.
The separate ArgumentResolver<T> interface has been removed. All resolvers now
implement ParameterResolver<T> directly:
// Before (v1.x)
public class MyResolver implements ArgumentResolver<MyType> {
public ParseResult<MyType> resolve(ArgumentInput input) { ... }
}
// After (v2.x)
public class MyResolver implements ParameterResolver<MyType> {
public Class<MyType> type() { return MyType.class; }
public boolean consumesInput() { return true; }
public ParseResult<MyType> resolve(ParameterParseContext context) { ... }
}Register with builder.resolver(myResolver) — argumentResolver() has been
removed.
FlagParameterResolver is gone. Flags are now described by a Flag record on
CommandParameter:
CommandParameter<Boolean> silent = CommandParameter.builder("silent", Boolean.class,
new BooleanFlagResolver(Boolean.class))
.flag(new Flag("silent", Set.of("--silent", "-s"), false))
.build();BooleanFlagResolver and StringFlagResolver implement ParameterResolver
directly.
Converted from record to class to support the optional Flag descriptor. Public
getters and the builder remain source-compatible for typical usage.
ExecutionStage has been decomposed into RouteSecurityChecker,
RouteLifecycleChecker, and RouteInvocation. CommandDispatcher subsystems
were extracted into DispatchPipelineFactory and CommandLifecycleManager.
These are internal changes — public APIs (CommandDispatcher.builder(),
registration, dispatch) are unchanged.
CommandMessenger is now a deprecated facade. Use ActorMessageSender for
debounced transport and CommandResultFactory for result creation:
ActorMessageSender sender = new ActorMessageSender(debouncer);
CommandResultFactory factory = new CommandResultFactory(messages, sender);
CommandResult result = factory.noPermission(context);ParameterSuggestionContext has been renamed to SuggestionContext.
Custom stages can now be registered on the dispatcher builder:
CommandDispatcher.builder()
.stage((context, continuation) -> {
// custom logic
return continuation.proceed(context);
})
.build();Use registerAnnotated(), registerClasses(), or registerPackage() instead.
Internal decomposition into focused components (BaseResolverSelector,
FlagResolverFactory, CommandMethodDiscovery, RouteFactory, etc.).
Public APIs are unchanged.
./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.