You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bookkeeper.apache.org by si...@apache.org on 2018/06/04 20:31:03 UTC

[bookkeeper] branch master updated: Abstract the tools framework to allow merging multiple CLI tools together

This is an automated email from the ASF dual-hosted git repository.

sijie pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/bookkeeper.git


The following commit(s) were added to refs/heads/master by this push:
     new 8792531  Abstract the tools framework to allow merging multiple CLI tools together
8792531 is described below

commit 879253176ac3df556242d0dacd7440b6e501af19
Author: Sijie Guo <si...@apache.org>
AuthorDate: Mon Jun 4 13:30:43 2018 -0700

    Abstract the tools framework to allow merging multiple CLI tools together
    
    Descriptions of the changes in this PR:
    
    *Motivation*
    
    There are multiple CLI tools spreading across multiple places, e.g. new bookkeeper cli, stream storage cli and dlog. There have similar implementations. It would be better to consolidate all these tools in one place `bookkeeper-tools`.
    
    This is a PR to prepare moving `stream/cli` to be part of bookkeeper-tools.
    
    *Solution*
    
    - Abstract the CLI logic in bookkeeper-tools into a simple tools framework that can be reused in a extensible way to unify multiple tools together.
    - organize the tools module into tools/framework and tools/all
    
    *Result*
    
    Example output of the tool using this framework is listed as below:
    
    ```
    $ bin/bkctl
    bkctl interacts and operates Apache BookKeeper clusters
    
    Usage:  bkctl [flags] [command group] [commands]
    
    Commands:
    
        bookie          Commands on operating a single bookie
        bookies         Commands on operating a cluster of bookies
        cluster         Commands on administrating bookkeeper clusters
        ledger          Commands on interacting with ledgers
        namespace       Commands on operating namespaces
        table           Commands on interacting with tables
        tables          Commands on operating tables
    
        help            Display help information about it
    
    Flags:
    
        -c, --conf
            Configuration file
    
        -n, --namespace
            Namespace scope to run commands (only valid for table service for now)
    
        -u, --service-uri
            Service Uri
    
        -h, --help
            Display help information
    
    Use "bkctl [command] --help" or "bkctl help [command]" for more information
    about a command
    ```
    
    *result from help sub-command*
    
    ```
    $ bin/bkctl help table
    Commands on interacting with tables
    
    Usage:  bkctl table [command] [command options]
    
    Commands:
    
        get         Get key/value pair from a table
        inc         Increment the amount of a key in a table
        put         Put key/value pair to a table
    
        help        Display help information about it
    
    Use "bkctl table [command] --help" or "bkctl table help [command]" for more
    information about a command
    ```
    
    *result from help sub-sub-command*
    
    ```
    $ bin/bkctl table help inc
    Increment the amount of a key in a table
    
    Usage:  bkctl table inc [flags] <table> <key> <amount>
    
    Flags:
    
        -h, --help
            Display help information
    ```
    
    Master Issue: #1000
    
    Author: Sijie Guo <si...@apache.org>
    
    Reviewers: Enrico Olivelli <eo...@gmail.com>, Jia Zhai <None>
    
    This closes #1471 from sijie/tools_framework
---
 pom.xml                                            |   2 +-
 {bookkeeper-tools => tools/all}/pom.xml            |  11 +-
 .../apache/bookkeeper/tools/cli/BookKeeperCLI.java |   0
 .../bookkeeper/tools/cli/commands/CmdBase.java     |   0
 .../bookkeeper/tools/cli/commands/CmdBookie.java   |   0
 .../bookkeeper/tools/cli/commands/CmdClient.java   |   0
 .../bookkeeper/tools/cli/commands/CmdCluster.java  |   0
 .../bookkeeper/tools/cli/commands/CmdMetadata.java |   0
 .../tools/cli/commands/package-info.java           |   0
 .../apache/bookkeeper/tools/cli/package-info.java  |   0
 .../bookkeeper/tools/cli/BookKeeperCLITest.java    |   0
 .../bookkeeper/tools/cli/commands/CmdBaseTest.java |   0
 .../cli/commands/bookie/LastMarkCommandTest.java   |   0
 .../cli/commands/client/SimpleTestCommandTest.java |   0
 .../commands/cluster/ListBookiesCommandTest.java   |   0
 .../tools/cli/helpers/BookieCommandTestBase.java   |   0
 .../tools/cli/helpers/ClientCommandTest.java       |   0
 .../tools/cli/helpers/ClientCommandTestBase.java   |   0
 .../tools/cli/helpers/CommandTestBase.java         |   0
 .../tools/cli/helpers/DiscoveryCommandTest.java    |   0
 .../cli/helpers/DiscoveryCommandTestBase.java      |   0
 tools/all/src/test/resources/log4j.properties      |  42 ++++
 {bookkeeper-tools => tools/framework}/pom.xml      |  19 +-
 .../apache/bookkeeper/tools/common/BKCommand.java  |  91 +++++++
 .../apache/bookkeeper/tools/common/BKFlags.java    |  25 +-
 .../bookkeeper/tools/common}/package-info.java     |   4 +-
 .../org/apache/bookkeeper/tools/framework/Cli.java | 250 +++++++++++++++++++
 .../bookkeeper/tools/framework/CliCommand.java     |  70 ++++++
 .../tools/framework/CliCommandGroup.java           |  80 ++++++
 .../bookkeeper/tools/framework/CliFlags.java       |  25 +-
 .../apache/bookkeeper/tools/framework/CliSpec.java | 242 ++++++++++++++++++
 .../apache/bookkeeper/tools/framework/Command.java |  73 ++++++
 .../bookkeeper/tools/framework/CommandGroup.java   |   7 +-
 .../bookkeeper/tools/framework/CommandUtils.java   | 218 ++++++++++++++++
 .../bookkeeper/tools/framework/HelpCommand.java    |  69 ++++++
 .../bookkeeper/tools/framework}/package-info.java  |   6 +-
 .../apache/bookkeeper/tools/framework/CliTest.java | 274 +++++++++++++++++++++
 .../apache/bookkeeper/tools/framework/TestCli.java | 129 ++++++++++
 .../tools/framework/commands/TestCommand.java      |  58 +++++
 {bookkeeper-tools => tools}/pom.xml                |  33 +--
 40 files changed, 1673 insertions(+), 55 deletions(-)

diff --git a/pom.xml b/pom.xml
index 7161044..a18146d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,7 +57,6 @@
     <module>bookkeeper-stats</module>
     <module>bookkeeper-proto</module>
     <module>bookkeeper-server</module>
-    <module>bookkeeper-tools</module>
     <module>bookkeeper-benchmark</module>
     <module>bookkeeper-stats-providers</module>
     <module>bookkeeper-http</module>
@@ -66,6 +65,7 @@
     <module>bookkeeper-dist</module>
     <module>microbenchmarks</module>
     <module>stream/distributedlog</module>
+    <module>tools</module>
   </modules>
   <mailingLists>
     <mailingList>
