mod_admin_shell: Refactor help to data structures for extensibility
authorMatthew Wild <mwild1@gmail.com>
Wed, 29 Nov 2023 17:18:17 +0000
changeset 13353 d5d4e386c6fb
parent 13352 47ede3d51833
child 13354 0573401b0f9f
mod_admin_shell: Refactor help to data structures for extensibility This makes it easier for commands added by other modules to add to the help output, for example.
plugins/mod_admin_shell.lua
--- a/plugins/mod_admin_shell.lua	Mon Nov 27 17:19:16 2023 +0100
+++ b/plugins/mod_admin_shell.lua	Wed Nov 29 17:18:17 2023 +0000
@@ -15,6 +15,7 @@
 local s2smanager = require "prosody.core.s2smanager";
 local portmanager = require "prosody.core.portmanager";
 local helpers = require "prosody.util.helpers";
+local it = require "prosody.util.iterators";
 local server = require "prosody.net.server";
 local st = require "prosody.util.stanza";
 
@@ -63,6 +64,86 @@
 local def_env = module:shared("env");
 local default_env_mt = { __index = def_env };
 
+local function new_section(section_desc)
+	return setmetatable({}, {
+		help = {
+			desc = section_desc;
+			commands = {};
+		};
+	});
+end
+
+local help_topics = {};
+local function help_topic(name)
+	return function (desc)
+		return function (content)
+			help_topics[name] = {
+				desc = desc;
+				content = content;
+			};
+		end;
+	end
+end
+
+-- Seed with default sections and their description text
+help_topic "console" "Help regarding the console itself" [[
+Hey! Welcome to Prosody's admin console.
+First thing, if you're ever wondering how to get out, simply type 'quit'.
+Secondly, note that we don't support the full telnet protocol yet (it's coming)
+so you may have trouble using the arrow keys, etc. depending on your system.
+
+For now we offer a couple of handy shortcuts:
+!! - Repeat the last command
+!old!new! - repeat the last command, but with 'old' replaced by 'new'
+
+For those well-versed in Prosody's internals, or taking instruction from those who are,
+you can prefix a command with > to escape the console sandbox, and access everything in
+the running server. Great fun, but be careful not to break anything :)
+]];
+
+local available_columns; --forward declaration so it is reachable from the help
+
+help_topic "columns" "Information about customizing session listings" (function (self, print)
+	print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]]
+	print [['columns' argument as described here.]]
+	print [[]]
+	print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]]
+	print [[Available columns are:]]
+	local meta_columns = {
+		{ title = "ID"; width = 5 };
+		{ title = "Column Title"; width = 12 };
+		{ title = "Description"; width = 12 };
+	};
+	-- auto-adjust widths
+	for column, spec in pairs(available_columns) do
+		meta_columns[1].width = math.max(meta_columns[1].width or 0, #column);
+		meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or ""));
+		meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or ""));
+	end
+	local row = format_table(meta_columns, self.session.width)
+	print(row());
+	for column, spec in iterators.sorted_pairs(available_columns) do
+		print(row({ column, spec.title, spec.description }));
+	end
+	print [[]]
+	print [[Most fields on the internal session structures can also be used as columns]]
+	-- Also, you can pass a table column specification directly, with mapper callback and all
+end);
+
+help_topic "roles"   "Show information about user roles" [[
+Roles may grant access or restrict users from certain operations.
+
+Built-in roles are:
+  prosody:guest      - Guest/anonymous user
+  prosody:registered - Registered user
+  prosody:member     - Provisioned user
+  prosody:admin      - Host administrator
+  prosody:operator - Server administrator
+
+Roles can be assigned using the user management commands (see 'help user').
+]];
+
+
 local function redirect_output(target, session)
 	local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
 	env.dofile = function(name)
@@ -143,11 +224,13 @@
 
 	local result = st.stanza("repl-result");
 
+	module:log("warn", "Processing line: %q", line);
+
 	if line:match("^>") then
 		line = line:gsub("^>", "");
 		useglobalenv = true;
 	else
-		local command = line:match("^%w+") or line:match("%p");
+		local command = line:match("^(%w+) ") or line:match("^%w+$") or line:match("%p");
 		if commands[command] then
 			commands[command](session, line);
 			event.origin.send(result);
@@ -213,11 +296,24 @@
 	return true;
 end);
 
