How to make a Rust info commands plugin (dynamic /rules, /discord)
A config-driven text command plugin: every entry in the config defines a command name and a list of chat lines, and the plugin registers them all with cmd.AddChatCommand during Init. It also ships a fixed /info listing command.
Every server ends up with the same handful of questions: what is the Discord, where are the rules, what is the website. Hard-coding a separate chat command for each one is fine until you want to change a line of text, and then you are editing and recompiling the plugin. This page shows a data-driven approach: the config holds a list of entries, each with a command name, an optional permission, a cooldown, and the lines to print. During Init the plugin loops over that list and registers each command with cmd.AddChatCommand, so adding /website is a config edit and a reload, not a code change. A separate /info command lists every configured command the player is allowed to use without exposing the rest.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Info Commands", "RustPluginGenerator", "1.0.0")]
[Description("Registers config-driven text commands such as /rules, /discord and /website.")]
public class InfoCommands : RustPlugin
{
private const string PermUse = "infocommands.use";
private PluginConfig config;
private readonly Dictionary<ulong, float> cooldowns = new Dictionary<ulong, float>();
private readonly HashSet<string> registered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private class InfoEntry
{
[JsonProperty("Command name")]
public string Name { get; set; } = "rules";
[JsonProperty("Permission (empty = base permission)")]
public string Permission { get; set; } = "";
[JsonProperty("Cooldown seconds (0 = none)")]
public float Cooldown { get; set; } = 0f;
[JsonProperty("Lines")]
public List<string> Lines { get; set; } = new List<string>();
}
private class PluginConfig
{
[JsonProperty("Commands")]
public List<InfoEntry> Commands { get; set; } = new List<InfoEntry>
{
new InfoEntry
{
Name = "discord",
Lines = new List<string> { "Join our Discord: <color=#7289DA>discord.gg/example</color>" }
},
new InfoEntry
{
Name = "rules",
Lines = new List<string>
{
"<color=#FFC107>Server rules:</color>",
"1. No cheating or exploiting.",
"2. No raiding before wipe day ends.",
"3. Be respectful in chat."
}
},
new InfoEntry
{
Name = "website",
Lines = new List<string> { "Website: <color=#4CAF50>example.com</color>" }
}
};
}
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["Header"] = "<color=#4CAF50>{0}</color>",
["NoPermission"] = "You do not have permission to use this command.",
["Cooldown"] = "Please wait {0} seconds before using this command again.",
["Listing"] = "Available info commands: {0}",
["None"] = "There are no info commands available to you.",
["NotFound"] = "Unknown info command."
}, this);
}
private string Msg(string key, string userId = null, params object[] args) =>
string.Format(lang.GetMessage(key, this, userId), args);
private void Init()
{
permission.RegisterPermission(PermUse, this);
if (config?.Commands == null) return;
foreach (var entry in config.Commands)
{
if (entry == null || string.IsNullOrEmpty(entry.Name)) continue;
string name = entry.Name.Trim().TrimStart('/').ToLowerInvariant();
if (name.Length == 0 || name.Contains(" ")) continue;
if (name == "info") continue;
if (registered.Contains(name)) continue;
if (!string.IsNullOrEmpty(entry.Permission) && !permission.PermissionExists(entry.Permission, this))
permission.RegisterPermission(entry.Permission, this);
cmd.AddChatCommand(name, this, nameof(CmdInfo));
registered.Add(name);
}
}
[ChatCommand("info")]
private void CmdList(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
var allowed = new List<string>();
foreach (var entry in config.Commands)
{
if (entry == null || string.IsNullOrEmpty(entry.Name)) continue;
if (CanUse(player, entry)) allowed.Add("/" + entry.Name.ToLowerInvariant());
}
if (allowed.Count == 0)
{
SendReply(player, Msg("None", player.UserIDString));
return;
}
allowed.Sort();
SendReply(player, Msg("Listing", player.UserIDString, string.Join(", ", allowed)));
}
private void CmdInfo(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
InfoEntry entry = null;
foreach (var candidate in config.Commands)
{
if (candidate != null && string.Equals(candidate.Name, command, StringComparison.OrdinalIgnoreCase))
{
entry = candidate;
break;
}
}
if (entry == null)
{
SendReply(player, Msg("NotFound", player.UserIDString));
return;
}
if (!CanUse(player, entry))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (entry.Cooldown > 0f)
{
float last;
if (cooldowns.TryGetValue(player.userID, out last))
{
float remaining = entry.Cooldown - (Time.realtimeSinceStartup - last);
if (remaining > 0f)
{
SendReply(player, Msg("Cooldown", player.UserIDString, Mathf.CeilToInt(remaining)));
return;
}
}
cooldowns[player.userID] = Time.realtimeSinceStartup;
}
SendReply(player, Msg("Header", player.UserIDString, entry.Name));
if (entry.Lines != null)
{
foreach (var line in entry.Lines)
SendReply(player, line ?? "");
}
}
private bool CanUse(BasePlayer player, InfoEntry entry)
{
if (!permission.UserHasPermission(player.UserIDString, PermUse)) return false;
if (!string.IsNullOrEmpty(entry.Permission) && !permission.UserHasPermission(player.UserIDString, entry.Permission)) return false;
return true;
}
private void Unload()
{
cooldowns.Clear();
registered.Clear();
}
}
}Config file
Created automatically on first load at oxide/config/InfoCommands.json.
{
"Commands": [
{
"Command name": "discord",
"Permission (empty = base permission)": "",
"Cooldown seconds (0 = none)": 0,
"Lines": [
"Join our Discord: <color=#7289DA>discord.gg/example</color>"
]
},
{
"Command name": "rules",
"Permission (empty = base permission)": "",
"Cooldown seconds (0 = none)": 0,
"Lines": [
"<color=#FFC107>Server rules:</color>",
"1. No cheating or exploiting.",
"2. No raiding before wipe day ends.",
"3. Be respectful in chat."
]
},
{
"Command name": "website",
"Permission (empty = base permission)": "",
"Cooldown seconds (0 = none)": 0,
"Lines": [
"Website: <color=#4CAF50>example.com</color>"
]
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/info | Lists the configured info commands the player is allowed to use. | infocommands.use |
| Permission | Grants |
|---|---|
infocommands.use | Allows a player to run /info and any config entry that has no permission of its own. |
How the code works
Model the config as a list of entries
PluginConfigholds a single[JsonProperty("Commands")]list ofInfoEntryobjects. Each entry hasName(the command word without the slash), an optionalPermission, aCooldownin seconds, and aLineslist of strings. Defaults shipdiscord,rulesandwebsiteso the plugin prints something useful on first load. Because the list is read at load time, adding an entry and runningoxide.reload InfoCommandsis all it takes for the new command to exist.Register commands dynamically in Init
Initregisters the baseinfocommands.usepermission and then walksconfig.Commands. For each entry it trims the name, lowercases it, skips empty names, names with spaces, or names already added in this pass, and registers any per-entry permission throughpermission.RegisterPermission. It then callscmd.AddChatCommand(name, this, nameof(CmdInfo))so every entry maps to the one handlerCmdInfo, and records the name in theregisteredset. A name ofinfois skipped because/infois reserved for the listing command.One handler for every entry
CmdInfohas the standard chat command signature(BasePlayer player, string command, string[] args). Becausecommandis exactly the word the player typed, the handler searchesconfig.Commandsfor an entry whoseNamematches case-insensitively. If none matches it replies with theNotFoundmessage. This keeps the code short: adding twenty entries in config adds no new methods, and every one of them runs through the same logic.Permission gate and cooldowns
CanUsereturns false unless the player hasinfocommands.useand, when the entry defines one, that entry's own permission, so a staff-only entry is possible. WhenCooldownis greater than zero,CmdInforeadscooldowns[player.userID]and compares againstTime.realtimeSinceStartup; if the entry is still cooling down it replies with the remaining whole seconds viaMathf.CeilToIntand returns early. Otherwise it stores the new timestamp and continues. Cooldowns are memory-only and rebuild on restart.Printing the lines
The handler first sends a header line built from the
Headerlang key and the entry name. It then iteratesentry.Linesand callsSendReplyfor each, printing an empty string for null entries so spacing in the config survives. Rust renders Unity rich text tags such as<color=#FFC107>in chat, so config lines can be styled without touching the plugin code. All player-facing text apart from the config lines goes throughlang.GetMessagevia theMsghelper.The /info listing
/infois registered statically with the[ChatCommand("info")]attribute rather than through config, so it always exists.CmdListchecksinfocommands.use, walksconfig.Commands, and collects every entry for whichCanUseis true, prefixing each name with/. The result is sorted, joined with commas and sent as a single reply. If the player has access to nothing it sends theNonemessage, so no information about restricted entries leaks to ordinary players.Clean unload
Commands added with
cmd.AddChatCommandbelong to the plugin instance and disappear when the plugin unloads or reloads.Unloadclears the cooldown dictionary and theregisteredset, so a follow-up reload starts fresh and cannot reject entries because of stale state. No data file is written, because cooldowns are intentionally transient. Every chat reply is localised through theLoadDefaultMessagesdictionary, so a translation file inoxide/langwill override the built-in English text.
Install it on your server
Save InfoCommands.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/InfoCommands.json(Carbon:carbon/configs/InfoCommands.json). After editing, reload it:oxide.reload InfoCommands c.reload InfoCommands # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default infocommands.use c.grant group default infocommands.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Popup panel instead of chat
Show the entry's lines in a CUI panel that closes with a button.
Generate this versionTimed broadcast rotation
Cycle through the configured entries in chat automatically.
Generate this versionWelcoming new players
Send the rules entry to a player the first time they connect.
Generate this version
FAQ
How do I add a new command like /website without recompiling?
Edit oxide/config/InfoCommands.json, add an object to Commands with a Name of website and your Lines, then run oxide.reload InfoCommands. The plugin registers commands during Init, so the new word works immediately after the reload and also appears in /info.
Why did my entry not get registered?
Names are trimmed, lowercased and skipped when empty, when they contain a space, when they are info (reserved for the listing), or when they duplicate an earlier entry in the same config. Check the spelling in oxide/config/InfoCommands.json and reload. Command names must be a single word with no slashes.
Can I restrict an entry to staff?
Set the entry's Permission field to something like infocommands.staff. The plugin registers that permission for you on load; grant it with oxide.grant group admin infocommands.staff. Players without it get the NoPermission reply, and the entry is hidden from their /info list.
How do I install it on Oxide or Carbon?
Drop InfoCommands.cs into oxide/plugins, run oxide.reload InfoCommands, then edit the generated config and reload again. Carbon loads the same file from its plugin folder. No other plugin or extension is required.
Does colour formatting work in the config lines?
Yes. Wrap text in <color=#RRGGBB> and </color> tags, and use <size> or <b> if you like; Rust's chat supports Unity rich text. Keep lines short, since very long lines are truncated by the chat renderer before the client shows them.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More utility plugins
- Command aliasesThis plugin maps short chat commands like /h and /k to longer ones such as /home and /kit, defined entirely in its config file, using theโฆOnServerInitializedOnPlayerCommandOnPlayerDisconnected
- Map grid locationA /grid chat command that converts a player's world position into a Rust map grid cell (like G12) using the server's world size, with exactโฆOnServerInitializedOnPlayerConnected
- Online players listAdds a /players chat command that prints an alphabetically sorted, paginated list of online players with online, sleeping and joining counts; adminsโฆ
Last updated Oct 26, 2026.
Customize info commands
Customize