changeset 2593:767b2627c92d

Implement cli/shell command-group support Reviewed-by: jerboaa Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2016-November/021748.html Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2016-December/021824.html Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-January/021934.html Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-February/022139.html
author Andrew Azores <aazores@redhat.com>
date Tue, 14 Feb 2017 11:26:33 -0500
parents 6d713f241106
children 097340ee0fec
files common/core/src/main/java/com/redhat/thermostat/common/utils/StringUtils.java common/core/src/test/java/com/redhat/thermostat/common/utils/StringUtilsTest.java distribution/docs/thermostat-plugin.xsd distribution/packaging/shared/bash-completion/thermostat-completion launcher/src/main/java/com/redhat/thermostat/launcher/internal/Activator.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/BasicCommandInfo.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfo.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandGroupMetadataSource.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfo.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSource.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/HelpCommand.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/LocaleResources.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfiguration.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParser.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginInfoSource.java launcher/src/main/resources/com/redhat/thermostat/launcher/internal/strings.properties launcher/src/test/java/com/redhat/thermostat/launcher/internal/ActivatorTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/BasicCommandInfoTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSourceTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/HelpCommandTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/LauncherImplTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParserTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginInfoSourceTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/TestCommandInfo.java
diffstat 25 files changed, 768 insertions(+), 68 deletions(-) [+]
line wrap: on
line diff
--- a/common/core/src/main/java/com/redhat/thermostat/common/utils/StringUtils.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/common/core/src/main/java/com/redhat/thermostat/common/utils/StringUtils.java	Tue Feb 14 11:26:33 2017 -0500
@@ -90,5 +90,23 @@
         return in;
     }
 
+    /**
+     * Compare nullable Strings.
+     * The "null string" is considered to be "greater than", or "come after", any non-null String.
+     * For two non-null values, this method is equal to s1.compareTo(s2).
+     */
+    public static int compare(String s1, String s2) {
+        if (s1 == null && s2 == null) {
+            return 0;
+        }
+        if (s1 == null) {
+            return 1;
+        }
+        if (s2 == null) {
+            return -1;
+        }
+        return s1.compareTo(s2);
+    }
+
 }
 
--- a/common/core/src/test/java/com/redhat/thermostat/common/utils/StringUtilsTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/common/core/src/test/java/com/redhat/thermostat/common/utils/StringUtilsTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -36,8 +36,10 @@
 
 package com.redhat.thermostat.common.utils;
 
+import static org.hamcrest.CoreMatchers.is;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThat;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -58,7 +60,7 @@
 
     static List<String> list(String... items) {
         if (items == null) {
-            return new ArrayList<String>();
+            return new ArrayList<>();
         } else {
             return Arrays.asList(items);
         }
@@ -77,4 +79,12 @@
         assertFalse(escapedFunnyCharacters.contains("/"));
     }
 
+    @Test
+    public void testStringCompare() {
+        assertThat(StringUtils.compare("a", "b"), is("a".compareTo("b")));
+        assertThat(StringUtils.compare("a", null), is(-1));
+        assertThat(StringUtils.compare(null, "a"), is(1));
+        assertThat(StringUtils.compare(null, null), is(0));
+    }
+
 }
--- a/distribution/docs/thermostat-plugin.xsd	Tue Feb 14 09:07:27 2017 -0500
+++ b/distribution/docs/thermostat-plugin.xsd	Tue Feb 14 11:26:33 2017 -0500
@@ -28,6 +28,54 @@
   </xs:annotation>
 </xs:element>
 
+<xs:element name="command-groups">
+  <xs:complexType>
+    <xs:sequence>
+      <xs:element ref="command-group" minOccurs="1" maxOccurs="unbounded"/>
+    </xs:sequence>
+  </xs:complexType>
+</xs:element>
+
+<xs:element name="command-group" type="xs:string">
+  <xs:annotation>
+    <xs:documentation>
+      A simple one-word tag used to group related commands together,
+      such as "vm", "agent", "thread", or "numa". Useful for scenarios
+      where subcommands are not applicable. Commands within a group are
+      placed together in help output and receive a "see also" mention
+      in help output for other commands within their group.
+    </xs:documentation>
+  </xs:annotation>
+</xs:element>
+
+<xs:element name="command-group-metadatas">
+  <xs:complexType>
+    <xs:sequence>
+      <xs:element ref="command-group-metadata" minOccurs="1" maxOccurs="unbounded"/>
+    </xs:sequence>
+  </xs:complexType>
+</xs:element>
+
+<xs:element name="command-group-metadata">
+  <xs:annotation>
+    <xs:documentation>
+      Metadata for the "command-group" elements which tag collections of related
+      commands. This metadata is used to provide stylized names and descriptions
+      as well as the order of appearance of command groups as displayed in 'help'
+      output. Metadata elements are expected to map 1:1 with command-group
+      elements and may be provided within the same thermostat-plugin.xml, or in
+      a separate thermostat-plugin.xml.
+    </xs:documentation>
+  </xs:annotation>
+  <xs:complexType>
+    <xs:sequence>
+      <xs:element ref="name"/>
+      <xs:element name="description" type="xs:string"/>
+      <xs:element name="sort-order" type="xs:integer"/>
+    </xs:sequence>
+  </xs:complexType>
+</xs:element>
+
 <xs:element name="short" type="xs:string"/>
 
 <xs:element name="long" type="xs:string"/>
@@ -49,6 +97,7 @@
       <xs:choice>
         <xs:sequence>
           <xs:element ref="commands"/>
+          <xs:element ref="command-group-metadatas" minOccurs="0" maxOccurs="1"/>
           <xs:element ref="extensions" minOccurs="0" maxOccurs="1"/>
         </xs:sequence>
         <xs:element ref="extensions"/>
@@ -93,6 +142,7 @@
       <xs:element ref="usage" minOccurs="0" maxOccurs="1"/>
       <xs:element ref="summary"/>
       <xs:element ref="description"/>
+      <xs:element ref="command-groups" minOccurs="0" maxOccurs="1"/>
       <xs:element ref="subcommands" minOccurs="0" maxOccurs="1"/>
       <xs:element ref="arguments" minOccurs="0" maxOccurs="1"/>
       <xs:element ref="options" minOccurs="0" maxOccurs="1"/>
--- a/distribution/packaging/shared/bash-completion/thermostat-completion	Tue Feb 14 09:07:27 2017 -0500
+++ b/distribution/packaging/shared/bash-completion/thermostat-completion	Tue Feb 14 11:26:33 2017 -0500
@@ -11,7 +11,7 @@
 
     # Thermostat Options
     # All valid commands and options are prefixed with a single space
-    opts="$( "${thermostat_install_dir}/bin/thermostat" "${thermostat_logging_opts}" help | grep '^ ' | cut -d " " -f 2 | tr '\n' ' ')"
+    opts="$( "${thermostat_install_dir}/bin/thermostat" "${thermostat_logging_opts}" help | grep '^ ' | cut -d " " -f 2 | sort | uniq | tr '\n' ' ')"
 
     COMPREPLY=($(compgen -W "${opts}" -- ${cur}))  
     return 0
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/Activator.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/Activator.java	Tue Feb 14 11:26:33 2017 -0500
@@ -79,6 +79,7 @@
         private ServiceRegistration cmdInfoReg;
         private ServiceRegistration exitStatusReg;
         private ServiceRegistration pluginConfReg;
+        private ServiceRegistration commandGroupMetaReg;
         private BundleContext context;
         private CurrentEnvironment env;
 
@@ -107,6 +108,8 @@
             ConfigurationInfoSource configurations = pluginSource;
             pluginConfReg = context.registerService(ConfigurationInfoSource.class, configurations, null);
 
+            commandGroupMetaReg = context.registerService(CommandGroupMetadataSource.class, pluginSource, null);
+
             CommandInfoSource commands = new CompoundCommandInfoSource(builtInCommandSource, pluginSource);
             cmdInfoReg = context.registerService(CommandInfoSource.class, commands, null);
 
@@ -134,6 +137,7 @@
             cmdInfoReg.unregister();
             exitStatusReg.unregister();
             pluginConfReg.unregister();
+            commandGroupMetaReg.unregister();
         }
 
     }
@@ -146,7 +150,7 @@
     private ShellCommand shellCommand;
     private TabCompletion tabCompletion;
     @SuppressWarnings("rawtypes")
-    private ServiceTracker commandInfoSourceTracker;
+    private MultipleServiceTracker commandInfoSourceTracker;
     private ServiceTracker dbServiceTracker;
 
     @SuppressWarnings({ "rawtypes", "unchecked" })
@@ -217,28 +221,34 @@
         shellTracker.open();
 
         final HelpCommandCompleterService helpCommandCompleterService = new HelpCommandCompleterService();