diff --git a/bookkeeper-tools/pom.xml b/tools/all/pom.xml
similarity index 90%
copy from bookkeeper-tools/pom.xml
copy to tools/all/pom.xml
index 37544d1..db6d91d 100644
--- a/bookkeeper-tools/pom.xml
+++ b/tools/all/pom.xml
@@ -18,7 +18,7 @@
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <parent>
-    <artifactId>bookkeeper</artifactId>
+    <artifactId>bookkeeper-tools-parent</artifactId>
     <groupId>org.apache.bookkeeper</groupId>
     <version>4.8.0-SNAPSHOT</version>
   </parent>
@@ -27,12 +27,13 @@
   <dependencies>
     <dependency>
       <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
-      <version>${project.parent.version}</version>
+      <artifactId>bookkeeper-tools-framework</artifactId>
+      <version>${project.version}</version>
     </dependency>
     <dependency>
-      <groupId>com.beust</groupId>
-      <artifactId>jcommander</artifactId>
+      <groupId>org.apache.bookkeeper</groupId>
+      <artifactId>bookkeeper-server</artifactId>
+      <version>${project.parent.version}</version>
     </dependency>
     <dependency>
       <groupId>org.apache.bookkeeper</groupId>
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/BookKeeperCLI.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/BookKeeperCLI.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/BookKeeperCLI.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/BookKeeperCLI.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBase.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBase.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBase.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBase.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBookie.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBookie.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBookie.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdBookie.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdClient.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdClient.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdClient.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdClient.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdCluster.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdCluster.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdCluster.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdCluster.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdMetadata.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdMetadata.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdMetadata.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/CmdMetadata.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
similarity index 100%
copy from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
copy to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/package-info.java b/tools/all/src/main/java/org/apache/bookkeeper/tools/cli/package-info.java
similarity index 100%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/package-info.java
rename to tools/all/src/main/java/org/apache/bookkeeper/tools/cli/package-info.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/BookKeeperCLITest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/BookKeeperCLITest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/BookKeeperCLITest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/BookKeeperCLITest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/CmdBaseTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/CmdBaseTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/CmdBaseTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/CmdBaseTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/bookie/LastMarkCommandTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/bookie/LastMarkCommandTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/bookie/LastMarkCommandTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/bookie/LastMarkCommandTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/client/SimpleTestCommandTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/client/SimpleTestCommandTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/client/SimpleTestCommandTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/client/SimpleTestCommandTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/cluster/ListBookiesCommandTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/cluster/ListBookiesCommandTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/commands/cluster/ListBookiesCommandTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/commands/cluster/ListBookiesCommandTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/BookieCommandTestBase.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/BookieCommandTestBase.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/BookieCommandTestBase.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/BookieCommandTestBase.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTestBase.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTestBase.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTestBase.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/ClientCommandTestBase.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/CommandTestBase.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/CommandTestBase.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/CommandTestBase.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/CommandTestBase.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTest.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTest.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTest.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTest.java
diff --git a/bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTestBase.java b/tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTestBase.java
similarity index 100%
rename from bookkeeper-tools/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTestBase.java
rename to tools/all/src/test/java/org/apache/bookkeeper/tools/cli/helpers/DiscoveryCommandTestBase.java
diff --git a/tools/all/src/test/resources/log4j.properties b/tools/all/src/test/resources/log4j.properties
new file mode 100644
index 0000000..10ae6bf
--- /dev/null
+++ b/tools/all/src/test/resources/log4j.properties
@@ -0,0 +1,42 @@
+#
+#
+# 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.
+#
+#
+
+#
+# Bookkeeper Logging Configuration
+#
+
+# Format is "<default threshold> (, <appender>)+
+
+# DEFAULT: console appender only, level INFO
+bookkeeper.root.logger=INFO,CONSOLE
+log4j.rootLogger=${bookkeeper.root.logger}
+
+#
+# Log INFO level and above messages to the console
+#
+log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
+log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
+log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} - %-5p - [%t:%C{1}@%L] - %m%n
+
+#disable zookeeper logging
+log4j.logger.org.apache.zookeeper=OFF
+log4j.logger.org.apache.bookkeeper.bookie=INFO
+log4j.logger.org.apache.bookkeeper.meta=INFO
diff --git a/bookkeeper-tools/pom.xml b/tools/framework/pom.xml
similarity index 77%
copy from bookkeeper-tools/pom.xml
copy to tools/framework/pom.xml
index 37544d1..516e3f8 100644
--- a/bookkeeper-tools/pom.xml
+++ b/tools/framework/pom.xml
@@ -18,32 +18,25 @@
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <parent>
-    <artifactId>bookkeeper</artifactId>
+    <artifactId>bookkeeper-tools-parent</artifactId>
     <groupId>org.apache.bookkeeper</groupId>
     <version>4.8.0-SNAPSHOT</version>
   </parent>
-  <artifactId>bookkeeper-tools</artifactId>
-  <name>Apache BookKeeper :: Tools</name>
+  <artifactId>bookkeeper-tools-framework</artifactId>
+  <name>Apache BookKeeper :: Tools :: Framework</name>
   <dependencies>
     <dependency>
-      <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
-      <version>${project.parent.version}</version>
-    </dependency>
-    <dependency>
       <groupId>com.beust</groupId>
       <artifactId>jcommander</artifactId>
     </dependency>
     <dependency>
       <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>buildtools</artifactId>
-      <version>${project.parent.version}</version>
-      <scope>test</scope>
+      <artifactId>bookkeeper-common</artifactId>
+      <version>${project.version}</version>
     </dependency>
     <dependency>
       <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
