Skip to content

Commands

One file per command. The module returns the command, or an array of them.

luau
local Operator = require(game:GetService("ServerStorage").Operator)
local Command, Types = Operator.Command, Operator.Types

export type KickArgs = { target: Player, reason: string }

local kick = Command.new("kick") :: Operator.Builder<KickArgs>

return kick
	:description("Kick a player")
	:arg(Types.Player, "target", "Player to kick")
	:arg(Types.String, "reason", "Reason shown to the player", { rest = true })
	:permission("moderator")
	:run(function(context, args)
		args.target:Kick(args.reason)
		context:reply(`kicked {args.target.Name}`)
	end)

Inside run, args.target is a Player and args.reason is a string. Already resolved, already checked. You never parse a string yourself.

KickArgs is the contract between the :arg calls and the handler. Each :arg adds a name, and KickArgs declares what that name will hold by the time run sees it. The field names must match the argument names, and each type must match what the argument type produces. Types.Player produces a Player, so target: Player. Get that right and your editor autocompletes args. correctly and flags a typo; get it wrong and the code still runs, but the types are lying to you.

Every method on the builder is listed in api/command. The ones you will reach for first:

  • :arg(type, name, description): a positional argument
  • :flag(name, description, type?): a named --flag. Pass a type and it takes a value
  • :permission(role): gates the command behind a role
  • :description(text): what help shows

The :: cast is required

Write Command.new("kick") :: Operator.Builder<KickArgs>. The annotation form, local kick: Operator.Builder<KickArgs> = Command.new("kick"), fails on the new Luau solver.

The link between :arg(Types.Player, "target") and KickArgs.target: Player is not checked. Types are erased at runtime, so nothing can verify it. You get a typed handler, not verified end-to-end type safety.

Arguments

:arg takes a type, a name, a description, and options:

luau
:arg(Types.Duration, "duration", "How long", { optional = true })
:arg(Types.String, "message", "What to say", { rest = true })
:arg(Types.Players, "targets", "Who", { variadic = true })
OptionEffect
optionalthe argument may be omitted
restjoins every remaining word into one string
variadiccollects every remaining word into a list

rest and variadic must be last, and a required argument cannot follow an optional one. Both are checked when you call :run(), so a badly-shaped command fails at load rather than the first time someone runs it.

Argument Types covers the nine built-ins and writing your own.

Flags

A flag is a named argument that can appear anywhere on the line:

luau
	:flag("silent", "Do not announce it")
	:flag("reason", "Why", Types.String)
ban bob --silent
ban bob --reason="spam bot"

Without a type a flag is a presence flag. context.flags.silent is true when given and nil when not. With a type it takes a value, already transformed. Flag values always need =; see Command Syntax.

Replying, and failing

The context your handler receives is how you talk back:

luau
	:run(function(context, args)
		if args.target == context.executor then
			return context:error("you cannot kick yourself")
		end
		context:reply("done")
	end)

context:reply adds a line to the caller's console. context:error marks the command failed and shows that message. Use it for an expected failure the player should see.

If your handler raises instead, the caller gets a generic the command failed and the real error goes to the log, never to the player. Full surface in api/context.

Grouping

:group() nests a command so it reads as a path:

luau
Command.new("ban"):group("admin")   --> admin ban

Longest match wins, so registering both admin and admin ban means admin ban bob runs admin ban with bob as its argument.

Registering them

Point Start at the folder and Operator loads everything below it:

luau
Operator.Start({
	Commands = admin.Commands,
})

It scans all descendants, so subfolders are fine. Group your files however you like.

Loading is all-or-nothing

If any module fails to require, returns the wrong shape, or collides with another command's path, nothing is registered and the error names every offending file.

To add a command after startup, use the handle Start returns:

luau
local operator = Operator.Start({ ... })
operator.registry:registerCommand(myCommand)
operator.refreshAll()

registerCommand adds it; refreshAll re-sends the manifest so it appears in open consoles. The full registry surface is in api/registry.

Running commands from your own code

You do not need the console. The dispatcher takes raw text:

luau
local result = operator.dispatcher:run("kick @me being rude", player)
if not result.ok then
	warn(result.message)
end

Omit the player to run as the server. The result shape and every status value are in api/dispatcher.

Restricting who can run one

:permission("moderator") is the usual answer, and it gates the manifest too. A player without the role never learns the command exists.

When the condition is not "who is this person" but something about the moment, such as a round in progress or a place-specific check, use a guard:

luau
	:guard(function(context)
		if RoundService.isActive() then
			return true
		end
		return false, "only between rounds"
	end)

Returning false denies and the second value is shown to the player.

Guards fail closed

A guard that raises, or takes longer than its timeout, denies the command. It never lets one through by accident.

Permissions covers roles in full; api/command has the guard signature.

A complete one

kick is deliberately minimal. A real command usually has an optional argument, a flag and a condition. Here is one with all three:

luau
local Operator = require(game:GetService("ServerStorage").Operator)
local Command, Types = Operator.Command, Operator.Types

export type FreezeArgs = { targets: { Player }, duration: number? }

local freeze = Command.new("freeze") :: Operator.Builder<FreezeArgs>

return freeze
	:description("Stop players moving, optionally for a set time")
	:arg(Types.Players, "targets", "Who to freeze")
	:arg(Types.Duration, "duration", "How long, such as 30s; omit to freeze until thawed", {
		optional = true,
	})
	:flag("quiet", "Do not tell them why")
	:permission("moderator")
	:guard(function(context)
		if RoundService.isActive() then
			return false, "not while a round is running"
		end
		return true
	end)
	:run(function(context, args)
		for _, player in args.targets do
			FreezeService.freeze(player, args.duration)
			if context.flags.quiet == nil then
				Notify.send(player, "You have been frozen by a moderator")
			end
		end

		local who = `{#args.targets} player(s)`
		context:reply(if args.duration then `froze {who} for {args.duration}s` else `froze {who}`)
	end)

All of these work:

freeze bob
freeze @all 30s
freeze bob,alice 5m --quiet

Four things worth noticing:

  • targets is { Player }, not Player, because Types.Players resolves several. That is what makes @all and comma lists work.
  • duration is number? in FreezeArgs because it is { optional = true }. The handler checks it before using it.
  • context.flags.quiet is nil or true. A flag with no type is presence-only.
  • The guard runs before the handler and before any argument is applied, so a refusal freezes nobody.

Next

Released under the MIT Licence.