-        commandInfoSourceTracker = new ServiceTracker(context, CommandInfoSource.class, null) {
+        final Class<?>[] helpCommandClasses = new Class<?>[] {
+                CommandInfoSource.class,
+                CommandGroupMetadataSource.class
+        };
+        commandInfoSourceTracker = new MultipleServiceTracker(context, helpCommandClasses, new Action() {
             @Override
-            public Object addingService(ServiceReference reference) {
-                CommandInfoSource infoSource = (CommandInfoSource) super.addingService(reference);
+            public void dependenciesAvailable(DependencyProvider services) {
+                CommandInfoSource infoSource = services.get(CommandInfoSource.class);
                 helpCommand.setCommandInfoSource(infoSource);
                 helpCommandCompleterService.bindCommandInfoSource(infoSource);
                 if (shellCommand != null) {
                     shellCommand.setCommandInfoSource(infoSource);
                 }
-                return infoSource;
+
+                CommandGroupMetadataSource commandGroupMetadataSource = services.get(CommandGroupMetadataSource.class);
+                helpCommand.setCommandGroupMetadataSource(commandGroupMetadataSource);
             }
 
             @Override
-            public void removedService(ServiceReference reference, Object service) {
+            public void dependenciesUnavailable() {
                 helpCommand.setCommandInfoSource(null);
+                helpCommand.setCommandGroupMetadataSource(null);
                 helpCommandCompleterService.unbindCommandInfoSource();
                 if (shellCommand != null) {
                     shellCommand.setCommandInfoSource(null);
                 }
-                super.removedService(reference, service);
             }
-        };
+        });
         commandInfoSourceTracker.open();
 
         dbServiceTracker = new ServiceTracker(context, DbService.class.getName(), new ServiceTrackerCustomizer() {
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BasicCommandInfo.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BasicCommandInfo.java	Tue Feb 14 11:26:33 2017 -0500
@@ -39,6 +39,7 @@
 import java.util.List;
 import java.util.Set;
 
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.Options;
 
 import com.redhat.thermostat.launcher.BundleInformation;
@@ -48,17 +49,19 @@
     private final String name;
     private final String summary;
     private final String description;
+    private final List<String> commandGroups;
     private final String usage;
     private final List<PluginConfiguration.Subcommand> subcommands;
     private final Options options;
     private final Set<Environment> environments;
     private final List<BundleInformation> bundles;
 
-    public BasicCommandInfo(String name, String summary, String description, String usage, Options options, List<PluginConfiguration.Subcommand> subcommands,
-                            Set<Environment> environments, List<BundleInformation> bundles) {
+    public BasicCommandInfo(String name, String summary, String description, List<String> commandGroups, String usage,
+                            Options options, List<PluginConfiguration.Subcommand> subcommands, Set<Environment> environments, List<BundleInformation> bundles) {
         this.name = name;
         this.summary = summary;
         this.description = description;
+        this.commandGroups = commandGroups;
         this.usage = usage;
         this.options = options;
         this.subcommands = subcommands;
@@ -82,6 +85,11 @@
     }
 
     @Override
+    public List<String> getCommandGroups() {
+        return commandGroups;
+    }
+
+    @Override
     public String getUsage() {
         return usage;
     }
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfo.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfo.java	Tue Feb 14 11:26:33 2017 -0500
@@ -61,6 +61,7 @@
     private static final String PROPERTY_BUNDLES = "bundles";
     private static final String PROPERTY_SUMMARY = "summary";
     private static final String PROPERTY_DESC = "description";
+    private static final String COMMAND_GROUP_DESC = "command-groups";
     private static final String PROPERTY_USAGE = "usage";
     private static final String PROPERTY_OPTIONS = "options";
     private static final String PROPERTY_ENVIRONMENTS = "environments";
@@ -72,6 +73,7 @@
     private static final String PROP_OPTDESC = ".description";
     
     private String name, summary, description, usage;
+    private List<String> commandGroups = new ArrayList<>();
     private Options options;
     private EnumSet<Environment> environment;
     private List<BundleInformation> dependencies;
@@ -87,6 +89,15 @@
                 summary = properties.getProperty(key);
             } else if (key.equals(PROPERTY_DESC)) {
                 description = properties.getProperty(key);
+            } else if (key.equals(COMMAND_GROUP_DESC)) {
+                String raw = properties.getProperty(key);
+                if (raw == null || raw.isEmpty()) {
+                    continue;
+                }
+                String[] parts = raw.split(",");
+                for (String part : parts) {
+                    commandGroups.add(part.trim());
+                }
             } else if (key.equals(PROPERTY_USAGE)) {
                 usage = properties.getProperty(key);
             } else if (key.equals(PROPERTY_OPTIONS)) {
@@ -378,6 +389,11 @@
     }
 
     @Override
+    public List<String> getCommandGroups() {
+        return commandGroups;
+    }
+
+    @Override
     public String getUsage() {
         return usage;
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandGroupMetadataSource.java	Tue Feb 14 11:26:33 2017 -0500
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * This file is part of Thermostat.
+ *
+ * Thermostat is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published
+ * by the Free Software Foundation; either version 2, or (at your
+ * option) any later version.
+ *
+ * Thermostat is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Thermostat; see the file COPYING.  If not see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * Linking this code with other modules is making a combined work
+ * based on this code.  Thus, the terms and conditions of the GNU
+ * General Public License cover the whole combination.
+ *
+ * As a special exception, the copyright holders of this code give
+ * you permission to link this code with independent modules to
+ * produce an executable, regardless of the license terms of these
+ * independent modules, and to copy and distribute the resulting
+ * executable under terms of your choice, provided that you also
+ * meet, for each linked independent module, the terms and conditions
+ * of the license of that module.  An independent module is a module
+ * which is not derived from or based on this code.  If you modify
+ * this code, you may extend this exception to your version of the
+ * library, but you are not obligated to do so.  If you do not wish
+ * to do so, delete this exception statement from your version.
+ */
+
+package com.redhat.thermostat.launcher.internal;
+
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
+
+import java.util.Map;
+
+interface CommandGroupMetadataSource {
+    Map<String, CommandGroupMetadata> getCommandGroupMetadata();
+}
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfo.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfo.java	Tue Feb 14 11:26:33 2017 -0500
@@ -39,6 +39,7 @@
 import java.util.List;
 import java.util.Set;
 
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.Options;
 
 import com.redhat.thermostat.launcher.BundleInformation;
@@ -64,6 +65,15 @@
     public String getDescription();
 
     /**
+     * A simple one-word tag used to group related commands together,
+     * such as "vm", "agent", "thread", or "numa". Useful for scenarios
+     * where subcommands are not applicable. Commands within a group are
+     * placed together in help output and receive a "see also" mention
+     * in help output for other commands within their group.
+     */
+    public List<String> getCommandGroups();
+
+    /**
      * How the user should invoke this command
      */
     public String getUsage();
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSource.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSource.java	Tue Feb 14 11:26:33 2017 -0500
@@ -132,6 +132,9 @@
 
         String summary = selectBest(info1.getSummary(), info2.getSummary());
         String description = selectBest(info1.getDescription(), info2.getDescription());
+        List<String> commandGroups = new ArrayList<>();
+        commandGroups.addAll(info1.getCommandGroups());
+        commandGroups.addAll(info2.getCommandGroups());
         String usage = selectBest(info1.getUsage(), info2.getUsage());
         List<PluginConfiguration.Subcommand> subcommands = selectBest(info1.getSubcommands(), info2.getSubcommands());
         Options options = selectBest(info1.getOptions(), info2.getOptions());
@@ -140,7 +143,7 @@
         bundles.addAll(info1.getBundles());
         bundles.addAll(info2.getBundles());
 
-        return new BasicCommandInfo(name, summary, description, usage, options, subcommands, environment, bundles);
+        return new BasicCommandInfo(name, summary, description, commandGroups, usage, options, subcommands, environment, bundles);
     }
 
     private <T> T selectBest(T first, T second) {
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/HelpCommand.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/HelpCommand.java	Tue Feb 14 11:26:33 2017 -0500
@@ -37,12 +37,21 @@
 package com.redhat.thermostat.launcher.internal;
 
 import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.logging.Logger;
 
+import com.redhat.thermostat.common.utils.LoggingUtils;
+import com.redhat.thermostat.common.utils.StringUtils;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.HelpFormatter;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
@@ -58,21 +67,33 @@
 public class HelpCommand extends AbstractCommand  {
 
     private static final Translate<LocaleResources> translator = LocaleResources.createLocalizer();
+    private static final Logger logger = LoggingUtils.getLogger(HelpCommand.class);
     static final String COMMAND_NAME = "help";
 
     private static final int COMMANDS_COLUMNS_WIDTH = 14;
+    public static final int MAX_COLUMN_WIDTH = 80;
     private static final String APP_NAME = "thermostat";
 
     private static final CommandInfoComparator comparator = new CommandInfoComparator();
+    private static final CommandGroupMetadata UNGROUPED_COMMANDS_METADATA = new CommandGroupMetadata(null, null, Integer.MAX_VALUE);
 
     private CommandInfoSource commandInfoSource;
+    private CommandGroupMetadataSource commandGroupMetadataSource;
 
     private Environment currentEnvironment;
 
+    private SortedMap<CommandGroupMetadata, SortedSet<CommandInfo>> commandGroupMap;
+    private Map<String, CommandGroupMetadata> commandGroupMetadataMap;
+    private Set<CommandInfo> contextualCommands = new HashSet<>();
+
     public void setCommandInfoSource(CommandInfoSource source) {
         this.commandInfoSource = source;
     }
 
+    public void setCommandGroupMetadataSource(CommandGroupMetadataSource commandGroupMetadataSource) {
+        this.commandGroupMetadataSource = commandGroupMetadataSource;
+    }
+
     public void setEnvironment(Environment env) {
         currentEnvironment = env;
     }
@@ -87,6 +108,20 @@
             return;
         }
 
+        if (commandGroupMetadataSource == null) {
+            ctx.getConsole().getError().print(translator.localize(LocaleResources.CANNOT_GET_COMMAND_GROUP_METADATA).getContents());
+            return;
+        }
+
+        for (CommandInfo info: commandInfoSource.getCommandInfos()) {
+            if (info.getEnvironments().contains(currentEnvironment)) {
+                contextualCommands.add(info);
+            }
+        }
+
+        commandGroupMetadataMap = new HashMap<>(commandGroupMetadataSource.getCommandGroupMetadata());
+        commandGroupMap = createCommandGroupMap();
+
         if (nonParsed.isEmpty()) {
             if (currentEnvironment == Environment.CLI) {
                 //CLI only since the framework will already be
@@ -100,24 +135,60 @@
         }
     }
 
+    private SortedMap<CommandGroupMetadata, SortedSet<CommandInfo>> createCommandGroupMap() {
+        Set<CommandInfo> seen = new HashSet<>();
+        Map<String, SortedSet<CommandInfo>> groupNameMap = new HashMap<>();
+        for (CommandInfo commandInfo : contextualCommands) {
+            for (String commandGroup : commandInfo.getCommandGroups()) {
+                seen.add(commandInfo);
+                if (!groupNameMap.containsKey(commandGroup)) {
+                    groupNameMap.put(commandGroup, new TreeSet<>(comparator));
+                }
+                groupNameMap.get(commandGroup).add(commandInfo);
+            }
+        }
+
+        SortedMap<CommandGroupMetadata, SortedSet<CommandInfo>> result = new TreeMap<>(new CommandGroupMetadataComparator());
+        for (Map.Entry<String, SortedSet<CommandInfo>> entry : groupNameMap.entrySet()) {
+            String groupName = entry.getKey();
+            CommandGroupMetadata metadata = commandGroupMetadataMap.get(groupName);
+            if (metadata == null) {
+                logger.warning("No metadata provided for command group \"" + groupName + "\"");
+                metadata = new CommandGroupMetadata(groupName, groupName, Integer.MAX_VALUE);
+                commandGroupMetadataMap.put(groupName, metadata);
+            }
+            result.put(metadata, entry.getValue());
+        }
+
+        SortedSet<CommandInfo> ungrouped = new TreeSet<>(comparator);
+        ungrouped.addAll(contextualCommands);
+        ungrouped.removeAll(seen);
+        result.put(UNGROUPED_COMMANDS_METADATA, ungrouped);
+
+        return result;
+    }
+
     private void printCommandSummaries(CommandContext ctx) {
         ctx.getConsole().getOutput().print(translator.localize(LocaleResources.COMMAND_HELP_COMMAND_LIST_HEADER).getContents());
 
         TableRenderer renderer = new TableRenderer(2, COMMANDS_COLUMNS_WIDTH);
 
-        Collection<CommandInfo> commandInfos = new ArrayList<>();
-        for (CommandInfo info: commandInfoSource.getCommandInfos()) {
-            if (info.getEnvironments().contains(currentEnvironment)) {
-                commandInfos.add(info);
+        for (Map.Entry<CommandGroupMetadata, SortedSet<CommandInfo>> group : commandGroupMap.entrySet()) {
+            CommandGroupMetadata commandGroupMetadata = group.getKey();
+            if (commandGroupMetadata.equals(UNGROUPED_COMMANDS_METADATA) || group.getValue().isEmpty()) {
+                continue;
             }
+            renderer.printLine(translator.localize(LocaleResources.COMMAND_GROUP_HEADER,
+                    commandGroupMetadata.getDescription()).getContents(), "");
+            for (CommandInfo info : group.getValue()) {
+                printCommandSummary(renderer, info);
+            }
+            renderer.printLine("", "");
+        }
+        for (CommandInfo ungroupedCommand : commandGroupMap.get(UNGROUPED_COMMANDS_METADATA)) {
+            printCommandSummary(renderer, ungroupedCommand);
         }
 
-        List<CommandInfo> sortedCommandInfos = new ArrayList<>(commandInfos);
-
-        Collections.sort(sortedCommandInfos, comparator);
-        for (CommandInfo info : sortedCommandInfos) {
-            printCommandSummary(renderer, info);
-        }
         renderer.render(ctx.getConsole().getOutput());
     }
 
@@ -160,28 +231,41 @@
         String usage = APP_NAME + " " + info.getUsage() + "\n" + info.getDescription();
         String header = "";
         if (isAvailabilityNoteNeeded(info)) {
-            header = header + getAvailabilityNote(info);
+            header = getAvailabilityNote(info);
         }
         header = header + "\n" + APP_NAME + " " + info.getName();
         Option help = CommonOptions.getHelpOption();
         options.addOption(help);
-        helpFormatter.printHelp(pw, 80, usage, header, options, 2, 4, null);
+        helpFormatter.printHelp(pw, MAX_COLUMN_WIDTH, usage, header, options, 2, 4, null);
 
         if (!info.getSubcommands().isEmpty()) {
             pw.println();
-            helpFormatter.printWrapped(pw, 80, translator.localize(LocaleResources.SUBCOMMANDS_SECTION_HEADER).getContents());
+            helpFormatter.printWrapped(pw, MAX_COLUMN_WIDTH, translator.localize(LocaleResources.SUBCOMMANDS_SECTION_HEADER).getContents());
             pw.println();
             for (PluginConfiguration.Subcommand subcommand : info.getSubcommands()) {
                 pw.println(translator.localize(LocaleResources.SUBCOMMAND_ENTRY_HEADER, subcommand.getName()).getContents());
                 pw.println(subcommand.getDescription());
                 Options o = subcommand.getOptions();
-                helpFormatter.printOptions(pw, 80, o, 2, 4);
+                helpFormatter.printOptions(pw, MAX_COLUMN_WIDTH, o, 2, 4);
                 if (!o.getOptions().isEmpty()) {
                     pw.println();
                 }
             }
         }
 
+        SortedSet<CommandInfo> relatedCommands = new TreeSet<>(comparator);
+        for (String commandGroup : info.getCommandGroups()) {
+            relatedCommands.addAll(commandGroupMap.get(commandGroupMetadataMap.get(commandGroup)));
+        }
+        relatedCommands.remove(info);
+        if (!relatedCommands.isEmpty()) {
+            pw.println();
+            pw.println(translator.localize(LocaleResources.SEE_ALSO_HEADER).getContents());
+            pw.print(' ');
+            RelatedCommandsFormatter relatedCommandsFormatter = new RelatedCommandsFormatter(relatedCommands);
+            pw.println(relatedCommandsFormatter.format());
+        }
+
         pw.flush();
     }
 
@@ -191,20 +275,15 @@
 
     /** Describe where command is available */
     private String getAvailabilityNote(CommandInfo info) {
-
-        String availabilityNote = "";
-
         // there are two mutually exclusive environments: if an availability
         // note is needed, it will just be about one
         if (info.getEnvironments().contains(Environment.SHELL)) {
-            availabilityNote = translator.localize(LocaleResources.COMMAND_AVAILABLE_INSIDE_SHELL).getContents();
+            return translator.localize(LocaleResources.COMMAND_AVAILABLE_INSIDE_SHELL).getContents();
         } else if (info.getEnvironments().contains(Environment.CLI)) {
-            availabilityNote = translator.localize(LocaleResources.COMMAND_AVAILABLE_OUTSIDE_SHELL).getContents();
+            return translator.localize(LocaleResources.COMMAND_AVAILABLE_OUTSIDE_SHELL).getContents();
         } else {
             throw new AssertionError("Need to handle a third environment");
         }
-
-        return availabilityNote;
     }
 
     @Override
@@ -226,11 +305,46 @@
             if (o2.getName().equals("help")) {
                 return 1;
             }
-
             return o1.getName().compareTo(o2.getName());
         }
 
     }
 
+    private static class CommandGroupMetadataComparator implements Comparator<CommandGroupMetadata> {
+        @Override
+        public int compare(CommandGroupMetadata cgm1, CommandGroupMetadata cgm2) {
+            int sortOrderComparison = Integer.compare(cgm1.getSortOrder(), cgm2.getSortOrder());
+            if (sortOrderComparison != 0) {
+                return sortOrderComparison;
+            }
+            return StringUtils.compare(cgm1.getName(), cgm2.getName());
+        }
+    }
+
+    private static class RelatedCommandsFormatter {
+
+        private final Set<CommandInfo> commandInfos;
+
+        public RelatedCommandsFormatter(Set<CommandInfo> commandInfos) {
+            this.commandInfos = commandInfos;
+        }
+
+        public String format() {
+            StringBuilder sb = new StringBuilder();
+            for (CommandInfo info : commandInfos) {
+                String next = info.getName();
+                if (sb.length() + (" " + next + ",").length() > MAX_COLUMN_WIDTH) {
+                    sb.append("\n ");
+                }
+                sb.append(' ').append(next).append(",");
+            }
+            String result = sb.toString();
+            if (result.endsWith(",")) {
+                result = result.substring(0, result.length() - 1);
+            }
+            return result;
+        }
+    }
+
 }
 
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/LocaleResources.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/LocaleResources.java	Tue Feb 14 11:26:33 2017 -0500
@@ -43,6 +43,7 @@
     MISSING_LAUNCHER,
 
     CANNOT_GET_COMMAND_INFO,
+    CANNOT_GET_COMMAND_GROUP_METADATA,
     UNKNOWN_COMMAND,
     COMMAND_COULD_NOT_LOAD_BUNDLES,
     COMMAND_DESCRIBED_BUT_NOT_AVAILALBE,
@@ -54,12 +55,16 @@
     COMMAND_HELP_COMMAND_LIST_HEADER,
     COMMAND_HELP_COMMAND_OPTION_HEADER,
 
+    COMMAND_GROUP_HEADER,
+
     COMMAND_SHELL_USER_GUIDE,
     COMMAND_SHELL_IO_EXCEPTION,
 
     SUBCOMMANDS_SECTION_HEADER,
     SUBCOMMAND_ENTRY_HEADER,
 
+    SEE_ALSO_HEADER,
+
     OPTION_DB_URL_DESC,
     OPTION_LOG_LEVEL_DESC,
     OPTION_HELP_DESC,
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfiguration.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfiguration.java	Tue Feb 14 11:26:33 2017 -0500
@@ -40,6 +40,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 import org.apache.commons.cli.Options;
@@ -49,14 +50,17 @@
 
 public class PluginConfiguration {
 
+    private final List<NewCommand> newCommands;
+    private final List<CommandGroupMetadata> commandGroupMetadataList;
     private final List<CommandExtensions> extensions;
-    private final List<NewCommand> newCommands;
 
     private final PluginID pluginID;
     private final Configurations configurations;
 
-    public PluginConfiguration(List<NewCommand> newCommands, List<CommandExtensions> extensions, PluginID pluginID, Configurations config) {
+    public PluginConfiguration(List<NewCommand> newCommands, List<CommandGroupMetadata> commandGroupMetadataList,
+                               List<CommandExtensions> extensions, PluginID pluginID, Configurations config) {
         this.newCommands = newCommands;
+        this.commandGroupMetadataList = commandGroupMetadataList;
         this.extensions = extensions;
         this.pluginID = pluginID;
         this.configurations = config;
@@ -70,6 +74,10 @@
         return newCommands;
     }
 
+    public List<CommandGroupMetadata> getCommandGroupMetadata() {
+        return commandGroupMetadataList;
+    }
+
     public PluginID getPluginID() {
         return this.pluginID;
     }
@@ -115,6 +123,7 @@
         private final String commandName;
         private final String summary;
         private final String description;
+        private final List<String> commandGroups;
         private final String usage;
         private final List<String> positionalArguments;
         private final Options options;
@@ -122,12 +131,13 @@
         private final Set<Environment> environment;
         private final List<BundleInformation> bundles;
 
-        public NewCommand(String name, String summary, String description, String usage,
+        public NewCommand(String name, String summary, String description, List<String> commandGroups, String usage,
                           List<String> positionalArguments, Options options, List<Subcommand> subcommands,
                           Set<Environment> environment, List<BundleInformation> bundles) {
             this.commandName = name;
             this.summary = summary;
             this.description = description;
+            this.commandGroups = commandGroups;
             this.usage = usage;
             this.positionalArguments = positionalArguments;
             this.options = options;
@@ -148,6 +158,10 @@
             return description;
         }
 
+        public List<String> getCommandGroups() {
+            return commandGroups;
+        }
+
         /**
          * The usage string may be null if no usage string was explicitly
          * provided. In that case, usage should be "computed" using options and
@@ -182,6 +196,47 @@
 
     }
 
+    public static class CommandGroupMetadata {
+        private final String name;
+        private final String description;
+        private final int sortOrder;
+
+        public CommandGroupMetadata(String name, String description, int sortOrder) {
+            this.name = name;
+            this.description = description;
+            this.sortOrder = sortOrder;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public int getSortOrder() {
+            return sortOrder;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (!(o instanceof CommandGroupMetadata)) {
+                return false;
+            }
+            if (this == o) {
+                return true;
+            }
+            CommandGroupMetadata other = (CommandGroupMetadata) o;
+            return Objects.equals(this.getName(), other.getName());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name);
+        }
+    }
+
     public static class PluginID {
         private final String pluginID;
 
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParser.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParser.java	Tue Feb 14 11:26:33 2017 -0500
@@ -55,6 +55,7 @@
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
 
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.OptionGroup;
 import org.apache.commons.cli.Options;
@@ -239,6 +240,7 @@
 
     private PluginConfiguration parseRootElement(String pluginName, Node root) {
         List<NewCommand> commands = Collections.emptyList();
+        List<CommandGroupMetadata> commandGroupMetadataList = Collections.emptyList();
         List<CommandExtensions> extensions = Collections.emptyList();
         Configurations configurations = null;
         String pluginID = null;
@@ -249,6 +251,8 @@
                 Node node = nodes.item(i);
                 if (node.getNodeName().equals("commands")) {
                     commands = parseCommands(pluginName, node);
+                } else if (node.getNodeName().equals("command-group-metadatas")) {
+                    commandGroupMetadataList  = parseCommandGroupMetadatas(node);
                 } else if (node.getNodeName().equals("extensions")) {
                     extensions = parseExtensions(pluginName, node);
                 } else if (node.getNodeName().equals("id")) {
@@ -270,7 +274,7 @@
             pluginID = "";
         }
 
-        return new PluginConfiguration(commands, extensions, new PluginID(pluginID), configurations);
+        return new PluginConfiguration(commands, commandGroupMetadataList, extensions, new PluginID(pluginID), configurations);
     }
 
     private List<NewCommand> parseCommands(String pluginName, Node commandsNode) {
@@ -288,6 +292,36 @@
         return newCommands;
     }
 
+    private List<CommandGroupMetadata> parseCommandGroupMetadatas(Node commandGroupMetadatasNode) {
+        NodeList childNodes = commandGroupMetadatasNode.getChildNodes();
+        List<CommandGroupMetadata> commandGroupMetadatas = new ArrayList<>(childNodes.getLength());
+        for (int i = 0; i < childNodes.getLength(); i++) {
+            Node node = childNodes.item(i);
+            if (node.getNodeName().equals("command-group-metadata")) {
+                commandGroupMetadatas.add(parseCommandGroupMetadata(node));
+            }
+        }
+        return commandGroupMetadatas;
+    }
+
+    private CommandGroupMetadata parseCommandGroupMetadata(Node commandGroupMetadataNode) {
+        String name = null;
+        String description = null;
+        int sortOrder = -1;
+        NodeList childNodes = commandGroupMetadataNode.getChildNodes();
+        for (int i = 0; i < childNodes.getLength(); i++) {
+            Node node = childNodes.item(i);
+            if (node.getNodeName().equals("name")) {
+                name = node.getTextContent().trim();
+            } else if (node.getNodeName().equals("description")) {
+                description = node.getTextContent().trim();
+            } else if (node.getNodeName().equals("sort-order")) {
+                sortOrder = Integer.parseInt(node.getTextContent().trim());
+            }
+        }
+        return new CommandGroupMetadata(name, description, sortOrder);
+    }
+
     private List<CommandExtensions> parseExtensions(String pluginName, Node extensionsNode) {
         List<CommandExtensions> commandExtensions = new ArrayList<CommandExtensions>();
         NodeList childNodes = extensionsNode.getChildNodes();
@@ -354,6 +388,7 @@
         String usage = null;
         String summary = null;
         String description = null;
+        List<String> commandGroups = new ArrayList<>();
         List<PluginConfiguration.Subcommand> subcommands = new ArrayList<>();
         List<String> arguments = new ArrayList<>();
         Options options = new Options();
@@ -371,6 +406,8 @@
                 summary = node.getTextContent().trim();
             } else if (node.getNodeName().equals("description")) {
                 description = parseDescription(node);
+            } else if (node.getNodeName().equals("command-groups")) {
+                commandGroups = parseCommandGroups(node);
             } else if (node.getNodeName().equals("subcommands")) {
                 subcommands = parseSubcommands(node);
             } else if (node.getNodeName().equals("arguments")) {
@@ -393,7 +430,8 @@
                     "name='" + name + "', summary='" + summary + ", description='" + description + "', options='" + options + "'");
             return null;
         } else {
-            return new NewCommand(name, summary, description, usage, arguments, options, subcommands, availableInEnvironments, bundles);
+            return new NewCommand(name, summary, description, commandGroups, usage, arguments, options, subcommands,
+                    availableInEnvironments, bundles);
         }
     }
 
@@ -449,6 +487,23 @@
         return result.toString().trim();
     }
 
+    private List<String> parseCommandGroups(Node commandGroupsNode) {
+        NodeList nodes = commandGroupsNode.getChildNodes();
+        List<String> groups = new ArrayList<>(nodes.getLength());
+        for (int i = 0; i < nodes.getLength(); i++) {
+            Node node = nodes.item(i);
+            if (node.getNodeName().equals("command-group")) {
+                String group = node.getTextContent().trim();
+                groups.add(group);
+            }
+        }
+        return groups;
+    }
+
+    private String parseCommandGroup(Node commandGroupNode) {
+        return commandGroupNode.getTextContent().trim().toLowerCase();
+    }
+
     private List<String> parseArguments(String pluginName, String commandName, Node argumentsNode) {
         return parseNodeAsList(pluginName, commandName, argumentsNode, "argument");
     }
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginInfoSource.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginInfoSource.java	Tue Feb 14 11:26:33 2017 -0500
@@ -45,12 +45,14 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Map.Entry;
+import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -58,6 +60,7 @@
 import com.redhat.thermostat.common.utils.LoggingUtils;
 import com.redhat.thermostat.launcher.BundleInformation;
 import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandExtensions;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import com.redhat.thermostat.launcher.internal.PluginConfiguration.Configurations;
 import com.redhat.thermostat.launcher.internal.PluginConfiguration.NewCommand;
 import com.redhat.thermostat.launcher.internal.PluginConfiguration.PluginID;
@@ -74,7 +77,7 @@
  *
  * @see PluginConfigurationParser how the thermostat-plugin.xml file is parsed
  */
-public class PluginInfoSource implements CommandInfoSource, ConfigurationInfoSource {
+public class PluginInfoSource implements CommandInfoSource, ConfigurationInfoSource, CommandGroupMetadataSource {
 
     private static final String PLUGIN_CONFIG_FILE = "thermostat-plugin.xml";
 
@@ -83,6 +86,8 @@
     private final UsageStringBuilder usageBuilder;
 
     private Map<String, BasicCommandInfo> allNewCommands = new HashMap<>();
+    private Map<CommandGroupMetadata, Set<String>> pluginMetadataMap = new HashMap<>();
+    private Map<String, CommandGroupMetadata> commandGroupMetadataMap = new HashMap<>();
     private Map<String, List<BundleInformation>> additionalBundlesForExistingCommands = new HashMap<>();
     private Map<PluginID, Configurations> allConfigs = new HashMap<>();
 
@@ -112,6 +117,7 @@
                 File configurationFile = new File(pluginDir, PLUGIN_CONFIG_FILE);
                 PluginConfiguration pluginConfig = parser.parse(configurationFile);
                 loadNewAndExtendedCommands(internalJarRoot, pluginDir, pluginConfig);
+                processCommandGroupMetadata(pluginConfig, pluginDir);
                 if (allConfigs.containsKey(pluginConfig.getPluginID())) {
                     logger.log(Level.WARNING, "Plugin with ID: " + pluginConfig.getPluginID() + " conflicts with a previous plugin's ID and the config file will not be overwritten.");
                 } else if (pluginConfig.hasValidID() && pluginConfig.hasConfigurations()) {
@@ -131,6 +137,7 @@
             }
         }
         combineCommands();
+        validateCommandGroupMetadataSources();
     }
 
     private void addPluginDirectory(List<File> allPluginDirectories, File aPluginRoot) {
@@ -176,6 +183,7 @@
             BasicCommandInfo info = new BasicCommandInfo(commandName,
                     command.getSummary(),
                     command.getDescription(),
+                    command.getCommandGroups(),
                     usage,
                     command.getOptions(), command.getSubcommands(),
                     command.getEnvironments(),
@@ -187,6 +195,33 @@
 
     }
 
+    private void processCommandGroupMetadata(PluginConfiguration pluginConfiguration, File pluginDir) {
+        List<CommandGroupMetadata> commandGroupMetadata = pluginConfiguration.getCommandGroupMetadata();
+        for (CommandGroupMetadata metadata : commandGroupMetadata) {
+            if (!pluginMetadataMap.containsKey(metadata)) {
+                pluginMetadataMap.put(metadata, new HashSet<String>());
+            }
+            pluginMetadataMap.get(metadata).add(pluginDir.getName());
+            String name = metadata.getName();
+            if (commandGroupMetadataMap.containsKey(name)) {
+                logger.warning("Already found metadata for command group \"" + name + "\"");
+                continue;
+            }
+            commandGroupMetadataMap.put(name, metadata);
+        }
+    }
+
+    private void validateCommandGroupMetadataSources() {
+        for (Map.Entry<CommandGroupMetadata, Set<String>> entry : pluginMetadataMap.entrySet()) {
+            CommandGroupMetadata metadata = entry.getKey();
+            Set<String> providingPlugins = entry.getValue();
+            if (providingPlugins.size() > 1) {
+                logger.warning(String.format("Metadata for command group \"%s\" is provided by multiple plugins: %s",
+                        metadata.getName(), providingPlugins));
+            }
+        }
+    }
+
     private void combineCommands() {
         Iterator<Entry<String, List<BundleInformation>>> iter = additionalBundlesForExistingCommands.entrySet().iterator();
         while (iter.hasNext()) {
@@ -199,6 +234,7 @@
                 BasicCommandInfo updated = new BasicCommandInfo(old.getName(),
                         old.getSummary(),
                         old.getDescription(),
+                        old.getCommandGroups(),
                         old.getUsage(),
                         old.getOptions(), old.getSubcommands(),
                         old.getEnvironments(),
@@ -232,8 +268,13 @@
         return result;
     }
 
+    @Override
+    public Map<String, CommandGroupMetadata> getCommandGroupMetadata() {
+        return new HashMap<>(commandGroupMetadataMap);
+    }
+
     private BasicCommandInfo createCommandInfo(String name, List<BundleInformation> bundles) {
-        return new BasicCommandInfo(name, null, null, null, null, null, null, bundles);
+        return new BasicCommandInfo(name, null, null, Collections.<String>emptyList(), null, null, null, null, bundles);
     }
 
     public Map<String, String> getConfiguration(String pluginID, String fileName) throws IOException {
--- a/launcher/src/main/resources/com/redhat/thermostat/launcher/internal/strings.properties	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/main/resources/com/redhat/thermostat/launcher/internal/strings.properties	Tue Feb 14 11:26:33 2017 -0500
@@ -1,6 +1,7 @@
 MISSING_LAUNCHER = Fatal Error: Could not locate launcher
 
 CANNOT_GET_COMMAND_INFO = no information about commands
+CANNOT_GET_COMMAND_GROUP_METADATA = command group metadata source is missing
 UNKNOWN_COMMAND = unknown command ''{0}''\n
 COMMAND_COULD_NOT_LOAD_BUNDLES = Could not load necessary bundles for {0}
 COMMAND_DESCRIBED_BUT_NOT_AVAILALBE = ERROR: Information about the command {0} is provided, but the command itself is not available. Was the Command object registered as an OSGi service? Was the bundle providing the command activated?
@@ -16,9 +17,13 @@
 COMMAND_HELP_COMMAND_LIST_HEADER = list of commands:\n\n
 COMMAND_HELP_COMMAND_OPTION_HEADER = list of global options:\n\n
 
+COMMAND_GROUP_HEADER = {0}:
+
 SUBCOMMANDS_SECTION_HEADER=Subcommands:
 SUBCOMMAND_ENTRY_HEADER={0}:
 
+SEE_ALSO_HEADER=See also:
+
 COMMAND_SHELL_USER_GUIDE = Please see the User Guide at {0}
 COMMAND_SHELL_IO_EXCEPTION = IOException caught during Thermostat shell session.
 
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/ActivatorTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/ActivatorTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -155,12 +155,12 @@
 
         assertCommandIsRegistered(context, "help", HelpCommand.class);
 
-        verify(mockTracker, times(2)).open();
+        verify(mockTracker, times(3)).open();
 
         Action action = actionCaptor.getValue();
         assertNotNull(action);
         activator.stop(context);
-        verify(mockTracker, times(2)).close();
+        verify(mockTracker, times(3)).close();
     }
     
     @Test
@@ -194,6 +194,12 @@
         };
         whenNew(MultipleServiceTracker.class).withParameterTypes(BundleContext.class, Class[].class, Action.class).withArguments(eq(context),
                 eq(agentIdCompleterDeps), actionCaptor.capture()).thenReturn(unusedTracker);
+        Class<?>[] helpCommandDeps = new Class[] {
+                CommandInfoSource.class,
+                CommandGroupMetadataSource.class
+        };
+        whenNew(MultipleServiceTracker.class).withParameterTypes(BundleContext.class, Class[].class, Action.class).withArguments(eq(context),
+                eq(helpCommandDeps), actionCaptor.capture()).thenReturn(unusedTracker);
 
         Activator activator = new Activator();
         context.registerService(Keyring.class, mock(Keyring.class), null);
@@ -276,6 +282,12 @@
         };
         whenNew(MultipleServiceTracker.class).withParameterTypes(BundleContext.class, Class[].class, Action.class).withArguments(eq(context),
                 eq(agentIdCompleterDeps), actionCaptor.capture()).thenReturn(unusedTracker);
+        Class<?>[] helpCommandDeps = new Class[] {
+                CommandInfoSource.class,
+                CommandGroupMetadataSource.class
+        };
+        whenNew(MultipleServiceTracker.class).withParameterTypes(BundleContext.class, Class[].class, Action.class).withArguments(eq(context),
+                eq(helpCommandDeps), actionCaptor.capture()).thenReturn(unusedTracker);
 
         Activator activator = new Activator();
         ConfigurationInfoSource configurationInfoSource = mock(ConfigurationInfoSource.class);
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BasicCommandInfoTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BasicCommandInfoTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -55,17 +55,20 @@
         final String NAME = "the_name";
         final String SUMMARY = "some-summary";
         final String DESCRIPTION = "some-description";
+        final List<String> COMMAND_GROUPS = Collections.singletonList("some-command-group");
+        final List<String> COMMAND_GROUP_METADATAS = Collections.emptyList();
         final String USAGE = "some-usage";
         final Options OPTIONS = new Options();
         final List<PluginConfiguration.Subcommand> SUBCOMMANDS = Collections.emptyList();
         final Set<Environment> ENVIRONMENT = EnumSet.noneOf(Environment.class);
         final List<BundleInformation> BUNDLES = Collections.emptyList();
 
-        BasicCommandInfo info = new BasicCommandInfo(NAME, SUMMARY, DESCRIPTION, USAGE, OPTIONS, SUBCOMMANDS, ENVIRONMENT, BUNDLES);
+        BasicCommandInfo info = new BasicCommandInfo(NAME, SUMMARY, DESCRIPTION, COMMAND_GROUPS, USAGE, OPTIONS, SUBCOMMANDS, ENVIRONMENT, BUNDLES);
 
         assertEquals(NAME, info.getName());
         assertEquals(SUMMARY, info.getSummary());
         assertEquals(DESCRIPTION, info.getDescription());
+        assertEquals(COMMAND_GROUPS, info.getCommandGroups());
         assertEquals(USAGE, info.getUsage());
         assertEquals(SUBCOMMANDS, info.getSubcommands());
         assertEquals(OPTIONS, info.getOptions());
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -42,6 +42,7 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Properties;
@@ -105,6 +106,18 @@
     }
 
     @Test
+    public void verifyGetCommandGroup() {
+        Properties props = new Properties();
+        String name = "command-groups";
+        String groups = "a,b";
+        props.put(name, groups);
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props);
+
+        List<String> commandGroups = info.getCommandGroups();
+        assertEquals(Arrays.asList("a", "b"), commandGroups);
+    }
+
+    @Test
     public void verifyGetUsage() {
         Properties props = new Properties();
         String name = "name";
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSourceTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSourceTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -102,6 +102,7 @@
     public void verifyGetCommandInfoMergesResultFromBothSources() {
         String NAME = "test-command-please-ignore";
         String DESCRIPTION = "test-description";
+        List<String> COMMAND_GROUPS = Collections.singletonList("test-command-group");
         String USAGE = "test-usage";
         List<PluginConfiguration.Subcommand> SUBCOMMANDS = Collections.emptyList();
         Options OPTIONS = new Options();
@@ -111,6 +112,7 @@
         CommandInfo cmdInfo1 = mock(CommandInfo.class);
         when(cmdInfo1.getName()).thenReturn(NAME);
         when(cmdInfo1.getDescription()).thenReturn(DESCRIPTION);
+        when(cmdInfo1.getCommandGroups()).thenReturn(COMMAND_GROUPS);
         when(cmdInfo1.getUsage()).thenReturn(USAGE);
         when(cmdInfo1.getOptions()).thenReturn(OPTIONS);
         when(cmdInfo1.getSubcommands()).thenReturn(SUBCOMMANDS);
@@ -119,6 +121,7 @@
         CommandInfo cmdInfo2 = mock(CommandInfo.class);
         when(cmdInfo2.getName()).thenReturn(NAME);
         when(cmdInfo2.getBundles()).thenReturn(DEPS2);
+        when(cmdInfo2.getCommandGroups()).thenReturn(Collections.<String>emptyList());
 
         when(source1.getCommandInfo(NAME)).thenReturn(cmdInfo1);
         when(source2.getCommandInfo(NAME)).thenReturn(cmdInfo2);
@@ -126,6 +129,7 @@
         CommandInfo result = compoundSource.getCommandInfo(NAME);
         assertEquals(NAME, result.getName());
         assertEquals(DESCRIPTION, result.getDescription());
+        assertEquals(COMMAND_GROUPS, result.getCommandGroups());
         assertEquals(USAGE, result.getUsage());
         assertEquals(OPTIONS, result.getOptions());
         assertEquals(SUBCOMMANDS, result.getSubcommands());
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/HelpCommandTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/HelpCommandTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -65,13 +65,16 @@
             + " --boot-delegation        boot delegation string passed on to the OSGi framework\n";
 
     private TestCommandContextFactory  ctxFactory;
-    private CommandInfoSource infos;
+    private CommandInfoSource commandInfoSource;
+    private CommandGroupMetadataSource commandGroupMetadataSource;
 
     @Before
     public void setUp() {
         ctxFactory = new TestCommandContextFactory();
 
-        infos = mock(CommandInfoSource.class);
+        commandInfoSource = mock(CommandInfoSource.class);
+        commandGroupMetadataSource = mock(CommandGroupMetadataSource.class);
+        when(commandGroupMetadataSource.getCommandGroupMetadata()).thenReturn(Collections.<String, PluginConfiguration.CommandGroupMetadata>emptyMap());
     }
 
     @Test
@@ -86,6 +89,7 @@
 
         Arguments args = mock(Arguments.class);
         when(args.getNonOptionArguments()).thenReturn(Arrays.asList("test1"));
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         cmd.run(ctxFactory.createContext(args));
 
         assertEquals("no information about commands", ctxFactory.getError());
@@ -93,9 +97,23 @@
     }
 
     @Test
+    public void verifyHelpFailsWithoutCommandGroupMetadataSource() {
+        HelpCommand cmd = new HelpCommand();
+
+        Arguments args = mock(Arguments.class);
+        when(args.getNonOptionArguments()).thenReturn(Arrays.asList("test1"));
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.run(ctxFactory.createContext(args));
+
+        assertEquals("command group metadata source is missing", ctxFactory.getError());
+        assertEquals("", ctxFactory.getOutput());
+    }
+
+    @Test
     public void verifyHelpNoArgPrintsListOfCommandsNoCommands() {
         HelpCommand cmd = new HelpCommand();
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         Arguments args = mock(Arguments.class);
         cmd.run(ctxFactory.createContext(args));
         String expected = "list of commands:\n\n";
@@ -120,11 +138,12 @@
         when(info2.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI, Environment.SHELL));
         infoList.add(info2);
 
-        when(infos.getCommandInfos()).thenReturn(infoList);
+        when(commandInfoSource.getCommandInfos()).thenReturn(infoList);
 
         HelpCommand cmd = new HelpCommand();
         cmd.setEnvironment(Environment.CLI);
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
 
         Arguments args = mock(Arguments.class);
         cmd.run(ctxFactory.createContext(args));
@@ -156,10 +175,11 @@
         when(subcommand.getOptions()).thenReturn(subOptions);
         when(testCommandInfo.getSubcommands()).thenReturn(Collections.singletonList(subcommand));
 
-        when(infos.getCommandInfo("test1")).thenReturn(testCommandInfo);
+        when(commandInfoSource.getCommandInfo("test1")).thenReturn(testCommandInfo);
 
         HelpCommand cmd = new HelpCommand();
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         Arguments args = mock(Arguments.class);
         when(args.getNonOptionArguments()).thenReturn(Arrays.asList("test1"));
         cmd.run(ctxFactory.createContext(args));
@@ -204,11 +224,12 @@
         when(info4.getSummary()).thenReturn("test command 4");
         when(info4.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI, Environment.SHELL));
 
-        when(infos.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info4, info3, info1));
+        when(commandInfoSource.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info4, info3, info1));
 
         HelpCommand cmd = new HelpCommand();
         cmd.setEnvironment(Environment.CLI);
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         Arguments args = mock(Arguments.class);
         when(args.getNonOptionArguments()).thenReturn(new ArrayList<String>());
         cmd.run(ctxFactory.createContext(args));
@@ -253,11 +274,12 @@
         when(info4.getSummary()).thenReturn("test command 4");
         when(info4.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI));
 
-        when(infos.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info4, info3, info1));
+        when(commandInfoSource.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info4, info3, info1));
 
         HelpCommand cmd = new HelpCommand();
         cmd.setEnvironment(Environment.CLI);
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         Arguments args = mock(Arguments.class);
         when(args.getNonOptionArguments()).thenReturn(new ArrayList<String>());
         cmd.run(ctxFactory.createContext(args));
