Skip to content

Command Framework

Petrus Pradella edited this page Aug 3, 2026 · 9 revisions

Command Framework

What this page covers: the annotation-driven command framework (@FinalCMD). You write a plain class, annotate a method (or the class) with @FinalCMD, annotate more methods with @FinalCMD.SubCMD, and register it once. The framework builds the command, wires sub-commands, parses and tab-completes arguments, checks permissions, renders an automatic help screen, and works identically on Bukkit and Hytale.

A command is not limited to two levels: @FinalCMD.Node adds a branch of any depth, and a branch can eat tokens of its own - /arena duel Colosseum spawn set 2 is one command, not a naming convention. See the command tree.

Argument details live on Argument Parsing; the localized @FCLocale messages you send from a command are on Localization.


The 30-second version

import br.com.finalcraft.evernifecore.api.common.commandsender.FCommandSender;
import br.com.finalcraft.evernifecore.commands.finalcmd.annotations.Arg;
import br.com.finalcraft.evernifecore.commands.finalcmd.annotations.FinalCMD;

@FinalCMD(
        aliases = {"template", "tpl"},
        permission = "myplugin.command"
)
public class TemplateCommand {

    @FinalCMD.SubCMD(subcmd = "info")
    public void info(FCommandSender sender) {
        sender.sendMessage("&aHello from /template info");
    }

    @FinalCMD.SubCMD(subcmd = "coins")
    public void coins(FCommandSender sender, @Arg("<amount>") Integer amount) {
        sender.sendMessage("&aYou asked for &6" + amount + " &acoins.");
    }
}

Register it once, from your plugin bootstrap:

import br.com.finalcraft.evernifecore.commands.finalcmd.FinalCMDManager;

FinalCMDManager.registerCommand(ecPluginData, TemplateCommand.class);

That's a working /template (alias /tpl) with two sub-commands, typed argument parsing on <amount>, tab-completion, permission checks, and a /template help screen - none of which you wrote by hand.


@FinalCMD - the command

Put @FinalCMD on the class (as above) or on a single method. Its attributes:

Attribute Type Default Purpose
aliases String[] (required) Command names. First is primary, the rest are aliases.
permission String "" Permission node required to run/see the command. Empty = no permission.
usage String "" Usage line shown in help, but only legal when the method declares no @Arg and no @Arg.Flag (see Automatic help).
helpHeader String "" Header text for the auto-help screen (rendered centered with a rule).
useDefaultHelp CMDHelpType FULL Controls the automatic help sub-command (see below).
validation Class<? extends CMDAccessValidation>[] {} Access gates evaluated before the command runs (see Access validation).
locales FCLocale[] {} Inline localized description for this command - the only declarative way to describe a command; there is no desc attribute. See Localization.

The sender parameter is a portable FCommandSender - it works on both platforms. On Bukkit you may also declare a Player or CommandSender parameter directly; the framework injects the right one. See Argument Parsing for the full list of auto-injected (contextual) parameter types.


@FinalCMD.SubCMD - the sub-commands

Each method annotated with @FinalCMD.SubCMD becomes a sub-command of the class-level @FinalCMD.

@FinalCMD.SubCMD(
        subcmd = {"coins"},
        permission = "myplugin.command.coins",
        validation = {MustBePlayerValidation.class}
)
public void coins(FCommandSender sender, @Arg("<amount>") Integer amount) { ... }

@FinalCMD.SubCMD carries subcmd (its names/aliases), plus usage, permission, validation, and locales with the same meaning as on @FinalCMD. A sub-command with no permission of its own falls back to the parent command's permission check only.

📌 Note - @FinalCMD.Ignore on a method tells the scanner to skip a method that was inherited from a super-class (the scanner walks the class hierarchy up to Object, so an annotated method on a parent class is picked up unless you ignore it).

Two shapes of a command class

The scanner supports two layouts, and they behave differently:

  • One @FinalCMD + N @FinalCMD.SubCMD (the common case): @FinalCMD is on the class or on a single method, and the @SubCMD methods become its sub-commands. This is what gives you /template info, /template coins, etc.
  • N @FinalCMD methods on one class: each annotated method becomes its own stand-alone command with no sub-commands. @FinalCMD.SubCMD is not allowed in this layout and is ignored with a warning.

Deeper than two levels: the command tree

A @FinalCMD.SubCMD is a leaf. When a segment needs children of its own, it is a node:

EverNifeCore's own /ecstorage is the smallest real one - a leaf next to a branch that eats nothing:

/ecstorage                                  ROOT      @FinalCMD          (class or method)
├── status                                  LEAF      @FinalCMD.SubCMD   (method)
└── transfer                                NODE      @FinalCMD.Node     (class or FIELD)
    ├── section <plugin:section> <backend>  LEAF
    └── network <backend>                   LEAF

A branch that eats a token of its own goes one step further. This is TheDuel's arena command, and it is the shape the rest of this section builds on:

/arena                                 ROOT      @FinalCMD          (class or method)
├── list                               LEAF      @FinalCMD.SubCMD   (method)
└── duel                               NODE      @FinalCMD.Node     (class or FIELD)
    │  eats <arena>                              @FinalCMD.Capture  (method of the node)
    │  runnable on its own                       @FinalCMD.Execute  (method of the node, optional)
    ├── info                           LEAF
    └── spawn                          NODE      (no capture)
        └── set <index>                LEAF

Three annotations carry it, and one more reads it back:

Annotation Where What it does
@FinalCMD.Node class or field declares a branch: subcmd, permission, context, validation, locales. No usage.
@FinalCMD.Capture method of a node class produces: the tokens the branch eats right after its own label
@FinalCMD.Execute method of a node class makes the branch itself runnable
@Arg.NodeCaptured parameter of any descendant consumes: hands that branch's captured value to a leaf

@FinalCMD.Capture produces and @Arg.NodeCaptured consumes - that pairing is why the consumer carries Node in its name. They sit in different annotations because they describe different things: Capture declares a node, so it stays on @FinalCMD; NodeCaptured is one of the four sources of a parameter's value, so it lives with the other three under @Arg (see Argument Parsing).

@FinalCMD(aliases = {"arena"}, permission = "theduel.use")
public class CMDArena {

    @FinalCMD.SubCMD(subcmd = "list")
    public void list(FCommandSender sender) { }

    @FinalCMD.Node(subcmd = {"duel", "d"}, permission = "theduel.duel")
    public static class DuelNode {

        @FinalCMD.Capture
        public DuelArena capture(FCommandSender sender, @Arg("<arena>") DuelArena arena) {
            return arena;
        }

        @FinalCMD.Execute   // /arena duel Colosseum - the node IS the label, it has no name of its own
        public void show(FCommandSender sender, @Arg.NodeCaptured DuelArena arena) { }

        @FinalCMD.SubCMD(subcmd = "info")
        public void info(FCommandSender sender, @Arg.NodeCaptured DuelArena arena) { }

        @FinalCMD.Node(subcmd = "spawn")
        public static class SpawnNode {
            @FinalCMD.SubCMD(subcmd = "set")
            public void set(FCommandSender sender,
                            @Arg.NodeCaptured DuelArena arena,
                            @Arg("<index>") Integer index,
                            @Arg(value = "[force]", def = "true") Boolean force) { }
        }
    }
}

Two ways to mount a node, one meaning

@FinalCMD.Node targets a type or a field. The mount point declares the segment (labels, permission, validation, locales); the class declares the content.

  • Inner class - the tree in the code is the tree in the chat. Best for a small stateless branch.
  • Field - the field's type is the branch (there is no delegate = X.class), so the same class can be mounted more than once, each mount with its own labels and permission, already built with the state and generics you gave it:
@FinalCMD(aliases = {"arena"})
public class CMDArena {

    @FinalCMD.Node(subcmd = {"duel"}, permission = "theduel.duel")
    private final CMDBaseArenaSettings<DuelArena> duel = new CMDBaseArenaSettings<>(DUEL_MODULE);

    @FinalCMD.Node(subcmd = {"ffa"}, permission = "theduel.ffa")
    private final CMDBaseArenaSettings<FfaArena> ffa = new CMDBaseArenaSettings<>(FFA_MODULE);
}

A null field is instantiated through the type's no-arg constructor. The class a field mounts must not carry @FinalCMD.Node itself - the segment would be declared twice, and that is refused.

The capture eats a fixed number of tokens

@FinalCMD.Capture marks at most one method per node class. Its @Arg parameters are the tokens the branch consumes, in order, and it consumes all of them, always: k declared is k eaten, which is what keeps the traversal a single pass with no lookahead and no backtracking. So [optional], fromSender and the variadic <tail...> are all refused inside a capture.

The return value is the branch's context, filed under the branch's node path. void is legal only for a branch that exists to declare flags.