+local function describe_command(s)
+	local section, name, args, desc = s:match("^([%w_]+):([%w_]+)%(([^)]*)%) %- (.+)$");
+	if not section then
+		error("Failed to parse command description: "..s);
+	end
+	local command_help = getmetatable(def_env[section]).help.commands;
+	command_help[name] = {
+		desc = desc;
+		args = array.collect(args:gmatch("[%w_]+")):map(function (name)
+			return { name = name };
+		end);
+	};
+end
+
 -- Console commands --
 -- These are simple commands, not valid standalone in Lua
 
-local available_columns; --forward declaration so it is reachable from the help
-
+-- Help about individual topics is handled by def_env.help
 function commands.help(session, data)
 	local print = session.print;
 	local section = data:match("^help (%w+)");
@@ -227,145 +323,22 @@
 		print [[]]
 		local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width)
 		print(row())
-		print(row { "c2s"; "Commands to manage local client-to-server sessions" })
-		print(row { "s2s"; "Commands to manage sessions between this server and others" })
-		print(row { "http"; "Commands to inspect HTTP services" }) -- XXX plural but there is only one so far
-		print(row { "module"; "Commands to load/reload/unload modules/plugins" })
-		print(row { "host"; "Commands to activate, deactivate and list virtual hosts" })
-		print(row { "user"; "Commands to create and delete users, and change their passwords" })
-		print(row { "roles"; "Show information about user roles" })
-		print(row { "muc"; "Commands to create, list and manage chat rooms" })
-		print(row { "stats"; "Commands to show internal statistics" })
-		print(row { "server"; "Uptime, version, shutting down, etc." })
-		print(row { "port"; "Commands to manage ports the server is listening on" })
-		print(row { "dns"; "Commands to manage and inspect the internal DNS resolver" })
-		print(row { "xmpp"; "Commands for sending XMPP stanzas" })
-		print(row { "debug"; "Commands for debugging the server" })
-		print(row { "watch"; "Commands for watching live logs from the server" })
-		print(row { "config"; "Reloading the configuration, etc." })
-		print(row { "columns"; "Information about customizing session listings" })
-		print(row { "console"; "Help regarding the console itself" })
-	elseif section == "c2s" then
-		print [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
-		print [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
-		print [[c2s:count() - Count sessions without listing them]]
-		print [[c2s:close(jid) - Close all sessions for the specified JID]]
-		print [[c2s:closeall() - Close all active c2s connections ]]
-	elseif section == "s2s" then
-		print [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
-		print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
-		print [[s2s:close(from, to) - Close a connection from one domain to another]]
-		print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
-	elseif section == "http" then
-		print [[http:list(hosts) - Show HTTP endpoints]]
-	elseif section == "module" then
-		print [[module:info(module, host) - Show information about a loaded module]]
-		print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
-		print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
-		print [[module:unload(module, host) - The same, but just unloads the module from memory]]
-		print [[module:list(host) - List the modules loaded on the specified host]]
-	elseif section == "host" then
-		print [[host:activate(hostname) - Activates the specified host]]
-		print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
-		print [[host:list() - List the currently-activated hosts]]
-	elseif section == "user" then
-		print [[user:create(jid, password, role) - Create the specified user account]]
-		print [[user:password(jid, password) - Set the password for the specified user account]]
-		print [[user:roles(jid, host) - Show current roles for an user]]
-		print [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]]
-		print [[user:addrole(jid, host, role) - Add a secondary role to a user]]
-		print [[user:delrole(jid, host, role) - Remove a secondary role from a user]]
-		print [[user:disable(jid) - Disable the specified user account, preventing login]]
-		print [[user:enable(jid) - Enable the specified user account, restoring login access]]
-		print [[user:delete(jid) - Permanently remove the specified user account]]
-		print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
-	elseif section == "roles" then
-		print [[Roles may grant access or restrict users from certain operations]]
-		print [[Built-in roles are:]]
-		print [[  prosody:guest      - Guest/anonymous user]]
-		print [[  prosody:registered - Registered user]]
-		print [[  prosody:member     - Provisioned user]]
-		print [[  prosody:admin      - Host administrator]]
-		print [[  prosody:operator - Server administrator]]
-		print [[]]
-		print [[Roles can be assigned using the user management commands (see 'help user').]]
-	elseif section == "muc" then
-		-- TODO `muc:room():foo()` commands
-		print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
-		print [[muc:list(host) - List rooms on the specified MUC component]]
-		print [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]]
-		print [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]]
-		print [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]]
-	elseif section == "server" then
-		print [[server:version() - Show the server's version number]]
-		print [[server:uptime() - Show how long the server has been running]]
-		print [[server:memory() - Show details about the server's memory usage]]
-		print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
-	elseif section == "port" then
-		print [[port:list() - Lists all network ports prosody currently listens on]]
-		print [[port:close(port, interface) - Close a port]]
-	elseif section == "dns" then
-		print [[dns:lookup(name, type, class) - Do a DNS lookup]]
-		print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
-		print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
-		print [[dns:purge() - Clear the DNS cache]]
-		print [[dns:cache() - Show cached records]]
-	elseif section == "xmpp" then
-		print [[xmpp:ping(localhost, remotehost) -- Sends a ping to a remote XMPP server and reports the response]]
-	elseif section == "config" then
-		print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
-		print [[config:get([host,] option) - Show the value of a config option.]]
-		print [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]]
-	elseif section == "stats" then -- luacheck: ignore 542
-		print [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern]]
-		print [[stats:show():cfgraph() - Show a cumulative frequency graph]]
-		print [[stats:show():histogram() - Show a histogram of selected metric]]
-	elseif section == "debug" then
-		print [[debug:async() - Show information about pending asynchronous tasks]]
-		print [[debug:logevents(host) - Enable logging of fired events on host]]
-		print [[debug:events(host, event) - Show registered event handlers]]
-		print [[debug:timers() - Show information about scheduled timers]]
-	elseif section == "watch" then
-		print [[watch:log() - Follow debug logs]]
-		print [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]]
-	elseif section == "console" then
-		print [[Hey! Welcome to Prosody's admin console.]]
-		print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
-		print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
-		print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
-		print [[]]
-		print [[For now we offer a couple of handy shortcuts:]]
-		print [[!! - Repeat the last command]]
-		print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
-		print [[]]
-		print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
-		print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
-		print [[the running server. Great fun, but be careful not to break anything :)]]
-	elseif section == "columns" then
-		print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]]
-		print [['columns' argument as described here.]]
-		print [[]]
-		print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]]
-		print [[Available columns are:]]
-		local meta_columns = {
-			{ title = "ID"; width = 5 };
-			{ title = "Column Title"; width = 12 };
-			{ title = "Description"; width = 12 };
-		};
-		-- auto-adjust widths
-		for column, spec in pairs(available_columns) do
-			meta_columns[1].width = math.max(meta_columns[1].width or 0, #column);
-			meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or ""));
-			meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or ""));
+		for section_name, section in it.sorted_pairs(def_env) do
+			local section_mt = getmetatable(section);
+			local section_help = section_mt and section_mt.help;
+			print(row { section_name; section_help and section_help.desc or "" });
 		end