@@ -291,11 +313,12 @@
         when(info2.getSummary()).thenReturn("test command 2");
         when(info2.getEnvironments()).thenReturn(EnumSet.of(Environment.SHELL));
 
-        when(infos.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info1));
+        when(commandInfoSource.getCommandInfos()).thenReturn(Arrays.asList(info2, helpInfo, info1));
 
         HelpCommand cmd = new HelpCommand();
         cmd.setEnvironment(Environment.SHELL);
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         Arguments args = mock(Arguments.class);
         when(args.getNonOptionArguments()).thenReturn(new ArrayList<String>());
         cmd.run(ctxFactory.createContext(args));
@@ -310,10 +333,11 @@
 
     @Test
     public void verifyHelpUnknownCmdPrintsSummaries() {
-        when(infos.getCommandInfo("test1")).thenThrow(new CommandInfoNotFoundException("test1"));
+        when(commandInfoSource.getCommandInfo("test1")).thenThrow(new CommandInfoNotFoundException("test1"));
 
         HelpCommand cmd = new HelpCommand();
-        cmd.setCommandInfoSource(infos);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
         SimpleArguments args = new SimpleArguments();
         args.addNonOptionArgument("test1");
         cmd.run(ctxFactory.createContext(args));
@@ -325,5 +349,98 @@
         assertEquals(expected, actual);
     }
 
