Skip to content
RustPluginGenerator

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

InfoCommands.cs
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.

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

Chat and console commands
CommandWhat it doesPermission
/infoLists the configured info commands the player is allowed to use.infocommands.use
Permissions
PermissionGrants
infocommands.useAllows a player to run /info and any config entry that has no permission of its own.

How the code works

  1. Model the config as a list of entries

    PluginConfig holds a single [JsonProperty("Commands")] list of InfoEntry objects. Each entry has Name (the command word without the slash), an optional Permission, a Cooldown in seconds, and a Lines list of strings. Defaults ship discord, rules and website so the plugin prints something useful on first load. Because the list is read at load time, adding an entry and running oxide.reload InfoCommands is all it takes for the new command to exist.

  2. Register commands dynamically in Init

    Init registers the base infocommands.use permission and then walks config.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 through permission.RegisterPermission. It then calls cmd.AddChatCommand(name, this, nameof(CmdInfo)) so every entry maps to the one handler CmdInfo, and records the name in the registered set. A name of info is skipped because /info is reserved for the listing command.

  3. One handler for every entry

    CmdInfo has the standard chat command signature (BasePlayer player, string command, string[] args). Because command is exactly the word the player typed, the handler searches config.Commands for an entry whose Name matches case-insensitively. If none matches it replies with the NotFound message. This keeps the code short: adding twenty entries in config adds no new methods, and every one of them runs through the same logic.

  4. Permission gate and cooldowns

    CanUse returns false unless the player has infocommands.use and, when the entry defines one, that entry's own permission, so a staff-only entry is possible. When Cooldown is greater than zero, CmdInfo reads cooldowns[player.userID] and compares against Time.realtimeSinceStartup; if the entry is still cooling down it replies with the remaining whole seconds via Mathf.CeilToInt and returns early. Otherwise it stores the new timestamp and continues. Cooldowns are memory-only and rebuild on restart.

  5. Printing the lines

    The handler first sends a header line built from the Header lang key and the entry name. It then iterates entry.Lines and calls SendReply for 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 through lang.GetMessage via the Msg helper.

  6. The /info listing

    /info is registered statically with the [ChatCommand("info")] attribute rather than through config, so it always exists. CmdList checks infocommands.use, walks config.Commands, and collects every entry for which CanUse is 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 the None message, so no information about restricted entries leaks to ordinary players.

  7. Clean unload

    Commands added with cmd.AddChatCommand belong to the plugin instance and disappear when the plugin unloads or reloads. Unload clears the cooldown dictionary and the registered set, 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 the LoadDefaultMessages dictionary, so a translation file in oxide/lang will override the built-in English text.

Install it on your server

  1. Save InfoCommands.cs

    Keep the file name exactly as the class name. Oxide matches them.
  2. 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.
  3. Edit the config if you want

    On first load the default config is written to oxide/config/InfoCommands.json (Carbon: carbon/configs/InfoCommands.json). After editing, reload it:oxide.reload InfoCommands c.reload InfoCommands # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip: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 version
  • Timed broadcast rotation

    Cycle through the configured entries in chat automatically.

    Generate this version
  • Welcoming 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.

Prompt: Create an Oxide plugin called InfoCommands with config-driven text commands: a list of entries each with Name, optional Permission, Cooldown and Lines, registered dynamically on load, plus an /info listing under permission infocommands.use.
Customize for my server

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026.

Customize info commands

Customize