-		local row = format_table(meta_columns, session.width)
-		print(row());
-		for column, spec in iterators.sorted_pairs(available_columns) do
-			print(row({ column, spec.title, spec.description }));
-		end
-		print [[]]
-		print [[Most fields on the internal session structures can also be used as columns]]
-		-- Also, you can pass a table column specification directly, with mapper callback and all
+	else
+		return def_env.help[section]({ session = session });
+	end
+
+	print("");
+
+	print [[In addition to info about commands, the following general topics are available:]]
+
+	print("");
+	for topic_name, topic in it.sorted_pairs(help_topics) do
+		print(topic_name .. " - "..topic.desc);
 	end
 end
 
@@ -379,7 +352,7 @@
 	table_iterator = "pairs";
 })
 
-def_env.output = {};
+def_env.output = new_section("Configure admin console output");
 function def_env.output:configure(opts)
 	if type(opts) ~= "table" then
 		opts = { preset = opts };
@@ -401,7 +374,57 @@
 	self.session.serialize = serialization.new(opts);
 end
 
-def_env.server = {};
+def_env.help = setmetatable({}, {
+	help = {
+		desc = "Show this help about available commands";
+		commands = {};
+	};
+	__index = function (_, section_name)
+		return function (self)
+			local print = self.session.print;
+			local section_mt = getmetatable(def_env[section_name]);
+			local section_help = section_mt and section_mt.help;
+
+			local c = 0;
+
+			if section_help then
+				print("Help: "..section_name);
+				if section_help.desc then
+					print(section_help.desc);
+				end
+				print(("-"):rep(#(section_help.desc or section_name)));
+				print("");
+
+				if section_help.content then
+					print(section_help.content);
+					print("");
+				end
+
+				for command, command_help in it.sorted_pairs(section_help.commands or {}) do
+					c = c + 1;
+					local args = command_help.args:pluck("name"):concat(", ");
+					local desc = command_help.desc or command_help.module and ("Provided by mod_"..command_help.module) or "";
+					print(("%s:%s(%s) - %s"):format(section_name, command, args, desc));
+				end
+			elseif help_topics[section_name] then
+				local topic = help_topics[section_name];
+				if type(topic.content) == "function" then
+					topic.content(self, print);
+				else
+					print(topic.content);
+				end
+				print("");
+				return true, "Showing help topic '"..section_name.."'";
+			else
+				print("Unknown topic: "..section_name);
+			end
+			print("");
+			return true, ("%d command(s) listed"):format(c);
+		end;
+	end;
+});
+
+def_env.server = new_section("Uptime, version, shutting down, etc.");
 
 function def_env.server:insane_reload()
 	prosody.unlock_globals();
@@ -410,10 +433,12 @@
 	return true, "Server reloaded";
 end
 
+describe_command [[server:version() - Show the server's version number]]
 function def_env.server:version()
 	return true, tostring(prosody.version or "unknown");
 end
 
+describe_command [[server:uptime() - Show how long the server has been running]]
 function def_env.server:uptime()
 	local t = os.time()-prosody.start_time;
 	local seconds = t%60;
@@ -428,6 +453,7 @@
 		minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
 end
 
+describe_command [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
 function def_env.server:shutdown(reason, code)
 	prosody.shutdown(reason, code);
 	return true, "Shutdown initiated";
@@ -437,6 +463,7 @@
 	return format_number(kb*1024, "B", "b");
 end
 
+describe_command [[server:memory() - Show details about the server's memory usage]]
 function def_env.server:memory()
 	if not has_pposix or not pposix.meminfo then
 		return true, "Lua is using "..human(collectgarbage("count"));
@@ -449,7 +476,7 @@
 	return true, "OK";
 end
 
-def_env.module = {};
+def_env.module = new_section("Commands to load/reload/unload modules/plugins");
 
 local function get_hosts_set(hosts)
 	if type(hosts) == "table" then
@@ -495,6 +522,7 @@
 	return hosts_set;
 end
 
+describe_command [[module:info(module, host) - Show information about a loaded module]]
 function def_env.module:info(name, hosts)
 	if not name then
 		return nil, "module name expected";
@@ -599,6 +627,7 @@
 	return true;
 end
 
+describe_command [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
 function def_env.module:load(name, hosts)
 	hosts = get_hosts_with_module(hosts);
 
@@ -632,6 +661,7 @@
 	return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
 end
 
+describe_command [[module:unload(module, host) - The same, but just unloads the module from memory]]
 function def_env.module:unload(name, hosts)
 	hosts = get_hosts_with_module(hosts, name);
 
@@ -664,6 +694,7 @@
 	else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end
 end
 
+describe_command [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
 function def_env.module:reload(name, hosts)
 	hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts)
 
@@ -687,6 +718,7 @@
 	return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
 end
 
+describe_command [[module:list(host) - List the modules loaded on the specified host]]
 function def_env.module:list(hosts)
 	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
 
@@ -713,7 +745,8 @@
 	end
 end
 
-def_env.config = {};
+def_env.config = new_section("Reloading the configuration, etc.");
+
 function def_env.config:load(filename, format)
 	local config_load = require "prosody.core.configmanager".load;
 	local ok, err = config_load(filename, format);
@@ -723,6 +756,7 @@
 	return true, "Config loaded";
 end
 
+describe_command [[config:get([host,] option) - Show the value of a config option.]]
 function def_env.config:get(host, key)
 	if key == nil then
 		host, key = "*", host;
@@ -731,6 +765,7 @@
 	return true, serialize_config(config_get(host, key));
 end
 
+describe_command [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]]
 function def_env.config:set(host, key, value)
 	if host ~= "*" and not prosody.hosts[host] then
 		host, key, value = "*", host, key;
@@ -738,12 +773,13 @@
 	return require "prosody.core.configmanager".set(host, key, value);
 end
 
+describe_command [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
 function def_env.config:reload()
 	local ok, err = prosody.reload_config();
 	return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
 end
 
-def_env.c2s = {};
+def_env.c2s = new_section("Commands to manage local client-to-server sessions");
 
 local function get_jid(session)
 	if session.username then
@@ -780,6 +816,7 @@
 	end);
 end
 
+describe_command [[c2s:count() - Count sessions without listing them]]
 function def_env.c2s:count()
 	local c2s = get_c2s();
 	return true, "Total: "..  #c2s .." clients";
@@ -1044,6 +1081,7 @@
 	return columns;
 end
 
+describe_command [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
 function def_env.c2s:show(match_jid, colspec)
 	local print = self.session.print;
 	local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" });
@@ -1084,6 +1122,7 @@
 	return true, ("%d c2s sessions shown"):format(total_count);
 end
 
+describe_command [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
 function def_env.c2s:show_tls(match_jid)
 	return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" });
 end
@@ -1097,6 +1136,7 @@
 	end
 end
 
+describe_command [[c2s:close(jid) - Close all sessions for the specified JID]]
 function def_env.c2s:close(match_jid, text, condition)
 	local count = 0;
 	show_c2s(function (jid, session)
@@ -1108,6 +1148,7 @@
 	return true, "Total: "..count.." sessions closed";
 end
 
+describe_command [[c2s:closeall() - Close all active c2s connections ]]
 function def_env.c2s:closeall(text, condition)
 	local count = 0;
 	--luacheck: ignore 212/jid
@@ -1119,7 +1160,8 @@
 end
 
 
-def_env.s2s = {};
+def_env.s2s = new_section("Commands to manage sessions between this server and others");
+
 local function _sort_s2s(a, b)
 	local a_local, a_remote = get_s2s_hosts(a);
 	local b_local, b_remote = get_s2s_hosts(b);
@@ -1144,6 +1186,7 @@
 	return false;
 end
 
+describe_command [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
 function def_env.s2s:show(match_jid, colspec)
 	local print = self.session.print;
 	local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" });
@@ -1184,6 +1227,7 @@
 	return true, ("%d s2s connections shown"):format(total_count);
 end
 
+describe_command [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
 function def_env.s2s:show_tls(match_jid)
 	return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" });
 end
@@ -1306,6 +1350,7 @@
 		.." presented by "..domain..".");
 end
 
+describe_command [[s2s:close(from, to) - Close a connection from one domain to another]]
 function def_env.s2s:close(from, to, text, condition)
 	local print, count = self.session.print, 0;
 	local s2s_sessions = module:shared"/*/s2s/sessions";
@@ -1330,6 +1375,7 @@
 	return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
 end
 
+describe_command [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
 function def_env.s2s:closeall(host, text, condition)
 	local count = 0;
 	local s2s_sessions = module:shared"/*/s2s/sessions";
@@ -1343,15 +1389,19 @@
 	else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
 end
 
-def_env.host = {}; def_env.hosts = def_env.host;
+def_env.host = new_section("Commands to activate, deactivate and list virtual hosts");
 
+describe_command [[host:activate(hostname) - Activates the specified host]]
 function def_env.host:activate(hostname, config)
 	return hostmanager.activate(hostname, config);
 end
+
+describe_command [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
 function def_env.host:deactivate(hostname, reason)
 	return hostmanager.deactivate(hostname, reason);
 end
 
+describe_command [[host:list() - List the currently-activated hosts]]
 function def_env.host:list()
 	local print = self.session.print;
 	local i = 0;
@@ -1372,8 +1422,9 @@
 	return true, i.." hosts";
 end
 
-def_env.port = {};
+def_env.port = new_section("Commands to manage ports the server is listening on");
 
+describe_command [[port:list() - Lists all network ports prosody currently listens on]]
 function def_env.port:list()
 	local print = self.session.print;
 	local services = portmanager.get_active_services().data;
@@ -1392,6 +1443,7 @@
 	return true, n_services.." services listening on "..n_ports.." ports";
 end
 
+describe_command [[port:close(port, interface) - Close a port]]
 function def_env.port:close(close_port, close_interface)
 	close_port = assert(tonumber(close_port), "Invalid port number");
 	local n_closed = 0;
@@ -1414,7 +1466,7 @@
 	return true, "Closed "..n_closed.." ports";
 end
 
-def_env.muc = {};
+def_env.muc = new_section("Commands to create, list and manage chat rooms");
 
 local console_room_mt = {
 	__index = function (self, k) return self.room[k]; end;
@@ -1447,6 +1499,7 @@
 
 local muc_util = module:require"muc/util";
 
+describe_command [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
 function def_env.muc:create(room_jid, config)
 	local room_name, host = check_muc(room_jid);
 	if not room_name then
@@ -1458,6 +1511,7 @@
 	return prosody.hosts[host].modules.muc.create_room(room_jid, config);
 end
 
+describe_command [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]]
 function def_env.muc:room(room_jid)
 	local room_obj, err = get_muc(room_jid);
 	if not room_obj then
@@ -1466,6 +1520,7 @@
 	return setmetatable({ room = room_obj }, console_room_mt);
 end
 
+describe_command [[muc:list(host) - List rooms on the specified MUC component]]
 function def_env.muc:list(host)
 	local host_session = prosody.hosts[host];
 	if not host_session or not host_session.modules.muc then
@@ -1480,6 +1535,7 @@
 	return true, c.." rooms";
 end
 
+describe_command [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]]
 function def_env.muc:occupants(room_jid, filter)
 	local room_obj, err = get_muc(room_jid);
 	if not room_obj then
@@ -1524,6 +1580,7 @@
 	end
 end
 
+describe_command [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]]
 function def_env.muc:affiliations(room_jid, filter)
 	local room_obj, err = get_muc(room_jid);
 	if not room_obj then
@@ -1576,7 +1633,9 @@
 
 local um = require"prosody.core.usermanager";
 
-def_env.user = {};
+def_env.user = new_section("Commands to create and delete users, and change their passwords");
+
+describe_command [[user:create(jid, password, role) - Create the specified user account]]
 function def_env.user:create(jid, password, role)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1597,6 +1656,7 @@
 	return true, ("Created %s with role '%s'"):format(jid, role);
 end
 
+describe_command [[user:disable(jid) - Disable the specified user account, preventing login]]
 function def_env.user:disable(jid)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1612,6 +1672,7 @@
 	end
 end
 
+describe_command [[user:enable(jid) - Enable the specified user account, restoring login access]]
 function def_env.user:enable(jid)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1627,6 +1688,7 @@
 	end
 end
 
+describe_command [[user:delete(jid) - Permanently remove the specified user account]]
 function def_env.user:delete(jid)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1642,6 +1704,7 @@
 	end
 end
 
+describe_command [[user:password(jid, password) - Set the password for the specified user account]]
 function def_env.user:password(jid, password)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1657,6 +1720,7 @@
 	end
 end
 
+describe_command [[user:roles(jid, host) - Show current roles for an user]]
 function def_env.user:role(jid, host)
 	local print = self.session.print;
 	local username, userhost = jid_split(jid);
@@ -1682,6 +1746,7 @@
 end
 def_env.user.roles = def_env.user.role;
 
+describe_command [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]]
 -- user:setrole("someone@example.com", "example.com", "prosody:admin")
 -- user:setrole("someone@example.com", "prosody:admin")
 function def_env.user:setrole(jid, host, new_role)
@@ -1695,6 +1760,7 @@
 	return um.set_user_role(username, host, new_role);
 end
 
+describe_command [[user:addrole(jid, host, role) - Add a secondary role to a user]]
 function def_env.user:addrole(jid, host, new_role)
 	local username, userhost = jid_split(jid);
 	if new_role == nil then host, new_role = userhost, host; end
@@ -1706,6 +1772,7 @@
 	return um.add_user_secondary_role(username, host, new_role);
 end
 
+describe_command [[user:delrole(jid, host, role) - Remove a secondary role from a user]]
 function def_env.user:delrole(jid, host, role_name)
 	local username, userhost = jid_split(jid);
 	if role_name == nil then host, role_name = userhost, host; end
@@ -1717,6 +1784,7 @@
 	return um.remove_user_secondary_role(username, host, role_name);
 end
 
+describe_command [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
 -- TODO switch to table view, include roles
 function def_env.user:list(host, pat)
 	if not host then
@@ -1736,8 +1804,9 @@
 	return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
 end
 
-def_env.xmpp = {};
+def_env.xmpp = new_section("Commands for sending XMPP stanzas");;
 
+describe_command [[xmpp:ping(localhost, remotehost) - Sends a ping to a remote XMPP server and reports the response]]
 local new_id = require "prosody.util.id".medium;
 function def_env.xmpp:ping(localhost, remotehost, timeout)
 	localhost = select(2, jid_split(localhost));
@@ -1789,7 +1858,7 @@
 	end);
 end
 
-def_env.dns = {};
+def_env.dns = new_section("Commands to manage and inspect the internal DNS resolver");
 local adns = require"prosody.net.adns";
 
 local function get_resolver(session)
@@ -1801,36 +1870,42 @@
 	return resolver;
 end
 
+describe_command [[dns:lookup(name, type, class) - Do a DNS lookup]]
 function def_env.dns:lookup(name, typ, class)
 	local resolver = get_resolver(self.session);
 	return resolver:lookup_promise(name, typ, class)
 end
 
+describe_command [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
 function def_env.dns:addnameserver(...)
 	local resolver = get_resolver(self.session);
 	resolver._resolver:addnameserver(...)
 	return true
 end
 
+describe_command [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
 function def_env.dns:setnameserver(...)
 	local resolver = get_resolver(self.session);
 	resolver._resolver:setnameserver(...)
 	return true
 end
 
+describe_command [[dns:purge() - Clear the DNS cache]]
 function def_env.dns:purge()
 	local resolver = get_resolver(self.session);
 	resolver._resolver:purge()
 	return true
 end
 
+describe_command [[dns:cache() - Show cached records]]
 function def_env.dns:cache()
 	local resolver = get_resolver(self.session);
 	return true, "Cache:\n"..tostring(resolver._resolver.cache)
 end
 
-def_env.http = {};
+def_env.http = new_section("Commands to inspect HTTP services");
 
+describe_command [[http:list(hosts) - Show HTTP endpoints]]
 function def_env.http:list(hosts)
 	local print = self.session.print;
 	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
@@ -1875,8 +1950,9 @@
 	return true;
 end
 
-def_env.watch = {};
+def_env.watch = new_section("Commands for watching live logs from the server");
 
+describe_command [[watch:log() - Follow debug logs]]
 function def_env.watch:log()
 	local writing = false;
 	local sink = logger.add_simple_sink(function (source, level, message)
@@ -1894,6 +1970,7 @@
 	end
 end
 
+describe_command [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]]
 local stanza_watchers = module:require("mod_debug_stanzas/watcher");
 function def_env.watch:stanzas(target_spec, filter_spec)
 	local function handler(event_type, stanza, session)
@@ -1929,8 +2006,9 @@
 	stanza_watchers.remove(handler);
 end
 
-def_env.debug = {};
+def_env.debug = new_section("Commands for debugging the server");
 
+describe_command [[debug:logevents(host) - Enable logging of fired events on host]]
 function def_env.debug:logevents(host)
 	if host == "*" then
 		helpers.log_events(prosody.events);
@@ -1943,6 +2021,7 @@
 	return true;
 end
 
+describe_command [[debug:events(host, event) - Show registered event handlers]]
 function def_env.debug:events(host, event)
 	local events_obj;
 	if host and host ~= "*" then
@@ -1959,6 +2038,7 @@
 	return true, helpers.show_events(events_obj, event);
 end
 
+describe_command [[debug:timers() - Show information about scheduled timers]]
 function def_env.debug:timers()
 	local print = self.session.print;
 	local add_task = require"prosody.util.timer".add_task;
@@ -2015,6 +2095,7 @@
 	return true;
 end
 
+describe_command [[debug:async() - Show information about pending asynchronous tasks]]
 function def_env.debug:async(runner_id)
 	local print = self.session.print;
 	local time_now = time.now();
@@ -2080,7 +2161,7 @@
 -- COMPAT: debug:timers() was timer:info() for some time in trunk
 def_env.timer = { info = def_env.debug.timers };
 
-def_env.stats = {};
+def_env.stats = new_section("Commands to show internal statistics");
 
 local short_units = {
 	seconds = "s",
@@ -2319,6 +2400,7 @@
 	return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt);
 end
 
+describe_command [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern. Append :cfgraph() or :histogram() for graphs]]
 function def_env.stats:show(name_filter)
 	local statsman = require "prosody.core.statsmanager"
 	local collect = statsman.collect