-      <type>test-jar</type>
+      <artifactId>buildtools</artifactId>
       <version>${project.parent.version}</version>
       <scope>test</scope>
     </dependency>
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKCommand.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKCommand.java
new file mode 100644
index 0000000..2f2724b
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKCommand.java
@@ -0,0 +1,91 @@
+/*
+ * 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.bookkeeper.tools.common;
+
+import com.google.common.base.Strings;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Paths;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.bookkeeper.common.net.ServiceURI;
+import org.apache.bookkeeper.tools.framework.Cli;
+import org.apache.bookkeeper.tools.framework.CliCommand;
+import org.apache.bookkeeper.tools.framework.CliFlags;
+import org.apache.bookkeeper.tools.framework.CliSpec;
+import org.apache.commons.configuration.CompositeConfiguration;
+import org.apache.commons.configuration.ConfigurationException;
+import org.apache.commons.configuration.PropertiesConfiguration;
+
+/**
+ * Base bk command class.
+ */
+@Slf4j
+public abstract class BKCommand<CommandFlagsT extends CliFlags> extends CliCommand<BKFlags, CommandFlagsT> {
+
+    protected BKCommand(CliSpec<CommandFlagsT> spec) {
+        super(spec);
+    }
+
+    @Override
+    public Boolean apply(BKFlags globalFlags, String[] args) {
+        CliSpec<CommandFlagsT> newSpec = CliSpec.newBuilder(spec)
+            .withRunFunc(cmdFlags -> apply(globalFlags, cmdFlags))
+            .build();
+        return 0 == Cli.runCli(newSpec, args);
+    }
+
+    protected boolean apply(BKFlags bkFlags, CommandFlagsT cmdFlags) {
+        ServiceURI serviceURI = null;
+
+        if (null != bkFlags.serviceUri) {
+            serviceURI = ServiceURI.create(bkFlags.serviceUri);
+            if (!acceptServiceUri(serviceURI)) {
+                log.error("Unresolvable service uri by command '{}' : {}",
+                    path(), bkFlags.serviceUri);
+                return false;
+            }
+        }
+
+        CompositeConfiguration conf = new CompositeConfiguration();
+        if (!Strings.isNullOrEmpty(bkFlags.configFile)) {
+            try {
+                URL configFileUrl = Paths.get(bkFlags.configFile).toUri().toURL();
+                PropertiesConfiguration loadedConf = new PropertiesConfiguration(configFileUrl);
+                conf.addConfiguration(loadedConf);
+            } catch (MalformedURLException e) {
+                log.error("Could not open configuration file : {}", bkFlags.configFile, e);
+                throw new IllegalArgumentException(e);
+            } catch (ConfigurationException e) {
+                log.error("Malformed configuration file : {}", bkFlags.configFile, e);
+                throw new IllegalArgumentException(e);
+            }
+        }
+
+        return apply(serviceURI, conf, cmdFlags);
+    }
+
+    protected boolean acceptServiceUri(ServiceURI serviceURI) {
+        return true;
+    }
+
+    protected abstract boolean apply(ServiceURI serviceURI,
+                                     CompositeConfiguration conf,
+                                     CommandFlagsT cmdFlags);
+
+}
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKFlags.java
similarity index 61%
copy from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
copy to tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKFlags.java
index 5e1aed5..890a3df 100644
--- a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/BKFlags.java
@@ -16,7 +16,28 @@
  * limitations under the License.
  */
 
+package org.apache.bookkeeper.tools.common;
+
+import com.beust.jcommander.Parameter;
+import org.apache.bookkeeper.tools.framework.CliFlags;
+
 /**
- * BookKeeper CLI commands.
+ * Default BK flags.
  */
-package org.apache.bookkeeper.tools.cli.commands;
\ No newline at end of file
+public final class BKFlags extends CliFlags {
+
+    @Parameter(
+        names = {
+            "-u", "--service-uri"
+        },
+        description = "Service Uri")
+    public String serviceUri = null;
+
+    @Parameter(
+        names = {
+            "-c", "--conf"
+        },
+        description = "Configuration file")
+    public String configFile = null;
+
+}
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/package-info.java
similarity index 90%
copy from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
copy to tools/framework/src/main/java/org/apache/bookkeeper/tools/common/package-info.java
index 5e1aed5..2268b63 100644
--- a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/common/package-info.java
@@ -17,6 +17,6 @@
  */
 
 /**
- * BookKeeper CLI commands.
+ * Common classes used across multiple tools.
  */