⚠️ Returning null aborts the whole dispatch and the framework says nothing. That is the contract, not an oversight: the capture is the only code that knows why its segment cannot be entered ("no such arena", "you do not own it"), so whoever answers null is whoever has already told the sender. A capture that returns null without sending a message leaves the sender staring at an unchanged screen. On the consuming end the mirror holds: an @Arg.NodeCaptured parameter is never null, so a null check there can only be dead code.

More than one token is fine:

@FinalCMD.Capture
public ArenaInWorld capture(@Arg("<world>") String world,
                            @Arg("<arena>") DuelArena arena) {
    return new ArenaInWorld(world, arena);
}
// /arena duel nether Colosseum spawn set 2

@Arg.NodeCaptured - reading it back

The framework's rule is that an unannotated parameter is the caller and an annotated one comes from outside. So the captured value is explicit, and no wrapper type has to exist just to tell the target apart from the sender:

public void info(FCommandSender sender,                  // contextual: whoever typed it
                 @Arg.NodeCaptured DuelArena arena) { }  // captured: what it is about

Left empty, it resolves to the only compatible capture on the path. When more than one fits, naming the branch is mandatory, and the registration error says exactly which names exist:

public void diff(FCommandSender sender,
                 @Arg.NodeCaptured("duel")         DuelArena a,
                 @Arg.NodeCaptured("duel.against") DuelArena b) { }

The name is a node path - primary labels, dot joined - and it hands over what that node's @Capture returned. To take a single @Arg of a multi-token capture instead, append :<argName>, spelled exactly as the capture declared it:

@FinalCMD.Capture
public ArenaInWorld capture(@Arg("<world>") String world,
                            @Arg("<arena>") DuelArena arena) { ... }

@FinalCMD.SubCMD(subcmd = "four")
public void four(FCommandSender sender,
                 @Arg.NodeCaptured("duel")         ArenaInWorld ctx,    // the @Capture's return
                 @Arg.NodeCaptured("duel:<world>") String world) { }    // one token of it

Both forms live in the same method, and the second works for a one-token capture too. The consequence is worth knowing: the name of a capture's @Arg is public contract. Renaming <world> is refused at boot, listing the names that do exist - never silently, in a leaf three levels away.

An executable node

@FinalCMD.Execute makes the branch itself runnable: /arena duel Colosseum runs the method instead of printing the branch's help. It cannot declare a positional @Arg, and that restriction is the whole reason the traversal stays unambiguous: with no positional of its own, every bare token after a node still has to be a child's label. The other three sources are fine, precisely because none of them claims a bare token - a contextual parameter and an @Arg.NodeCaptured read no token at all, and an @Arg.Flag is addressed by its own --name.

Without an @Execute, typing the branch prints its help.

What eats the next token

Situation at a node Rule
node with a capture of k tokens eats the next k tokens, always
the token names a child that child, always - a declaration beats every convention below
the token is a flag the node already reads the path ends here; the token belongs to the window
help word (help, ?, ajuda) nothing claims the node's help
anything else unknown sub-command, naming the children that do exist

Because the capture eats before any literal is matched, a target whose name happens to equal a child's label is still reachable - and, for the same reason, a target named help is too.

The five phases of a dispatch

Fixed order, and it exists to break a circle - finding the leaf would need the arity of the flags, and the arity of the flags needs the leaf:

1. WALK      match literals, COUNT capture tokens, resolve the leaf.
             No @Capture runs. No parser runs.
             A flag token before the path ends is an error (see the Flags page).
2. EXTRACT   pull every declared flag out of what is left, with the bindings accumulated root..leaf.
             Runs on every command, and stops where a variadic tail begins.
3. CAPTURE   run each @Capture, root -> leaf, each parsing its own @Arg and receiving its @Arg.Flag.
4. PARSE     resolve the leaf's parameters, in the window the path left behind.
5. INVOKE    call the method.

Phase 4 is itself ordered, and the order is what lets a parser read a value the invocation already produced: captures, then early contextuals, then flags, then positionals, then late contextuals. Captures come first because phase 3 already resolved them - making anything wait would only hide them from whoever runs first. The five steps and the ResolutionPhase lever that moves a parameter between the second and the last are on Argument Parsing.

Tab-completion runs only phase 1 - it counts tokens and never resolves a value, because it runs once per keystroke. See Argument Parsing for what a parser is handed while completing.

Nothing thrown by phase 5 escapes the framework: an exception out of your method's body is logged with its stack on the owning plugin's log, the sender gets a generic localized "something went wrong running this command", and the platform never sees it. Handle what you can explain; the rest is a bug report, not a message.

Depth costs nothing at registration, and everything is refused early

A malformed tree is refused while the server is still opening, with a message that names the class, the member, the node path and the call that fixes it. The command is then simply not registered - the reason is in the log and the rest of the plugin loads. What is refused: a node with no children, two captures on one node, a capture that eats nothing, an optional/variadic/fromSender capture argument, an ambiguous or unresolvable @Arg.NodeCaptured, two children claiming one word, a label written with brackets, a node class also carrying @FinalCMD, a mount cycle, an @Execute with an @Arg, a flag spelling an ancestor already claims, a parameter carrying two of the four @Arg... families, and a primitive parameter on an argument that may resolve to nothing.

Every one of them arrives as an ArgMountException, including the ones a parser raises about its own declaration - the parser's sentence travels, with the parameter it was about added to it. One type for the whole catalog, so a caller can tell "this declaration is wrong" from "somebody typed something wrong" by the exception alone.

📌 The evernifecore-common-tests artifact publishes that whole battery as CommandShapeErrors.check(harness), plus a four-level Commands.referenceTree() and harness.tree(cmd) structural assertions - so a plugin building trees tests them without copying a fixture.


Registering commands

Registration is a static call. It needs your plugin's ECPluginData (the handle every ECPlugin gets) and either the command class (the framework instantiates it via its no-arg constructor) or an already-built instance:

public static void registerCommands(ECPluginData ecPluginData) {
    FinalCMDManager.registerCommand(ecPluginData, TemplateCommand.class);
    // or, if you need a pre-built instance:
    FinalCMDManager.registerCommand(ecPluginData, new TemplateCommand());
}

Call this from your plugin's enable/reload path (the same method typically runs on both the Bukkit and Hytale bootstrap). Registration also scans the class for static LocaleMessage fields and loads them, so your @FCLocale messages are ready by the time the command runs.

To remove a command at runtime:

FinalCMDManager.unregisterCommand("template");

⚠️ Gotcha - the command class must have a public no-arg constructor when you register it by .class. If it doesn't, registration logs the failure at SEVERE on your plugin's log, with the cause, and the command is skipped - the boot never stops for it.


The command registry

Every command registered through FinalCMDManager is tracked on its owning plugin's ECPluginData, so a plugin can inspect or tear down what it registered without keeping its own bookkeeping:

List<FinalCMDPluginCommand> mine = ecPluginData.getRegisteredCommands();  // immutable snapshot
ecPluginData.findRegisteredCommand("template");                          // Optional<...>, by any label

FinalCMDManager.unregisterAllCommands(ecPluginData);  // tear down everything this plugin registered
// or, for a single command instance you're holding:
command.unregister();                                 // idempotent - safe to call twice

A plugin implementing IECPluginBootstrap gets this for free: the default onECPluginShutdownPre() already calls FinalCMDManager.unregisterAllCommands(getPluginData()) (alongside ECListener.unregisterAll(...)) before the rest of shutdown runs, so a well-behaved ECPlugin cleans up its commands automatically. Override the hook only if you need different ordering, and call the default (or its two calls) yourself if you still want the cleanup.

The command registry files - one per plugin, and off by default

An admin can disable any command (or any branch of one) and rename its aliases from a file, without touching the plugin. The feature is opt-in and starts off: while it is off nothing is read, nothing is written, no folder is created, and the annotations are the only source of truth.

Turn it on in EverNifeCore's config.yml:

Settings:
  Commands:
    REGISTRY_FILES_ENABLED: true

From the next boot, every plugin that registers a command gets its own file under EverNifeCore's data folder, seeded with that plugin's whole tree - leaves included, so the paths are discoverable without reading any code:

plugins/EverNifeCore/commands/MyPlugin.yml
plugins/EverNifeCore/commands/TheDuel.yml
plugins/EverNifeCore/commands/EverNifeCore.yml

The file nests exactly like the tree: each node's key is its primary label, and its children live under nodes:.

commands:
  arena:
    enabled: true
    aliases: [arenas, ar]
    nodes:
      duel:
        enabled: true
        aliases: [d]
        nodes:
          spawn:
            enabled: false          # kills /arena duel spawn and everything under it
      ffa:
        enabled: true
  • enabled: false removes that node and its whole subtree - from dispatch, from tab-complete and from help at once, because the node is dropped before the command reaches the platform. On the root it means the command is never registered at all.
  • aliases: replaces the extra labels only. The key of the entry is the primary label: it is the entry's identity and can never be changed here, and it always keeps answering.
  • An entry you delete simply goes back to what the annotation says - the file never has to be complete.
  • permission is deliberately not configurable here. It is the plugin's contract, and a typo in a yaml file is not an acceptable way to turn one into a security hole.
  • Changes apply on the owning plugin's next boot or reload - there is no hot rebind.

