Commands
A WASM plugin registers proxy commands through ctx.command(name). The call returns a builder for aliases, a description, usage, a permission node, the handler and a tab-completer; register() installs the command on the host and answers what the host registered. Commands looks up and lists every registered command. The command capability is in the baseline set, so commands work without any extra grant in config.
Register a command
Call ctx.command inside on_enable and finish the chain with register():
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct GreetPlugin;
#[plugin(id = "greet", name = "Greet Plugin")]
impl Plugin for GreetPlugin {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.command("greet")
.alias("hi")
.description("Greets the caller")
.handler(|invocation| {
let _ = invocation.reply(format!("hello, {}!", invocation.sender.name()));
})
.register()?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
WARNING
Nothing is registered until you call register(). The builder is consumed by that call, so chain every option before it. A command without a handler does nothing when it runs.
The builder
Context::command returns a CommandBuilder. Each method takes self and returns self, so the calls chain.
| Method | Signature | Effect |
|---|---|---|
alias | alias(impl Into<String>) | Add one alias |
aliases | aliases(impl IntoIterator<Item = impl Into<String>>) | Add several aliases |
description | description(impl Into<String>) | Set the help text |
usage | usage(impl Into<String>) | Set the usage line shown in help |
permission | permission(impl Into<String>) | Require a permission node to see and run the command |
hidden | hidden(bool) | Keep the command out of help listings |
handler | handler(impl FnMut(CommandInvocation) + 'static) | Set what runs when the command is invoked |
completer | completer(impl Fn(&Completion) -> Vec<S> + 'static) where S: Into<Suggestion> | Set the tab-completion function |
register | register(self) -> Result<CommandRegistration, Error> | Install the command (terminal) |
let registration = ctx
.command("warp")
.aliases(["tp", "go"])
.description("Teleport to a configured warp point")
.usage("/warp <name>")
.permission("warps.use")
.handler(|invocation| warp(&invocation))
.completer(|completion| complete_warps(completion.partial()))
.register()?;
if !registration.rejected_aliases.is_empty() {
warn!("aliases taken by other plugins: {:?}", registration.rejected_aliases);
}2
3
4
5
6
7
8
9
10
11
12
CommandRegistration carries the registered name, its namespaced form, the aliases the host accepted and the rejected_aliases it skipped.
The handler
The handler receives a CommandInvocation:
pub struct CommandInvocation {
pub label: String,
pub args: Vec<String>,
pub raw: String,
pub sender: CommandSender,
}
pub enum CommandSender {
Console,
Player(PlayerRef),
}2
3
4
5
6
7
8
9
10
11
label is the name or alias the sender typed, args the tokens after it, and raw the whole line. sender.player() returns the PlayerRef of a player sender, sender.name() the player's username or Console. The console runs a plugin command when the line is not one of its own commands; <plugin-id>:<name> always works there.
invocation.reply(message) answers the sender: a player gets a chat message (which needs player-write), the console gets a log line.
ctx.command("ping")
.handler(|invocation| match invocation.player() {
Some(player) => info!("ping from {}", player.username),
None => info!("ping from console"),
})
.register()?;2
3
4
5
6
A player's command does not run in that player's session: the host hands it to the player's command queue, which runs the player's commands one at a time, in the order they were typed, while the session goes on with the player's packets. A handler can therefore call connect or request_cookie for the player who typed the command and wait for the answer. Tab completions take the same queue. See Calls that wait on the player's own session.
Tab-completion
The completer receives a Completion with the sender, the argument tokens typed so far (args), and the cursor. The last token of args is the one under the cursor: it is empty after a trailing space, so args is never empty and args.len() is the 1-based position of the argument being completed.
| Typed | args | cursor | partial() |
|---|---|---|---|
/warp | [""] | 0 | "" |
/warp sp | ["sp"] | 2 | "sp" |
/warp spawn | ["spawn", ""] | 6 | "" |
/warp a b | ["a", "b"] | 5 | "b" |
/warp é | ["é"] | 2 | "é" |
cursor is the byte offset of the cursor in the argument text, which is the input after the command label and the one space that follows it. It counts UTF-8 bytes, not characters, and counts every extra space. Completions are asked for at the end of the line, so cursor is the length of that text in bytes. completion.partial() returns the last token. The completer answers suggestions; a &str or String converts into a plain Suggestion, and Suggestion::new(text).with_tooltip(component) adds a hover tooltip.
.completer(|completion| {
["world", "everyone", "friend"]
.into_iter()
.filter(|candidate| candidate.starts_with(completion.partial()))
.map(|candidate| Suggestion::new(candidate).with_tooltip("greet them"))
.collect::<Vec<_>>()
})2
3
4
5
6
7
The completer is Fn, not FnMut: it cannot mutate captured state. The handler is FnMut and can. A command without a completer returns no candidates. A tooltip the host cannot accept is dropped, keeping the suggestion.
Unregister a command
ctx.unregister_command(label) removes a command this plugin registered, both on the host and in the guest, and returns Ok(true). The label is any name the command answers to: its name, an alias the host accepted, or its <plugin-id>:<name> form, matched case-insensitively like on the host. Removing a command by one label removes all its labels. It returns Ok(false) and leaves the host alone when no command of this plugin answers to that label, so it cannot remove another plugin's command, and an alias listed in rejected_aliases does not match. CommandRegistration::unregister() does the same for the command it describes. A handler or completer may unregister its own command; the closure is dropped once the running call returns.
let event = ctx.command("event").handler(|_| start_event()).register()?;
ctx.command("stop-event")
.handler(|_| {
let _ = Context::new().unregister_command("event");
})
.register()?;2
3
4
5
6
Registering a name the plugin already registered replaces the earlier command and drops its closures once the host accepts the new one.
Looking up commands
if let Some(info) = Commands::get("spawn")? {
info!("/spawn belongs to {:?}", info.plugin_id);
}2
3
| Function | Returns | Matches |
|---|---|---|
Commands::get(label) | Result<Option<CommandInfo>, Error> | The command a player reaches by typing label: its name, an alias, or <plugin-id>:<name> |
Commands::get_by_name(name) | Result<Option<CommandInfo>, Error> | Only a name or a <plugin-id>:<name>, never an alias |
Commands::get_by_alias(alias) | Result<Option<CommandInfo>, Error> | Only an accepted alias, never a name |
Commands::contains(label) | Result<bool, Error> | Same labels as get |
Commands::list() | Result<Vec<CommandInfo>, Error> | Every registered command, built-ins and other plugins included, sorted by name |
Commands::list_owned() | Result<Vec<CommandInfo>, Error> | The commands your plugin registered |
Lookups ignore case and see built-ins and other plugins' commands, native and WASM alike. A label resolves the way the proxy resolves what a player types, so an alias that was rejected at registration does not reach your command, and get_by_alias does not find it. Each call asks the host, so a command registered or removed since the last call shows up in the next answer.
CommandInfo carries the registered spec, with the name lowercased and only the accepted aliases: name, aliases, description, usage, permission and hidden. plugin_id is None for built-ins, and namespaced() returns Some("<plugin-id>:<name>"), or None for built-ins.
The lookups need the command capability, like register; without it each one returns PermissionDenied.
Name conflicts and ownership
The host keeps one command table for every plugin, native and WASM alike, and applies the same rules to both:
- Every command is also reachable as
<plugin-id>:<name>, for examplegreet:greet. That form always runs your command. - The built-in names and aliases (
infrarust,ir) are reserved. - A bare name goes to the first plugin that registers it. If another plugin already owns the name, your registration is refused.
- An alias that is reserved or already taken is skipped; the command keeps its other names and the alias shows up in
rejected_aliases.
A refused registration returns an Error: Conflict for a name that is reserved or owned by another plugin, InvalidArgument for a name that is not a valid command name, PermissionDenied without the command capability, LimitExceeded when the plugin already holds [wasm.quotas] commands commands (256 by default). Registering a name the plugin already holds replaces it and never hits the quota; unregistering a command frees room for another. The guest keeps no handler for a refused command, so unregister_command on it returns Ok(false). The host also logs the refusal, rate-limited so a guest that retries in a loop cannot flood the log.
unregister_command goes through the same ownership check on the host: it can only remove commands this plugin registered, even if the guest passes another plugin's name.
Under the hood
The SDK assigns each command a handler id and registers the spec through the command-manager.register host import, passing that id. The handler and completer closures stay in a per-instance table keyed by the id. When a player or the console runs the command, the host calls the guest handle-command export (or tab-complete for completion) with the same id, and the SDK dispatches to the stored closure.
sequenceDiagram
participant Plugin as Guest plugin
participant SDK
participant Host
Plugin->>SDK: ctx.command("greet").handler(..).register()
SDK->>Host: command-manager.register(spec, handler_id)
Host-->>SDK: command-registration
SDK->>SDK: store handler under handler_id
Note over Host: player runs /greet world
Host->>SDK: handle-command(handler_id, invocation)
SDK->>Plugin: handler(CommandInvocation { label, args, raw, sender })2
3
4
5
6
7
8
9
10
11
12
The id is what links a host invocation back to the right closure, so two commands in the same plugin never collide. After a recovery, the fresh instance registers its commands again and the host points the existing commands at the new handler ids.
The lookups keep nothing in the guest: each Commands function is one call to the command-manager import of the same name (get-by-name, list-owned and so on), answered from the host's command table.
Worked example
This command greets a target and answers the caller with a styled message.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct CommandPlugin;
#[plugin(id = "command-plugin", name = "Command Plugin")]
impl Plugin for CommandPlugin {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.command("greet")
.description("Greets the caller")
.handler(|invocation| {
let target = invocation.args.first().map_or("world", String::as_str);
let message = Component::text(format!("Hello, {target}!")).color(NamedColor::Gold);
if let Err(error) = invocation.reply(message) {
warn!("greet reply failed: {error}");
}
})
.completer(|completion| {
["world", "everyone", "friend"]
.into_iter()
.filter(|candidate| candidate.starts_with(completion.partial()))
.collect::<Vec<_>>()
})
.register()?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
reply returns PlayerGone when the sender disconnected before the command ran. This example needs only the baseline command and player-write capabilities.
See also
- Services: the player registry, server manager, ban, and config accessors used inside handlers.
- Events: react to connection and chat events instead of explicit commands.
- Capabilities: why
commandworks out of the box and which capabilities are opt-in. - Examples: full plugins built from these pieces.