-package org.apache.bookkeeper.tools.cli.commands;
\ No newline at end of file
+package org.apache.bookkeeper.tools.common;
\ No newline at end of file
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Cli.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Cli.java
new file mode 100644
index 0000000..44b210f
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Cli.java
@@ -0,0 +1,250 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import com.beust.jcommander.JCommander;
+import com.google.common.base.Strings;
+import java.io.PrintStream;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.bookkeeper.tools.framework.CliSpec.Builder;
+
+/**
+ * Cli to execute {@link CliSpec}.
+ */
+@Slf4j
+public class Cli<CliFlagsT extends CliFlags> {
+
+    private final CliSpec<CliFlagsT> spec;
+    private final JCommander commander;
+    @Getter(AccessLevel.PACKAGE)
+    private final Map<String, Command> commandMap;
+    private final Function<CliFlagsT, Boolean> runFunc;
+    private final String cmdPath;
+    private final PrintStream console;
+
+    public Cli(CliSpec<CliFlagsT> spec) {
+        this.spec = updateSpecWithDefaultValues(spec);
+        this.cmdPath = getCmdPath(spec);
+        this.commandMap = new TreeMap<>();
+        this.console = setupConsole(spec);
+        this.commander = setupCli(cmdPath, spec, commandMap);
+        if (null == spec.runFunc()) {
+            this.runFunc = (args) -> {
+                usage();
+                return false;
+            };
+        } else {
+            this.runFunc = spec.runFunc();
+        }
+
+        // inject default commands if needed
+        if (!spec.commands().isEmpty() && !hasCommand("help")) {
+            commandMap.put("help", new HelpCommand<>(this));
+        }
+    }
+
+    static <CliFlagsT extends CliFlags> String getCmdPath(CliSpec<CliFlagsT> spec) {
+        if (Strings.isNullOrEmpty(spec.parent())) {
+            return spec.name();
+        } else {
+            return spec.parent() + " " + spec.name();
+        }
+    }
+
+    static <CliFlagsT extends CliFlags> PrintStream setupConsole(CliSpec<CliFlagsT> spec) {
+        if (null == spec.console()) {
+            return System.out;
+        } else {
+            return spec.console();
+        }
+    }
+
+    CliSpec<CliFlagsT> updateSpecWithDefaultValues(CliSpec<CliFlagsT> spec) {
+        Builder<CliFlagsT> builder = CliSpec.newBuilder(spec);
+        if (Strings.isNullOrEmpty(spec.usage())) {
+            // simple command
+            if (spec.commands().isEmpty()) {
+                builder.withUsage(
+                    String.format(
+                        "%s [flags] " + spec.argumentsUsage(),
+                        getCmdPath(spec)));
+            // command group
+            } else {
+                builder.withUsage(
+                    String.format(
+                        "%s [flags] [command] [command options]",
+                        getCmdPath(spec)));
+            }
+        }
+
+        if (!spec.commands().isEmpty() && Strings.isNullOrEmpty(spec.tailer())) {
+            builder.withTailer(
+                String.format(
+                      "Use \"%s [command] --help\" or \"%s help [command]\" for "
+                    + "more information about a command",
+                    getCmdPath(spec), getCmdPath(spec)));
+        }
+        return builder.build();
+    }
+
+    String name() {
+        return spec.name();
+    }
+
+    String cmdPath() {
+        return cmdPath;
+    }
+
+    boolean hasCommand(String command) {
+        return commandMap.containsKey(command);
+    }
+
+    Command getCommand(String command) {
+        return commandMap.get(command);
+    }
+
+    static <CliFlagsT extends CliFlags> JCommander setupCli(String cmdPath,
+                                                            CliSpec<CliFlagsT> spec,
+                                                            Map<String, Command> commandMap) {
+        JCommander commander = new JCommander();
+        commander.setProgramName(cmdPath);
+        if (null != spec.flags()) {
+            commander.addObject(spec.flags());
+        }
+        for (Command cmd : spec.commands()) {
+            commandMap.put(cmd.name(), cmd);
+        }
+        // trigger command to generate help information
+        StringBuilder usageBuilder = new StringBuilder();
+        commander.usage(usageBuilder);
+        return commander;
+    }
+
+    protected void console(String msg) {
+        console().println(msg);
+    }
+
+    protected PrintStream console() {
+        return console;
+    }
+
+    void usage() {
+        usage(null);
+    }
+
+    void usage(String errorMsg) {
+        if (Strings.isNullOrEmpty(errorMsg)) {
+            CommandUtils.printDescription(console(), 0, 0, spec.description());
+            console("");
+        } else {
+            CommandUtils.printDescription(console(), 0, 0, "Error : " + errorMsg);
+            console("");
+        }
+
+        // usage section
+        CommandUtils.printUsage(console(), spec.usage());
+
+        // command section
+        CommandUtils.printAvailableCommands(commandMap, console());
+
+        // flags section
+        if (!spec.isCommandGroup()) {
+            CommandUtils.printAvailableFlags(commander, console());
+        }
+
+        if (!spec.commands().isEmpty()) {
+            // tailer section
+            CommandUtils.printDescription(console(), 0, 0, spec.tailer());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    boolean run(String[] args) {
+        // the cli has commands and args is empty
+        if (!spec.commands().isEmpty() && args.length == 0) {
+            usage();
+            return false;
+        }
+
+        int cmdPos = IntStream.range(0, args.length)
+            .filter(pos -> commandMap.containsKey(args[pos]))
+            .findFirst()
+            .orElse(args.length);
+
+        String[] flagArgs = Arrays.copyOfRange(args, 0, cmdPos);
+
+        // if this cli is a sub-command group, the flag args should be empty
+        if (spec.isCommandGroup() && flagArgs.length > 0) {
+            usage();
+            return false;
+        }
+
+        // parse flags
+        if (flagArgs.length != 0) {
+            // skip parsing flags
+            try {
+                commander.parse(flagArgs);
+            } catch (Exception e) {
+                usage(e.getMessage());
+                return false;
+            }
+        }
+
+        // print help messages
+        if (spec.flags().help) {
+            usage();
+            return false;
+        }
+
+        // found a sub command
+        if (cmdPos == args.length) {
+            return runFunc.apply(spec.flags());
+        } else {
+            String cmd = args[cmdPos];
+            Command command = commandMap.get(cmd);
+            String[] subCmdArgs = Arrays.copyOfRange(
+                args, cmdPos + 1, args.length);
+
+            try {
+                return command.apply(spec.flags(), subCmdArgs);
+            } catch (Exception e) {
+                usage(e.getMessage());
+                return false;
+            }
+        }
+    }
+
+    public static <CliOptsT extends CliFlags> int runCli(
+            CliSpec<CliOptsT> spec, String[] args) {
+        Cli<CliOptsT> cli = new Cli<>(spec);
+        return cli.run(args) ? 0 : -1;
+    }
+
+    public static <CliOptsT extends CliFlags> void printUsage(CliSpec<CliOptsT> spec) {
+        Cli<CliOptsT> cli = new Cli<>(spec);
+        cli.usage();
+    }
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommand.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommand.java
new file mode 100644
index 0000000..ab7b955
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommand.java
@@ -0,0 +1,70 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+/**
+ * A command that runs a CLI spec. it is typically used for nested sub commands.
+ */
+public class CliCommand<GlobalFlagsT extends CliFlags, CommandFlagsT extends CliFlags>
+        implements Command<GlobalFlagsT> {
+
+    private final String name;
+    private String parent;
+    protected CliSpec<CommandFlagsT> spec;
+
+    public CliCommand(CliSpec<CommandFlagsT> spec) {
+        this.spec = spec;
+        this.name = spec.name();
+        this.parent = spec.parent();
+    }
+
+    public void setParent(String parent) {
+        this.parent = parent;
+        this.spec = CliSpec.newBuilder(spec)
+            .withParent(parent)
+            .build();
+    }
+
+    @Override
+    public String name() {
+        return name;
+    }
+
+    @Override
+    public String path() {
+        return parent + " " + name;
+    }
+
+    @Override
+    public String description() {
+        return spec.description();
+    }
+
+    @Override
+    public Boolean apply(GlobalFlagsT globalFlags, String[] args) {
+        int res = Cli.runCli(spec, args);
+        return res == 0;
+    }
+
+    @Override
+    public void usage() {
+        // run with "empty args", which will print the usage for this command group.
+        Cli.printUsage(spec);
+    }
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommandGroup.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommandGroup.java
new file mode 100644
index 0000000..efe8fdd
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliCommandGroup.java
@@ -0,0 +1,80 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import com.google.common.base.Strings;
+
+/**
+ * A command group that group commands together. They share same global flags.
+ */
+public abstract class CliCommandGroup<GlobalFlagsT extends CliFlags>
+        extends CliCommand<GlobalFlagsT, GlobalFlagsT> implements CommandGroup<GlobalFlagsT> {
+
+    @SuppressWarnings("unchecked")
+    private static <GlobalFlagsT extends CliFlags> CliSpec<GlobalFlagsT> updateSpec(CliSpec<GlobalFlagsT> spec) {
+        CliSpec<GlobalFlagsT> newSpec;
+        if (Strings.isNullOrEmpty(spec.usage())) {
+            newSpec = CliSpec.newBuilder(spec)
+                .withUsage(String.format("%s %s [command] [command options]",
+                    spec.parent(), spec.name()
+                ))
+                .withFlags(null)
+                .build();
+        } else {
+            newSpec = CliSpec.newBuilder(spec)
+                .withFlags(null)
+                .build();
+        }
+
+        String path = newSpec.parent() + " " + newSpec.name();
+
+        for (Command<GlobalFlagsT> cmd : newSpec.commands()) {
+            if (cmd instanceof CliCommand) {
+                CliCommand<GlobalFlagsT, GlobalFlagsT> cliCmd = (CliCommand<GlobalFlagsT, GlobalFlagsT>) cmd;
+                cliCmd.setParent(path);
+            }
+        }
+
+        return newSpec;
+    }
+
+    protected CliCommandGroup(CliSpec<GlobalFlagsT> spec) {
+        super(updateSpec(spec));
+    }
+
+    @Override
+    public Boolean apply(GlobalFlagsT globalFlags, String[] args) {
+        CliSpec<GlobalFlagsT> newSpec = CliSpec.newBuilder(spec)
+            .withFlags(globalFlags)
+            .setCommandGroup(true)
+            .build();
+
+        return 0 == Cli.runCli(newSpec, args);
+    }
+
+    @Override
+    public void usage() {
+        CliSpec<GlobalFlagsT> newSpec = CliSpec.newBuilder(spec)
+            .setCommandGroup(true)
+            .build();
+
+        // run with "empty args", which will print the usage for this command group.
+        Cli.printUsage(newSpec);
+    }
+}
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliFlags.java
similarity index 59%
copy from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
copy to tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliFlags.java
index 5e1aed5..3bff70d 100644
--- a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliFlags.java
@@ -16,7 +16,28 @@
  * limitations under the License.
  */
 
+package org.apache.bookkeeper.tools.framework;
+
+
+import com.beust.jcommander.Parameter;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.util.ArrayList;
+import java.util.List;
+
 /**
- * BookKeeper CLI commands.
+ * Default CLI Options.
  */
-package org.apache.bookkeeper.tools.cli.commands;
\ No newline at end of file
+@SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
+public class CliFlags {
+
+    @Parameter(description = "args")
+    public List<String> arguments = new ArrayList<>();
+
+    @Parameter(
+        names = {
+            "-h", "--help"
+        },
+        description = "Display help information")
+    public boolean help = false;
+
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliSpec.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliSpec.java
new file mode 100644
index 0000000..bbae3f5
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CliSpec.java
@@ -0,0 +1,242 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import com.google.common.collect.Sets;
+import java.io.PrintStream;
+import java.util.Set;
+import java.util.function.Function;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * A spec to build CLI.
+ */
+@ToString
+@EqualsAndHashCode
+public class CliSpec<CliFlagsT extends CliFlags> {
+
+    /**
+     * Create a new builder to build the cli spec.
+     *
+     * @return a new builder to build the cli spec.
+     */
+    public static <T extends CliFlags> Builder<T> newBuilder() {
+        return new Builder<>();
+    }
+
+    /**
+     * Create a new builder to build the cli spec from an existing <tt>spec</tt>.
+     *
+     * @param spec cli spec
+     * @return a new builder to build the cli spec from an existing <tt>spec</tt>.
+     */
+    public static <T extends CliFlags> Builder<T> newBuilder(CliSpec<T> spec) {
+        return new Builder<>(spec);
+    }
+
+    /**
+     * Builder to build a cli spec.
+     */
+    public static class Builder<CliFlagsT extends CliFlags> {
+
+        private String name = "unknown";
+        private String parent = "";
+        private String usage = "";
+        private CliFlagsT flags = null;
+        private String description = "unset";
+        private final Set<Command> commands = Sets.newHashSet();
+        private String tailer = "";
+        private Function<CliFlagsT, Boolean> runFunc = null;
+        private PrintStream console = System.out;
+        private boolean isCommandGroup = false;
+        private String argumentsUsage = "";
+
+        private Builder() {}
+
+        private Builder(CliSpec<CliFlagsT> spec) {
+            this.name = spec.name;
+            this.parent = spec.parent;
+            this.usage = spec.usage;
+            this.argumentsUsage = spec.argumentsUsage;
+            this.flags = spec.flags;
+            this.description = spec.description;
+            this.commands.clear();
+            this.commands.addAll(spec.commands);
+            this.tailer = spec.tailer;
+            this.runFunc = spec.runFunc;
+            this.console = spec.console;
+            this.isCommandGroup = spec.isCommandGroup;
+        }
+
+        public Builder<CliFlagsT> withName(String name) {
+            this.name = name;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withParent(String parent) {
+            this.parent = parent;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withUsage(String usage) {
+            this.usage = usage;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withArgumentsUsage(String usage) {
+            this.argumentsUsage = usage;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withFlags(CliFlagsT flags) {
+            this.flags = flags;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withDescription(String description) {
+            this.description = description;
+            return this;
+        }
+
+        public Builder<CliFlagsT> addCommand(Command command) {
+            this.commands.add(command);
+            return this;
+        }
+
+        public Builder<CliFlagsT> withTailer(String tailer) {
+            this.tailer = tailer;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withRunFunc(Function<CliFlagsT, Boolean> func) {
+            this.runFunc = func;
+            return this;
+        }
+
+        public Builder<CliFlagsT> withConsole(PrintStream console) {
+            this.console = console;
+            return this;
+        }
+
+        public Builder<CliFlagsT> setCommandGroup(boolean enabled) {
+            this.isCommandGroup = enabled;
+            return this;
+        }
+
+        public CliSpec<CliFlagsT> build() {
+            return new CliSpec<>(
+                name,
+                parent,
+                usage,
+                argumentsUsage,
+                flags,
+                description,
+                commands,
+                tailer,
+                runFunc,
+                console,
+                isCommandGroup
+            );
+        }
+
+    }
+
+    private final String name;
+    private final String parent;
+    private final String usage;
+    private final String argumentsUsage;
+    private final CliFlagsT flags;
+    private final String description;
+    private final Set<Command> commands;
+    private final String tailer;
+    private final Function<CliFlagsT, Boolean> runFunc;
+    private final PrintStream console;
+    // whether the cli spec is for a command group.
+    private final boolean isCommandGroup;
+
+    private CliSpec(String name,
+                    String parent,
+                    String usage,
+                    String argumentsUsage,
+                    CliFlagsT flags,
+                    String description,
+                    Set<Command> commands,
+                    String tailer,
+                    Function<CliFlagsT, Boolean> runFunc,
+                    PrintStream console,
+                    boolean isCommandGroup) {
+        this.name = name;
+        this.parent = parent;
+        this.usage = usage;
+        this.flags = flags;
+        this.argumentsUsage = argumentsUsage;
+        this.description = description;
+        this.commands = commands;
+        this.tailer = tailer;
+        this.runFunc = runFunc;
+        this.console = console;
+        this.isCommandGroup = isCommandGroup;
+    }
+
+    public String name() {
+        return name;
+    }
+
+    public String parent() {
+        return parent;
+    }
+
+    public String usage() {
+        return usage;
+    }
+
+    public String argumentsUsage() {
+        return argumentsUsage;
+    }
+
+    public CliFlagsT flags() {
+        return flags;
+    }
+
+    public String description() {
+        return description;
+    }
+
+    public Set<Command> commands() {
+        return commands;
+    }
+
+    public String tailer() {
+        return tailer;
+    }
+
+    public Function<CliFlagsT, Boolean> runFunc() {
+        return runFunc;
+    }
+
+    public PrintStream console() {
+        return console;
+    }
+
+    public boolean isCommandGroup() {
+        return isCommandGroup;
+    }
+
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Command.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Command.java
new file mode 100644
index 0000000..186b29f
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/Command.java
@@ -0,0 +1,73 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+/**
+ * Command of a cli.
+ */
+public interface Command<GlobalFlagsT extends CliFlags> {
+
+    /**
+     * Returns whether to hide this command from showing in help message.
+     *
+     * @return true if hide this command from help message.
+     */
+    default boolean hidden() {
+        return false;
+    }
+
+    /**
+     * Return command name.
+     *
+     * @return command name.
+     */
+    String name();
+
+    /**
+     * Return command path in a cli path.
+     *
+     * <p>This is used for printing usage information.
+     *
+     * @return command path
+     */
+    default String path() {
+        return name();
+    }
+
+    /**
+     * Return description name.
+     *
+     * @return description name.
+     */
+    String description();
+
+    /**
+     * Process the command.
+     *
+     * @param args command args
+     * @return true if successfully apply the args, otherwise false
+     */
+    Boolean apply(GlobalFlagsT globalFlagsT,
+                  String[] args) throws Exception;
+
+    /**
+     * Print the help information.
+     */
+    void usage();
+}
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandGroup.java
similarity index 80%
copy from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
copy to tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandGroup.java
index 5e1aed5..2e092f2 100644
--- a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandGroup.java
@@ -16,7 +16,10 @@
  * limitations under the License.
  */
 
+package org.apache.bookkeeper.tools.framework;
+
 /**
- * BookKeeper CLI commands.
+ * A command group that group commands together.
  */
-package org.apache.bookkeeper.tools.cli.commands;
\ No newline at end of file
+public interface CommandGroup<GlobalFlagsT extends CliFlags> extends Command<GlobalFlagsT> {
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandUtils.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandUtils.java
new file mode 100644
index 0000000..77c6b48
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/CommandUtils.java
@@ -0,0 +1,218 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.ParameterDescription;
+import com.beust.jcommander.WrappedParameter;
+import com.google.common.collect.Lists;
+import java.io.PrintStream;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.IntStream;
+
+/**
+ * Utils to process a commander.
+ */
+public class CommandUtils {
+
+    private static final int MAX_COLUMN_SIZE = 79;
+    private static final int DEFAULT_INDENT = 4;
+    private static final String USAGE_HEADER = "Usage:";
+
+    private static final Comparator<? super ParameterDescription> PD_COMPARATOR =
+        (Comparator<ParameterDescription>) (p0, p1) -> p0.getLongestName().compareTo(p1.getLongestName());
+
+    private static void printIndent(PrintStream printer, int indent) {
+        IntStream.range(0, indent).forEach(ignored -> printer.print(" "));
+    }
+
+    public static void printUsage(PrintStream printer, String usage) {
+        final int indent = ((USAGE_HEADER.length() / DEFAULT_INDENT) + 1) * DEFAULT_INDENT;
+        final int firstIndent = indent - USAGE_HEADER.length();
+        printer.print(USAGE_HEADER);
+        printDescription(
+            printer,
+            firstIndent,
+            indent,
+            usage);
+        printer.println();
+    }
+
+    /**
+     * Print the available flags in <tt>commander</tt>.
+     *
+     * @param commander commander
+     * @param printer printer
+     */
+    public static void printAvailableFlags(JCommander commander, PrintStream printer) {
+        List<ParameterDescription> sorted = Lists.newArrayList();
+        List<ParameterDescription> pds = commander.getParameters();
+
+        // Align the descriptions at the `longestName`
+        int longestName = 0;
+        for (ParameterDescription pd : pds) {
+           if (pd.getParameter().hidden()) {
+               continue;
+           }
+
+           sorted.add(pd);
+           int length = pd.getNames().length() + 2;
+           if (length > longestName) {
+               longestName = length;
+           }
+        }
+
+        if (sorted.isEmpty()) {
+            return;
+        }
+
+        // Sorted the flags
+        Collections.sort(sorted, PD_COMPARATOR);
+
+        // Display the flags
+        printer.println("Flags:");
+        printer.println();
+
+        ParameterDescription helpPd = null;
+
+        for (ParameterDescription pd : sorted) {
+            if ("--help".equals(pd.getLongestName())) {
+                helpPd = pd;
+                continue;
+            }
+
+            printFlag(pd, DEFAULT_INDENT, printer);
+            printer.println();
+        }
+
+        if (null != helpPd) {
+            printer.println();
+            printFlag(helpPd, DEFAULT_INDENT, printer);
+            printer.println();
+        }
+
+    }
+
+    private static void printFlag(ParameterDescription pd, int indent, PrintStream printer) {
+        WrappedParameter parameter = pd.getParameter();
+        // print flag
+        printIndent(printer, indent);
+        printer.print(pd.getNames());
+        printer.print(parameter.required() ? " (*)" : "");
+        printer.println();
+        // print flag description
+        int descIndent = 2 * indent;
+        printDescription(printer, descIndent, descIndent, pd.getDescription());
+    }
+
+    public static void printDescription(PrintStream printer,
+                                        int firstLineIndent,
+                                        int indent,
+                                        String description) {
+        int max = MAX_COLUMN_SIZE;
+        String[] words = description.split(" ");
+        int current = indent;
+        int i = 0;
+        printIndent(printer, firstLineIndent);
+        while (i < words.length) {
+            String word = words[i];
+            if (word.length() > max || current + word.length() <= max) {
+                if (i != 0) {
+                    printer.print(" ");
+                }
+                printer.print(word);
+                current += (word.length() + 1);
+            } else {
+                printer.println();
+                printIndent(printer, indent);
+                printer.print(word);
+                current = indent;
+            }
+            i++;
+        }
+        printer.println();
+    }
+
+    /**
+     * Print the available commands in <tt>commander</tt>.
+     *
+     * @param commands commands
+     * @param printer printer
+     */
+    public static void printAvailableCommands(Map<String, Command> commands,
+                                              PrintStream printer) {
+        if (commands.isEmpty()) {
+            return;
+        }
+
+        printer.println("Commands:");
+        printer.println();
+
+        int longestCommandName = commands
+            .keySet()
+            .stream()
+            .mapToInt(name -> name.length())
+            .max()
+            .orElse(0);
+
+        for (Map.Entry<String, Command> commandEntry : commands.entrySet()) {
+            if ("help".equals(commandEntry.getKey())) {
+                // don't print help message along with available other commands
+                continue;
+            }
+            printCommand(printer, commandEntry.getKey(), commandEntry.getValue(), longestCommandName);
+        }
+
+        Command helpCmd = commands.get("help");
+        if (null != helpCmd) {
+            printer.println();
+            printCommand(printer, "help", helpCmd, longestCommandName);
+        }
+
+        printer.println();
+    }
+
+    private static void printCommand(PrintStream printer,
+                                     String name,
+                                     Command command,
+                                     final int longestCommandName) {
+        if (command.hidden()) {
+            return;
+        }
+
+        final int indent = DEFAULT_INDENT;
+        final int startOfDescription =
+            (((indent + longestCommandName) / DEFAULT_INDENT) + 2) * DEFAULT_INDENT;
+
+        int current = 0;
+        printIndent(printer, indent);
+        printer.print(name);
+        current += (indent + name.length());
+        printIndent(printer, startOfDescription - current);
+        printDescription(
+            printer,
+            0,
+            startOfDescription,
+            command.description());
+    }
+
+}
diff --git a/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/HelpCommand.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/HelpCommand.java
new file mode 100644
index 0000000..9981b7c
--- /dev/null
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/HelpCommand.java
@@ -0,0 +1,69 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+/**
+ * Help command to show help information.
+ */
+class HelpCommand<GlobalFlagsT extends CliFlags, CommandFlagsT extends CliFlags>
+        implements Command<GlobalFlagsT> {
+
+    private final Cli<CommandFlagsT> cli;
+
+    HelpCommand(Cli<CommandFlagsT> cli) {
+        this.cli = cli;
+    }
+
+    @Override
+    public String name() {
+        return "help";
+    }
+
+    @Override
+    public String description() {
+        return "Display help information about it";
+    }
+
+    @Override
+    public Boolean apply(GlobalFlagsT globalFlags, String[] args) throws Exception {
+        if (args.length == 0) {
+            cli.usage();
+            return true;
+        }
+
+        String cmd = args[0];
+        Command command = cli.getCommand(cmd);
+        if (null != command) {
+            command.usage();
+        } else {
+            cli.usage("Command \"" + cmd + "\" is not found.");
+        }
+
+        return true;
+    }
+
+    @Override
+    public void usage() {
+        cli.console("Help provides help for any command in this cli.");
+        cli.console("Simply type '" + cli.cmdPath() + " help [command]' for full details.");
+        cli.console("");
+        CommandUtils.printUsage(cli.console(), cli.cmdPath() + " help [command] [options]");
+    }
+
+}
diff --git a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/package-info.java
similarity index 84%
rename from bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
rename to tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/package-info.java
index 5e1aed5..a488fd7 100644
--- a/bookkeeper-tools/src/main/java/org/apache/bookkeeper/tools/cli/commands/package-info.java
+++ b/tools/framework/src/main/java/org/apache/bookkeeper/tools/framework/package-info.java
@@ -17,6 +17,8 @@
  */
 
 /**
- * BookKeeper CLI commands.
+ * BookKeeper Tool Framework.
+ *
+ * <p>This package provides classes for writing various bookkeeper tools.
  */
-package org.apache.bookkeeper.tools.cli.commands;
\ No newline at end of file
+package org.apache.bookkeeper.tools.framework;
\ No newline at end of file
diff --git a/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/CliTest.java b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/CliTest.java
new file mode 100644
index 0000000..150f551
--- /dev/null
+++ b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/CliTest.java
@@ -0,0 +1,274 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.Lists;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.util.concurrent.CompletableFuture;
+import org.apache.bookkeeper.common.concurrent.FutureUtils;
+import org.apache.bookkeeper.tools.framework.TestCli.TestNestedFlags;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Testing the Cli framework using {@link TestCli}.
+ */
+public class CliTest {
+
+    private ByteArrayOutputStream consoleBuffer;
+    private PrintStream console;
+
+    @Before
+    public void setup() throws Exception {
+        this.consoleBuffer = new ByteArrayOutputStream();
+        this.console = new PrintStream(consoleBuffer, true, UTF_8.name());
+    }
+
+    @Test
+    public void testEmptyArgs() {
+        assertEquals(-1, TestCli.doMain(console, new String[] {}));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains("Usage:"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+    }
+
+    @Test
+    public void testHelpCommand() {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "help"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains("Usage:"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+    }
+
+    @Test
+    public void testHelpFlag() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "help"
+        }));
+        String buffer1 = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        ByteArrayOutputStream anotherBuffer = new ByteArrayOutputStream();
+        PrintStream anotherConsole = new PrintStream(anotherBuffer, true, UTF_8.name());
+
+        assertEquals(-1, TestCli.doMain(anotherConsole, new String[] {
+            "-h"
+        }));
+        String buffer2 = new String(anotherBuffer.toByteArray(), UTF_8);
+
+        assertEquals(buffer1, buffer2);
+    }
+
+    @Test
+    public void testPrintCommandUsage() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "help", "dog"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        ByteArrayOutputStream expectedBufferStream = new ByteArrayOutputStream();
+        PrintStream expectedConsole = new PrintStream(expectedBufferStream, true, UTF_8.name());
+        expectedConsole.println("command dog");
+        String expectedBuffer = new String(expectedBufferStream.toByteArray(), UTF_8);
+
+        assertEquals(expectedBuffer, consoleBufferStr);
+    }
+
+    @Test
+    public void testPrintHelpCommandUsage() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "help", "help"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains(
+            "Usage:  bktest help [command] [options]"
+        ));
+    }
+
+    @Test
+    public void testInvalidFlags() throws Exception {
+        assertEquals(-1, TestCli.doMain(console, new String[] {
+            "-s", "string",
+            "-i", "1234",
+            "-l"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains("Error : Expected a value after parameter -l"));
+        // help message should be printed
+        assertTrue(consoleBufferStr.contains("Usage:"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+    }
+
+    @Test
+    public void testNestedCommandEmptySubCommand() {
+        assertEquals(-1, TestCli.doMain(console, new String[] {
+            "nested"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        // should print usage of 'nested' command
+        assertTrue(consoleBufferStr.contains("Usage:  bktest nested"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("cat"));
+        assertTrue(consoleBufferStr.contains("fish"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+        assertTrue(consoleBufferStr.contains("--nested-int-flag"));
+    }
+
+    @Test
+    public void testNestedCommandHelpCommand() {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "nested", "help"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        // should print usage of 'nested' command
+        assertTrue(consoleBufferStr.contains("Usage:  bktest nested"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("cat"));
+        assertTrue(consoleBufferStr.contains("fish"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+        assertTrue(consoleBufferStr.contains("--nested-int-flag"));
+    }
+
+    @Test
+    public void testNestedCommandHelpFlag() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "nested", "help"
+        }));
+        String buffer1 = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        ByteArrayOutputStream anotherBuffer = new ByteArrayOutputStream();
+        PrintStream anotherConsole = new PrintStream(anotherBuffer, true, UTF_8.name());
+
+        assertEquals(-1, TestCli.doMain(anotherConsole, new String[] {
+            "nested", "-h"
+        }));
+        String buffer2 = new String(anotherBuffer.toByteArray(), UTF_8);
+
+        assertEquals(buffer1, buffer2);
+    }
+
+    @Test
+    public void testPrintSubCommandUsage() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "nested", "help", "cat"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        ByteArrayOutputStream expectedBufferStream = new ByteArrayOutputStream();
+        PrintStream expectedConsole = new PrintStream(expectedBufferStream, true, UTF_8.name());
+        expectedConsole.println("command cat");
+        String expectedBuffer = new String(expectedBufferStream.toByteArray(), UTF_8);
+
+        assertEquals(expectedBuffer, consoleBufferStr);
+    }
+
+    @Test
+    public void testPrintHelpSubCommandUsage() throws Exception {
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "nested", "help", "help"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains(
+            "Usage:  bktest nested help [command] [options]"
+        ));
+    }
+
+    @Test
+    public void testSubCommandInvalidFlags() throws Exception {
+        assertEquals(-1, TestCli.doMain(console, new String[] {
+            "nested",
+            "-s", "string",
+            "-i", "1234",
+            "-l"
+        }));
+
+        String consoleBufferStr = new String(consoleBuffer.toByteArray(), UTF_8);
+
+        assertTrue(consoleBufferStr.contains("Error : Expected a value after parameter -l"));
+        // help message should be printed
+        assertTrue(consoleBufferStr.contains("Usage:  bktest nested"));
+        assertTrue(consoleBufferStr.contains("Commands:"));
+        assertTrue(consoleBufferStr.contains("cat"));
+        assertTrue(consoleBufferStr.contains("fish"));
+        assertTrue(consoleBufferStr.contains("Flags:"));
+        assertTrue(consoleBufferStr.contains("--nested-int-flag"));
+    }
+
+    @Test
+    public void testSubCommandFlags() throws Exception {
+        CompletableFuture<TestNestedFlags> flagsFuture = FutureUtils.createFuture();
+        assertEquals(0, TestCli.doMain(console, new String[] {
+            "nested",
+            "-s", "string",
+            "-i", "1234",
+            "-l", "str1,str2,str3",
+            "additional-args"
+        }, (flags) -> flagsFuture.complete(flags)));
+
+        dumpConsole();
+
+        TestNestedFlags flags = FutureUtils.result(flagsFuture);
+        assertEquals("string", flags.stringFlag);
+        assertEquals(1234, flags.intFlag);
+        Assert.assertEquals(
+            Lists.newArrayList("str1", "str2", "str3"),
+            flags.listFlag
+        );
+        assertEquals(1, flags.arguments.size());
+        Assert.assertEquals(Lists.newArrayList("additional-args"), flags.arguments);
+    }
+
+    //
+    // Util functions
+    //
+
+    private void dumpConsole() {
+        String buffer = new String(
+            consoleBuffer.toByteArray(), UTF_8);
+
+        System.out.println(buffer);
+    }
+
+}
diff --git a/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/TestCli.java b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/TestCli.java
new file mode 100644
index 0000000..3f5f678
--- /dev/null
+++ b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/TestCli.java
@@ -0,0 +1,129 @@
+/*
+ * 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.bookkeeper.tools.framework;
+
+import com.beust.jcommander.Parameter;
+import com.google.common.collect.Lists;
+import java.io.PrintStream;
+import java.util.List;
+import java.util.function.Function;
+import org.apache.bookkeeper.tools.framework.commands.TestCommand;
+
+/**
+ * A CLI used for testing.
+ */
+public class TestCli {
+
+    private static final String NAME = "bktest";
+
+    private static final String DESC = "bookkeeper test";
+
+    static class TestFlags extends CliFlags {
+
+        @Parameter(
+            names = {
+                "-s", "--string-flag"
+            },
+            description = "string flag")
+        public String stringFlag = null;
+
+        @Parameter(
+            names = {
+                "-i", "--int-flag"
+            },
+            description = "int flag")
+        public int intFlag = 0;
+
+        @Parameter(
+            names = {
+                "-l", "--list-flags"
+            },
+            description = "list flag")
+        public List<String> listFlag = Lists.newArrayList();
+
+    }
+
+    static class TestNestedFlags extends CliFlags {
+
+        @Parameter(
+            names = {
+                "-s", "--nested-string-flag"
+            },
+            description = "string flag")
+        public String stringFlag = null;
+
+        @Parameter(
+            names = {
+                "-i", "--nested-int-flag"
+            },
+            description = "int flag")
+        public int intFlag = 0;
+
+        @Parameter(
+            names = {
+                "-l", "--nested-list-flags"
+            },
+            description = "list flag")
+        public List<String> listFlag = Lists.newArrayList();
+
+    }
+
+    public static void main(String[] args) {
+        Runtime.getRuntime().exit(
+            doMain(System.out, args));
+    }
+
+    static int doMain(PrintStream console, String[] args) {
+        return doMain(console, args, null);
+    }
+
+    static int doMain(PrintStream console,
+                      String[] args,
+                      Function<TestNestedFlags, Boolean> func) {
+        String nestedCommandName = "nested";
+        String nestedCommandDesc = "bookkeeper test-nested";
+        CliSpec<TestNestedFlags> nestedSpec = CliSpec.<TestNestedFlags>newBuilder()
+            .withName(nestedCommandName)
+            .withParent(NAME)
+            .withDescription(nestedCommandDesc)
+            .withFlags(new TestNestedFlags())
+            .withConsole(console)
+            .addCommand(new TestCommand("fish", console))
+            .addCommand(new TestCommand("cat", console))
+            .withRunFunc(func)
+            .build();
+        CliCommand<TestFlags, TestNestedFlags> nestedCommand =
+            new CliCommand<>(nestedSpec);
+
+        CliSpec<TestFlags> spec = CliSpec.<TestFlags>newBuilder()
+            .withName(NAME)
+            .withDescription(DESC)
+            .withFlags(new TestFlags())
+            .withConsole(console)
+            .addCommand(new TestCommand("monkey", console))
+            .addCommand(new TestCommand("dog", console))
+            .addCommand(nestedCommand)
+            .build();
+
+        return Cli.runCli(spec, args);
+    }
+
+
+
+}
diff --git a/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/commands/TestCommand.java b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/commands/TestCommand.java
new file mode 100644
index 0000000..19f10e2
--- /dev/null
+++ b/tools/framework/src/test/java/org/apache/bookkeeper/tools/framework/commands/TestCommand.java
@@ -0,0 +1,58 @@
+/*
+ * 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.bookkeeper.tools.framework.commands;
+
+import java.io.PrintStream;
+import org.apache.bookkeeper.tools.framework.CliFlags;
+import org.apache.bookkeeper.tools.framework.Command;
+
+/**
+ * Test Command.
+ */
+public class TestCommand implements Command<CliFlags> {
+
+    private final String label;
+    private final PrintStream console;
+
+    public TestCommand(String label,
+                       PrintStream console) {
+        this.label = label;
+        this.console = console;
+    }
+
+    @Override
+    public String name() {
+        return label;
+    }
+
+    @Override
+    public String description() {
+        return "Command " + label;
+    }
+
+    @Override
+    public Boolean apply(CliFlags globalFlags, String[] args) {
+        return true;
+    }
+
+    @Override
+    public void usage() {
+        console.println("command " + label);
+    }
+}
diff --git a/bookkeeper-tools/pom.xml b/tools/pom.xml
similarity index 58%
rename from bookkeeper-tools/pom.xml
rename to tools/pom.xml
index 37544d1..46b7763 100644
--- a/bookkeeper-tools/pom.xml
+++ b/tools/pom.xml
@@ -22,30 +22,11 @@
     <groupId>org.apache.bookkeeper</groupId>
     <version>4.8.0-SNAPSHOT</version>
   </parent>
-  <artifactId>bookkeeper-tools</artifactId>
-  <name>Apache BookKeeper :: Tools</name>
-  <dependencies>
-    <dependency>
-      <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
-      <version>${project.parent.version}</version>
-    </dependency>
-    <dependency>
-      <groupId>com.beust</groupId>
-      <artifactId>jcommander</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>buildtools</artifactId>
-      <version>${project.parent.version}</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.bookkeeper</groupId>
-      <artifactId>bookkeeper-server</artifactId>
-      <type>test-jar</type>
-      <version>${project.parent.version}</version>
-      <scope>test</scope>
-    </dependency>
-  </dependencies>
+  <artifactId>bookkeeper-tools-parent</artifactId>
+  <name>Apache BookKeeper :: Tools :: Parent</name>
+  <packaging>pom</packaging>
+  <modules>
+    <module>framework</module>
+    <module>all</module>
+  </modules>
 </project>

-- 
To stop receiving notification emails like this one, please contact
sijie@apache.org.