A lightweight, annotation-driven command system for Bukkit/Paper that does not require plugin.yml.
Commands are registered dynamically into the server CommandMap, integrate with DI (ServiceRegistry) and
use i18n (MiniMessage + YAML) for messages. Includes auto tab-completion and per-subcommand permissions.
- No
plugin.ymlrequired – dynamic registration viaCommandMap - Annotations:
@Command,@Subcommand,@TabComplete - Auto Tab-Completion (root + subcommand-specific)
- DI integration – command instances get injected (
Injector.wire) - i18n integration – user-facing messages come from
I18n - Per-command & per-subcommand permissions
- Safe, reflection-based execution signatures
gg.nextforge.core.commands
├─ Command.java (@Command on classes)
├─ Subcommand.java (@Subcommand on methods)
├─ TabComplete.java (@TabComplete on methods)
└─ CommandManager.java (dynamic registration & dispatch)
@Documented @Retention(RUNTIME) @Target(TYPE)
public @interface Command {
String name(); // primary command label
String[] aliases() default {}; // alternative labels
String permission() default ""; // base permission (optional)
String descriptionKey() default ""; // i18n key for description (optional)
}@Documented @Retention(RUNTIME) @Target(METHOD)
public @interface Subcommand {
String value(); // e.g. "reload", "hello"
String permission() default ""; // optional per-sub permission
String descriptionKey() default ""; // i18n key (optional)
}@Documented @Retention(RUNTIME) @Target(METHOD)
public @interface TabComplete {
String value() default ""; // "" = root completer, or a sub-name like "hello"
}For @Subcommand methods:
(CommandSender sender, String[] args)(Player player, String[] args)(CommandSender sender)(Player player)()
For @TabComplete methods:
(CommandSender sender, String[] args)(Player player, String[] args)(CommandSender sender)(Player player)()→ returnsList<String>
If the signature does not match these forms, a localized error (command.bad_signature) is returned.
package gg.nextforge.core.commands;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
@Command(name = "nextforge", aliases = {"nf"}, descriptionKey = "command.nextforge.desc")
public class NextForgeCommand {
@Subcommand("reload")
public void reload(CommandSender sender) {
sender.sendMessage("Reloading…");
}
@Subcommand("hello")
public void hello(Player player, String[] args) {
String who = args.length > 0 ? args[0] : player.getName();
player.sendMessage("Hello, " + who + "!");
}
@TabComplete("") // root suggestions
public java.util.List<String> rootTabs(CommandSender sender, String[] args) {
return java.util.List.of("reload", "hello");
}
@TabComplete("hello")
public java.util.List<String> helloTabs(Player player, String[] args) {
return java.util.List.of("Steve", "Alex", "Max");
}
}@Override
protected void beforeEnable(gg.nextforge.core.plugin.inject.ServiceRegistry services) {
var cmdMgr = new gg.nextforge.core.commands.CommandManager(this, services);
services.register(gg.nextforge.core.commands.CommandManager.class, cmdMgr);
cmdMgr.register(new gg.nextforge.core.commands.NextForgeCommand());
}
@Override
public void disable() {
services().get(gg.nextforge.core.commands.CommandManager.class)
.ifPresent(gg.nextforge.core.commands.CommandManager::unregisterAll);
}
CommandManagerwires your command instance via DI (Injector.wire) and registers a reflectiveCommandin the serverCommandMap.
unregisterAll()removes dynamically registered commands on plugin disable (Paper has direct API; on Spigot it falls back to reflection).
- Command descriptions (
descriptionKey) and messages (e.g.,command.usage,command.unknown,command.no_permission,command.error,command.bad_signature) are read via theI18nservice. - Ensure your YAML contains these keys, e.g. in
en.yml:
command:
usage: "<gray>Usage: <yellow><label></yellow></gray>"
unknown: "<red>Unknown subcommand: <yellow><sub></yellow></red>"
error: "<red>An internal error occurred.</red>"
no_permission: "<red>You don't have permission.</red>"
bad_signature: "<red>Unsupported command method signature.</red>"
nextforge:
desc: "Core commands for NextForge."- Root completion: method annotated with
@TabComplete("")(no sub value). - Sub-specific completion:
@TabComplete("subname"). - If no completer exists, an empty list is returned.
- The framework automatically filters root subcommands by permission.
- Base permission from
@Command(permission="...")is checked first. - Subcommand permission from
@Subcommand(permission="...")is checked per invocation. - If missing, access is allowed by default.
- Keep command methods tiny — delegate heavy work to services (DI).
- Use i18n
MiniMessagefor colorful, consistent messages. - Avoid blocking operations on the main thread; move to your Scheduler and return to main for output.
- Group related subcommands in the same class to keep discovery cheap.
Do I still need plugin.yml?
No. This system registers commands directly against the CommandMap at runtime.
Is it Paper-only?
It works on Paper and Spigot. Unregister uses Paper API when available; on Spigot it falls back to reflection.
Can I scan a package and auto-register all commands?
Yes — add a simple classpath scanner and call cmdMgr.register(instance) for each discovered class. (Ask if you want a ready-made helper.)
gg.nextforge.core.i18n.*– YAML + MiniMessage i18n systemgg.nextforge.core.plugin.inject.*– DI container & Injectorgg.nextforge.core.scheduler.*– Custom scheduler for async/sync tasks
Happy commanding!