Skip to content

HanielCota/CommandFramework

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

137 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚀 CommandFramework

Build CodeQL Javadoc JitPack

A modern, lightweight, type-safe command framework for Minecraft server plugins.

Supports Paper (Bukkit) and Velocity proxy platforms with a unified, annotation-driven API.


✨ Features

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

📦 Modules

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

📥 Installation (JitPack)

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.


⚙️ Requirements

  • Java: 21+
  • Paper API: 1.21.11+
  • Velocity API: 3.5.0-SNAPSHOT+

🚀 Quick Start

Paper Plugin

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();
    }
}

Velocity Plugin

@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());
    }
}

🧩 Cognitive-Load Helpers

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);
}

📝 Registration Helpers

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 literal

When 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.


♻️ Less Boilerplate

Composable Annotations

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.

Convention-Based Permissions

@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.


🚩 CLI-Style Flags

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).


✅ Declarative Validation

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.


💬 Custom Parse Messages

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
}

🏷️ Enum Display Names

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.

.enumAlias() for enums you don't own

@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 @DisplayName when both target the same constant. Use @DisplayName for 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. TestCommandFramework also 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.


⚠️ Destructive Command Confirmation

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.


🧑‍💻 Type-Safe Actor Access

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.

Wrapping any sender as an actor — framework.actorOf(...)

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.

actor.sendDualMessage(target, selfMsg, otherMsg) — the self/other pattern

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.


↩️ Reply-and-Return Result Factories

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.


📁 Subcommand Groups

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.


🎨 Adventure Component Support

Send Adventure Component directly from the core API:

Component component = MiniMessage.miniMessage().deserialize("<green>Hello!</green>");
actor.sendMessage(component); // Works on both Paper and Velocity

The 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();

🛡️ Centralized Exception Handling

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

📖 Advanced Help

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>.

Auto-Generated Help

The framework provides two help command implementations:

Simple Help

CommandRoute helpRoute = AutoHelpCommand.create("help", dispatcher, messageProvider);
dispatcher.register(helpRoute);

Lists all available routes filtered by permission and sender requirements.

Paginated Help

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.


🔌 Rich Interceptors

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.


🔍 Declarative Suggestions

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();

📊 Metrics

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();

🧵 Thread Safety

  • 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 schedules sendMessage and 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. @Async is safe for most operations.

Note on Registration: Route registration (register(), unregister()) is synchronized but should happen during plugin startup, not concurrently with command dispatch.


🔒 Safety Features

  • 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.ROOT to avoid Turkish locale bugs (i vs ı).

⚙️ Advanced Features

Builder.copyFrom()

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();

Configuration Overlay

Override route settings at runtime via CommandConfiguration:

CommandDispatcher dispatcher = CommandDispatcher.builder()
    .configuration(myYamlConfig)
    .build();

Supported overrides: permission, cooldown, aliases, description, syntax, async.

Parse Caching

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.


💬 MiniMessage / Adventure Formatting

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.


🧪 Testing

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.


🔄 Migration Guide (v3.1.0)

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.

ArgumentResolverParameterResolver

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.

Flag parameters

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.

CommandParameter is now a class

Converted from record to class to support the optional Flag descriptor. Public getters and the builder remain source-compatible for typical usage.

ExecutionStage and CommandDispatcher internals

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 split

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);

SuggestionContext renamed

ParameterSuggestionContext has been renamed to SuggestionContext.

Pipeline extensibility

Custom stages can now be registered on the dispatcher builder:

CommandDispatcher.builder()
    .stage((context, continuation) -> {
        // custom logic
        return continuation.proceed(context);
    })
    .build();

PlatformCommandAdapter.scan() is no longer public

Use registerAnnotated(), registerClasses(), or registerPackage() instead.

MethodParameterBinder and AnnotatedCommandScanner decomposed

Internal decomposition into focused components (BaseResolverSelector, FlagResolverFactory, CommandMethodDiscovery, RouteFactory, etc.). Public APIs are unchanged.


🏗️ Building

./gradlew build

The 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.


📚 Documentation


📄 License

MIT License — see LICENSE for details.

About

Lightweight type-safe command framework for Paper and Velocity Minecraft servers

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages