changeset 936:1936ad067842

Make it easier to install plugins Reviewed-by: jerboaa, neugens Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2013-January/005355.html PR 1254
author Omair Majid <omajid@redhat.com>
date Mon, 28 Jan 2013 16:03:37 -0500
parents 186115da601f
children 37c9527f664f
files common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfo.java common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfoSource.java common/core/src/main/java/com/redhat/thermostat/common/config/Configuration.java distribution/pom.xml 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/BuiltInCommandInfoSource.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfoImpl.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfoSourceImpl.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSource.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginCommandInfoSource.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfiguration.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParseException.java launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParser.java 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/BuiltInCommandInfoSourceTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/CommandInfoImplTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/CommandInfoSourceTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginCommandInfoSourceTest.java launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParserTest.java
diffstat 23 files changed, 1816 insertions(+), 734 deletions(-) [+]
line wrap: on
line diff
--- a/common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfo.java	Wed Jan 16 18:58:59 2013 +0100
+++ b/common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfo.java	Mon Jan 28 16:03:37 2013 -0500
@@ -50,6 +50,7 @@
 
     public Options getOptions();
 
+    /** Returns a list of jar that this command depends on */
     public List<String> getDependencyResourceNames();
 
 }
--- a/common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfoSource.java	Wed Jan 16 18:58:59 2013 +0100
+++ b/common/core/src/main/java/com/redhat/thermostat/common/cli/CommandInfoSource.java	Mon Jan 28 16:03:37 2013 -0500
@@ -38,7 +38,6 @@
 
 import java.util.Collection;
 
-
 public interface CommandInfoSource {
 
     public CommandInfo getCommandInfo(String name) throws CommandInfoNotFoundException;
--- a/common/core/src/main/java/com/redhat/thermostat/common/config/Configuration.java	Wed Jan 16 18:58:59 2013 +0100
+++ b/common/core/src/main/java/com/redhat/thermostat/common/config/Configuration.java	Mon Jan 28 16:03:37 2013 -0500
@@ -68,6 +68,10 @@
         return home + File.separator + THERMOSTAT_USER_DIR;
     }
 
+    public String getPluginRoot() throws InvalidConfigurationException {
+        return home + File.separator + "plugins";
+    }
+
     public File getBackendsBaseDirectory() throws InvalidConfigurationException {
         String loc = getThermostatHome() + File.separatorChar + "backends";
         File file = new File(loc);
--- a/distribution/pom.xml	Wed Jan 16 18:58:59 2013 +0100
+++ b/distribution/pom.xml	Mon Jan 28 16:03:37 2013 -0500
@@ -196,6 +196,7 @@
                 <mkdir dir="${project.build.directory}/client" />
                 <mkdir dir="${project.build.directory}/osgi-cache" />
                 <mkdir dir="${project.build.directory}/libs/native" />
+                <mkdir dir="${project.build.directory}/plugins/" />
                 
                 <!--  also copy the native libraries -->
                 <copy file="${main.basedir}/keyring/target/libGnomeKeyringWrapper.so"
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/Activator.java	Wed Jan 16 18:58:59 2013 +0100
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/Activator.java	Mon Jan 28 16:03:37 2013 -0500
@@ -73,10 +73,16 @@
             // keyring is now ready
             Keyring keyring = (Keyring)context.getService(reference);
             Configuration config = bundleService.getConfiguration();
+
             String commandsDir = config.getThermostatHome() + File.separator + "etc" +
                     File.separator + "commands";
-            CommandInfoSourceImpl commands =
-                    new CommandInfoSourceImpl(commandsDir, config.getLibRoot());
+            CommandInfoSource builtInCommandSource =
+                    new BuiltInCommandInfoSource(commandsDir, config.getLibRoot());
+            CommandInfoSource pluginCommandSource = new PluginCommandInfoSource(
+                            config.getLibRoot(), config.getPluginRoot());
+            CommandInfoSource commands = new CompoundCommandInfoSource(builtInCommandSource, pluginCommandSource);
+
+
             cmdInfoReg = context.registerService(CommandInfoSource.class, commands, null);
             bundleService.setCommandInfoSource(commands);
             // Register Launcher service since FrameworkProvider is waiting for it blockingly.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BasicCommandInfo.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2013 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 java.util.List;
+
+import org.apache.commons.cli.Options;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+
+public class BasicCommandInfo implements CommandInfo {
+
+    private final String name;
+    private final String description;
+    private final String usage;
+    private final Options options;
+    private final List<String> resources;
+
+    public BasicCommandInfo(String name, String description, String usage, Options options, List<String> resources) {
+        this.name = name;
+        this.description = description;
+        this.usage = usage;
+        this.options = options;
+        this.resources = resources;
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+
+    @Override
+    public String getUsage() {
+        return usage;
+    }
+
+    @Override
+    public Options getOptions() {
+        return options;
+    }
+
+    @Override
+    public List<String> getDependencyResourceNames() {
+        return resources;
+    }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfo.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2012, 2013 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 java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Properties;
+import java.util.logging.Logger;
+
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.OptionGroup;
+import org.apache.commons.cli.Options;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.common.utils.LoggingUtils;
+
+public class BuiltInCommandInfo implements CommandInfo {
+
+    private static final Logger logger = LoggingUtils.getLogger(BuiltInCommandInfo.class);
+    private static final String PROPERTY_BUNDLES = "bundles";
+    private static final String PROPERTY_DESC = "description";
+    private static final String PROPERTY_USAGE = "usage";
+    private static final String PROPERTY_OPTIONS = "options";
+
+    private static final String PROP_SHORTOPT = ".short";
+    private static final String PROP_LONGOPT = ".long";
+    private static final String PROP_OPTHASARG = ".hasarg";
+    private static final String PROP_OPTREQUIRED = ".required";
+    private static final String PROP_OPTDESC = ".description";
+    
+    private String name, description, usage;
+    private Options options;
+    private List<String> dependencies;
+
+    BuiltInCommandInfo(String name, Properties properties, String libRoot) {
+        options = new Options();
+        this.name = name;
+        for (Entry<Object,Object> entry: properties.entrySet()) {
+            String key = (String) entry.getKey();
+            if (key.equals(PROPERTY_BUNDLES)) {
+                learnDependencies((String) entry.getValue(), libRoot);
+            } else if (key.equals(PROPERTY_DESC)) {
+                description = properties.getProperty(key);
+            } else if (key.equals(PROPERTY_USAGE)) {
+                usage = properties.getProperty(key);
+            } else if (key.equals(PROPERTY_OPTIONS)) {
+                learnOptions((String) entry.getValue(), properties);
+            }
+        }
+    }
+
+    private void learnDependencies(String bundlesValue, String libRoot) {
+        List<String> resourceNames = Arrays.asList(bundlesValue.split(","));
+        dependencies = new ArrayList<>(resourceNames.size());
+        for (String value : resourceNames) {
+            String resource = value.trim();
+            if (resource.length() == 0) {
+                continue;
+            }
+            File file = new File(libRoot, value.trim());
+            String path = file.toURI().toString();
+            if (!file.exists()) {
+                logger.severe("Bundle " + path + " required by " + getName() +
+                        " command does not exist in the filesystem.  This will cause" +
+                        " osgi wiring issue when attempting to run this command.");
+                // Allow to proceed because this command may never be called.
+            } else {
+                dependencies.add(path);
+            }
+        }
+    }
+
+    private void learnOptions(String optionsValue, Properties props) {
+        List<String> optionNames = Arrays.asList(optionsValue.split(","));
+        for (String optionString : optionNames) {
+            List<String> optionsList = Arrays.asList(optionString.trim().split("\\|"));
+            if (optionsList.size() == 1) {
+                learnOption(optionsList.get(0).trim(), props);
+            } else {
+                learnOptionGroup(optionsList, props);
+            }
+        }
+    }
+
+    private void learnOption(String name, Properties props) {
+        if (name.equals(CommonOptions.OPTIONS_COMMON_DB_OPTIONS)) {
+            addDbOptions();
+        } else if (name.equals(CommonOptions.OPTIONS_COMMON_LOG_OPTION)) {
+            options.addOption(CommonOptions.getLogOption());
+        } else {
+            Option option = optionFromProperties(name, props);
+            options.addOption(option);
+        }
+    }
+
+    private void addDbOptions() {
+        for (Option opt: CommonOptions.getDbOptions()) {
+            options.addOption(opt);
+        }
+    }
+
+    /* TODO currently this assumes that any set of mutually exclusive options will be
+     * required.  Needs some sort of enhancement in properties file to allow them to
+     * be optional.  For the time being this is good enough, since in practice all such
+     * sets *are* required.
+     */
+    private void learnOptionGroup(List<String> optionsList, Properties props) {
+        OptionGroup og = new OptionGroup();
+        og.setRequired(true);
+        for (String optionName : optionsList) {
+            Option option = optionFromProperties(optionName.trim(), props);
+            og.addOption(option);
+        }
+        options.addOptionGroup(og);
+    }
+
+    private Option optionFromProperties(String name, Properties props) {
+        String opt = null;
+        String longOpt = null;
+        boolean hasArg = false;
+        boolean required = false;
+        String description = null;
+
+        String optKey = name + PROP_SHORTOPT;
+        String longKey = name + PROP_LONGOPT;
+        String argKey = name + PROP_OPTHASARG;
+        String requiredKey = name + PROP_OPTREQUIRED;
+        String descKey = name + PROP_OPTDESC;
+        
+        // required property of common options are allowed to be overridden by
+        // command.properties files
+        if (CommonOptions.ALL_COMMON_OPTIONS.contains(name) && options.hasOption(name)) {
+            if (props.containsKey(requiredKey)) {
+                Option optionToChange = options.getOption(name);
+                required = Boolean.parseBoolean((String) props.getProperty(requiredKey));
+                optionToChange.setRequired(required);
+                return optionToChange;
+            }
+        }
+
+        if (props.containsKey(optKey)) {
+            opt = (String) props.getProperty(optKey);
+        }
+        if (props.containsKey(longKey)) {
+            longOpt = (String) props.getProperty(longKey);
+        }
+        if (opt == null && longOpt == null) {
+            logger.severe("Neither short nor long version of option " + name + " was set.  Check properties file.");
+        }
+        if (props.containsKey(argKey)) {
+            hasArg = Boolean.parseBoolean((String) props.getProperty(argKey));
+        } else {
+            logger.warning("The 'hasarg' property for " + name + " was not set.  Assuming FALSE");
+        }
+        if (props.containsKey(requiredKey)) {
+            required = Boolean.parseBoolean((String) props.getProperty(requiredKey));
+        } else {
+            logger.warning("The 'required' property for " + name + " was not set.  Assuming FALSE");
+        }
+        if (props.containsKey(descKey)) {
+            description = (String) props.getProperty(descKey);
+        }
+
+        Option option = new Option(opt, longOpt, hasArg, description);
+        option.setArgName(name);
+        option.setRequired(required);
+        return option;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public String getUsage() {
+        return usage;
+    }
+
+    public Options getOptions() {
+        return options;
+    }
+
+    public List<String> getDependencyResourceNames() {
+        return dependencies;
+    }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoSource.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2012, 2013 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 java.io.File;
+import java.io.FileReader;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.logging.Logger;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.common.cli.CommandInfoNotFoundException;
+import com.redhat.thermostat.common.cli.CommandInfoSource;
+import com.redhat.thermostat.common.utils.LoggingUtils;
+
+public class BuiltInCommandInfoSource implements CommandInfoSource {
+
+    private static final Logger logger = LoggingUtils.getLogger(BuiltInCommandInfoSource.class);
+    private Map<String, CommandInfo> commands;
+
+    BuiltInCommandInfoSource(String commandsDir, String libRoot) {
+        commands = new HashMap<>();
+        final File dir = new File(commandsDir);
+        if (dir.isDirectory()) {
+            FilenameFilter filter = new FilenameFilter() {
+
+                @Override
+                public boolean accept(File theDir, String filename) {
+                    if (!theDir.equals(dir)) {
+                        return false;
+                    }
+                    return filename.endsWith(".properties");
+                }
+
+            };
+            File[] commandPropertyFiles = dir.listFiles(filter);
+            for (File file : commandPropertyFiles) {
+                Properties commandProps = new Properties();
+                try {
+                    commandProps.load(new FileReader(file));
+                } catch (IOException ignore) {
+                    // This means the command won't work, if it has dependencies it
+                    // needs to load.  Also, it will not appear in help listing.
+                    logger.warning("Issue loading properties file: " + file.getPath());
+                }
+                String commandName = deduceCommandName(file.getName());
+                commands.put(commandName, new BuiltInCommandInfo(commandName, commandProps, libRoot));
+            }
+        } else {
+            logger.warning("Command configuration directory not found or not a directory: " + dir.getPath());
+        }
+    }
+
+    private String deduceCommandName(String fileName) {
+        int dotIndex = fileName.lastIndexOf(".");
+        return fileName.substring(0, dotIndex);
+    }
+
+    @Override
+    public CommandInfo getCommandInfo(String name) throws CommandInfoNotFoundException {
+        CommandInfo cmdInfo = commands.get(name);
+        if (cmdInfo == null) {
+            throw new CommandInfoNotFoundException(name);
+        }
+        return cmdInfo;
+    }
+
+    @Override
+    public Collection<CommandInfo> getCommandInfos() {
+        return commands.values();
+    }
+
+}
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfoImpl.java	Wed Jan 16 18:58:59 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-/*
- * Copyright 2012, 2013 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 java.io.File;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map.Entry;
-import java.util.Properties;
-import java.util.logging.Logger;
-
-import org.apache.commons.cli.Option;
-import org.apache.commons.cli.OptionGroup;
-import org.apache.commons.cli.Options;
-
-import com.redhat.thermostat.common.cli.CommandInfo;
-import com.redhat.thermostat.common.utils.LoggingUtils;
-
-
-public class CommandInfoImpl implements CommandInfo {
-
-    private static final Logger logger = LoggingUtils.getLogger(CommandInfoSourceImpl.class);
-    private static final String PROPERTY_BUNDLES = "bundles";
-    private static final String PROPERTY_DESC = "description";
-    private static final String PROPERTY_USAGE = "usage";
-    private static final String PROPERTY_OPTIONS = "options";
-
-    private static final String PROP_SHORTOPT = ".short";
-    private static final String PROP_LONGOPT = ".long";
-    private static final String PROP_OPTHASARG = ".hasarg";
-    private static final String PROP_OPTREQUIRED = ".required";
-    private static final String PROP_OPTDESC = ".description";
-    
-    private String name, description, usage;
-    private Options options;
-    private List<String> dependencies;
-
-    CommandInfoImpl(String name, Properties properties, String libRoot) {
-        options = new Options();
-        this.name = name;
-        for (Entry<Object,Object> entry: properties.entrySet()) {
-            String key = (String) entry.getKey();
-            if (key.equals(PROPERTY_BUNDLES)) {
-                learnDependencies((String) entry.getValue(), libRoot);
-            } else if (key.equals(PROPERTY_DESC)) {
-                description = properties.getProperty(key);
-            } else if (key.equals(PROPERTY_USAGE)) {
-                usage = properties.getProperty(key);
-            } else if (key.equals(PROPERTY_OPTIONS)) {
-                learnOptions((String) entry.getValue(), properties);
-            }
-        }
-    }
-
-    private void learnDependencies(String bundlesValue, String libRoot) {
-        List<String> resourceNames = Arrays.asList(bundlesValue.split(","));
-        dependencies = new ArrayList<>(resourceNames.size());
-        for (String value : resourceNames) {
-            String resource = value.trim();
-            if (resource.length() == 0) {
-                continue;
-            }
-            File file = new File(libRoot, value.trim());
-            String path = file.toURI().toString();
-            if (!file.exists()) {
-                logger.severe("Bundle " + path + " required by " + getName() +
-                        " command does not exist in the filesystem.  This will cause" +
-                        " osgi wiring issue when attempting to run this command.");
-                // Allow to proceed because this command may never be called.
-            } else {
-                dependencies.add(path);
-            }
-        }
-    }
-
-    private void learnOptions(String optionsValue, Properties props) {
-        List<String> optionNames = Arrays.asList(optionsValue.split(","));
-        for (String optionString : optionNames) {
-            List<String> optionsList = Arrays.asList(optionString.trim().split("\\|"));
-            if (optionsList.size() == 1) {
-                learnOption(optionsList.get(0).trim(), props);
-            } else {
-                learnOptionGroup(optionsList, props);
-            }
-        }
-    }
-
-    private void learnOption(String name, Properties props) {
-        if (name.equals(CommonOptions.OPTIONS_COMMON_DB_OPTIONS)) {
-            addDbOptions();
-        } else if (name.equals(CommonOptions.OPTIONS_COMMON_LOG_OPTION)) {
-            options.addOption(CommonOptions.getLogOption());
-        } else {
-            Option option = optionFromProperties(name, props);
-            options.addOption(option);
-        }
-    }
-
-    private void addDbOptions() {
-        for (Option opt: CommonOptions.getDbOptions()) {
-            options.addOption(opt);
-        }
-    }
-
-    /* TODO currently this assumes that any set of mutually exclusive options will be
-     * required.  Needs some sort of enhancement in properties file to allow them to
-     * be optional.  For the time being this is good enough, since in practice all such
-     * sets *are* required.
-     */
-    private void learnOptionGroup(List<String> optionsList, Properties props) {
-        OptionGroup og = new OptionGroup();
-        og.setRequired(true);
-        for (String optionName : optionsList) {
-            Option option = optionFromProperties(optionName.trim(), props);
-            og.addOption(option);
-        }
-        options.addOptionGroup(og);
-    }
-
-    private Option optionFromProperties(String name, Properties props) {
-        String opt = null;
-        String longOpt = null;
-        boolean hasArg = false;
-        boolean required = false;
-        String description = null;
-
-        String optKey = name + PROP_SHORTOPT;
-        String longKey = name + PROP_LONGOPT;
-        String argKey = name + PROP_OPTHASARG;
-        String requiredKey = name + PROP_OPTREQUIRED;
-        String descKey = name + PROP_OPTDESC;
-        
-        // required property of common options are allowed to be overridden by
-        // command.properties files
-        if (CommonOptions.ALL_COMMON_OPTIONS.contains(name) && options.hasOption(name)) {
-            if (props.containsKey(requiredKey)) {
-                Option optionToChange = options.getOption(name);
-                required = Boolean.parseBoolean((String) props.getProperty(requiredKey));
-                optionToChange.setRequired(required);
-                return optionToChange;
-            }
-        }
-
-        if (props.containsKey(optKey)) {
-            opt = (String) props.getProperty(optKey);
-        }
-        if (props.containsKey(longKey)) {
-            longOpt = (String) props.getProperty(longKey);
-        }
-        if (opt == null && longOpt == null) {
-            logger.severe("Neither short nor long version of option " + name + " was set.  Check properties file.");
-        }
-        if (props.containsKey(argKey)) {
-            hasArg = Boolean.parseBoolean((String) props.getProperty(argKey));
-        } else {
-            logger.warning("The 'hasarg' property for " + name + " was not set.  Assuming FALSE");
-        }
-        if (props.containsKey(requiredKey)) {
-            required = Boolean.parseBoolean((String) props.getProperty(requiredKey));
-        } else {
-            logger.warning("The 'required' property for " + name + " was not set.  Assuming FALSE");
-        }
-        if (props.containsKey(descKey)) {
-            description = (String) props.getProperty(descKey);
-        }
-
-        Option option = new Option(opt, longOpt, hasArg, description);
-        option.setArgName(name);
-        option.setRequired(required);
-        return option;
-    }
-
-    public String getName() {
-        return name;
-    }
-
-    public String getDescription() {
-        return description;
-    }
-
-    public String getUsage() {
-        return usage;
-    }
-
-    public Options getOptions() {
-        return options;
-    }
-
-    public List<String> getDependencyResourceNames() {
-        return dependencies;
-    }
-}
-
--- a/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CommandInfoSourceImpl.java	Wed Jan 16 18:58:59 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,112 +0,0 @@
-/*
- * Copyright 2012, 2013 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 java.io.File;
-import java.io.FileReader;
-import java.io.FilenameFilter;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Properties;
-import java.util.logging.Logger;
-
-import com.redhat.thermostat.common.cli.CommandInfo;
-import com.redhat.thermostat.common.cli.CommandInfoNotFoundException;
-import com.redhat.thermostat.common.cli.CommandInfoSource;
-import com.redhat.thermostat.common.utils.LoggingUtils;
-
-public class CommandInfoSourceImpl implements CommandInfoSource {
-
-    private static final Logger logger = LoggingUtils.getLogger(CommandInfoSourceImpl.class);
-    private Map<String, CommandInfo> commands;
-
-    CommandInfoSourceImpl(String commandsDir, String libRoot) {
-        commands = new HashMap<>();
-        final File dir = new File(commandsDir);
-        if (dir.isDirectory()) {
-            FilenameFilter filter = new FilenameFilter() {
-
-                @Override
-                public boolean accept(File theDir, String filename) {
-                    if (!theDir.equals(dir)) {
-                        return false;
-                    }
-                    return filename.endsWith(".properties");
-                }
-
-            };
-            File[] commandPropertyFiles = dir.listFiles(filter);
-            for (File file : commandPropertyFiles) {
-                Properties commandProps = new Properties();
-                try {
-                    commandProps.load(new FileReader(file));
-                } catch (IOException ignore) {
-                    // This means the command won't work, if it has dependencies it
-                    // needs to load.  Also, it will not appear in help listing.
-                    logger.warning("Issue loading properties file: " + file.getPath());
-                }
-                String commandName = deduceCommandName(file.getName());
-                commands.put(commandName, new CommandInfoImpl(commandName, commandProps, libRoot));
-            }
-        } else {
-            logger.warning("Command configuration directory not found or not a directory: " + dir.getPath());
-        }
-    }
-
-    private String deduceCommandName(String fileName) {
-        int dotIndex = fileName.lastIndexOf(".");
-        return fileName.substring(0, dotIndex);
-    }
-
-    @Override
-    public CommandInfo getCommandInfo(String name) throws CommandInfoNotFoundException {
-        CommandInfo cmdInfo = commands.get(name);
-        if (cmdInfo == null) {
-            throw new CommandInfoNotFoundException(name);
-        }
-        return cmdInfo;
-    }
-
-    @Override
-    public Collection<CommandInfo> getCommandInfos() {
-        return commands.values();
-    }
-
-}
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/CompoundCommandInfoSource.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2013 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 java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.cli.Options;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.common.cli.CommandInfoNotFoundException;
+import com.redhat.thermostat.common.cli.CommandInfoSource;
+
+/**
+ * Presents multiple {@link CommandInfoSource}s as one
+ * <p>
+ * Unfortunately, it can't just delegate requests; it has to merge them.
+ */
+public class CompoundCommandInfoSource implements CommandInfoSource {
+
+    private final CommandInfoSource source1;
+    private final CommandInfoSource source2;
+
+    public CompoundCommandInfoSource(CommandInfoSource source1, CommandInfoSource source2) {
+        this.source1 = Objects.requireNonNull(source1);
+        this.source2 = Objects.requireNonNull(source2);
+    }
+
+    @Override
+    public CommandInfo getCommandInfo(String name) throws CommandInfoNotFoundException {
+        CommandInfo info1 = source1.getCommandInfo(name);
+        CommandInfo info2 = source2.getCommandInfo(name);
+        if (info1 == null) {
+            return info2;
+        } if (info2 == null) {
+            return info1;
+        } else {
+            return merge(info1, info2);
+        }
+    }
+
+    @Override
+    public Collection<CommandInfo> getCommandInfos() {
+        return mergeAll(source1.getCommandInfos(), source2.getCommandInfos());
+    }
+
+    private Collection<CommandInfo> mergeAll(Collection<CommandInfo> commandInfos1, Collection<CommandInfo> commandInfos2) {
+        Map<String, CommandInfo> result = new HashMap<>();
+        for (CommandInfo info : commandInfos1) {
+            result.put(info.getName(), info);
+        }
+        for (CommandInfo info : commandInfos2) {
+            String cmdName = info.getName();
+            if (!result.containsKey(cmdName)) {
+                result.put(cmdName, info);
+                continue;
+            }
+
+            result.put(cmdName, merge(result.get(cmdName), info));
+        }
+
+        return result.values();
+    }
+
+    private CommandInfo merge(CommandInfo info1, CommandInfo info2) {
+        if (!info1.getName().equals(info2.getName())) {
+            throw new IllegalArgumentException("command information have different names");
+        }
+        String name = info1.getName();
+
+        String description = selectBest(info1.getDescription(), info2.getDescription());
+        String usage = selectBest(info1.getUsage(), info2.getUsage());
+        Options options = selectBest(info1.getOptions(), info2.getOptions());
+        List<String> resources = new ArrayList<>();
+        resources.addAll(info1.getDependencyResourceNames());
+        resources.addAll(info2.getDependencyResourceNames());
+
+        return new BasicCommandInfo(name, description, usage, options, resources);
+    }
+
+    private <T> T selectBest(T first, T second) {
+        T result;
+        if (Objects.equals(first, second)) {
+            result = first;
+        } else if (first == null) {
+            result = second;
+        } else if (second == null) {
+            result = first;
+        } else {
+            throw new IllegalArgumentException("two conflicting values!");
+        }
+        return result;
+    }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginCommandInfoSource.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2013 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 java.io.File;
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.common.cli.CommandInfoNotFoundException;
+import com.redhat.thermostat.common.cli.CommandInfoSource;
+import com.redhat.thermostat.common.utils.LoggingUtils;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandExtensions;
+
+public class PluginCommandInfoSource implements CommandInfoSource {
+
+    private Logger logger = LoggingUtils.getLogger(PluginCommandInfoSource.class);
+
+    private Map<String, List<String>> allInfo = new HashMap<>();
+
+    public PluginCommandInfoSource(String internalJarRoot, String pluginRootDir) {
+        this(new File(internalJarRoot), new File(pluginRootDir), new PluginConfigurationParser());
+    }
+
+    public PluginCommandInfoSource(File internalJarRoot, File pluginRootDir,
+            PluginConfigurationParser parser) {
+        File[] pluginDirs = pluginRootDir.listFiles();
+        if (pluginDirs == null) {
+            logger.log(Level.SEVERE, "plugin root dir " + pluginRootDir + " does not exist");
+            return;
+        }
+
+        for (File pluginDir : pluginDirs) {
+            try {
+                File configurationFile = new File(pluginDir, "plugin.conf");
+                PluginConfiguration pluginConfig = parser.parse(configurationFile);
+                loadNewAndExtendedCommands(internalJarRoot, pluginDir, pluginConfig);
+            } catch (PluginConfigurationParseException | FileNotFoundException exception) {
+                logger.log(Level.WARNING, "unable to parse plugin configuration", exception);
+            }
+        }
+    }
+
+    private void loadNewAndExtendedCommands(File coreJarRoot, File pluginDir,
+            PluginConfiguration pluginConfig) {
+
+        List<CommandExtensions> allExtensions = pluginConfig.getExtendedCommands();
+
+        for (CommandExtensions extension : allExtensions) {
+            String commandName = extension.getCommandName();
+            List<String> pluginBundles = extension.getAdditionalBundles();
+            List<String> dependencyBundles = extension.getDepenedencyBundles();
+            logger.config("plugin at " + pluginDir + " contributes " +
+                    pluginBundles.size() + " bundles to comamnd '" + commandName + "'");
+
+            List<String> bundlePaths = allInfo.get(commandName);
+            if (bundlePaths == null) {
+                bundlePaths = new LinkedList<>();
+            }
+
+            for (String bundle : pluginBundles) {
+                bundlePaths.add(new File(pluginDir, bundle).toURI().toString());
+            }
+            for (String bundle : dependencyBundles) {
+                bundlePaths.add(new File(coreJarRoot, bundle).toURI().toString());
+            }
+
+            allInfo.put(commandName, bundlePaths);
+        }
+    }
+
+    @Override
+    public CommandInfo getCommandInfo(String name) throws CommandInfoNotFoundException {
+        List<String> bundles = allInfo.get(name);
+        if (bundles == null) {
+            return null;
+        }
+        return new BasicCommandInfo(name, null, null, null, bundles);
+    }
+
+    @Override
+    public Collection<CommandInfo> getCommandInfos() {
+        List<CommandInfo> result = new ArrayList<>();
+        for (Entry<String, List<String>> entry : allInfo.entrySet()) {
+            result.add(new BasicCommandInfo(entry.getKey(), null, null, null, entry.getValue()));
+        }
+        return result;
+    }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfiguration.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2013 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 java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.cli.Options;
+
+public class PluginConfiguration {
+
+    private final List<CommandExtensions> extensions;
+    private final List<NewCommand> newCommands;
+
+    public PluginConfiguration(List<NewCommand> newCommands, List<CommandExtensions> extensions) {
+        this.newCommands = newCommands;
+        this.extensions = extensions;
+    }
+
+    public List<CommandExtensions> getExtendedCommands() {
+        return extensions;
+    }
+
+    public List<NewCommand> getNewCommands() {
+        return newCommands;
+    }
+
+    public static class CommandExtensions {
+
+        private final String commandName;
+        private final List<String> additionalResources;
+        private final List<String> coreDeps;
+
+        public CommandExtensions(String name, List<String> additionalResources, List<String> coreDeps) {
+            this.commandName = name;
+            this.additionalResources = additionalResources;
+            this.coreDeps = coreDeps;
+        }
+
+        public String getCommandName() {
+            return commandName;
+        }
+
+        public List<String> getAdditionalBundles() {
+            return Collections.unmodifiableList(additionalResources);
+        }
+
+        public List<String> getDepenedencyBundles() {
+            return coreDeps;
+        }
+    }
+
+    public static class NewCommand {
+
+        private final String commandName;
+        private final String description;
+        private final String usage;
+        private final Options options;
+        private final List<String> additionalResources;
+        private final List<String> coreDeps;
+
+        public NewCommand(String name, String usage, String description,
+                Options options, List<String> additionalResources, List<String> coreDeps) {
+            this.commandName = name;
+            this.description = description;
+            this.usage = usage;
+            this.options = options;
+            this.additionalResources = additionalResources;
+            this.coreDeps = coreDeps;
+        }
+
+        public String getCommandName() {
+            return commandName;
+        }
+
+        public String getDescription() {
+            return description;
+        }
+
+        public String getUsage() {
+            return usage;
+        }
+
+        public Options getOptions() {
+            return options;
+        }
+
+        public List<String> getAdditionalBundles() {
+            return Collections.unmodifiableList(additionalResources);
+        }
+
+        public List<String> getCoreDepenedencyBundles() {
+            return Collections.unmodifiableList(coreDeps);
+        }
+    }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParseException.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2013 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;
+
+public class PluginConfigurationParseException extends RuntimeException {
+
+    public PluginConfigurationParseException(String message) {
+        super(message);
+    }
+
+    public PluginConfigurationParseException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/main/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParser.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2013 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 java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.apache.commons.cli.Options;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+import com.redhat.thermostat.common.Pair;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandExtensions;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.NewCommand;
+
+public class PluginConfigurationParser {
+
+    // no state :)
+
+    public PluginConfiguration parse(File configurationFile) throws FileNotFoundException {
+        return parse(new FileInputStream(configurationFile));
+    }
+
+    public PluginConfiguration parse(InputStream configurationStream) {
+        try {
+            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+            DocumentBuilder builder = factory.newDocumentBuilder();
+            Document xmlDoc = builder.parse(configurationStream);
+            Node rootNode = xmlDoc.getFirstChild();
+            if (rootNode == null) {
+                throw new PluginConfigurationParseException("no configuration found");
+            }
+            return parseRootElement(rootNode);
+        } catch (ParserConfigurationException | SAXException | IOException exception) {
+            throw new PluginConfigurationParseException("failed to parse plugin configuration", exception);
+        }
+    }
+
+    private PluginConfiguration parseRootElement(Node root) {
+        List<NewCommand> newCommands = Collections.emptyList();
+        List<CommandExtensions> extensions = Collections.emptyList();
+
+        Pair<List<NewCommand>, List<CommandExtensions>> commands = new Pair<>(newCommands, extensions);
+        if (root.getNodeName().equals("plugin")) {
+            NodeList nodes = root.getChildNodes();
+            for (int i = 0; i < nodes.getLength(); i++) {
+                Node node = nodes.item(i);
+                if (node.getNodeName().equals("commands")) {
+                    commands = parseCommands(node);
+                }
+            }
+        }
+
+        return new PluginConfiguration(commands.getFirst(), commands.getSecond());
+    }
+
+    private Pair<List<NewCommand>, List<CommandExtensions>> parseCommands(Node commandsNode) {
+        List<NewCommand> newCommands = new ArrayList<NewCommand>();
+        List<CommandExtensions> extendedCommands = new ArrayList<CommandExtensions>();
+        NodeList childNodes = commandsNode.getChildNodes();
+        for (int i = 0; i < childNodes.getLength(); i++) {
+            Node node = childNodes.item(i);
+            if (node.getNodeName().equals("new")) {
+                newCommands.add(parseNewCommand(node));
+            } else if (node.getNodeName().equals("existing")) {
+                extendedCommands.add(parseAdditionsToExistingCommand(node));
+            }
+        }
+        return new Pair<>(newCommands, extendedCommands);
+    }
+
+    private CommandExtensions parseAdditionsToExistingCommand(Node commandNode) {
+        String name = null;
+        List<String> bundles = new ArrayList<>();
+        List<String> dependencies = new ArrayList<>();
+
+        NodeList nodes = commandNode.getChildNodes();
+        for (int i = 0; i < nodes.getLength(); i++) {
+            Node node = nodes.item(i);
+            if (node.getNodeName().equals("name")) {
+                name = node.getTextContent();
+            } else if (node.getNodeName().equals("bundles")) {
+                String[] bundleNames = node.getTextContent().split(",");
+                for (String bundleName : bundleNames) {
+                    if (bundleName.trim().length() == 0) {
+                        continue;
+                    }
+                    bundles.add(bundleName.trim());
+                }
+            } else if (node.getNodeName().equals("dependencies")) {
+                String[] dependencyNames = node.getTextContent().split(",");
+                for (String bundleName : dependencyNames) {
+                    if (bundleName.trim().length() == 0) {
+                        continue;
+                    }
+                    dependencies.add(bundleName);
+                }
+            }
+        }
+        return new CommandExtensions(name, bundles, dependencies);
+    }
+
+    private NewCommand parseNewCommand(Node commandNode) {
+        String name = null;
+        String usage = null;
+        String description = null;
+        Options options = null;
+        List<String> bundles = new ArrayList<>();
+        List<String> dependencies = new ArrayList<>();
+
+        NodeList nodes = commandNode.getChildNodes();
+        for (int i = 0; i < nodes.getLength(); i++) {
+            Node node = nodes.item(i);
+            if (node.getNodeName().equals("name")) {
+                name = node.getTextContent();
+            } else if (node.getNodeName().equals("usage")) {
+                usage = node.getTextContent();
+            } else if (node.getNodeName().equals("description")) {
+                description = node.getTextContent();
+            } else if (node.getNodeName().equals("arguments")) {
+                options = parseArguments(node);
+            } else if (node.getNodeName().equals("bundles")) {
+                String[] bundleNames = node.getTextContent().split(",");
+                for (String bundleName : bundleNames) {
+                    if (bundleName.trim().length() == 0) {
+                        continue;
+                    }
+                    bundles.add(bundleName);
+                }
+            } else if (node.getNodeName().equals("dependencies")) {
+                String[] dependencyNames = node.getTextContent().split(",");
+                for (String bundleName : dependencyNames) {
+                    if (bundleName.trim().length() == 0) {
+                        continue;
+                    }
+                    dependencies.add(bundleName);
+                }
+            }
+        }
+        return new NewCommand(name, usage, description, options, bundles, dependencies);
+    }
+
+    private Options parseArguments(Node argumentsNode) {
+        // need to identify a way to express arguments
+        return null;
+    }
+
+}
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/ActivatorTest.java	Wed Jan 16 18:58:59 2013 +0100
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/ActivatorTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -40,6 +40,7 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyString;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Matchers.isA;
 import static org.mockito.Matchers.isNull;
@@ -56,6 +57,7 @@
 import java.util.Hashtable;
 
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
@@ -124,11 +126,24 @@
         when(config.getThermostatHome()).thenReturn("");
         when(registryService.getConfiguration()).thenReturn(config);
 
-        CommandInfoSourceImpl commands = mock(CommandInfoSourceImpl.class);
-        when(commands.getCommandInfos()).thenReturn(new ArrayList<CommandInfo>());
-        whenNew(CommandInfoSourceImpl.class).
+        BuiltInCommandInfoSource source1 = mock(BuiltInCommandInfoSource.class);
+        when(source1.getCommandInfos()).thenReturn(new ArrayList<CommandInfo>());
+        whenNew(BuiltInCommandInfoSource.class).
                 withParameterTypes(String.class, String.class).
-                withArguments(isA(String.class), isA(String.class)).thenReturn(commands);
+                withArguments(isA(String.class), isA(String.class)).thenReturn(source1);
+
+        PluginCommandInfoSource source2 = mock(PluginCommandInfoSource.class);
+        when(source2.getCommandInfos()).thenReturn(new ArrayList<CommandInfo>());
+        whenNew(PluginCommandInfoSource.class)
+                .withParameterTypes(String.class, String.class)
+                .withArguments(anyString(), anyString())
+                .thenReturn(source2);
+
+        CompoundCommandInfoSource commands = mock(CompoundCommandInfoSource.class);
+        whenNew(CompoundCommandInfoSource.class)
+                .withParameterTypes(CommandInfoSource.class, CommandInfoSource.class)
+                .withArguments(source1, source2)
+                .thenReturn(commands);
 
         tracker = mock(MultipleServiceTracker.class);
         whenNew(MultipleServiceTracker.class).
@@ -180,13 +195,13 @@
         ServiceReference ref = context.getServiceReference(Keyring.class);
         customizer.addingService(ref);
         
-        assertTrue(context.isServiceRegistered(CommandInfoSource.class.getName(), mock(CommandInfoSourceImpl.class).getClass()));
+        assertTrue(context.isServiceRegistered(CommandInfoSource.class.getName(), mock(CompoundCommandInfoSource.class).getClass()));
         assertTrue(context.isServiceRegistered(BundleManager.class.getName(), BundleManagerImpl.class));
         assertTrue(context.isServiceRegistered(Launcher.class.getName(), LauncherImpl.class));
 
         customizer.removedService(null, null);
         
-        assertFalse(context.isServiceRegistered(CommandInfoSource.class.getName(), CommandInfoSourceImpl.class));
+        assertFalse(context.isServiceRegistered(CommandInfoSource.class.getName(), CompoundCommandInfoSource.class));
         assertFalse(context.isServiceRegistered(BundleManager.class.getName(), BundleManagerImpl.class));
         assertFalse(context.isServiceRegistered(Launcher.class.getName(), LauncherImpl.class));
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BasicCommandInfoTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2013 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 static org.junit.Assert.assertEquals;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.cli.Options;
+import org.junit.Test;
+
+public class BasicCommandInfoTest {
+
+    @Test
+    public void testBasics() {
+        final String NAME = "name";
+        final String DESCRIPTION = "description";
+        final String USAGE = "usage";
+        final Options OPTIONS = new Options();
+        final List<String> RESOURCES = Collections.emptyList();
+
+        BasicCommandInfo info = new BasicCommandInfo(NAME, DESCRIPTION, USAGE, OPTIONS, RESOURCES);
+
+        assertEquals(NAME, info.getName());
+        assertEquals(DESCRIPTION, info.getDescription());
+        assertEquals(USAGE, info.getUsage());
+        assertEquals(OPTIONS, info.getOptions());
+        assertEquals(RESOURCES, info.getDependencyResourceNames());
+
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoSourceTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2012, 2013 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Properties;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.launcher.internal.BuiltInCommandInfoSource;
+
+public class BuiltInCommandInfoSourceTest {
+
+    private Path tempThermostatHome;
+
+    private File tempLibs;
+    private File tempEtc;
+    private File tempCommands;
+    private File tempPropsFile;
+
+    @Before
+    public void setUp() throws IOException {
+
+        tempThermostatHome = Files.createTempDirectory("test");
+        tempThermostatHome.toFile().deleteOnExit();
+        System.setProperty("THERMOSTAT_HOME", tempThermostatHome.toString());
+        
+        tempLibs = new File(tempThermostatHome.toFile(), "libs");
+
+        tempEtc = new File(tempThermostatHome.toFile(), "etc");
+        tempEtc.mkdirs();
+        tempEtc.deleteOnExit();
+
+        tempCommands = new File(tempEtc, "commands");
+        tempCommands.mkdirs();
+        tempCommands.deleteOnExit();
+
+        Properties props = new Properties(); // Don't need to put anything in here.
+        writeProperties(props);
+    }
+
+    private void writeProperties(Properties props) {
+        tempPropsFile = new File(tempCommands, "foo.properties");
+        try {
+            props.store(new FileOutputStream(tempPropsFile), "Nothing here matters.  It's a comment.");
+        } catch (IOException e) {
+            // The test setup is broken; the test hasn't started yet.
+            throw new RuntimeException("Exception was thrown while setting up for test.", e);
+        }
+        tempPropsFile.deleteOnExit();
+    }
+
+    @Test
+    public void testGetCommandInfo() {
+        BuiltInCommandInfoSource bundles =
+                new BuiltInCommandInfoSource(tempCommands.toString(), tempLibs.toString());
+        CommandInfo info = bundles.getCommandInfo("foo");
+        assertNotNull(info);
+        assertEquals("foo", info.getName());
+    }
+
+    @Test
+    public void testGetCommandInfos() {
+        BuiltInCommandInfoSource bundles =
+                new BuiltInCommandInfoSource(tempCommands.toString(), tempLibs.toString());
+        Collection<CommandInfo> infos = bundles.getCommandInfos();
+        assertNotNull(infos);
+        assertEquals(1, infos.size());
+        CommandInfo info = infos.iterator().next();
+        assertNotNull(info);
+        assertEquals("foo", info.getName());
+    }
+
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/BuiltInCommandInfoTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2012, 2013 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.OptionGroup;
+import org.apache.commons.cli.Options;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.redhat.thermostat.common.locale.Translate;
+
+public class BuiltInCommandInfoTest {
+
+    private Path tempThermostatHome, someJarName1, someJarName2, missingJarName;
+    private File tempLibs;
+
+    @Before
+    public void setUp() throws IOException {
+        tempThermostatHome = Files.createTempDirectory("test");
+        tempThermostatHome.toFile().deleteOnExit();
+        System.setProperty("THERMOSTAT_HOME", tempThermostatHome.toString());
+
+        tempLibs = new File(tempThermostatHome.toFile(), "libs");
+        tempLibs.mkdirs();
+        tempLibs.deleteOnExit();
+
+        File someJar1 = new File(tempLibs, "thermostat-osgi-fluff1.jar");
+        someJar1.createNewFile();
+        someJar1.deleteOnExit();
+        someJarName1 = someJar1.toPath();
+        
+        File someJar2 = new File(tempLibs, "thermostat-osgi-fluff2.jar");
+        someJar2.createNewFile();
+        someJar2.deleteOnExit();
+        someJarName2 = someJar2.toPath();
+
+        File missingJar = new File(tempLibs, "thisjar_noexist.jar");
+        missingJarName = missingJar.toPath();
+    }
+
+    private String resolvedJar(Path jar) {
+        return "file:" + jar.toString();
+    }
+
+    @Test
+    public void verifyGetName() {
+        Properties props = new Properties();
+        String name = "name";
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, "");
+
+        String commandName = info.getName();
+        assertEquals(name, commandName);
+    }
+
+    @Test
+    public void verifySingleResource() {
+        Properties props = new Properties();
+        props.setProperty("bundles", someJarName1.getFileName().toString());
+        String name = "name";
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        List<String> resources = info.getDependencyResourceNames();
+        assertEquals(1, resources.size());
+        assertTrue(resources.contains(resolvedJar(someJarName1)));
+    }
+
+    @Test
+    public void verifyMultipleResources() {
+        Properties props = new Properties();
+        props.setProperty("bundles", someJarName1.getFileName() + "," + someJarName2.getFileName());
+        String name = "name";
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        List<String> resources = info.getDependencyResourceNames();
+        assertEquals(2, resources.size());
+        assertTrue(resources.contains(resolvedJar(someJarName1)));
+        assertTrue(resources.contains(resolvedJar(someJarName2)));
+    }
+
+    @Test
+    public void verifyMissingResource() {
+        Properties props = new Properties();
+        props.setProperty("bundles", missingJarName.getFileName().toString());
+        String name = "name";
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        List<String> resources = info.getDependencyResourceNames();
+        assertEquals(0, resources.size());
+        assertFalse(resources.contains(resolvedJar(missingJarName)));
+    }
+
+    @Test
+    public void verifyGetDescription() {
+        Properties props = new Properties();
+        String name = "name";
+        String desc = "desc";
+        props.put("description", desc);
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        String commandDesc = info.getDescription();
+        assertEquals(desc, commandDesc);
+    }
+
+    @Test
+    public void verifyGetUsage() {
+        Properties props = new Properties();
+        String name = "name";
+        String usage = "some sort of usage message";
+        props.put("usage", usage);
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        String commandUsage = info.getUsage();
+        assertEquals(usage, commandUsage);
+    }
+
+    @Test
+    public void verifyGetOptions() {
+        Properties props = new Properties();
+        String name = "name";
+        props.put("options", "foo, bar");
+        props.put("foo.short", "f");
+        props.put("foo.long", "foo");
+        props.put("foo.hasarg", "true");
+        props.put("foo.required", "TRUE");
+        props.put("foo.description", "the foo option");
+        props.put("bar.short", "b");
+        props.put("bar.long", "bar");
+        props.put("bar.hasarg", "FALSE");
+        props.put("bar.required", "this will evaluate as false");
+        props.put("bar.description", "the bar option");
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        Options options = info.getOptions();
+        Option foo = options.getOption("foo");
+        assertEquals("foo", foo.getArgName());
+        assertEquals("f", foo.getOpt());
+        assertEquals("foo", foo.getLongOpt());
+        assertTrue(foo.hasArg());
+        assertTrue(foo.isRequired());
+        assertEquals("the foo option", foo.getDescription());
+        Option bar = options.getOption("bar");
+        assertEquals("bar", bar.getArgName());
+        assertEquals("b", bar.getOpt());
+        assertEquals("bar", bar.getLongOpt());
+        assertFalse(bar.hasArg());
+        assertFalse(bar.isRequired());
+        assertEquals("the bar option", bar.getDescription());
+    }
+    
+    @Test
+    public void canAddCommonDBOptions() {
+        Properties props = new Properties();
+        String name = "name";
+        props.put("options", "AUTO_DB_OPTIONS");
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        Options options = info.getOptions();
+        assertTrue(options.hasOption(CommonOptions.DB_URL_ARG));
+        assertTrue(options.hasOption(CommonOptions.USERNAME_ARG));
+        assertTrue(options.hasOption(CommonOptions.PASSWORD_ARG));
+        assertFalse(options.getOption(CommonOptions.DB_URL_ARG).isRequired());
+        Option dbUrlOption = options.getOption(CommonOptions.DB_URL_ARG);
+        Translate<LocaleResources> t = LocaleResources.createLocalizer();
+        assertEquals(t.localize(LocaleResources.OPTION_DB_URL_DESC), dbUrlOption.getDescription());
+        assertEquals("d", dbUrlOption.getOpt());
+        assertEquals("dbUrl", dbUrlOption.getLongOpt());
+    }
+    
+    @Test
+    public void requiredCommandPropertyOverridesCommonDbOptions() {
+        Properties props = new Properties();
+        String name = "name";
+        props.put("options", "AUTO_DB_OPTIONS, dbUrl");
+        props.put("dbUrl.long", "ignored");
+        props.put("dbUrl.required", "true");
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        Options options = info.getOptions();
+        assertTrue(options.hasOption(CommonOptions.DB_URL_ARG));
+        Option dbUrlOption = options.getOption(CommonOptions.DB_URL_ARG);
+        assertTrue(dbUrlOption.isRequired());
+        assertEquals("dbUrl", dbUrlOption.getLongOpt());
+    }
+    
+    @Test
+    public void canAddLogOption() {
+        Properties props = new Properties();
+        String name = "name";
+        props.put("options", "AUTO_LOG_OPTION");
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        Options options = info.getOptions();
+        assertTrue(options.hasOption(CommonOptions.LOG_LEVEL_ARG));
+        assertFalse(options.getOption(CommonOptions.LOG_LEVEL_ARG).isRequired());
+    }
+
+    @Test
+    public void verifyOptionGroup() {
+        Properties props = new Properties();
+        String name = "name";
+        props.put("options", "foo|bar");
+        props.put("foo.short", "f");
+        props.put("bar.short", "b");
+        BuiltInCommandInfo info = new BuiltInCommandInfo(name, props, tempLibs.toString());
+
+        Options options = info.getOptions();
+        Option foo = options.getOption("f");
+        assertNotNull(foo);
+        OptionGroup og = options.getOptionGroup(foo);
+        assertNotNull(og);
+        Option bar = options.getOption("b");
+        @SuppressWarnings("rawtypes")
+        Collection members = og.getOptions();
+        assertTrue(members.contains(foo));
+        assertTrue(members.contains(bar));
+    }
+}
+
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/CommandInfoImplTest.java	Wed Jan 16 18:58:59 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,266 +0,0 @@
-/*
- * Copyright 2012, 2013 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 static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Collection;
-import java.util.List;
-import java.util.Properties;
-
-import org.apache.commons.cli.Option;
-import org.apache.commons.cli.OptionGroup;
-import org.apache.commons.cli.Options;
-import org.junit.Before;
-import org.junit.Test;
-
-import com.redhat.thermostat.common.locale.Translate;
-
-public class CommandInfoImplTest {
-
-    private Path tempThermostatHome, someJarName1, someJarName2, missingJarName;
-    private File tempLibs;
-
-    @Before
-    public void setUp() throws IOException {
-        tempThermostatHome = Files.createTempDirectory("test");
-        tempThermostatHome.toFile().deleteOnExit();
-        System.setProperty("THERMOSTAT_HOME", tempThermostatHome.toString());
-
-        tempLibs = new File(tempThermostatHome.toFile(), "libs");
-        tempLibs.mkdirs();
-        tempLibs.deleteOnExit();
-
-        File someJar1 = new File(tempLibs, "thermostat-osgi-fluff1.jar");
-        someJar1.createNewFile();
-        someJar1.deleteOnExit();
-        someJarName1 = someJar1.toPath();
-        
-        File someJar2 = new File(tempLibs, "thermostat-osgi-fluff2.jar");
-        someJar2.createNewFile();
-        someJar2.deleteOnExit();
-        someJarName2 = someJar2.toPath();
-
-        File missingJar = new File(tempLibs, "thisjar_noexist.jar");
-        missingJarName = missingJar.toPath();
-    }
-
-    private String resolvedJar(Path jar) {
-        return "file:" + jar.toString();
-    }
-
-    @Test
-    public void verifyGetName() {
-        Properties props = new Properties();
-        String name = "name";
-        CommandInfoImpl info = new CommandInfoImpl(name, props, "");
-
-        String commandName = info.getName();
-        assertEquals(name, commandName);
-    }
-
-    @Test
-    public void verifySingleResource() {
-        Properties props = new Properties();
-        props.setProperty("bundles", someJarName1.getFileName().toString());
-        String name = "name";
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        List<String> resources = info.getDependencyResourceNames();
-        assertEquals(1, resources.size());
-        assertTrue(resources.contains(resolvedJar(someJarName1)));
-    }
-
-    @Test
-    public void verifyMultipleResources() {
-        Properties props = new Properties();
-        props.setProperty("bundles", someJarName1.getFileName() + "," + someJarName2.getFileName());
-        String name = "name";
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        List<String> resources = info.getDependencyResourceNames();
-        assertEquals(2, resources.size());
-        assertTrue(resources.contains(resolvedJar(someJarName1)));
-        assertTrue(resources.contains(resolvedJar(someJarName2)));
-    }
-
-    @Test
-    public void verifyMissingResource() {
-        Properties props = new Properties();
-        props.setProperty("bundles", missingJarName.getFileName().toString());
-        String name = "name";
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        List<String> resources = info.getDependencyResourceNames();
-        assertEquals(0, resources.size());
-        assertFalse(resources.contains(resolvedJar(missingJarName)));
-    }
-
-    @Test
-    public void verifyGetDescription() {
-        Properties props = new Properties();
-        String name = "name";
-        String desc = "desc";
-        props.put("description", desc);
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        String commandDesc = info.getDescription();
-        assertEquals(desc, commandDesc);
-    }
-
-    @Test
-    public void verifyGetUsage() {
-        Properties props = new Properties();
-        String name = "name";
-        String usage = "some sort of usage message";
-        props.put("usage", usage);
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        String commandUsage = info.getUsage();
-        assertEquals(usage, commandUsage);
-    }
-
-    @Test
-    public void verifyGetOptions() {
-        Properties props = new Properties();
-        String name = "name";
-        props.put("options", "foo, bar");
-        props.put("foo.short", "f");
-        props.put("foo.long", "foo");
-        props.put("foo.hasarg", "true");
-        props.put("foo.required", "TRUE");
-        props.put("foo.description", "the foo option");
-        props.put("bar.short", "b");
-        props.put("bar.long", "bar");
-        props.put("bar.hasarg", "FALSE");
-        props.put("bar.required", "this will evaluate as false");
-        props.put("bar.description", "the bar option");
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        Options options = info.getOptions();
-        Option foo = options.getOption("foo");
-        assertEquals("foo", foo.getArgName());
-        assertEquals("f", foo.getOpt());
-        assertEquals("foo", foo.getLongOpt());
-        assertTrue(foo.hasArg());
-        assertTrue(foo.isRequired());
-        assertEquals("the foo option", foo.getDescription());
-        Option bar = options.getOption("bar");
-        assertEquals("bar", bar.getArgName());
-        assertEquals("b", bar.getOpt());
-        assertEquals("bar", bar.getLongOpt());
-        assertFalse(bar.hasArg());
-        assertFalse(bar.isRequired());
-        assertEquals("the bar option", bar.getDescription());
-    }
-    
-    @Test
-    public void canAddCommonDBOptions() {
-        Properties props = new Properties();
-        String name = "name";
-        props.put("options", "AUTO_DB_OPTIONS");
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        Options options = info.getOptions();
-        assertTrue(options.hasOption(CommonOptions.DB_URL_ARG));
-        assertTrue(options.hasOption(CommonOptions.USERNAME_ARG));
-        assertTrue(options.hasOption(CommonOptions.PASSWORD_ARG));
-        assertFalse(options.getOption(CommonOptions.DB_URL_ARG).isRequired());
-        Option dbUrlOption = options.getOption(CommonOptions.DB_URL_ARG);
-        Translate<LocaleResources> t = LocaleResources.createLocalizer();
-        assertEquals(t.localize(LocaleResources.OPTION_DB_URL_DESC), dbUrlOption.getDescription());
-        assertEquals("d", dbUrlOption.getOpt());
-        assertEquals("dbUrl", dbUrlOption.getLongOpt());
-    }
-    
-    @Test
-    public void requiredCommandPropertyOverridesCommonDbOptions() {
-        Properties props = new Properties();
-        String name = "name";
-        props.put("options", "AUTO_DB_OPTIONS, dbUrl");
-        props.put("dbUrl.long", "ignored");
-        props.put("dbUrl.required", "true");
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        Options options = info.getOptions();
-        assertTrue(options.hasOption(CommonOptions.DB_URL_ARG));
-        Option dbUrlOption = options.getOption(CommonOptions.DB_URL_ARG);
-        assertTrue(dbUrlOption.isRequired());
-        assertEquals("dbUrl", dbUrlOption.getLongOpt());
-    }
-    
-    @Test
-    public void canAddLogOption() {
-        Properties props = new Properties();
-        String name = "name";
-        props.put("options", "AUTO_LOG_OPTION");
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        Options options = info.getOptions();
-        assertTrue(options.hasOption(CommonOptions.LOG_LEVEL_ARG));
-        assertFalse(options.getOption(CommonOptions.LOG_LEVEL_ARG).isRequired());
-    }
-
-    @Test
-    public void verifyOptionGroup() {
-        Properties props = new Properties();
-        String name = "name";
-        props.put("options", "foo|bar");
-        props.put("foo.short", "f");
-        props.put("bar.short", "b");
-        CommandInfoImpl info = new CommandInfoImpl(name, props, tempLibs.toString());
-
-        Options options = info.getOptions();
-        Option foo = options.getOption("f");
-        assertNotNull(foo);
-        OptionGroup og = options.getOptionGroup(foo);
-        assertNotNull(og);
-        Option bar = options.getOption("b");
-        @SuppressWarnings("rawtypes")
-        Collection members = og.getOptions();
-        assertTrue(members.contains(foo));
-        assertTrue(members.contains(bar));
-    }
-}
-
--- a/launcher/src/test/java/com/redhat/thermostat/launcher/internal/CommandInfoSourceTest.java	Wed Jan 16 18:58:59 2013 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,119 +0,0 @@
-/*
- * Copyright 2012, 2013 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 static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Collection;
-import java.util.Properties;
-
-import org.junit.Before;
-import org.junit.Test;
-
-import com.redhat.thermostat.common.cli.CommandInfo;
-import com.redhat.thermostat.launcher.internal.CommandInfoSourceImpl;
-
-public class CommandInfoSourceTest {
-
-    private Path tempThermostatHome;
-
-    private File tempLibs;
-    private File tempEtc;
-    private File tempCommands;
-    private File tempPropsFile;
-
-    @Before
-    public void setUp() throws IOException {
-
-        tempThermostatHome = Files.createTempDirectory("test");
-        tempThermostatHome.toFile().deleteOnExit();
-        System.setProperty("THERMOSTAT_HOME", tempThermostatHome.toString());
-        
-        tempLibs = new File(tempThermostatHome.toFile(), "libs");
-
-        tempEtc = new File(tempThermostatHome.toFile(), "etc");
-        tempEtc.mkdirs();
-        tempEtc.deleteOnExit();
-
-        tempCommands = new File(tempEtc, "commands");
-        tempCommands.mkdirs();
-        tempCommands.deleteOnExit();
-
-        Properties props = new Properties(); // Don't need to put anything in here.
-        writeProperties(props);
-    }
-
-    private void writeProperties(Properties props) {
-        tempPropsFile = new File(tempCommands, "foo.properties");
-        try {
-            props.store(new FileOutputStream(tempPropsFile), "Nothing here matters.  It's a comment.");
-        } catch (IOException e) {
-            // The test setup is broken; the test hasn't started yet.
-            throw new RuntimeException("Exception was thrown while setting up for test.", e);
-        }
-        tempPropsFile.deleteOnExit();
-    }
-
-    @Test
-    public void testGetCommandInfo() {
-        CommandInfoSourceImpl bundles =
-                new CommandInfoSourceImpl(tempCommands.toString(), tempLibs.toString());
-        CommandInfo info = bundles.getCommandInfo("foo");
-        assertNotNull(info);
-        assertEquals("foo", info.getName());
-    }
-
-    @Test
-    public void testGetCommandInfos() {
-        CommandInfoSourceImpl bundles =
-                new CommandInfoSourceImpl(tempCommands.toString(), tempLibs.toString());
-        Collection<CommandInfo> infos = bundles.getCommandInfos();
-        assertNotNull(infos);
-        assertEquals(1, infos.size());
-        CommandInfo info = infos.iterator().next();
-        assertNotNull(info);
-        assertEquals("foo", info.getName());
-    }
-
-}
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginCommandInfoSourceTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2013 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Matchers.isA;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.redhat.thermostat.common.cli.CommandInfo;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandExtensions;
+
+public class PluginCommandInfoSourceTest {
+
+    // name paths so anything tying to use them/create them will blow up
+    private static final String PLUGIN_ROOT = "/fake/${PLUGIN_ROOT}";
+    private static final String JAR_ROOT = "/fake/${JAR_ROOT}";
+    private File jarRootDir;
+    private File pluginRootDir;
+    private PluginConfigurationParser parser;
+    private PluginConfiguration parserResult;
+
+    @Before
+    public void setUp() throws FileNotFoundException {
+        parser = mock(PluginConfigurationParser.class);
+        parserResult = mock(PluginConfiguration.class);
+        when(parser.parse(isA(File.class))).thenReturn(parserResult);
+        pluginRootDir = mock(File.class);
+        jarRootDir = new File(JAR_ROOT);
+    }
+
+    @Test
+    public void verifyParserIsInvokedOnAllConfigurationFiles() throws FileNotFoundException {
+        File[] pluginDirs = new File[] {
+                new File(PLUGIN_ROOT, "plugin1"),
+                new File(PLUGIN_ROOT, "plugin2"),
+        };
+
+        when(pluginRootDir.listFiles()).thenReturn(pluginDirs);
+
+        PluginCommandInfoSource source = new PluginCommandInfoSource(jarRootDir, pluginRootDir, parser);
+
+        ArgumentCaptor<File> configFilesCaptor = ArgumentCaptor.forClass(File.class);
+        verify(parser, times(pluginDirs.length)).parse(configFilesCaptor.capture());
+
+        List<File> configurationFiles = configFilesCaptor.getAllValues();
+        assertEquals(pluginDirs.length, configurationFiles.size());
+        for (int i = 0; i < pluginDirs.length; i++) {
+            assertEquals(new File(pluginDirs[i], "plugin.conf"), configurationFiles.get(i));
+        }
+    }
+
+    @Test
+    public void verifyMissingConfigurationFileIsHandledCorrectly() throws FileNotFoundException {
+        File[] pluginDirs = new File[] { new File(PLUGIN_ROOT, "plugin1") };
+
+        when(pluginRootDir.listFiles()).thenReturn(pluginDirs);
+        when(parser.parse(isA(File.class))).thenThrow(new FileNotFoundException("test"));
+
+        PluginCommandInfoSource source = new PluginCommandInfoSource(jarRootDir, pluginRootDir, parser);
+    }
+
+    @Test
+    public void verifyCommandInfoObjectsToExtendExistingCommandsAreCreated() {
+        CommandExtensions extensions = mock(CommandExtensions.class);
+        when(extensions.getCommandName()).thenReturn("command-name");
+        when(extensions.getAdditionalBundles()).thenReturn(Arrays.asList("additional-bundle"));
+        when(extensions.getDepenedencyBundles()).thenReturn(Arrays.asList("dependency-bundle"));
+
+        when(parserResult.getExtendedCommands()).thenReturn(Arrays.asList(extensions));
+
+        File[] pluginDirs = new File[] { new File(PLUGIN_ROOT, "plugin1") };
+        when(pluginRootDir.listFiles()).thenReturn(pluginDirs);
+
+        PluginCommandInfoSource source = new PluginCommandInfoSource(jarRootDir, pluginRootDir, parser);
+
+        CommandInfo info = source.getCommandInfo("command-name");
+        assertEquals("command-name", info.getName());
+
+        String expectedDep1Name = new File(PLUGIN_ROOT + "/plugin1/additional-bundle").toURI().toString();
+        String expectedDep2Name = new File(JAR_ROOT + "/dependency-bundle").toURI().toString();
+
+        assertTrue(info.getDependencyResourceNames().contains(expectedDep1Name));
+        assertTrue(info.getDependencyResourceNames().contains(expectedDep2Name));
+    }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/launcher/src/test/java/com/redhat/thermostat/launcher/internal/PluginConfigurationParserTest.java	Mon Jan 28 16:03:37 2013 -0500
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2013 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.CommandExtensions;
+import com.redhat.thermostat.launcher.internal.PluginConfiguration.NewCommand;
+
+public class PluginConfigurationParserTest {
+
+    @Test(expected = PluginConfigurationParseException.class)
+    public void testEmptyConfigurationThrowsException() throws UnsupportedEncodingException {
+        String config = "<?xml version=\"1.0\"?>\n";
+        PluginConfigurationParser parser = new PluginConfigurationParser();
+        parser.parse(new ByteArrayInputStream(config.getBytes("UTF-8")));
+        fail("should not reach here");
+    }
+
+    @Test
+    public void testMinimalConfiguration() throws UnsupportedEncodingException {
+        PluginConfigurationParser parser = new PluginConfigurationParser();
+        String config = "" +
+                "<?xml version=\"1.0\"?>\n" +
+                "<plugin>\n" +
+                "</plugin>";
+        PluginConfiguration result = parser.parse(new ByteArrayInputStream(config.getBytes("UTF-8")));
+
+        assertEquals(0, result.getExtendedCommands().size());
+        assertEquals(0, result.getNewCommands().size());
+    }
+
+    @Test
+    public void testConfigurationThatExtendsExistingCommand() throws UnsupportedEncodingException {
+        String config = "<?xml version=\"1.0\"?>\n" +
+                "<plugin>\n" +
+                "  <commands>\n" +
+                "    <existing>\n" +
+                "      <name>test</name>\n" +
+                "      <bundles>foo,bar,baz,</bundles>\n" +
+                "      <dependencies>thermostat-foo</dependencies>\n" +
+                "    </existing>\n" +
+                "  </commands>\n" +
+                "</plugin>";
+
+        PluginConfiguration result = new PluginConfigurationParser()
+                .parse(new ByteArrayInputStream(config.getBytes("UTF-8")));
+
+        assertEquals(0, result.getNewCommands().size());
+
+        List<CommandExtensions> extensions = result.getExtendedCommands();
+        assertEquals(1, extensions.size());
+
+        CommandExtensions first = extensions.get(0);
+        assertEquals("test", first.getCommandName());
+        assertEquals(Arrays.asList("foo", "bar", "baz"), first.getAdditionalBundles());
+        assertEquals(Arrays.asList("thermostat-foo"), first.getDepenedencyBundles());
+    }
+
+    @Test
+    public void testConfigurationThatAddsNewCommand() throws UnsupportedEncodingException {
+        String config = "<?xml version=\"1.0\"?>\n" +
+                "<plugin>\n" +
+                "  <commands>\n" +
+                "    <new>\n" +
+                "      <name>test</name>\n" +
+                "      <usage>usage: test</usage>\n" +
+                "      <description>description</description>\n" +
+                "      <bundles>foo,bar,baz,</bundles>\n" +
+                "      <dependencies>thermostat-foo</dependencies>\n" +
+                "    </new>\n" +
+                "  </commands>\n" +
+                "</plugin>";
+
+        PluginConfiguration result = new PluginConfigurationParser()
+                .parse(new ByteArrayInputStream(config.getBytes("UTF-8")));
+
+        List<CommandExtensions> extensions = result.getExtendedCommands();
+        assertEquals(0, extensions.size());
+
+        List<NewCommand> newCommands = result.getNewCommands();
+        assertEquals(1, newCommands.size());
+
+        NewCommand newCommand = newCommands.get(0);
+        assertEquals("test", newCommand.getCommandName());
+        assertEquals("usage: test", newCommand.getUsage());
+        assertEquals("description", newCommand.getDescription());
+        assertEquals(null, newCommand.getOptions());
+        assertEquals(Arrays.asList("foo", "bar", "baz"), newCommand.getAdditionalBundles());
+        assertEquals(Arrays.asList("thermostat-foo"), newCommand.getCoreDepenedencyBundles());
+    }
+
+}