Command
local Operator = require(game:GetService("ServerStorage").Operator)
local Command = Operator.CommandBuilds a command. Every method returns the builder, so calls chain; :run returns the finished, frozen Command.
Functions
Command.new
Command.new<A>(name: string): Builder<A>Starts a builder. name must begin with a letter and contain only letters, digits, underscores and hyphens. Anything else raises immediately.
export type KickArgs = { target: Player, reason: string }
local kick = Command.new("kick") :: Command.Builder<KickArgs>The cast is required
local kick = Command.new("kick") :: Builder<KickArgs> works; the annotation form local kick: 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. You get a typed handler, not verified end-to-end type safety.
Builder<A>
:description
:description(text: string): Builder<A>Help text, shown by help and in the console's suggestion card.
:group
:group(...: string): Builder<A>Nests the command. :group("admin") on a command named ban gives the path admin ban.
:arg
:arg(argType: AnyArgType, name: string, description: string?, options: ArgOptions?): Builder<A>Adds a positional argument. Order of declaration is order on the command line.
:arg(Types.Duration, "duration", "How long", { optional = true })
:arg(Types.String, "message", "What to say", { rest = true })
:arg(Types.Players, "targets", "Who", { variadic = true }):flag
:flag(name: string, description: string?, argType: AnyArgType?): Builder<A>Adds a named flag. Passing argType makes it take a value (--reason=spam); omitting it makes it a presence flag (--silent).
:guard
:guard(fn: GuardFn): Builder<A>Adds a predicate run before the handler. Guards declared here run after any global guards on the dispatcher, in declaration order.
:permission
:permission(role: string): Builder<A>Gates the command behind a role. A command with permissions runs for a player holding any one of them, directly or inherited. Call it more than once to accept several roles.
The method is permission; the argument is a role name
That inconsistency is in the API itself, not just the docs. :permission("moderator") takes the name of a role, and everywhere else the package calls that a role.
:destructive
:destructive(): Builder<A>Forces a confirmation round-trip before running. With no confirm handler installed the command is cancelled rather than executed. It fails closed.
:noAudit
:noAudit(): Builder<A>Excludes the command from audit records.
:run
:run(handler: (context: Context, args: A) -> ()): CommandSets the handler and returns the finished command. Everything is validated here, not on first use: an argument named badly, a required argument after an optional one, a rest or variadic argument that is not last, or a duplicate flag all raise immediately. The returned command is frozen.
:runClient
:runClient(handler: (context: ClientContext, args: A) -> ()): CommandSets the handler and marks the command client-run: it executes on the executor's machine with no server round trip. The handler receives a ClientContext, which is narrower than the server one.
:run and :runClient are mutually exclusive. Declaring both on one builder is an error naming the command.
:destructive() and :runClient() are also mutually exclusive, in either order. A client cannot enforce a confirmation, and the same spelling must not mean two different guarantees.
Types
ArgOptions
type ArgOptions = {
optional: boolean?,
variadic: boolean?,
rest: boolean?,
}| Option | Effect |
|---|---|
optional | absence is not an error; must not precede a required argument |
variadic | collects every remaining word into a list; must be last |
rest | joins every remaining word into one string; must be last |
Argument
type Argument = {
name: string,
description: string?,
argType: AnyArgType,
optional: boolean,
variadic: boolean,
rest: boolean,
}Flag
type Flag = {
name: string,
description: string?,
argType: AnyArgType?,
takesValue: boolean,
}Command
The finished command, as the registry and dispatcher see it.
type Command = {
name: string,
path: { string },
description: string?,
arguments: { Argument },
flags: { Flag },
argSpecs: { Binder.ArgSpec },
flagSpecs: { Binder.FlagSpec },
guards: { GuardFn },
permissions: { string },
destructive: boolean,
audit: boolean,
clientRun: boolean,
run: (context: Context, args: any) -> (),
}argSpecs and flagSpecs are the binder shapes, built once at :run() so the dispatcher does not rebuild them per call.
GuardFn
type GuardFn = (context: Context) -> (boolean, string?)Returning false denies, and the second value becomes the message the caller sees. Guards may yield and are each wrapped in a timeout. A guard that raises or times out denies, because guards fail closed.
Example
local Operator = require(game:GetService("ServerStorage").Operator)
local Command = Operator.Command
local Types = Operator.Types
export type BanArgs = { target: Player, duration: number?, reason: string? }
return (Command.new("ban") :: Command.Builder<BanArgs>)
:description("Ban a player")
:arg(Types.Player, "target", "Who to ban")
:arg(Types.Duration, "duration", "How long", { optional = true })
:arg(Types.String, "reason", "Why", { optional = true, rest = true })
:flag("silent", "Do not announce it")
:permission("moderator")
:destructive()
:run(function(context, args)
context:reply(`banned {args.target.Name}`)
end)