A parent that is off wins over a child that is explicitly on: reviving the child would leave a path nobody can type. When the file changed anything, the console gets one line naming the plugin and the file; the path-by-path detail sits behind the COMMAND_REGISTRY debug module.

A plugin can ask what survived, over the tree that is live right now:

ecPluginData.isCommandPathEnabled(path);   // false once the file pruned it; true while it is active

With the feature off it answers true for everything - nothing was ever pruned.


Automatic help

Every command with sub-commands gets a generated help screen for free. useDefaultHelp on @FinalCMD chooses the policy:

CMDHelpType Behaviour
FULL Always provide the help sub-command and list every sub-command. (default)
EXCEPT_EMPTY Provide help, but omit sub-commands that have no description/usage.
NONE No automatic help sub-command.

Each help line is built from the sub-command's name, its @Arg names (e.g. <amount>), and its description. helpHeader sets a centered title.

help, ? and ajuda open the help at every depth, so /arena duel Colosseum spawn help prints that branch's help. They are an interceptor, not a reservation: a child labelled help, or a capture standing where the word was typed, claims the token first, so @FinalCMD.SubCMD(subcmd = "help") is reachable and a player named Ajuda is addressable. The word means help wherever nothing else claims it, which is everywhere by default. The list itself is a server setting - Settings.Commands.HELP_WORDS in EverNifeCore's config.yml - so a Spanish or French server adds ayuda/aide without touching a single command; emptying it does not turn the help off, the shipped words come back.

A branch's help lists only its own children (one level, not the whole subtree - four levels rendered at once overflow a chat screen), and every line carries the whole path the sender has to type, click-suggest included. A child the sender cannot reach never shows up - its own permission, its own validations and its own playerOnly all filter the line out, and a sender left with no line at all is told which of the two it was. It is the child's own declaration that decides: walking the subtree to ask whether anything further down is still reachable is what the tab and the mistyped-word list do, and the help stops at the level it prints.

A branch with more children than Settings.Commands.HELP_PAGE_SIZE (8 by default) is paged: the page is the token right after the help word (/arena duel help 2), a footer says where the sender is and spells out the command for the next page, and asking for a page past the end lands on the last one. A help that fits in a single page is printed exactly as it always was, with no page indicator at all.

A sub-command that declares a HelpLine parameter can also render its own single help line on demand - handy for "wrong arguments, show usage":

@FinalCMD.SubCMD(subcmd = "set", usage = "<PluginName> <LocaleName>")
public void set(FCommandSender sender, MultiArgumentos argumentos, HelpLine helpLine) {
    if (argumentos.emptyArgs(0, 1)) {
        helpLine.sendTo(sender); // prints this sub-command's usage line
        return;
    }
    // ...
}

⚠️ usage is only legal when the method declares NO @Arg and no @Arg.Flag. The help line of an annotated method is always built from those names, so a usage written next to them would be dead text - declaring both is refused at registration. Whatever you put in usage is rendered exactly as written: nothing inside it is a placeholder and nothing is stripped out of it. The framework already prints ▶ /${label} ${path} in front of it (e.g. ▶ /eccooldown set), so the sub-command's own name does not belong in the string.


Flags

Beyond positional @Arg parameters, a method can declare @Arg.Flag parameters for --name value style options that can appear anywhere on the command line (/eccooldown set myid 5m --network):

@FinalCMD.SubCMD(subcmd = "set")
public void set(FCommandSender sender,
                 @Arg("<CooldownID>") String cooldownId,
                 @Arg("<duration>") String duration,
                 @Arg.Flag(value = "--network", aliases = "-n", def = "false") Boolean network) {
    Cooldown cooldown = network ? Cooldown.network(cooldownId) : Cooldown.of(cooldownId);
    // ...
}

A visible flag gets a compact [--network] token on the usage line - one spelling, the one usageName() picked - and its own hover block, which is where every spelling is listed. Full syntax rules (quoting, -- end-of-flags, negative numbers), the @Arg.Flag attributes, permission-per-flag, and the manual MultiArgumentos.getFlags() escape hatch are on the dedicated Flags page.


