Centralize command logic to be run from one class

Using the Command Pattern as inspiration, all the logic for running a
command has been moved to the FluoProgram class. The purpose of this
was to prevent calling System.exit() from within the command classes. 
This will allow the commands to be more easily reused outside of a CLI.
This also makes it easier to print standard error messages for expected
exceptions by throwing a FluoCommandException, and to print a stack 
trace for all other exceptions.

Fixes #983
diff --git a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java b/modules/command/src/main/java/org/apache/fluo/command/AppCommand.java
similarity index 70%
rename from modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
rename to modules/command/src/main/java/org/apache/fluo/command/AppCommand.java
index 004c2ae..3409d51 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/AppCommand.java
@@ -16,19 +16,27 @@
 package org.apache.fluo.command;
 
 import com.beust.jcommander.Parameter;
+import com.google.common.annotations.VisibleForTesting;
+import org.apache.fluo.api.config.FluoConfiguration;
 
-class CommonOpts extends ConfigOpts {
+abstract class AppCommand extends ConfigCommand {
 
   @Parameter(names = "-a", required = true, description = "Fluo application name")
   private String applicationName;
 
+  @Override
+  FluoConfiguration getConfig() {
+    FluoConfiguration config = super.getConfig();
+    config.setApplicationName(applicationName);
+    return config;
+  }
+
   String getApplicationName() {
     return applicationName;
   }
 
-  public static CommonOpts parse(String programName, String[] args) {
-    CommonOpts opts = new CommonOpts();
-    parse(programName, opts, args);
-    return opts;
+  @VisibleForTesting
+  void setApplicationName(String applicationName) {
+    this.applicationName = applicationName;
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java b/modules/command/src/main/java/org/apache/fluo/command/BaseCommand.java
similarity index 68%
copy from modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
copy to modules/command/src/main/java/org/apache/fluo/command/BaseCommand.java
index 004c2ae..7e2c734 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/BaseCommand.java
@@ -4,9 +4,9 @@
  * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License. You may obtain a
  * copy of the License at
- * 
+ *
  * http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software distributed under the License
  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
  * or implied. See the License for the specific language governing permissions and limitations under
@@ -17,18 +17,12 @@
 
 import com.beust.jcommander.Parameter;
 
-class CommonOpts extends ConfigOpts {
+public abstract class BaseCommand implements FluoCommand {
+  @Parameter(names = {"-h", "-help", "--help"}, help = true, description = "Prints help")
+  private boolean help;
 
-  @Parameter(names = "-a", required = true, description = "Fluo application name")
-  private String applicationName;
-
-  String getApplicationName() {
-    return applicationName;
-  }
-
-  public static CommonOpts parse(String programName, String[] args) {
-    CommonOpts opts = new CommonOpts();
-    parse(programName, opts, args);
-    return opts;
+  @Override
+  public boolean isHelp() {
+    return help;
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/BaseOpts.java b/modules/command/src/main/java/org/apache/fluo/command/BaseOpts.java
deleted file mode 100644
index c700afe..0000000
--- a/modules/command/src/main/java/org/apache/fluo/command/BaseOpts.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional information regarding
- * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance with the License. You may obtain a
- * copy of the License at
- * 
- * http://www.apache.org/licenses/LICENSE-2.0
- * 
- * Unless required by applicable law or agreed to in writing, software distributed under the License
- * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
- * or implied. See the License for the specific language governing permissions and limitations under
- * the License.
- */
-
-package org.apache.fluo.command;
-
-import com.beust.jcommander.JCommander;
-import com.beust.jcommander.Parameter;
-import com.beust.jcommander.ParameterException;
-
-public class BaseOpts {
-
-  @Parameter(names = {"-h", "-help", "--help"}, help = true, description = "Prints help")
-  boolean help;
-
-  public static void parse(String programName, BaseOpts opts, String[] args) {
-    JCommander jcommand = new JCommander(opts);
-    jcommand.setProgramName(programName);
-    try {
-      jcommand.parse(args);
-    } catch (ParameterException e) {
-      System.err.println(e.getMessage());
-      jcommand.usage();
-      System.exit(-1);
-    }
-
-    if (opts.help) {
-      jcommand.usage();
-      System.exit(1);
-    }
-  }
-}
diff --git a/modules/command/src/main/java/org/apache/fluo/command/CommandUtil.java b/modules/command/src/main/java/org/apache/fluo/command/CommandUtil.java
index ee839f4..762f17d 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/CommandUtil.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/CommandUtil.java
@@ -23,11 +23,13 @@
 
 public class CommandUtil {
 
+  public static final String FLUO_CONN_PROPS = "fluo.conn.props";
+
   public static void verifyAppInitialized(FluoConfiguration config) {
     if (!FluoAdminImpl.isInitialized(config)) {
-      System.out.println("A Fluo '" + config.getApplicationName() + "' application has not "
-          + "been initialized yet in Zookeeper at " + config.getAppZookeepers());
-      System.exit(-1);
+      throw new FluoCommandException(
+          "A Fluo '" + config.getApplicationName() + "' application has not "
+              + "been initialized yet in Zookeeper at " + config.getAppZookeepers());
     }
   }
 
@@ -35,15 +37,14 @@
     verifyAppInitialized(config);
     try (FluoAdminImpl admin = new FluoAdminImpl(config)) {
       if (!admin.applicationRunning()) {
-        System.out.println("A Fluo '" + config.getApplicationName()
+        throw new FluoCommandException("A Fluo '" + config.getApplicationName()
             + "' application is initialized but is not running!");
-        System.exit(-1);
       }
     }
   }
 
   public static FluoConfiguration resolveFluoConfig() {
-    String connPropsPath = System.getProperty("fluo.conn.props");
+    String connPropsPath = System.getProperty(FLUO_CONN_PROPS);
     if (connPropsPath == null) {
       return new FluoConfiguration();
     } else {
diff --git a/modules/command/src/main/java/org/apache/fluo/command/ConfigOpts.java b/modules/command/src/main/java/org/apache/fluo/command/ConfigCommand.java
similarity index 74%
rename from modules/command/src/main/java/org/apache/fluo/command/ConfigOpts.java
rename to modules/command/src/main/java/org/apache/fluo/command/ConfigCommand.java
index 09c9dea..35b5c36 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/ConfigOpts.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/ConfigCommand.java
@@ -21,9 +21,10 @@
 
 import com.beust.jcommander.Parameter;
 import com.beust.jcommander.converters.IParameterSplitter;
+import com.google.common.annotations.VisibleForTesting;
 import org.apache.fluo.api.config.FluoConfiguration;
 
-public class ConfigOpts extends BaseOpts {
+public abstract class ConfigCommand extends BaseCommand {
 
   public static class NullSplitter implements IParameterSplitter {
     @Override
@@ -36,30 +37,35 @@
       description = "Override configuration set in properties file. Expected format: -o <key>=<value>")
   private List<String> properties = new ArrayList<>();
 
-  List<String> getProperties() {
-    return properties;
-  }
-
-  void overrideFluoConfig(FluoConfiguration config) {
+  private void overrideFluoConfig(FluoConfiguration config) {
     for (String prop : getProperties()) {
       String[] propArgs = prop.split("=", 2);
       if (propArgs.length == 2) {
         String key = propArgs[0].trim();
         String value = propArgs[1].trim();
         if (key.isEmpty() || value.isEmpty()) {
-          throw new IllegalArgumentException("Invalid command line -o option: " + prop);
+          throw new FluoCommandException("Invalid command line -o option: " + prop);
         } else {
           config.setProperty(key, value);
         }
       } else {
-        throw new IllegalArgumentException("Invalid command line -o option: " + prop);
+        throw new FluoCommandException("Invalid command line -o option: " + prop);
       }
     }
   }
 
-  public static ConfigOpts parse(String programName, String[] args) {
-    ConfigOpts opts = new ConfigOpts();
-    parse(programName, opts, args);
-    return opts;
+  FluoConfiguration getConfig() {
+    FluoConfiguration config = CommandUtil.resolveFluoConfig();
+    overrideFluoConfig(config);
+    return config;
+  }
+
+  public List<String> getProperties() {
+    return properties;
+  }
+
+  @VisibleForTesting
+  void setProperties(List<String> properties) {
+    this.properties = properties;
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java b/modules/command/src/main/java/org/apache/fluo/command/FluoCommand.java
similarity index 66%
copy from modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
copy to modules/command/src/main/java/org/apache/fluo/command/FluoCommand.java
index 004c2ae..e2f6bb3 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoCommand.java
@@ -15,20 +15,9 @@
 
 package org.apache.fluo.command;
 
-import com.beust.jcommander.Parameter;
+public interface FluoCommand {
 
-class CommonOpts extends ConfigOpts {
+  void execute() throws FluoCommandException;
 
-  @Parameter(names = "-a", required = true, description = "Fluo application name")
-  private String applicationName;
-
-  String getApplicationName() {
-    return applicationName;
-  }
-
-  public static CommonOpts parse(String programName, String[] args) {
-    CommonOpts opts = new CommonOpts();
-    parse(programName, opts, args);
-    return opts;
-  }
+  boolean isHelp();
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java b/modules/command/src/main/java/org/apache/fluo/command/FluoCommandException.java
similarity index 66%
copy from modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
copy to modules/command/src/main/java/org/apache/fluo/command/FluoCommandException.java
index 004c2ae..60d25ed 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/CommonOpts.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoCommandException.java
@@ -4,9 +4,9 @@
  * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License. You may obtain a
  * copy of the License at
- * 
+ *
  * http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software distributed under the License
  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
  * or implied. See the License for the specific language governing permissions and limitations under
@@ -15,20 +15,22 @@
 
 package org.apache.fluo.command;
 
-import com.beust.jcommander.Parameter;
+public class FluoCommandException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-class CommonOpts extends ConfigOpts {
-
-  @Parameter(names = "-a", required = true, description = "Fluo application name")
-  private String applicationName;
-
-  String getApplicationName() {
-    return applicationName;
+  public FluoCommandException() {
+    super();
   }
 
-  public static CommonOpts parse(String programName, String[] args) {
-    CommonOpts opts = new CommonOpts();
-    parse(programName, opts, args);
-    return opts;
+  public FluoCommandException(String msg) {
+    super(msg);
+  }
+
+  public FluoCommandException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
+
+  public FluoCommandException(Throwable cause) {
+    super(cause);
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoConfig.java b/modules/command/src/main/java/org/apache/fluo/command/FluoConfig.java
index 467b928..605ec74 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoConfig.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoConfig.java
@@ -17,17 +17,18 @@
 
 import java.util.Map;
 
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.client.FluoAdmin;
 import org.apache.fluo.api.client.FluoFactory;
 import org.apache.fluo.api.config.FluoConfiguration;
 
-public class FluoConfig {
+@Parameters(commandNames = "config",
+    commandDescription = "Prints application configuration stored in Zookeeper for <app>")
+public class FluoConfig extends AppCommand {
 
-  public static void main(String[] args) {
-    CommonOpts opts = CommonOpts.parse("fluo config", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     CommandUtil.verifyAppInitialized(config);
     try (FluoAdmin admin = FluoFactory.newAdmin(config)) {
       for (Map.Entry<String, String> entry : admin.getApplicationConfig().toMap().entrySet()) {
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoExec.java b/modules/command/src/main/java/org/apache/fluo/command/FluoExec.java
index bffc448..1a993f5 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoExec.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoExec.java
@@ -15,17 +15,22 @@
 
 package org.apache.fluo.command;
 
+import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
-import java.util.Arrays;
+import java.util.List;
 
 import javax.inject.Provider;
 
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
 
-public class FluoExec {
+@Parameters(commandNames = "exec",
+    commandDescription = "Executes <class> with <args> using classpath for <app>")
+public class FluoExec extends BaseCommand implements FluoCommand {
 
   private static class FluoConfigModule extends AbstractModule {
 
@@ -44,25 +49,40 @@
     }
   }
 
-  public static void main(String[] args) throws Exception {
-    if (args.length < 2) {
-      System.err.println("Usage: fluo exec <app> <class> args...");
-      System.exit(-1);
+  @Parameter(description = "<app> <class> args...", variableArity = true)
+  private List<String> args;
+
+  @Override
+  public void execute() throws FluoCommandException {
+    if (args.size() < 2) {
+      throw new FluoCommandException("Usage: fluo exec <app> <class> args...");
     }
-    final String applicationName = args[0];
-    final String className = args[1];
+    final String applicationName = args.get(0);
+    final String className = args.get(1);
 
     FluoConfiguration fluoConfig = CommandUtil.resolveFluoConfig();
     fluoConfig.setApplicationName(applicationName);
     CommandUtil.verifyAppInitialized(fluoConfig);
     fluoConfig = FluoAdminImpl.mergeZookeeperConfig(fluoConfig);
 
-    Class<?> clazz = Class.forName(className);
+    try {
+      Class<?> clazz = Class.forName(className);
 
-    // inject fluo configuration
-    Guice.createInjector(new FluoConfigModule(clazz, fluoConfig));
+      // inject fluo configuration
+      Guice.createInjector(new FluoConfigModule(clazz, fluoConfig));
 
-    Method method = clazz.getMethod("main", String[].class);
-    method.invoke(null, (Object) Arrays.copyOfRange(args, 2, args.length));
+      Method method = clazz.getMethod("main", String[].class);
+      List<String> execArgs = args.subList(2, args.size());
+      method.invoke(null, (Object) execArgs.toArray(new String[execArgs.size()]));
+    } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
+      throw new FluoCommandException(String.format("Class %s must have a main method", className),
+          e);
+    } catch (ClassNotFoundException e) {
+      throw new FluoCommandException(String.format("Class %s not found", className), e);
+    }
+  }
+
+  public List<String> getArgs() {
+    return args;
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoGetJars.java b/modules/command/src/main/java/org/apache/fluo/command/FluoGetJars.java
index 2ac32fe..4f55bb2 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoGetJars.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoGetJars.java
@@ -4,9 +4,9 @@
  * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License. You may obtain a
  * copy of the License at
- * 
+ *
  * http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software distributed under the License
  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
  * or implied. See the License for the specific language governing permissions and limitations under
@@ -16,9 +16,12 @@
 package org.apache.fluo.command;
 
 import java.io.File;
+import java.io.IOException;
 import java.net.URI;
+import java.net.URISyntaxException;
 
 import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
 import org.apache.commons.io.FileUtils;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
@@ -28,57 +31,46 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class FluoGetJars {
+@Parameters(commandNames = "get-jars",
+    commandDescription = "Copies <app> jars from DFS to local <dir>")
+public class FluoGetJars extends AppCommand {
 
   private static final Logger log = LoggerFactory.getLogger(FluoGetJars.class);
 
-  public static class GetJarsOpts extends CommonOpts {
+  @Parameter(names = "-d", required = true, description = "Download directory path")
+  private String downloadPath;
 
-    @Parameter(names = "-d", required = true, description = "Download directory path")
-    private String downloadPath;
-
-    String getDownloadPath() {
-      return downloadPath;
-    }
-
-    public static GetJarsOpts parse(String[] args) {
-      GetJarsOpts opts = new GetJarsOpts();
-      parse("fluo get-jars", opts, args);
-      return opts;
-    }
+  String getDownloadPath() {
+    return downloadPath;
   }
 
-  public static void main(String[] args) {
-
-    GetJarsOpts opts = GetJarsOpts.parse(args);
-
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
 
     CommandUtil.verifyAppInitialized(config);
     config = FluoAdminImpl.mergeZookeeperConfig(config);
     if (config.getObserverJarsUrl().isEmpty()) {
-      log.info("No observer jars found for the '{}' Fluo application!", opts.getApplicationName());
+      log.info("No observer jars found for the '{}' Fluo application!", getApplicationName());
       return;
     }
 
-    try {
-      if (config.getObserverJarsUrl().startsWith("hdfs://")) {
-        try (FileSystem fs = FileSystem.get(new URI(config.getDfsRoot()), new Configuration())) {
-          File downloadPathFile = new File(opts.getDownloadPath());
-          if (downloadPathFile.exists()) {
-            FileUtils.deleteDirectory(downloadPathFile);
-          }
-          fs.copyToLocalFile(new Path(config.getObserverJarsUrl()),
-              new Path(opts.getDownloadPath()));
+    if (config.getObserverJarsUrl().startsWith("hdfs://")) {
+      try (FileSystem fs = FileSystem.get(new URI(config.getDfsRoot()), new Configuration())) {
+        File downloadPathFile = new File(getDownloadPath());
+        if (downloadPathFile.exists()) {
+          FileUtils.deleteDirectory(downloadPathFile);
         }
-      } else {
-        log.error("Unsupported url prefix for {}={}", FluoConfiguration.OBSERVER_JARS_URL_PROP,
-            config.getObserverJarsUrl());
+        fs.copyToLocalFile(new Path(config.getObserverJarsUrl()), new Path(getDownloadPath()));
+      } catch (URISyntaxException e) {
+        throw new FluoCommandException(
+            String.format("Error parsing DFS ROOT URI: %s", e.getMessage()), e);
+      } catch (IOException e) {
+        throw new FluoCommandException(e);
       }
-    } catch (Exception e) {
-      log.error("", e);
+    } else {
+      throw new FluoCommandException(String.format("Unsupported url prefix for %s=%s",
+          FluoConfiguration.OBSERVER_JARS_URL_PROP, config.getObserverJarsUrl()));
     }
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoInit.java b/modules/command/src/main/java/org/apache/fluo/command/FluoInit.java
index 9eeb5b9..e1c9bb2 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoInit.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoInit.java
@@ -4,9 +4,9 @@
  * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
  * "License"); you may not use this file except in compliance with the License. You may obtain a
  * copy of the License at
- * 
+ *
  * http://www.apache.org/licenses/LICENSE-2.0
- * 
+ *
  * Unless required by applicable law or agreed to in writing, software distributed under the License
  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
  * or implied. See the License for the specific language governing permissions and limitations under
@@ -22,65 +22,59 @@
 import java.util.Optional;
 
 import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
 import com.google.common.base.Preconditions;
 import org.apache.fluo.api.client.FluoAdmin;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
 
-public class FluoInit {
+@Parameters(commandNames = "init",
+    commandDescription = "Initializes Fluo application for <app> using <appProps>")
+public class FluoInit extends AppCommand {
 
-  public static class InitOptions extends CommonOpts {
+  @Parameter(names = "-p", required = true, description = "Path to application properties file")
+  private String appPropsPath;
 
-    @Parameter(names = "-p", required = true, description = "Path to application properties file")
-    private String appPropsPath;
+  @Parameter(names = {"-f", "--force"},
+      description = "Skip all prompts and clears Zookeeper and Accumulo table.  Equivalent to "
+          + "setting both --clearTable --clearZookeeper")
+  private boolean force;
 
-    @Parameter(names = {"-f", "--force"},
-        description = "Skip all prompts and clears Zookeeper and Accumulo table.  Equivalent to "
-            + "setting both --clearTable --clearZookeeper")
-    private boolean force;
+  @Parameter(names = {"--clearTable"}, description = "Skips prompt and clears Accumulo table")
+  private boolean clearTable;
 
-    @Parameter(names = {"--clearTable"}, description = "Skips prompt and clears Accumulo table")
-    private boolean clearTable;
+  @Parameter(names = {"--clearZookeeper"}, description = "Skips prompt and clears Zookeeper")
+  private boolean clearZookeeper;
 
-    @Parameter(names = {"--clearZookeeper"}, description = "Skips prompt and clears Zookeeper")
-    private boolean clearZookeeper;
+  @Parameter(names = {"-u", "--update"}, description = "Update Fluo configuration in Zookeeper")
+  private boolean update;
 
-    @Parameter(names = {"-u", "--update"}, description = "Update Fluo configuration in Zookeeper")
-    private boolean update;
+  @Parameter(names = "--retrieveProperty",
+      description = "Gets specified property without initializing")
+  private String retrieveProperty;
 
-    @Parameter(names = "--retrieveProperty",
-        description = "Gets specified property without initializing")
-    private String retrieveProperty;
+  String getAppPropsPath() {
+    return appPropsPath;
+  }
 
-    String getAppPropsPath() {
-      return appPropsPath;
-    }
+  boolean getForce() {
+    return force;
+  }
 
-    boolean getForce() {
-      return force;
-    }
+  boolean getClearTable() {
+    return clearTable;
+  }
 
-    boolean getClearTable() {
-      return clearTable;
-    }
+  boolean getClearZookeeper() {
+    return clearZookeeper;
+  }
 
-    boolean getClearZookeeper() {
-      return clearZookeeper;
-    }
+  boolean getUpdate() {
+    return update;
+  }
 
-    boolean getUpdate() {
-      return update;
-    }
-
-    String getRetrieveProperty() {
-      return retrieveProperty;
-    }
-
-    public static InitOptions parse(String[] args) {
-      InitOptions opts = new InitOptions();
-      parse("fluo init", opts, args);
-      return opts;
-    }
+  String getRetrieveProperty() {
+    return retrieveProperty;
   }
 
   private static boolean readYes() {
@@ -90,7 +84,7 @@
       try {
         input = Optional.ofNullable(bufferedReader.readLine()).orElse("").trim();
       } catch (IOException e) {
-        throw new IllegalStateException(e);
+        throw new FluoCommandException(e);
       }
       if (input.equalsIgnoreCase("y")) {
         return true;
@@ -102,53 +96,49 @@
     }
   }
 
-  public static void main(String[] args) {
-
-    InitOptions opts = InitOptions.parse(args);
-    File applicationPropsFile = new File(opts.getAppPropsPath());
+  @Override
+  public void execute() throws FluoCommandException {
+    File applicationPropsFile = new File(getAppPropsPath());
     Preconditions.checkArgument(applicationPropsFile.exists(),
-        opts.getAppPropsPath() + " does not exist");
+        getAppPropsPath() + " does not exist");
 
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
+    FluoConfiguration config = getConfig();
     config.load(applicationPropsFile);
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
 
-    String propKey = opts.getRetrieveProperty();
+    String propKey = getRetrieveProperty();
     if (propKey != null && !propKey.isEmpty()) {
       if (config.containsKey(propKey)) {
         System.out.println(config.getString(propKey));
       }
-      System.exit(0);
+      return;
     }
 
     if (!config.hasRequiredAdminProps()) {
-      System.err.println("Error - Required properties are not set in " + opts.getAppPropsPath());
-      System.exit(-1);
+      throw new FluoCommandException(
+          "Error - Required properties are not set in " + getAppPropsPath());
     }
     try {
       config.validate();
     } catch (Exception e) {
-      System.err.println("Error - Invalid configuration due to " + e.getMessage());
-      System.exit(-1);
+      throw new FluoCommandException("Error - Invalid configuration due to " + e.getMessage(), e);
     }
 
     try (FluoAdminImpl admin = new FluoAdminImpl(config)) {
 
       FluoAdmin.InitializationOptions initOpts = new FluoAdmin.InitializationOptions();
 
-      if (opts.getUpdate()) {
+      if (getUpdate()) {
         System.out.println("Updating configuration for the Fluo '" + config.getApplicationName()
-            + "' application in Zookeeper using " + opts.getAppPropsPath());
+            + "' application in Zookeeper using " + getAppPropsPath());
         admin.updateSharedConfig();
         System.out.println("Update is complete.");
-        System.exit(0);
+        return;
       }
 
-      if (opts.getForce()) {
+      if (getForce()) {
         initOpts.setClearZookeeper(true).setClearTable(true);
       } else {
-        if (opts.getClearZookeeper()) {
+        if (getClearZookeeper()) {
           initOpts.setClearZookeeper(true);
         } else if (admin.zookeeperInitialized()) {
           System.out.print("A Fluo '" + config.getApplicationName()
@@ -158,12 +148,11 @@
           if (readYes()) {
             initOpts.setClearZookeeper(true);
           } else {
-            System.out.println("Aborted initialization.");
-            System.exit(-1);
+            throw new FluoCommandException("Aborted initialization.");
           }
         }
 
-        if (opts.getClearTable()) {
+        if (getClearTable()) {
           initOpts.setClearTable(true);
         } else if (admin.accumuloTableExists()) {
           System.out.print("The Accumulo table '" + config.getAccumuloTable()
@@ -171,24 +160,18 @@
           if (readYes()) {
             initOpts.setClearTable(true);
           } else {
-            System.out.println("Aborted initialization.");
-            System.exit(-1);
+            throw new FluoCommandException("Aborted initialization.");
           }
         }
       }
 
       System.out.println("Initializing Fluo '" + config.getApplicationName()
-          + "' application using " + opts.getAppPropsPath());
+          + "' application using " + getAppPropsPath());
 
       admin.initialize(initOpts);
       System.out.println("Initialization is complete.");
-    } catch (FluoAdmin.AlreadyInitializedException e) {
-      System.err.println(e.getMessage());
-      System.exit(-1);
-    } catch (Exception e) {
-      System.out.println("Initialization failed due to the following exception:");
-      e.printStackTrace();
-      System.exit(-1);
+    } catch (FluoAdmin.AlreadyInitializedException | FluoAdmin.TableExistsException e) {
+      throw new FluoCommandException(e.getMessage(), e);
     }
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoList.java b/modules/command/src/main/java/org/apache/fluo/command/FluoList.java
index 02f9768..a430d0c 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoList.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoList.java
@@ -18,29 +18,30 @@
 import java.util.Collections;
 import java.util.List;
 
+import com.beust.jcommander.Parameters;
 import org.apache.curator.framework.CuratorFramework;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
 import org.apache.fluo.core.util.CuratorUtil;
 
-public class FluoList {
+@Parameters(commandNames = "list",
+    commandDescription = "Lists all Fluo applications in Fluo instance")
+public class FluoList extends ConfigCommand {
 
-  public static void main(String[] args) throws Exception {
-
-    ConfigOpts commandOpts = ConfigOpts.parse("fluo list", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    commandOpts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
 
     try (CuratorFramework curator = CuratorUtil.newFluoCurator(config)) {
       curator.start();
 
-      if (curator.checkExists().forPath("/") == null) {
+      if (!checkCuratorExists(curator)) {
         System.out.println("Fluo instance (" + config.getInstanceZookeepers() + ") has not been "
             + "created yet in Zookeeper.  It will be created when the first Fluo application is "
             + "initialized for this instance.");
         return;
       }
-      List<String> children = curator.getChildren().forPath("/");
+      List<String> children = getCuratorChildren(curator);
       if (children.isEmpty()) {
         System.out.println("Fluo instance (" + config.getInstanceZookeepers() + ") does not "
             + "contain any Fluo applications.");
@@ -54,17 +55,43 @@
       System.out.println("-----------     ------     ---------");
 
       for (String path : children) {
-        FluoConfiguration appConfig = new FluoConfiguration(config);
-        appConfig.setApplicationName(path);
-        try (FluoAdminImpl admin = new FluoAdminImpl(appConfig)) {
-          String state = "STOPPED";
-          if (admin.applicationRunning()) {
-            state = "RUNNING";
-          }
-          int numWorkers = admin.numWorkers();
-          System.out.format("%-15s %-11s %4d\n", path, state, numWorkers);
-        }
+        listApp(config, path);
       }
     }
   }
+
+  private boolean checkCuratorExists(CuratorFramework curator) {
+    try {
+      return curator.checkExists().forPath("/") != null;
+    } catch (RuntimeException e) {
+      throw e;
+    } catch (Exception e) {
+      // throwing RuntimeException so stack trace is printed on command line
+      throw new RuntimeException("Error getting curator children", e);
+    }
+  }
+
+  private List<String> getCuratorChildren(CuratorFramework curator) {
+    try {
+      return curator.getChildren().forPath("/");
+    } catch (RuntimeException e) {
+      throw e;
+    } catch (Exception e) {
+      // throwing RuntimeException so stack trace is printed on command line
+      throw new RuntimeException("Error getting curator children", e);
+    }
+  }
+
+  private void listApp(FluoConfiguration config, String path) {
+    FluoConfiguration appConfig = new FluoConfiguration(config);
+    appConfig.setApplicationName(path);
+    try (FluoAdminImpl admin = new FluoAdminImpl(appConfig)) {
+      String state = "STOPPED";
+      if (admin.applicationRunning()) {
+        state = "RUNNING";
+      }
+      int numWorkers = admin.numWorkers();
+      System.out.format("%-15s %-11s %4d\n", path, state, numWorkers);
+    }
+  }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoOracle.java b/modules/command/src/main/java/org/apache/fluo/command/FluoOracle.java
index 3cea36a..0f2f896 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoOracle.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoOracle.java
@@ -15,30 +15,24 @@
 
 package org.apache.fluo.command;
 
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.client.FluoFactory;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.util.UtilWaitThread;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class FluoOracle {
+@Parameters(commandNames = "oracle", commandDescription = "Starts Fluo Oracle process for <app>")
+public class FluoOracle extends AppCommand {
 
-  private static final Logger log = LoggerFactory.getLogger(FluoOracle.class);
-
-  public static void main(String[] args) {
-    CommonOpts opts = CommonOpts.parse("fluo oracle", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     CommandUtil.verifyAppInitialized(config);
-    try {
-      org.apache.fluo.api.service.FluoOracle oracle = FluoFactory.newOracle(config);
-      oracle.start();
-      while (true) {
-        UtilWaitThread.sleep(10000);
-      }
-    } catch (Exception e) {
-      log.error("Exception running FluoOracle: ", e);
+    org.apache.fluo.api.service.FluoOracle oracle = FluoFactory.newOracle(config);
+    oracle.start();
+    while (true) {
+      UtilWaitThread.sleep(10000);
     }
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoProgram.java b/modules/command/src/main/java/org/apache/fluo/command/FluoProgram.java
new file mode 100644
index 0000000..dfa9bed
--- /dev/null
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoProgram.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.fluo.command;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.ParameterException;
+import com.google.common.collect.Iterables;
+
+public class FluoProgram {
+
+  public static void main(String[] args) {
+    List<FluoCommand> fluoCommands = Arrays.asList(new FluoConfig(), new FluoExec(),
+        new FluoGetJars(), new FluoInit(), new FluoList(), new FluoOracle(), new FluoRemove(),
+        new FluoScan(), new FluoStatus(), new FluoWait(), new FluoWorker());
+    try {
+      runFluoCommand(fluoCommands, args);
+    } catch (FluoCommandException | ParameterException e) {
+      System.exit(1);
+    }
+  }
+
+  public static void runFluoCommand(List<FluoCommand> fluoCommands, String[] args) {
+    JCommander.Builder jCommanderBuilder = JCommander.newBuilder();
+    fluoCommands.forEach(jCommanderBuilder::addCommand);
+    JCommander jcommand = jCommanderBuilder.build();
+
+    try {
+      jcommand.parse(args);
+    } catch (ParameterException e) {
+      System.err.println(e.getMessage());
+      String commandName = Optional.ofNullable(jcommand.getParsedCommand()).orElse("");
+      JCommander parsedJCommandOrProgram =
+          Optional.ofNullable(jcommand.findCommandByAlias(commandName)).orElse(jcommand);
+      parsedJCommandOrProgram.setProgramName(String.format("fluo %s", commandName));
+      parsedJCommandOrProgram.usage();
+      throw e;
+    }
+
+    String parsedCommandType = jcommand.getParsedCommand();
+    JCommander parsedJCommand = jcommand.findCommandByAlias(parsedCommandType);
+    String programName = String.format("fluo %s", parsedCommandType);
+    parsedJCommand.setProgramName(programName);
+    FluoCommand parsedFluoCommand =
+        (FluoCommand) Iterables.getOnlyElement(parsedJCommand.getObjects());
+
+    if (parsedFluoCommand.isHelp()) {
+      parsedJCommand.usage();
+      return;
+    }
+
+    try {
+      parsedFluoCommand.execute();
+    } catch (FluoCommandException e) {
+      System.err.println(String.format("%s failed - %s", programName, e.getMessage()));
+      throw e;
+    }
+  }
+}
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoRemove.java b/modules/command/src/main/java/org/apache/fluo/command/FluoRemove.java
index a1b37e2..a12fced 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoRemove.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoRemove.java
@@ -15,19 +15,17 @@
 
 package org.apache.fluo.command;
 
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.api.exceptions.FluoException;
 import org.apache.fluo.core.client.FluoAdminImpl;
 
-public class FluoRemove {
+@Parameters(commandNames = "remove", commandDescription = "Removes Fluo application for <app>")
+public class FluoRemove extends AppCommand {
 
-  public static void main(String[] args) {
-
-    CommonOpts opts = CommonOpts.parse("fluo remove", args);
-
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     config = FluoAdminImpl.mergeZookeeperConfig(config);
 
     try (FluoAdminImpl admin = new FluoAdminImpl(config)) {
@@ -35,12 +33,7 @@
       admin.remove();
       System.out.println("Remove is complete.");
     } catch (FluoException e) {
-      System.err.println(e.getMessage());
-      System.exit(-1);
-    } catch (Exception e) {
-      System.out.println("Remove failed due to the following exception:");
-      e.printStackTrace();
-      System.exit(-1);
+      throw new FluoCommandException(e);
     }
   }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoScan.java b/modules/command/src/main/java/org/apache/fluo/command/FluoScan.java
index 3844e4d..25a2d87 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoScan.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoScan.java
@@ -21,6 +21,7 @@
 import java.util.List;
 
 import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
 import org.apache.fluo.core.util.ScanUtil;
@@ -28,126 +29,110 @@
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
 
-public class FluoScan {
+@Parameters(commandNames = "scan", commandDescription = "Prints snapshot of data in Fluo <app>")
+public class FluoScan extends AppCommand {
 
-  public static class ScanOptions extends CommonOpts {
+  @Parameter(names = "-s", description = "Start row (inclusive) of scan")
+  private String startRow;
 
-    @Parameter(names = "-s", description = "Start row (inclusive) of scan")
-    private String startRow;
+  @Parameter(names = "-e", description = "End row (inclusive) of scan")
+  private String endRow;
 
-    @Parameter(names = "-e", description = "End row (inclusive) of scan")
-    private String endRow;
+  @Parameter(names = "-c", description = "Columns of scan in comma separated format: "
+      + "<<columnfamily>[:<columnqualifier>]{,<columnfamily>[:<columnqualifier>]}> ")
+  private List<String> columns;
 
-    @Parameter(names = "-c", description = "Columns of scan in comma separated format: "
-        + "<<columnfamily>[:<columnqualifier>]{,<columnfamily>[:<columnqualifier>]}> ")
-    private List<String> columns;
+  @Parameter(names = "-r", description = "Exact row to scan")
+  private String exactRow;
 
-    @Parameter(names = "-r", description = "Exact row to scan")
-    private String exactRow;
+  @Parameter(names = "-p", description = "Row prefix to scan")
+  private String rowPrefix;
 
-    @Parameter(names = "-p", description = "Row prefix to scan")
-    private String rowPrefix;
+  @Parameter(names = {"-esc", "--escape-non-ascii"}, help = true,
+      description = "Hex encode non ascii bytes", arity = 1)
+  public boolean hexEncNonAscii = true;
 
-    @Parameter(names = {"-esc", "--escape-non-ascii"}, help = true,
-        description = "Hex encode non ascii bytes", arity = 1)
-    public boolean hexEncNonAscii = true;
+  @Parameter(names = "--raw", help = true,
+      description = "Show underlying key/values stored in Accumulo. Interprets the data using Fluo "
+          + "internal schema, making it easier to comprehend.")
+  public boolean scanAccumuloTable = false;
 
-    @Parameter(names = "--raw", help = true,
-        description = "Show underlying key/values stored in Accumulo. Interprets the data using Fluo "
-            + "internal schema, making it easier to comprehend.")
-    public boolean scanAccumuloTable = false;
+  @Parameter(names = "--json", help = true,
+      description = "Export key/values stored in Accumulo as JSON file.")
+  public boolean exportAsJson = false;
 
-    @Parameter(names = "--json", help = true,
-        description = "Export key/values stored in Accumulo as JSON file.")
-    public boolean exportAsJson = false;
+  @Parameter(names = "--ntfy", help = true, description = "Scan active notifications")
+  public boolean scanNtfy = false;
 
-    @Parameter(names = "--ntfy", help = true, description = "Scan active notifications")
-    public boolean scanNtfy = false;
-
-    public String getStartRow() {
-      return startRow;
+  /**
+   * Check if the parameters informed can be used together.
+   */
+  private void checkScanOptions() {
+    if (this.scanAccumuloTable && this.exportAsJson) {
+      throw new FluoCommandException("Both \"--raw\" and \"--json\" can not be set together.");
     }
 
-    public String getEndRow() {
-      return endRow;
-    }
-
-    public String getExactRow() {
-      return exactRow;
-    }
-
-    public String getRowPrefix() {
-      return rowPrefix;
-    }
-
-    public List<String> getColumns() {
-      if (columns == null) {
-        return Collections.emptyList();
-      }
-      return columns;
-    }
-
-    /**
-     * Check if the parameters informed can be used together.
-     */
-    private void checkScanOptions() {
-      if (this.scanAccumuloTable && this.exportAsJson) {
-        throw new IllegalArgumentException(
-            "Both \"--raw\" and \"--json\" can not be set together.");
-      }
-
-      if (this.scanAccumuloTable && this.scanNtfy) {
-        throw new IllegalArgumentException(
-            "Both \"--raw\" and \"--ntfy\" can not be set together.");
-      }
-    }
-
-    public ScanUtil.ScanOpts getScanOpts() {
-      EnumSet<ScanFlags> flags = EnumSet.noneOf(ScanFlags.class);
-
-      ScanUtil.setFlag(flags, help, ScanFlags.HELP);
-      ScanUtil.setFlag(flags, hexEncNonAscii, ScanFlags.HEX);
-      ScanUtil.setFlag(flags, scanAccumuloTable, ScanFlags.ACCUMULO);
-      ScanUtil.setFlag(flags, exportAsJson, ScanFlags.JSON);
-      ScanUtil.setFlag(flags, scanNtfy, ScanFlags.NTFY);
-
-      return new ScanUtil.ScanOpts(startRow, endRow, columns, exactRow, rowPrefix, flags);
-    }
-
-    public static ScanOptions parse(String[] args) {
-      ScanOptions opts = new ScanOptions();
-      parse("fluo scan", opts, args);
-      return opts;
+    if (this.scanAccumuloTable && this.scanNtfy) {
+      throw new FluoCommandException("Both \"--raw\" and \"--ntfy\" can not be set together.");
     }
   }
 
-  public static void main(String[] args) {
+  public ScanUtil.ScanOpts getScanOpts() {
+    EnumSet<ScanFlags> flags = EnumSet.noneOf(ScanFlags.class);
 
+    ScanUtil.setFlag(flags, isHelp(), ScanFlags.HELP);
+    ScanUtil.setFlag(flags, hexEncNonAscii, ScanFlags.HEX);
+    ScanUtil.setFlag(flags, scanAccumuloTable, ScanFlags.ACCUMULO);
+    ScanUtil.setFlag(flags, exportAsJson, ScanFlags.JSON);
+    ScanUtil.setFlag(flags, scanNtfy, ScanFlags.NTFY);
+
+    return new ScanUtil.ScanOpts(startRow, endRow, columns, exactRow, rowPrefix, flags);
+  }
+
+  @Override
+  public void execute() throws FluoCommandException {
     Logger.getRootLogger().setLevel(Level.ERROR);
     Logger.getLogger("org.apache.fluo").setLevel(Level.ERROR);
 
-    ScanOptions options = ScanOptions.parse(args);
-    options.checkScanOptions();
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(options.getApplicationName());
-    options.overrideFluoConfig(config);
+    checkScanOptions();
+    FluoConfiguration config = getConfig();
 
     try {
-      options.overrideFluoConfig(config);
-      if (options.scanAccumuloTable) {
+      if (scanAccumuloTable) {
         config = FluoAdminImpl.mergeZookeeperConfig(config);
-        ScanUtil.scanAccumulo(options.getScanOpts(), config, System.out);
-      } else if (options.scanNtfy) {
+        ScanUtil.scanAccumulo(getScanOpts(), config, System.out);
+      } else if (scanNtfy) {
         config = FluoAdminImpl.mergeZookeeperConfig(config);
-        ScanUtil.scanNotifications(options.getScanOpts(), config, System.out);
+        ScanUtil.scanNotifications(getScanOpts(), config, System.out);
       } else {
         CommandUtil.verifyAppRunning(config);
-        ScanUtil.scanFluo(options.getScanOpts(), config, System.out);
+        ScanUtil.scanFluo(getScanOpts(), config, System.out);
       }
-    } catch (RuntimeException | IOException e) {
-      System.err.println("Scan failed - " + e.getMessage());
-      System.exit(-1);
+    } catch (IOException e) {
+      throw new FluoCommandException(e);
     }
   }
 
+  public String getStartRow() {
+    return startRow;
+  }
+
+  public String getEndRow() {
+    return endRow;
+  }
+
+  public String getExactRow() {
+    return exactRow;
+  }
+
+  public String getRowPrefix() {
+    return rowPrefix;
+  }
+
+  public List<String> getColumns() {
+    if (columns == null) {
+      return Collections.emptyList();
+    }
+    return columns;
+  }
 }
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoStatus.java b/modules/command/src/main/java/org/apache/fluo/command/FluoStatus.java
index 4cd227f..27ecd5d 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoStatus.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoStatus.java
@@ -15,16 +15,17 @@
 
 package org.apache.fluo.command;
 
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.client.FluoAdminImpl;
 
-public class FluoStatus {
+@Parameters(commandNames = "status",
+    commandDescription = "Prints status of Fluo application for <app>")
+public class FluoStatus extends AppCommand {
 
-  public static void main(String[] args) throws Exception {
-    CommonOpts opts = CommonOpts.parse("fluo status", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     try (FluoAdminImpl admin = new FluoAdminImpl(config)) {
       if (!admin.zookeeperInitialized()) {
         System.out.println("NOT_FOUND");
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoWait.java b/modules/command/src/main/java/org/apache/fluo/command/FluoWait.java
index 464a904..5b0fa55 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoWait.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoWait.java
@@ -18,6 +18,7 @@
 import java.util.Collections;
 import java.util.List;
 
+import com.beust.jcommander.Parameters;
 import org.apache.accumulo.core.client.AccumuloException;
 import org.apache.accumulo.core.client.AccumuloSecurityException;
 import org.apache.accumulo.core.client.Scanner;
@@ -33,7 +34,9 @@
 
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-public class FluoWait {
+@Parameters(commandNames = "wait",
+    commandDescription = "Waits until all notifications are processed for <app>")
+public class FluoWait extends AppCommand {
 
   private static final Logger log = LoggerFactory.getLogger(FluoWait.class);
   private static final long MIN_SLEEP_MS = 250;
@@ -109,17 +112,17 @@
           break;
         }
       }
-    } catch (Exception e) {
-      log.error("An exception was thrown -", e);
-      System.exit(-1);
+    } catch (AccumuloSecurityException | AccumuloException e) {
+      throw new FluoCommandException(String.format("Error getting table ranges: ", e.getMessage()),
+          e);
+    } catch (TableNotFoundException e) {
+      throw new FluoCommandException(String.format("Table %s not found", e.getTableName()), e);
     }
   }
 
-  public static void main(String[] args) throws Exception {
-    CommonOpts opts = CommonOpts.parse("fluo wait", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     CommandUtil.verifyAppRunning(config);
     config = FluoAdminImpl.mergeZookeeperConfig(config);
     waitUntilFinished(config);
diff --git a/modules/command/src/main/java/org/apache/fluo/command/FluoWorker.java b/modules/command/src/main/java/org/apache/fluo/command/FluoWorker.java
index 6a0f982..3fd02a7 100644
--- a/modules/command/src/main/java/org/apache/fluo/command/FluoWorker.java
+++ b/modules/command/src/main/java/org/apache/fluo/command/FluoWorker.java
@@ -15,32 +15,25 @@
 
 package org.apache.fluo.command;
 
+import com.beust.jcommander.Parameters;
 import org.apache.fluo.api.client.FluoFactory;
 import org.apache.fluo.api.config.FluoConfiguration;
 import org.apache.fluo.core.util.UtilWaitThread;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class FluoWorker {
+@Parameters(commandNames = "worker", commandDescription = "Starts Fluo Worker process for <app>")
+public class FluoWorker extends AppCommand {
 
-  private static final Logger log = LoggerFactory.getLogger(FluoWorker.class);
-
-  public static void main(String[] args) {
-
-    CommonOpts opts = CommonOpts.parse("fluo worker", args);
-    FluoConfiguration config = CommandUtil.resolveFluoConfig();
-    config.setApplicationName(opts.getApplicationName());
-    opts.overrideFluoConfig(config);
+  @Override
+  public void execute() throws FluoCommandException {
+    FluoConfiguration config = getConfig();
     CommandUtil.verifyAppInitialized(config);
 
-    try {
-      org.apache.fluo.api.service.FluoWorker worker = FluoFactory.newWorker(config);
-      worker.start();
-      while (true) {
-        UtilWaitThread.sleep(10000);
-      }
-    } catch (Exception e) {
-      log.error("Exception running FluoWorker: ", e);
+    org.apache.fluo.api.service.FluoWorker worker = FluoFactory.newWorker(config);
+    worker.start();
+    while (true) {
+      UtilWaitThread.sleep(10000);
     }
   }
 }
diff --git a/modules/command/src/test/java/org/apache/fluo/command/AppCommandTest.java b/modules/command/src/test/java/org/apache/fluo/command/AppCommandTest.java
new file mode 100644
index 0000000..c9e079b
--- /dev/null
+++ b/modules/command/src/test/java/org/apache/fluo/command/AppCommandTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.fluo.command;
+
+import java.net.URL;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.fluo.api.config.FluoConfiguration;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class AppCommandTest {
+
+  @Test
+  public void testGetConfig() {
+    AppCommand appCommand = new AppCommand() {
+      @Override
+      public void execute() throws FluoCommandException {}
+    };
+
+    URL testConfig = getClass().getClassLoader().getResource("test-fluo-conn.properties");
+    System.setProperty(CommandUtil.FLUO_CONN_PROPS, testConfig.getPath());
+
+    String newAppName = "new-app-name";
+    int newZookeeperTimeout = 100;
+    List<String> overrideConfig = Collections.singletonList(
+        FluoConfiguration.CONNECTION_ZOOKEEPER_TIMEOUT_PROP + "=" + newZookeeperTimeout);
+    appCommand.setApplicationName(newAppName);
+    appCommand.setProperties(overrideConfig);
+
+    FluoConfiguration fluoConfiguration = appCommand.getConfig();
+
+    assertEquals(newAppName, fluoConfiguration.getApplicationName());
+    assertEquals(newZookeeperTimeout, fluoConfiguration.getZookeeperTimeout());
+  }
+}
diff --git a/modules/command/src/test/java/org/apache/fluo/command/CommandUtilTest.java b/modules/command/src/test/java/org/apache/fluo/command/CommandUtilTest.java
new file mode 100644
index 0000000..802f71a
--- /dev/null
+++ b/modules/command/src/test/java/org/apache/fluo/command/CommandUtilTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.fluo.command;
+
+import java.net.URL;
+
+import org.apache.fluo.api.config.FluoConfiguration;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class CommandUtilTest {
+
+  @Test
+  public void testResolveFluoConfig() {
+    URL testConfig = getClass().getClassLoader().getResource("test-fluo-conn.properties");
+    System.setProperty(CommandUtil.FLUO_CONN_PROPS, testConfig.getPath());
+
+    FluoConfiguration fluoConfiguration = CommandUtil.resolveFluoConfig();
+
+    assertEquals("app-name", fluoConfiguration.getApplicationName());
+    assertEquals(999, fluoConfiguration.getZookeeperTimeout());
+  }
+}
diff --git a/modules/command/src/test/java/org/apache/fluo/command/ConfigCommandTest.java b/modules/command/src/test/java/org/apache/fluo/command/ConfigCommandTest.java
new file mode 100644
index 0000000..494362f
--- /dev/null
+++ b/modules/command/src/test/java/org/apache/fluo/command/ConfigCommandTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.fluo.command;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.fluo.api.config.FluoConfiguration;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class ConfigCommandTest {
+
+  private ConfigCommand configCommand;
+
+  @Before
+  public void setUp() {
+    configCommand = new ConfigCommand() {
+      @Override
+      public void execute() throws FluoCommandException {}
+    };
+
+    URL testConfig = getClass().getClassLoader().getResource("test-fluo-conn.properties");
+    System.setProperty(CommandUtil.FLUO_CONN_PROPS, testConfig.getPath());
+  }
+
+  @Test
+  public void testNullSplitter() {
+    String testStr = "asdf, safggr = adfjc :";
+    ConfigCommand.NullSplitter nullSplitter = new ConfigCommand.NullSplitter();
+
+    List<String> nullSplitList = nullSplitter.split(testStr);
+
+    assertEquals(1, nullSplitList.size());
+    assertEquals(testStr, nullSplitList.get(0));
+  }
+
+  @Test
+  public void testGetConfigWithOneOverriddenProp() {
+    int newZookeeperTimeout = 100;
+    List<String> overrideConfig = Collections.singletonList(
+        FluoConfiguration.CONNECTION_ZOOKEEPER_TIMEOUT_PROP + "=" + newZookeeperTimeout);
+    configCommand.setProperties(overrideConfig);
+
+    FluoConfiguration fluoConfiguration = configCommand.getConfig();
+
+    assertEquals(newZookeeperTimeout, fluoConfiguration.getZookeeperTimeout());
+    assertEquals("app-name", fluoConfiguration.getApplicationName());
+  }
+
+  @Test
+  public void testGetConfigWithTwoOverriddenProp() {
+    int newZookeeperTimeout = 100;
+    int loaderQueueSize = 256;
+    List<String> overrideConfig = Arrays.asList(
+        FluoConfiguration.CONNECTION_ZOOKEEPER_TIMEOUT_PROP + "=" + newZookeeperTimeout,
+        FluoConfiguration.LOADER_QUEUE_SIZE_PROP + "=" + "256");
+    configCommand.setProperties(overrideConfig);
+
+    FluoConfiguration fluoConfiguration = configCommand.getConfig();
+
+    assertEquals(newZookeeperTimeout, fluoConfiguration.getZookeeperTimeout());
+    assertEquals(loaderQueueSize, fluoConfiguration.getLoaderQueueSize());
+    assertEquals("app-name", fluoConfiguration.getApplicationName());
+  }
+
+  @Test(expected = FluoCommandException.class)
+  public void testGetConfigInvalidOption() {
+    configCommand.setProperties(Collections.singletonList("Invalid-Option"));
+
+    configCommand.getConfig();
+  }
+
+  @Test(expected = FluoCommandException.class)
+  public void testGetConfigMissingKey() {
+    configCommand.setProperties(Collections.singletonList(" =value"));
+
+    configCommand.getConfig();
+  }
+
+  @Test(expected = FluoCommandException.class)
+  public void testGetConfigMissingValue() {
+    configCommand.setProperties(Collections.singletonList("key= "));
+
+    configCommand.getConfig();
+  }
+}
diff --git a/modules/command/src/test/java/org/apache/fluo/command/FluoProgramTest.java b/modules/command/src/test/java/org/apache/fluo/command/FluoProgramTest.java
new file mode 100644
index 0000000..e3586a4
--- /dev/null
+++ b/modules/command/src/test/java/org/apache/fluo/command/FluoProgramTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional information regarding
+ * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License. You may obtain a
+ * copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.fluo.command;
+
+import java.io.PrintStream;
+import java.util.Collections;
+
+import com.beust.jcommander.ParameterException;
+import com.beust.jcommander.Parameters;
+import org.apache.commons.io.output.NullOutputStream;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+public class FluoProgramTest {
+  @Parameters(commandNames = "test")
+  class MockFluoCommand implements FluoCommand {
+
+    boolean help = false;
+    boolean throwException = false;
+    private boolean executed = false;
+
+    @Override
+    public void execute() throws FluoCommandException {
+      if (throwException) {
+        throw new FluoCommandException();
+      } else {
+        executed = true;
+      }
+    }
+
+    @Override
+    public boolean isHelp() {
+      return help;
+    }
+
+    public boolean isExecuted() {
+      return executed;
+    }
+  }
+
+  private MockFluoCommand mockFluoCommand;
+  private static PrintStream outPS;
+  private static PrintStream errPS;
+
+  @BeforeClass
+  public static void disablePrinting() {
+    outPS = System.out;
+    errPS = System.err;
+    // This will hide usage and error logs when running tests
+    try (PrintStream ps = new PrintStream(new NullOutputStream())) {
+      System.setOut(ps);
+      System.setErr(ps);
+    }
+  }
+
+  @AfterClass
+  public static void restorePrinting() {
+    System.setOut(outPS);
+    System.setErr(errPS);
+  }
+
+  @Before
+  public void setUp() {
+    mockFluoCommand = new MockFluoCommand();
+  }
+
+  @Test(expected = ParameterException.class)
+  public void testUnparsableCommand() {
+    FluoProgram.runFluoCommand(Collections.singletonList(new MockFluoCommand()),
+        new String[] {"invalid", "command"});
+  }
+
+  @Test
+  public void testHelpCommand() {
+    mockFluoCommand.help = true;
+
+    FluoProgram.runFluoCommand(Collections.singletonList(mockFluoCommand), new String[] {"test"});
+
+    assertFalse(mockFluoCommand.isExecuted());
+  }
+
+  @Test
+  public void testExecutedCommand() {
+    FluoProgram.runFluoCommand(Collections.singletonList(mockFluoCommand), new String[] {"test"});
+
+    assertTrue(mockFluoCommand.isExecuted());
+  }
+
+  @Test(expected = FluoCommandException.class)
+  public void testExecutionError() {
+    mockFluoCommand.throwException = true;
+
+    FluoProgram.runFluoCommand(Collections.singletonList(mockFluoCommand), new String[] {"test"});
+  }
+}
diff --git a/modules/command/src/test/java/org/apache/fluo/command/ScanTest.java b/modules/command/src/test/java/org/apache/fluo/command/ScanTest.java
index 20377a9..fabc3aa 100644
--- a/modules/command/src/test/java/org/apache/fluo/command/ScanTest.java
+++ b/modules/command/src/test/java/org/apache/fluo/command/ScanTest.java
@@ -30,10 +30,10 @@
 public class ScanTest {
 
   private SnapshotScanner.Opts parseArgs(String args) {
-    FluoScan.ScanOptions options = new FluoScan.ScanOptions();
-    JCommander jcommand = new JCommander(options);
+    FluoScan scan = new FluoScan();
+    JCommander jcommand = new JCommander(scan);
     jcommand.parse(args.split(" "));
-    ScanUtil.ScanOpts opts = options.getScanOpts();
+    ScanUtil.ScanOpts opts = scan.getScanOpts();
     return new SnapshotScanner.Opts(ScanUtil.getSpan(opts), ScanUtil.getColumns(opts), false);
   }
 
diff --git a/modules/command/src/test/resources/test-fluo-conn.properties b/modules/command/src/test/resources/test-fluo-conn.properties
new file mode 100644
index 0000000..113f9f3
--- /dev/null
+++ b/modules/command/src/test/resources/test-fluo-conn.properties
@@ -0,0 +1,15 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more contributor license
+# agreements.  See the NOTICE file distributed with this work for additional information regarding
+# copyright ownership.  The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with the License.  You may
+# obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License
+# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+# or implied. See the License for the specific language governing permissions and limitations under
+# the License.
+
+fluo.connection.application.name=app-name
+fluo.connection.zookeeper.timeout=999
diff --git a/modules/distribution/src/main/lib/fetch.sh b/modules/distribution/src/main/lib/fetch.sh
index 5f9c854..4cedc82 100755
--- a/modules/distribution/src/main/lib/fetch.sh
+++ b/modules/distribution/src/main/lib/fetch.sh
@@ -55,7 +55,7 @@
 extra)
   echo "Fetching extra Fluo dependencies"
   download aopalliance:aopalliance:jar:1.0
-  download com.beust:jcommander:jar:1.72
+  download com.beust:jcommander:jar:1.78
   download com.google.code.gson:gson:jar:2.8.5
   download com.google.guava:guava:jar:27.0-jre
   download com.google.inject:guice:jar:4.2.2
diff --git a/modules/distribution/src/main/scripts/fluo b/modules/distribution/src/main/scripts/fluo
index 9a9426b..7372ddc 100755
--- a/modules/distribution/src/main/scripts/fluo
+++ b/modules/distribution/src/main/scripts/fluo
@@ -94,9 +94,6 @@
     echo -e "The application name (set by <app>) cannot be an empty string!\n"
     print_usage
   fi
-  if [[ $1 = *"-h"* ]]; then
-    print_usage
-  fi
 }
 
 function check_hadoop {
@@ -113,13 +110,14 @@
 function setup_service {
   if [[ "$@" =~ ^.*-a\ *([^\ ]*).*$ ]]; then
     app=${BASH_REMATCH[1]}
+
     verify_app "$app"
     check_conn_props
     # create a temp dir to fetch application jars to
     app_lib=$(mktemp -d "$FLUO_TMP"/fluo-"$app"-XXXXXXXXX) || die "fatal: unable to allocate a temporary directory"
     # schedule removal of app_lib tmp dir when this script exits
     trap "rm -rf '""$app_lib""'" EXIT HUP INT QUIT TERM
-    $JAVA org.apache.fluo.command.FluoGetJars -d "$app_lib" "$@"
+    $JAVA org.apache.fluo.command.FluoProgram get-jars -d "$app_lib" "$@"
     export CLASSPATH="$conf:$app_lib/*:$CLASSPATH"
   else
     echo "Application name must be set!"
@@ -131,50 +129,50 @@
 case "$1" in
 config)
   check_conn_props
-  $JAVA org.apache.fluo.command.FluoConfig "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 get-jars)
   check_conn_props
-  $JAVA org.apache.fluo.command.FluoGetJars "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 init)
   if [[ $2 = *"-h"* ]]; then
-    $JAVA org.apache.fluo.command.FluoInit -h
+    $JAVA org.apache.fluo.command.FluoProgram $1 -h
     exit 0
   fi
-  init_dir=$($JAVA org.apache.fluo.command.FluoInit "${@:2}" --retrieveProperty fluo.observer.init.dir)
+  init_dir=$($JAVA org.apache.fluo.command.FluoProgram "$@" --retrieveProperty fluo.observer.init.dir)
   if [ -d "$init_dir" ]; then
     echo "Adding $init_dir/* to CLASSPATH"
     export CLASSPATH="$init_dir/*:$CLASSPATH"
   fi
-  $JAVA org.apache.fluo.command.FluoInit "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 remove)
   if [[ $2 = *"-h"* ]]; then
-    $JAVA org.apache.fluo.command.FluoRemove -h
+    $JAVA org.apache.fluo.command.FluoProgram $1 -h
     exit 0
   fi
-  $JAVA org.apache.fluo.command.FluoRemove "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 oracle)
   if [[ $2 = *"-h"* ]]; then
-    $JAVA org.apache.fluo.command.FluoOracle -h
+    $JAVA org.apache.fluo.command.FluoProgram $1 -h
     exit 0
   fi
   setup_service "${@:2}"
-  $JAVA org.apache.fluo.command.FluoOracle "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 worker)
   if [[ $2 = *"-h"* ]]; then
-    $JAVA org.apache.fluo.command.FluoWorker -h
+    $JAVA org.apache.fluo.command.FluoProgram $1 -h
     exit 0
   fi
   setup_service "${@:2}"
-  $JAVA org.apache.fluo.command.FluoWorker "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 scan)
   if [ -f "$FLUO_CONN_PROPS" ]; then
-    $JAVA org.apache.fluo.command.FluoScan "${@:2}"
+    $JAVA org.apache.fluo.command.FluoProgram "$@"
   else
     check_hadoop
     java org.apache.fluo.cluster.command.FluoCommand "$basedir" "$HADOOP_PREFIX" "$@"
@@ -185,7 +183,7 @@
   ;;
 list)
   if [ -f "$FLUO_CONN_PROPS" ]; then
-    $JAVA org.apache.fluo.command.FluoList "${@:2}"
+    $JAVA org.apache.fluo.command.FluoProgram "$@"
   else
     check_hadoop
     java org.apache.fluo.cluster.command.FluoCommand "$basedir" "$HADOOP_PREFIX" list app "${@:2}"
@@ -195,6 +193,10 @@
   echo "$CLASSPATH"
   ;;
 exec)
+  if [[ $2 = *"-h"* ]]; then
+    $JAVA org.apache.fluo.command.FluoProgram $1 -h
+    exit 0
+  fi
   app=$2
   verify_app "$app"
   check_conn_props
@@ -202,18 +204,18 @@
   app_lib=$(mktemp -d "$FLUO_TMP"/fluo-"$app"-XXXXXXXXX) || die "fatal: unable to allocate a temporary directory"
   # schedule removal of app_lib tmp dir when this script exits
   trap "rm -rf '""$app_lib""'" EXIT HUP INT QUIT TERM
-  $JAVA org.apache.fluo.command.FluoGetJars -d "$app_lib" -a "$app"
+  $JAVA org.apache.fluo.command.FluoProgram get-jars -d "$app_lib" -a "$app"
   export CLASSPATH="$conf:$app_lib/*:$CLASSPATH"
-  $JAVA org.apache.fluo.command.FluoExec "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 status)
-  $JAVA org.apache.fluo.command.FluoStatus "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 version)
   echo "$FLUO_VERSION"
   ;;
 wait)
-  $JAVA org.apache.fluo.command.FluoWait "${@:2}"
+  $JAVA org.apache.fluo.command.FluoProgram "$@"
   ;;
 *)
   print_usage
diff --git a/pom.xml b/pom.xml
index eebd34c..8715a45 100644
--- a/pom.xml
+++ b/pom.xml
@@ -71,7 +71,7 @@
       <dependency>
         <groupId>com.beust</groupId>
         <artifactId>jcommander</artifactId>
-        <version>1.72</version>
+        <version>1.78</version>
       </dependency>
       <dependency>
         <groupId>com.github.spotbugs</groupId>