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 {
Stringname(); // primary command labelString[] aliases() default {}; // alternative labelsStringpermission() default""; // base permission (optional)StringdescriptionKey() default""; // i18n key for description (optional)
}@Documented@Retention(RUNTIME) @Target(METHOD)
public @interface Subcommand {
Stringvalue(); // e.g. "reload", "hello"Stringpermission() default""; // optional per-sub permissionStringdescriptionKey() default""; // i18n key (optional)
}@Documented@Retention(RUNTIME) @Target(METHOD)
public @interface TabComplete {
Stringvalue() 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.
packagegg.nextforge.core.commands;
importorg.bukkit.command.CommandSender;
importorg.bukkit.entity.Player;
@Command(name = "nextforge", aliases = {"nf"}, descriptionKey = "command.nextforge.desc")
publicclassNextForgeCommand {
@Subcommand("reload")
publicvoidreload(CommandSendersender) {
sender.sendMessage("Reloading…");
}
@Subcommand("hello")
publicvoidhello(Playerplayer, String[] args) {
Stringwho = args.length > 0 ? args[0] : player.getName();
player.sendMessage("Hello, " + who + "!");
}
@TabComplete("") // root suggestionspublicjava.util.List<String> rootTabs(CommandSendersender, String[] args) {
returnjava.util.List.of("reload", "hello");
}
@TabComplete("hello")
publicjava.util.List<String> helloTabs(Playerplayer, String[] args) {
returnjava.util.List.of("Steve", "Alex", "Max");
}
}@OverrideprotectedvoidbeforeEnable(gg.nextforge.core.plugin.inject.ServiceRegistryservices) {
varcmdMgr = newgg.nextforge.core.commands.CommandManager(this, services);
services.register(gg.nextforge.core.commands.CommandManager.class, cmdMgr);
cmdMgr.register(newgg.nextforge.core.commands.NextForgeCommand());
}
@Overridepublicvoiddisable() {
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!