+    @Test
+    public void verifyHelpKnownCmdPrintsCommandGroupSeeAlso() {
+        CommandInfo info1 = mock(CommandInfo.class);
+        when(info1.getName()).thenReturn("test1");
+        when(info1.getUsage()).thenReturn("usage of test1 command");
+        when(info1.getDescription()).thenReturn("description of test1 command");
+        when(info1.getCommandGroups()).thenReturn(Collections.singletonList("group"));
+        when(info1.getOptions()).thenReturn(new Options());
+        when(info1.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI));
+
+        CommandInfo info2 = mock(CommandInfo.class);
+        when(info2.getName()).thenReturn("test2");
+        when(info2.getUsage()).thenReturn("usage of test2 command");
+        when(info2.getDescription()).thenReturn("description of test2 command");
+        when(info2.getCommandGroups()).thenReturn(Collections.singletonList("group"));
+        when(info2.getOptions()).thenReturn(new Options());
+        when(info2.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI));
+
+        CommandInfo info3 = mock(CommandInfo.class);
+        when(info3.getName()).thenReturn("test3");
+        when(info3.getUsage()).thenReturn("usage of test3 command");
+        when(info3.getDescription()).thenReturn("description of test3 command");
+        when(info3.getCommandGroups()).thenReturn(Collections.<String>emptyList());
+        when(info3.getOptions()).thenReturn(new Options());
+        when(info3.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI));
+
+        when(commandInfoSource.getCommandInfo("test1")).thenReturn(info1);
+        when(commandInfoSource.getCommandInfo("test2")).thenReturn(info2);
+        when(commandInfoSource.getCommandInfo("test3")).thenReturn(info3);
+        when(commandInfoSource.getCommandInfos()).thenReturn(Arrays.asList(info1, info2, info3));
+
+        HelpCommand cmd = new HelpCommand();
+        cmd.setEnvironment(Environment.CLI);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
+        Arguments args = mock(Arguments.class);
+        when(args.getNonOptionArguments()).thenReturn(Collections.singletonList("test1"));
+        cmd.run(ctxFactory.createContext(args));
+
+        String actual = ctxFactory.getOutput();
+        assertEquals("usage: thermostat usage of test1 command\n" +
+                "                  description of test1 command\n" +
+                "\n" +
+                "thermostat test1\n" +
+                "     --help    show usage of command\n" +
+                "\n" +
+                "See also:\n" +
+                "  test2\n", actual);
+    }
+
+    @Test
+    public void testCommandGroupMetadataDescriptions() {
+        when(commandGroupMetadataSource.getCommandGroupMetadata()).thenReturn(Collections.singletonMap(
+                "group1", new PluginConfiguration.CommandGroupMetadata("group1", "Group Name", 10)
+        ));
+
+        CommandInfo commandInfo = mock(CommandInfo.class);
+        when(commandInfo.getName()).thenReturn("test1");
+        when(commandInfo.getUsage()).thenReturn("usage of test1 command");
+        when(commandInfo.getDescription()).thenReturn("description of test1 command");
+        when(commandInfo.getSummary()).thenReturn("summary of test1 command");
+        when(commandInfo.getCommandGroups()).thenReturn(Arrays.asList("group1", "group2"));
+        when(commandInfo.getOptions()).thenReturn(new Options());
+        when(commandInfo.getEnvironments()).thenReturn(EnumSet.of(Environment.CLI));
+
+        when(commandInfoSource.getCommandInfo("test1")).thenReturn(commandInfo);
+        when(commandInfoSource.getCommandInfos()).thenReturn(Collections.singleton(commandInfo));
+
+        HelpCommand cmd = new HelpCommand();
+        cmd.setEnvironment(Environment.CLI);
+        cmd.setCommandInfoSource(commandInfoSource);
+        cmd.setCommandGroupMetadataSource(commandGroupMetadataSource);
+        Arguments args = mock(Arguments.class);
+        cmd.run(ctxFactory.createContext(args));
+
+        String output = ctxFactory.getOutput();
+        assertEquals("list of global options:\n" +
+                "\n" +
+                " --version                display the version of the current thermostat installation\n" +
+                " --print-osgi-info        print debug information related to the OSGi framework's boot/shutdown process\n" +
+                " --ignore-bundle-versions ignore exact bundle versions and use whatever version is available\n" +
+                " --boot-delegation        boot delegation string passed on to the OSGi framework\n" +
+                "\n" +
+                "list of commands:\n" +
+                "\n" +
+                "Group Name:    \n" +
+                " test1         summary of test1 command\n" +
+                "               \n" +
+                "group2:        \n" +
+                " test1         summary of test1 command\n" +
+                "               \n", output);
+    }
+
 }
 
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/LauncherImplTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/LauncherImplTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -157,6 +157,7 @@
     private Version version;
     private DbServiceFactory dbServiceFactory;
     private CommandInfoSource infos;
+    private CommandGroupMetadataSource commandGroupMetadataSource;
     private ActionNotifier<ApplicationState> notifier;
 
     private LauncherImpl launcher;
@@ -271,6 +272,10 @@
 
         helpCommand.setCommandInfoSource(infos);
 
+        commandGroupMetadataSource = mock(CommandGroupMetadataSource.class);
+        when(commandGroupMetadataSource.getCommandGroupMetadata()).thenReturn(Collections.<String, PluginConfiguration.CommandGroupMetadata>emptyMap());
+        helpCommand.setCommandGroupMetadataSource(commandGroupMetadataSource);
+
         registry = mock(BundleManager.class);
 
         timerFactory = new TestTimerFactory();
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParserTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParserTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -36,6 +36,7 @@
 
 package com.redhat.thermostat.launcher.internal;
 
+import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -51,8 +52,10 @@
 import java.io.UnsupportedEncodingException;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.OptionGroup;
 import org.apache.commons.cli.Options;
@@ -136,10 +139,13 @@
                 "      <name>test</name>\n" +
                 "      <summary>summary</summary>\n" +
                 "      <description>description</description>\n" +
-                "      <environments>" +
-                "        <environment>shell</environment>" +
-                "        <environment>cli</environment>" +
-                "      </environments>" +
+                "      <command-groups>\n" +
+                "        <command-group>group</command-group>\n" +
+                "      </command-groups>\n" +
+                "      <environments>\n" +
+                "        <environment>shell</environment>\n" +
+                "        <environment>cli</environment>\n" +
+                "      </environments>\n" +
                 "      <bundles>\n" +
                 "        <bundle><symbolic-name>foo</symbolic-name><version>1.0</version></bundle>\n" +
                 "        <bundle><symbolic-name>bar</symbolic-name><version>1.0</version></bundle>\n" +
@@ -165,6 +171,7 @@
         assertEquals("test", newCommand.getCommandName());
         assertEquals("summary", newCommand.getSummary());
         assertEquals("description", newCommand.getDescription());
+        assertEquals(Collections.singletonList("group"), newCommand.getCommandGroups());
         Options opts = newCommand.getOptions();
         assertTrue(opts.getOptions().isEmpty());
         assertTrue(opts.getRequiredOptions().isEmpty());
@@ -244,6 +251,58 @@
     }
 
     @Test
+    public void testConfigurationThatAddsNewCommandWithCommandGroupAndMetadata() throws UnsupportedEncodingException {
+        String config = "<?xml version=\"1.0\"?>\n" +
+                "<plugin xmlns=\"http://icedtea.classpath.org/thermostat/plugins/v1.0\"\n" +
+                " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n" +
+                " xsi:schemaLocation=\"http://icedtea.classpath.org/thermostat/plugins/v1.0\">\n" +
+                "  <commands>\n" +
+                "    <command>\n" +
+                "      <name>test</name>\n" +
+                "      <summary>summary</summary>\n" +
+                "      <description>description</description>\n" +
+                "      <command-groups>\n" +
+                "        <command-group>group</command-group>\n" +
+                "      </command-groups>\n" +
+                "      <environments>\n" +
+                "        <environment>shell</environment>\n" +
+                "        <environment>cli</environment>\n" +
+                "      </environments>\n" +
+                "      <bundles>\n" +
+                "        <bundle><symbolic-name>foo</symbolic-name><version>1.0</version></bundle>\n" +
+                "        <bundle><symbolic-name>bar</symbolic-name><version>1.0</version></bundle>\n" +
+                "        <bundle><symbolic-name>baz</symbolic-name><version>1.0</version></bundle>\n" +
+                "      </bundles>\n" +
+                "      <dependencies>\n" +
+                "        <dependency>thermostat-foo</dependency>\n" +
+                "      </dependencies>\n" +
+                "    </command>\n" +
+                "  </commands>\n" +
+                "  <command-group-metadatas>\n" +
+                "    <command-group-metadata>\n" +
+                "      <name>group</name>\n" +
+                "      <description>Group Name</description>\n" +
+                "      <sort-order>5</sort-order>\n" +
+                "    </command-group-metadata>\n" +
+                "    <command-group-metadata>\n" +
+                "      <name>foo</name>\n" +
+                "      <description>FooGroup</description>\n" +
+                "      <sort-order>7</sort-order>\n" +
+                "    </command-group-metadata>\n" +
+                "  </command-group-metadatas>\n" +
+                "</plugin>";
+
+        PluginConfiguration result = new PluginConfigurationParser()
+                .parse("test", new ByteArrayInputStream(config.getBytes("UTF-8")));
+
+        List<CommandGroupMetadata> metadata = result.getCommandGroupMetadata();
+        assertThat(metadata, is(equalTo(Arrays.asList(
+                new CommandGroupMetadata("group", "Group Name", 5),
+                new CommandGroupMetadata("foo", "FooGroup", 7)
+        ))));
+    }
+
+    @Test
     public void testSpacesAtStartAndEndAreTrimmed() throws UnsupportedEncodingException {
         String config = "<?xml version=\"1.0\"?>\n" +
                 "<plugin xmlns=\"http://icedtea.classpath.org/thermostat/plugins/v1.0\"\n" +
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginInfoSourceTest.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginInfoSourceTest.java	Tue Feb 14 11:26:33 2017 -0500
@@ -36,7 +36,10 @@
 
 package com.redhat.thermostat.launcher.internal;
 
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Matchers.isA;
 import static org.mockito.Mockito.mock;
@@ -52,12 +55,14 @@
 import java.nio.file.SimpleFileVisitor;
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandGroupMetadata;
 import org.apache.commons.cli.Options;
 import org.junit.After;
 import org.junit.Before;
@@ -190,9 +195,10 @@
     }
 
     @Test
