Command framework

RootCommand centralises routing, permission checks, player-only checks, usage errors and tab completion. The root command must still be declared in plugin.yml; Keystone binds its executor and completer.

commands:
  example:
    description: Manage Example.
    usage: /example help
RootCommand root = new RootCommand(messages, "usage")
    .register(SimpleSubcommand.of("give", this::give)
        .permission("example.command.give")
        .usage("give <item> [player]")
        .description("Give an item")
        .completer(this::completeGive))
    .register(SimpleSubcommand.of("menu", this::menu)
        .permission("example.command.menu")
        .requiresPlayer()
        .description("Open the item browser"))
    .register(new PlatformSubcommand(keystone, messages, "example.command.platform"))
    .defaultTo(SimpleSubcommand.of("help", this::help));

root.bind(this, "example");

Subcommand rules

aliases(...)String...

Additional names routed to the same subcommand.

permission(node)String

Deny execution when the sender lacks the node. Inaccessible subcommands are also hidden from first-argument tab completion.

requiresPlayer()

Reject console and command blocks before the handler runs. Inside the handler, context.requirePlayer() can then be used safely.

usage(text)String

Arguments shown by your help output or usage response. Use angle brackets for required values and square brackets for optional values.

description(text)String

Short action-oriented help text.

completer(function)

Receives the sender and arguments after the subcommand name. Return only values the sender may actually use.

Reading arguments

private void give(CommandContext context) {
    CommandArguments args = context.args();
    String itemId = args.get(0, "");
    Optional<Player> target = args.player(1);
    int amount = args.integer(2).orElse(1);

    if (itemId.isBlank()) {
        messages.send(context.sender(), "usage");
        return;
    }
}

CommandArguments also provides optional(index), lower(index), joinFrom(index), withoutFirst(), asList() and toArray(). Parsing methods return Optional so invalid input is not silently converted into a valid value.

Tab completion

private List<String> completeGive(CommandSender sender, CommandArguments args) {
    if (args.size() <= 1) {
        return RootCommand.matching(itemIds(), args.get(0, ""));
    }
    return RootCommand.matching(
        Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(),
        args.get(1, "")
    );
}

RootCommand.matching performs case-insensitive prefix filtering. Do not return secret ids, staff-only player data or values the sender lacks permission to select.

Verify the command boundary

Test one permitted player, one denied sender and console for every requiresPlayer() branch. Also tab-complete as the denied sender: hidden first-level subcommands are part of the permission model. If bind cannot find the root declared in plugin.yml, treat that startup failure as a packaging error rather than registering an ad-hoc fallback command.