Permissions

Permission checks run over the whole path at dispatch time, root first, and every segment gets a say:

  • A @FinalCMD, @FinalCMD.Node, @FinalCMD.SubCMD or @FinalCMD.Execute with a non-empty permission requires that node. Declaring it once on a branch covers everything under it, which is why /ecstorage transfer carries the node and its two leaves declare nothing.
  • The check also gates tab-completion, at every level: a sender without the branch's permission never sees the branch, and never completes anything inside it either.
  • An empty permission means "no node required".

Permission nodes are ordinary strings; keep them in a PermissionNodes constants class per plugin so they stay consistent between the annotation and your plugin.yml.


Access validation

For gates richer than a single permission node - "must be a player", "must be a clan leader" - extend CMDAccessValidation and list it in validation = { ... }. It runs before arguments are parsed:

public class MustBePlayerValidation extends CMDAccessValidation {

    @FCLocale(lang = LocaleType.EN_US, text = "&c Only players can run this command.")
    @FCLocale(lang = LocaleType.PT_BR, text = "&c Apenas jogadores podem usar este comando.")
    public static LocaleMessage ONLY_PLAYERS;

    @Override
    public boolean onPreCommandValidation(AccessContext accessContext) {
        if (!accessContext.isPlayer()) {
            ONLY_PLAYERS.send(accessContext.getSender()); // you MAY warn here
            return false;                                 // false denies execution
        }
        return true;
    }

    @Override
    public boolean onPreTabValidation(AccessContext accessContext) {
        return accessContext.isPlayer(); // hides the sub-command from tab-completion; do NOT warn here
    }
}

AccessContext exposes getSender(), isPlayer(), getPlayerData(), getPDSection(...), and hasProperPermission(). Returning false from onPreCommandValidation denies the command (and lets you send an explanation); returning false from onPreTabValidation simply hides the entry from tab-completion. A validation class with its own @FCLocale fields has them loaded automatically.

One contract, every surface

Permission and validation are evaluated by the same code for dispatch, tab, help and the "available subcommands" list a mistyped word gets - the only difference is that a dispatch speaks (permission message, or the validation's own warning) and a listing stays silent. The chain is always the whole one:

  • every node from the root down to the target, whether or not it has a method of its own. A @FinalCMD.Node that exists only to hold children still validates the paths that pass through it, and a validation declared on the @FinalCMD root guards the entire tree, not only its own method;
  • plus the @FinalCMD.Execute declaration of the node that runs, which carries a permission and validations the node it hangs on knows nothing about. A @SubCMD leaf and its node are one declaration, so there is nothing extra to ask there - and nothing runs twice.

That is what makes "hidden" mean hidden: a subcommand the tab does not offer is a subcommand the dispatch does not run, and a typo does not list it either.

playerOnly is decided separately, by the parsers a method declares, and it gets its own sentence: a console that runs (or asks the help of) a player-only command is told "Only a player can use this", never "you do not have the permission". No permission node would have opened it, and sending an admin to look for one is worse than saying nothing - which is what this used to do.


Locales in commands

Commands are the primary place localized messages are used. Two patterns coexist:

  • Inline description - the locales = { @FCLocale(...) } on @FinalCMD/@SubCMD provides the localized help description for that command.
  • Static message fields - declare public static LocaleMessage FOO; on the command class with @FCLocale annotations; the framework populates them when the command is registered. Send them with FOO.addPlaceholder("x", value).send(sender) - the key is declared bare, and the text cites ${x}. The scan walks up the class hierarchy, so a LocaleMessage declared on a base command class is loaded too.

Where an entry lands in the language file follows the tree, not the class name. A help-line entry is keyed by the command path - lp.user.permission.SET - and a static field by the chain of enclosing simple names. Two inner classes both called PermissionNode, under different parents, are two different commands, and a simple name cannot tell them apart.

Full details, language files, and the ${placeholder} mechanism are on Localization.


See also

  • Argument Parsing - @Arg, <required> vs [nullable], built-in types, def(), custom ArgParsers.
  • Flags - @Arg.Flag, the --name value syntax, help/tab rendering, the manual flag API.
  • Localization - @FCLocale, LocaleMessage, language files, placeholders.
  • FancyText - hover/click text you can build inside a command (as /eclocale list does).
  • Quick Start - a minimal plugin wiring a command, a config, and a locale end to end.
  • Platform Abstraction - FCommandSender/FPlayer, the portable sender types.

Clone this wiki locally