-    public void verifyCommandInfoObjectsForNewComamndsAreCreated() throws IOException {
+    public void verifyCommandInfoObjectsForNewCommandsAreCreated() throws IOException {
         final String NAME = "command-name";
         final String DESCRIPTION = "description of the command";
+        final List<String> COMMAND_GROUPS = Collections.singletonList("group");
         final String USAGE = "usage";
         final Options OPTIONS = new Options();
         final Set<Environment> ENVIRONMENTS = EnumSet.of(Environment.SHELL);
@@ -204,6 +210,7 @@
         NewCommand cmd = mock(NewCommand.class);
         when(cmd.getCommandName()).thenReturn(NAME);
         when(cmd.getDescription()).thenReturn(DESCRIPTION);
+        when(cmd.getCommandGroups()).thenReturn(COMMAND_GROUPS);
         when(usageBuilder.getUsage(NAME, false, OPTIONS)).thenReturn(USAGE);
         when(cmd.getOptions()).thenReturn(OPTIONS);
         when(cmd.getEnvironments()).thenReturn(ENVIRONMENTS);
@@ -219,6 +226,7 @@
 
         assertEquals(NAME, result.getName());
         assertEquals(DESCRIPTION, result.getDescription());
+        assertEquals(COMMAND_GROUPS, result.getCommandGroups());
         assertEquals(USAGE, result.getUsage());
         assertEquals(OPTIONS, result.getOptions());
 
@@ -228,6 +236,30 @@
     }
 
     @Test
+    public void verifyCommandGroupMetadataAreCreated() throws IOException {
+        CommandGroupMetadata metadata1 = new CommandGroupMetadata("foo", "FooGroup", 7);
+        CommandGroupMetadata metadata2 = new CommandGroupMetadata("bar", "BarGroup", 9);
+        CommandGroupMetadata metadata3 = new CommandGroupMetadata("foo", "FooGroup2", 11); // this one should get ignored
+
+        Path pluginDir = sysPluginRootDir.resolve("plugin1");
+        Files.createDirectories(pluginDir);
+
+        when(parserResult.getCommandGroupMetadata()).thenReturn(Arrays.asList(metadata1, metadata2, metadata3));
+
+        PluginInfoSource source = new PluginInfoSource(jarRootDir.toFile(), sysPluginRootDir.toFile(),
+                userPluginRootDir.toFile(), sysConfRootDir.toFile(), userConfRootDir.toFile(),
+                parser, usageBuilder);
+
+        Map<String, CommandGroupMetadata> actual = source.getCommandGroupMetadata();
+
+        Map<String, CommandGroupMetadata> expected = new HashMap<>();
+        expected.put("foo", metadata1);
+        expected.put("bar", metadata2);
+
+        assertThat(actual, is(equalTo(expected)));
+    }
+
+    @Test
     public void testConfigurationSysConfig() throws IOException {
         String pluginID = "com.redhat.thermostat.sys";
         String configName = "config.conf";
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/TestCommandInfo.java	Tue Feb 14 09:07:27 2017 -0500
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/TestCommandInfo.java	Tue Feb 14 11:26:33 2017 -0500
@@ -50,6 +50,7 @@
     private String name;
     private String summary;
     private String description;
+    private List<String> commandGroups;
     private String usage;
 
     private Options options = new Options();
@@ -83,6 +84,15 @@
     }
 
     @Override
+    public List<String> getCommandGroups() {
+        return commandGroups;
+    }
+
+    public void addCommandGroup(String commandGroup) {
+        commandGroups.add(commandGroup);
+    }
+
+    @Override
     public String getUsage() {
         return usage;
     }