You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by ni...@apache.org on 2022/09/03 12:19:38 UTC

[pulsar] branch branch-2.11 updated (d9cd5f4d18f -> a92aa1f2301)

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

nicoloboschi pushed a change to branch branch-2.11
in repository https://gitbox.apache.org/repos/asf/pulsar.git


    from d9cd5f4d18f [fix][txn] fix ack with txn compute ackedCount error (#17016)
     new 381d74de356 PIP-201 : Extensions mechanism for Pulsar Admin CLI tools (#17158)
     new f7874ab141e [improve][cli] CLI extensions: rename default location to 'cliextensions' (#17435)
     new a92aa1f2301 Fix cherry-pick pom files version

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 bin/pulsar-admin-common.sh                         |   2 +-
 conf/client.conf                                   |   6 +
 pom.xml                                            |   4 +
 .../pom.xml                                        |  58 ++----
 .../cli/extensions/CommandExecutionContext.java    |  33 +++
 .../pulsar/admin/cli/extensions/CustomCommand.java |  56 ++++++
 .../admin/cli/extensions/CustomCommandFactory.java |  35 ++++
 .../admin/cli/extensions/CustomCommandGroup.java   |  47 +++++
 .../admin/cli/extensions/ParameterDescriptor.java  |  38 ++++
 .../pulsar/admin/cli/extensions/ParameterType.java |  26 +++
 .../pulsar/admin/cli/extensions/package-info.java  |  19 ++
 pulsar-client-tools-customcommand-example/pom.xml  |  77 +++++++
 .../admin/cli/examples/MyCommandFactory.java       | 151 ++++++++++++++
 .../META-INF/services/command_factory.yml          |  19 ++
 pulsar-client-tools-test/pom.xml                   |  26 +++
 .../pulsar/admin/cli/PulsarAdminToolTest.java      | 127 ++++++++++++
 pulsar-client-tools/pom.xml                        |  10 +
 .../org/apache/pulsar/admin/cli/CliCommand.java    |   2 +-
 .../pulsar/admin/cli/CmdGenerateDocument.java      |   2 +-
 .../pulsar/admin/cli/CustomCommandsUtils.java      | 223 +++++++++++++++++++++
 .../apache/pulsar/admin/cli/PulsarAdminTool.java   |  63 ++++--
 .../cli/utils/CustomCommandFactoryDefinition.java  |  37 ++++
 .../cli/utils/CustomCommandFactoryDefinitions.java |  28 +++
 .../cli/utils/CustomCommandFactoryMetaData.java    |  37 ++++
 .../cli/utils/CustomCommandFactoryProvider.java    | 173 ++++++++++++++++
 25 files changed, 1238 insertions(+), 61 deletions(-)
 copy {pulsar-client-tools-test => pulsar-client-tools-api}/pom.xml (60%)
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CommandExecutionContext.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommand.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandFactory.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandGroup.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterDescriptor.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterType.java
 create mode 100644 pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/package-info.java
 create mode 100644 pulsar-client-tools-customcommand-example/pom.xml
 create mode 100644 pulsar-client-tools-customcommand-example/src/main/java/org/apache/pulsar/admin/cli/examples/MyCommandFactory.java
 create mode 100644 pulsar-client-tools-customcommand-example/src/main/resources/META-INF/services/command_factory.yml
 create mode 100644 pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CustomCommandsUtils.java
 create mode 100644 pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinition.java
 create mode 100644 pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinitions.java
 create mode 100644 pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryMetaData.java
 create mode 100644 pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java


[pulsar] 03/03: Fix cherry-pick pom files version

Posted by ni...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

nicoloboschi pushed a commit to branch branch-2.11
in repository https://gitbox.apache.org/repos/asf/pulsar.git

commit a92aa1f2301100f05366a50aa5145ec4476c7460
Author: Nicolò Boschi <bo...@gmail.com>
AuthorDate: Sat Sep 3 14:19:23 2022 +0200

    Fix cherry-pick pom files version
---
 pulsar-client-tools-api/pom.xml                   | 2 +-
 pulsar-client-tools-customcommand-example/pom.xml | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/pulsar-client-tools-api/pom.xml b/pulsar-client-tools-api/pom.xml
index 302f184e9c2..4d4082f8860 100644
--- a/pulsar-client-tools-api/pom.xml
+++ b/pulsar-client-tools-api/pom.xml
@@ -24,7 +24,7 @@
   <parent>
     <groupId>org.apache.pulsar</groupId>
     <artifactId>pulsar</artifactId>
-    <version>2.11.0-SNAPSHOT</version>
+    <version>2.11.0</version>
     <relativePath>..</relativePath>
   </parent>
 
diff --git a/pulsar-client-tools-customcommand-example/pom.xml b/pulsar-client-tools-customcommand-example/pom.xml
index 433eaf75ecd..6d9756c0004 100644
--- a/pulsar-client-tools-customcommand-example/pom.xml
+++ b/pulsar-client-tools-customcommand-example/pom.xml
@@ -22,7 +22,7 @@
   <parent>
     <groupId>org.apache.pulsar</groupId>
     <artifactId>pulsar</artifactId>
-    <version>2.11.0-SNAPSHOT</version>
+    <version>2.11.0</version>
     <relativePath>..</relativePath>
   </parent>
   <modelVersion>4.0.0</modelVersion>


[pulsar] 02/03: [improve][cli] CLI extensions: rename default location to 'cliextensions' (#17435)

Posted by ni...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

nicoloboschi pushed a commit to branch branch-2.11
in repository https://gitbox.apache.org/repos/asf/pulsar.git

commit f7874ab141e4f5bb0c15ad44035639b4af4e9f1a
Author: Nicolò Boschi <bo...@gmail.com>
AuthorDate: Fri Sep 2 21:55:14 2022 +0200

    [improve][cli] CLI extensions: rename default location to 'cliextensions' (#17435)
    
    (cherry picked from commit 57560bfb40566edfcb3dac3d4ea0e33f97589fa9)
---
 pulsar-client-tools-test/pom.xml                                      | 4 ++--
 .../test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java    | 2 +-
 .../apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java   | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/pulsar-client-tools-test/pom.xml b/pulsar-client-tools-test/pom.xml
index 07881400f45..ac4c5c90d3a 100644
--- a/pulsar-client-tools-test/pom.xml
+++ b/pulsar-client-tools-test/pom.xml
@@ -126,8 +126,8 @@
             <configuration>
               <target>
                 <echo>copy filters</echo>
-                <mkdir dir="${project.build.outputDirectory}/cliExtensions" />
-                <copy verbose="true" file="${basedir}/../pulsar-client-tools-customcommand-example/target/customCommands-nar.nar" tofile="${project.build.outputDirectory}/cliExtensions/customCommands-nar.nar" />
+                <mkdir dir="${project.build.outputDirectory}/cliextensions" />
+                <copy verbose="true" file="${basedir}/../pulsar-client-tools-customcommand-example/target/customCommands-nar.nar" tofile="${project.build.outputDirectory}/cliextensions/customCommands-nar.nar" />
               </target>
             </configuration>
           </execution>
diff --git a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
index 4bbb443278d..24842b9e11a 100644
--- a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
+++ b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
@@ -2314,7 +2314,7 @@ public class PulsarAdminToolTest {
 
     private static String runCustomCommand(String[] args) throws Exception {
         File narFile = new File(PulsarAdminTool.class.getClassLoader()
-                .getResource("cliExtensions/customCommands-nar.nar").getFile());
+                .getResource("cliextensions/customCommands-nar.nar").getFile());
         log.info("NAR FILE is {}", narFile);
 
         PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class);
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java
index 34155efd4a5..7aa581ef7e0 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java
@@ -55,7 +55,7 @@ public class CustomCommandFactoryProvider {
             return result;
         }
 
-        String directory = conf.getProperty("cliExtensionsDirectory", "cliExtensions");
+        String directory = conf.getProperty("cliExtensionsDirectory", "cliextensions");
         String narExtractionDirectory = NarClassLoader.DEFAULT_NAR_EXTRACTION_DIR;
         CustomCommandFactoryDefinitions definitions = searchForCustomCommandFactories(directory,
                 narExtractionDirectory);


[pulsar] 01/03: PIP-201 : Extensions mechanism for Pulsar Admin CLI tools (#17158)

Posted by ni...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

nicoloboschi pushed a commit to branch branch-2.11
in repository https://gitbox.apache.org/repos/asf/pulsar.git

commit 381d74de3566b207d3951c1cd414a8cde25e07a1
Author: Enrico Olivelli <eo...@apache.org>
AuthorDate: Wed Aug 31 11:20:36 2022 +0200

    PIP-201 : Extensions mechanism for Pulsar Admin CLI tools (#17158)
    
    (cherry picked from commit 57bcc977ce956d2bee120d1a88a9f1cb509fe5ec)
---
 bin/pulsar-admin-common.sh                         |   2 +-
 conf/client.conf                                   |   6 +
 pom.xml                                            |   4 +
 .../pom.xml                                        |  60 ++----
 .../cli/extensions/CommandExecutionContext.java    |  33 +++
 .../pulsar/admin/cli/extensions/CustomCommand.java |  56 ++++++
 .../admin/cli/extensions/CustomCommandFactory.java |  35 ++++
 .../admin/cli/extensions/CustomCommandGroup.java   |  47 +++++
 .../admin/cli/extensions/ParameterDescriptor.java  |  38 ++++
 .../pulsar/admin/cli/extensions/ParameterType.java |  26 +++
 .../pulsar/admin/cli/extensions/package-info.java  |  19 ++
 pulsar-client-tools-customcommand-example/pom.xml  |  77 +++++++
 .../admin/cli/examples/MyCommandFactory.java       | 151 ++++++++++++++
 .../META-INF/services/command_factory.yml          |  19 ++
 pulsar-client-tools-test/pom.xml                   |  26 +++
 .../pulsar/admin/cli/PulsarAdminToolTest.java      | 127 ++++++++++++
 pulsar-client-tools/pom.xml                        |  10 +
 .../org/apache/pulsar/admin/cli/CliCommand.java    |   2 +-
 .../pulsar/admin/cli/CmdGenerateDocument.java      |   2 +-
 .../pulsar/admin/cli/CustomCommandsUtils.java      | 223 +++++++++++++++++++++
 .../apache/pulsar/admin/cli/PulsarAdminTool.java   |  63 ++++--
 .../cli/utils/CustomCommandFactoryDefinition.java  |  37 ++++
 .../cli/utils/CustomCommandFactoryDefinitions.java |  28 +++
 .../cli/utils/CustomCommandFactoryMetaData.java    |  37 ++++
 .../cli/utils/CustomCommandFactoryProvider.java    | 173 ++++++++++++++++
 25 files changed, 1239 insertions(+), 62 deletions(-)

diff --git a/bin/pulsar-admin-common.sh b/bin/pulsar-admin-common.sh
index fdfed60beda..7d4b0d861bf 100755
--- a/bin/pulsar-admin-common.sh
+++ b/bin/pulsar-admin-common.sh
@@ -95,7 +95,7 @@ IS_JAVA_8=`$JAVA -version 2>&1 |grep version|grep '"1\.8'`
 # Start --add-opens options
 # '--add-opens' option is not supported in jdk8
 if [[ -z "$IS_JAVA_8" ]]; then
-  OPTS="$OPTS --add-opens java.base/sun.net=ALL-UNNAMED"
+  OPTS="$OPTS --add-opens java.base/sun.net=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED"
 fi
 
 OPTS="-cp $PULSAR_CLASSPATH $OPTS"
diff --git a/conf/client.conf b/conf/client.conf
index 50d9bf374c1..ea1d339a09c 100644
--- a/conf/client.conf
+++ b/conf/client.conf
@@ -87,3 +87,9 @@ tlsKeyStorePassword=
 # When TLS authentication with CACert is used, the valid value is either OPENSSL or JDK.
 # When TLS authentication with KeyStore is used, available options can be SunJSSE, Conscrypt and so on.
 webserviceTlsProvider=
+
+
+
+# Pulsar Admin Custom Commands
+#customCommandFactoriesDirectory=commandFactories
+#customCommandFactories=
diff --git a/pom.xml b/pom.xml
index 21add4a056d..3383b9b0df6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2089,7 +2089,9 @@ flexible messaging model and an intuitive client API.</description>
         <module>pulsar-client-admin-api</module>
         <module>pulsar-client-admin</module>
         <module>pulsar-client-admin-shaded</module>
+        <module>pulsar-client-tools-api</module>
         <module>pulsar-client-tools</module>
+        <module>pulsar-client-tools-customcommand-example</module>
         <module>pulsar-client-tools-test</module>
         <module>pulsar-client-all</module>
         <module>pulsar-websocket</module>
@@ -2152,7 +2154,9 @@ flexible messaging model and an intuitive client API.</description>
         <module>pulsar-client</module>
         <module>pulsar-client-admin-api</module>
         <module>pulsar-client-admin</module>
+        <module>pulsar-client-tools-api</module>
         <module>pulsar-client-tools</module>
+        <module>pulsar-client-tools-customcommand-example</module>
         <module>pulsar-client-tools-test</module>
         <module>pulsar-websocket</module>
         <module>pulsar-proxy</module>
diff --git a/pulsar-client-tools-test/pom.xml b/pulsar-client-tools-api/pom.xml
similarity index 59%
copy from pulsar-client-tools-test/pom.xml
copy to pulsar-client-tools-api/pom.xml
index 53df394654c..302f184e9c2 100644
--- a/pulsar-client-tools-test/pom.xml
+++ b/pulsar-client-tools-api/pom.xml
@@ -19,52 +19,36 @@
 
 -->
 <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">
+         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>
     <groupId>org.apache.pulsar</groupId>
     <artifactId>pulsar</artifactId>
-    <version>2.11.0</version>
+    <version>2.11.0-SNAPSHOT</version>
     <relativePath>..</relativePath>
   </parent>
 
-  <artifactId>pulsar-client-tools-test</artifactId>
-  <name>Pulsar Client Tools Test</name>
-  <description>Pulsar Client Tools Test</description>
+  <artifactId>pulsar-client-tools-api</artifactId>
+  <name>Pulsar Client Tools API</name>
+  <description>Pulsar Client Tools API</description>
 
   <dependencies>
     <dependency>
       <groupId>${project.groupId}</groupId>
-      <artifactId>pulsar-client-tools</artifactId>
+      <artifactId>pulsar-client-admin-api</artifactId>
       <version>${project.version}</version>
     </dependency>
-    <dependency>
-      <groupId>${project.groupId}</groupId>
-      <artifactId>testmocks</artifactId>
-      <version>${project.version}</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.awaitility</groupId>
-      <artifactId>awaitility</artifactId>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>${project.groupId}</groupId>
-      <artifactId>pulsar-broker</artifactId>
-      <version>${project.version}</version>
-      <type>test-jar</type>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>${project.groupId}</groupId>
-      <artifactId>pulsar-broker</artifactId>
-      <version>${project.version}</version>
-      <scope>test</scope>
-    </dependency>
   </dependencies>
   <build>
     <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <!-- same version of Pulsar Admin API -->
+          <release>${pulsar.client.compiler.release}</release>
+        </configuration>
+      </plugin>
       <plugin>
         <groupId>org.gaul</groupId>
         <artifactId>modernizer-maven-plugin</artifactId>
@@ -85,21 +69,10 @@
 
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
-        <artifactId>maven-deploy-plugin</artifactId>
-        <configuration>
-          <skip>true</skip>
-        </configuration>
-      </plugin>
-      <plugin>
-        <groupId>com.github.spotbugs</groupId>
-        <artifactId>spotbugs-maven-plugin</artifactId>
-        <version>${spotbugs-maven-plugin.version}</version>
-        <configuration>
-          <excludeFilterFile>${basedir}/src/test/resources/findbugsExclude.xml</excludeFilterFile>
-        </configuration>
+        <artifactId>maven-checkstyle-plugin</artifactId>
         <executions>
           <execution>
-            <id>spotbugs</id>
+            <id>checkstyle</id>
             <phase>verify</phase>
             <goals>
               <goal>check</goal>
@@ -109,4 +82,5 @@
       </plugin>
     </plugins>
   </build>
+
 </project>
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CommandExecutionContext.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CommandExecutionContext.java
new file mode 100644
index 00000000000..47f28479ab1
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CommandExecutionContext.java
@@ -0,0 +1,33 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+import java.util.Properties;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+
+/**
+ * Access to the Environment.
+ */
+public interface CommandExecutionContext {
+
+    PulsarAdmin getPulsarAdmin();
+
+    Properties getConfiguration();
+
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommand.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommand.java
new file mode 100644
index 00000000000..bae1698eed6
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommand.java
@@ -0,0 +1,56 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Custom command.
+ */
+public interface CustomCommand {
+
+    /**
+     * Name of the command.
+     * @return the name
+     */
+    String name();
+
+    /**
+     * Descritption of the command.
+     * @return the description
+     */
+    String description();
+
+    /**
+     * The parameters for the command.
+     * @return the parameters
+     */
+    List<ParameterDescriptor> parameters();
+
+    /**
+     * Execute the command.
+     * @param parameters the parameters, one entry per each parameter name
+     * @param context access the environment
+     * @return false in case of failure
+     * @throws Exception
+     */
+    boolean execute(Map<String, Object> parameters, CommandExecutionContext context) throws Exception;
+
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandFactory.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandFactory.java
new file mode 100644
index 00000000000..d4412703d0f
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandFactory.java
@@ -0,0 +1,35 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+import java.util.List;
+
+/**
+ * Entry point to build custom commands for the Pulsar Admin CLI tools.
+ */
+public interface CustomCommandFactory {
+
+    /**
+     * Generate the available command groups.
+     *
+     * @param context
+     * @return the list of new commands groups.
+     */
+    List<CustomCommandGroup> commandGroups(CommandExecutionContext context);
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandGroup.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandGroup.java
new file mode 100644
index 00000000000..b20f5c6895c
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/CustomCommandGroup.java
@@ -0,0 +1,47 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+import java.util.List;
+
+/**
+ * A group of commands.
+ */
+public interface CustomCommandGroup {
+
+    /**
+     * The name of the group.
+     * @return the name
+     */
+    String name();
+
+    /**
+     * The description of the group.
+     * @return the description
+     */
+    String description();
+
+    /**
+     * Generate the available commands.
+     *
+     * @param context
+     * @return the list of new commands.
+     */
+    List<CustomCommand> commands(CommandExecutionContext context);
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterDescriptor.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterDescriptor.java
new file mode 100644
index 00000000000..21f1074f864
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterDescriptor.java
@@ -0,0 +1,38 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Builder;
+import lombok.Getter;
+
+@Builder
+@Getter
+public final class ParameterDescriptor {
+    @Builder.Default
+    private List<String> names = new ArrayList<>();
+    private boolean mainParameter;
+    @Builder.Default
+    private String description = "";
+    @Builder.Default
+    private ParameterType type = ParameterType.STRING;
+    @Builder.Default
+    private  boolean required = false;
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterType.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterType.java
new file mode 100644
index 00000000000..a4f491cf492
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/ParameterType.java
@@ -0,0 +1,26 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
+
+public enum ParameterType {
+    STRING,
+    INTEGER,
+    BOOLEAN,
+    BOOLEAN_FLAG
+}
diff --git a/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/package-info.java b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/package-info.java
new file mode 100644
index 00000000000..dbefcce7516
--- /dev/null
+++ b/pulsar-client-tools-api/src/main/java/org/apache/pulsar/admin/cli/extensions/package-info.java
@@ -0,0 +1,19 @@
+/**
+ * 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.pulsar.admin.cli.extensions;
diff --git a/pulsar-client-tools-customcommand-example/pom.xml b/pulsar-client-tools-customcommand-example/pom.xml
new file mode 100644
index 00000000000..433eaf75ecd
--- /dev/null
+++ b/pulsar-client-tools-customcommand-example/pom.xml
@@ -0,0 +1,77 @@
+<!--
+
+    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.
+
+-->
+<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">
+  <parent>
+    <groupId>org.apache.pulsar</groupId>
+    <artifactId>pulsar</artifactId>
+    <version>2.11.0-SNAPSHOT</version>
+    <relativePath>..</relativePath>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+  <artifactId>pulsar-client-tools-customcommand-example</artifactId>
+  <packaging>jar</packaging>
+  <name>Pulsar CLI Custom command example</name>
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.pulsar</groupId>
+      <artifactId>pulsar-client-tools-api</artifactId>
+      <version>${project.version}</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-nar-maven-plugin</artifactId>
+        <version>1.3.2</version>
+        <extensions>true</extensions>
+        <configuration>
+          <finalName>customCommands</finalName>
+          <classifier>nar</classifier>
+        </configuration>
+        <executions>
+          <execution>
+            <id>default-nar</id>
+            <phase>package</phase>
+            <goals>
+              <goal>nar</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-deploy-plugin</artifactId>
+        <configuration>
+          <skip>true</skip>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.sonatype.plugins</groupId>
+        <artifactId>nexus-staging-maven-plugin</artifactId>
+        <configuration>
+          <skipNexusStagingDeployMojo>true</skipNexusStagingDeployMojo>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/pulsar-client-tools-customcommand-example/src/main/java/org/apache/pulsar/admin/cli/examples/MyCommandFactory.java b/pulsar-client-tools-customcommand-example/src/main/java/org/apache/pulsar/admin/cli/examples/MyCommandFactory.java
new file mode 100644
index 00000000000..e431d002465
--- /dev/null
+++ b/pulsar-client-tools-customcommand-example/src/main/java/org/apache/pulsar/admin/cli/examples/MyCommandFactory.java
@@ -0,0 +1,151 @@
+/**
+ * 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.pulsar.admin.cli.examples;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pulsar.admin.cli.extensions.CommandExecutionContext;
+import org.apache.pulsar.admin.cli.extensions.CustomCommand;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandFactory;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandGroup;
+import org.apache.pulsar.admin.cli.extensions.ParameterDescriptor;
+import org.apache.pulsar.admin.cli.extensions.ParameterType;
+import org.apache.pulsar.common.policies.data.TopicStats;
+
+@Slf4j
+public class MyCommandFactory implements CustomCommandFactory {
+  @Override
+  public List<CustomCommandGroup> commandGroups(CommandExecutionContext context) {
+    return Arrays.asList(
+            new MyCustomCommandGroup());
+  }
+
+    private static class MyCustomCommandGroup implements CustomCommandGroup {
+        @Override
+        public String name() {
+          return "customgroup";
+        }
+
+        @Override
+        public String description() {
+          return "Custom group 1 description";
+        }
+
+        @Override
+        public List<CustomCommand> commands(CommandExecutionContext context) {
+          return Arrays.asList(new Command1(), new Command2());
+        }
+
+        private static class Command1 implements CustomCommand {
+            @Override
+            public String name() {
+              return "command1";
+            }
+
+            @Override
+            public String description() {
+              return "Command 1 description";
+            }
+
+            @Override
+            public List<ParameterDescriptor> parameters() {
+              return Arrays.asList(
+                  ParameterDescriptor.builder()
+                      .description("Operation type")
+                      .type(ParameterType.STRING)
+                      .names(Arrays.asList("--type", "-t"))
+                      .required(true)
+                      .build(),
+                  ParameterDescriptor.builder()
+                      .description("Topic")
+                      .type(ParameterType.STRING)
+                      .mainParameter(true)
+                      .names(Arrays.asList("topic"))
+                      .required(true)
+                      .build());
+            }
+
+            @Override
+            public boolean execute(
+                    Map<String, Object> parameters, CommandExecutionContext context)
+                throws Exception {
+              System.out.println(
+                  "Execute: " + parameters + " properties " + context.getConfiguration());
+              String destination = parameters.getOrDefault("topic", "").toString();
+              TopicStats stats = context.getPulsarAdmin().topics().getStats(destination);
+              System.out.println("Topic stats: " + stats);
+              return false;
+            }
+        }
+
+        private static class Command2 implements CustomCommand {
+            @Override
+            public String name() {
+                return "command2";
+            }
+
+            @Override
+            public String description() {
+                return "Command 2 description";
+            }
+
+            @Override
+            public List<ParameterDescriptor> parameters() {
+                return Arrays.asList(
+                        ParameterDescriptor.builder()
+                                .description("mystring")
+                                .type(ParameterType.STRING)
+                                .names(Arrays.asList("-s"))
+                                .build(),
+                        ParameterDescriptor.builder()
+                                .description("myint")
+                                .type(ParameterType.INTEGER)
+                                .names(Arrays.asList("-i"))
+                                .build(),
+                        ParameterDescriptor.builder()
+                                .description("myboolean")
+                                .type(ParameterType.BOOLEAN)
+                                .names(Arrays.asList("-b"))
+                                .build(),
+                        ParameterDescriptor.builder()
+                                .description("mybooleanflag")
+                                .type(ParameterType.BOOLEAN_FLAG)
+                                .names(Arrays.asList("-bf"))
+                                .build(),
+                        ParameterDescriptor.builder()
+                                .description("main")
+                                .type(ParameterType.STRING)
+                                .mainParameter(true)
+                                .names(Arrays.asList("main"))
+                                .build());
+            }
+
+            @Override
+            public boolean execute(
+                    Map<String, Object> parameters, CommandExecutionContext context)
+                    throws Exception {
+                System.out.println(
+                        "Execute: " + parameters + " properties " + context.getConfiguration());
+                return false;
+            }
+        }
+  }
+}
diff --git a/pulsar-client-tools-customcommand-example/src/main/resources/META-INF/services/command_factory.yml b/pulsar-client-tools-customcommand-example/src/main/resources/META-INF/services/command_factory.yml
new file mode 100644
index 00000000000..e6007cb4b09
--- /dev/null
+++ b/pulsar-client-tools-customcommand-example/src/main/resources/META-INF/services/command_factory.yml
@@ -0,0 +1,19 @@
+# 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.
+factoryClass: org.apache.pulsar.admin.cli.examples.MyCommandFactory
+name: dummy
+description: Example of a Custom command factory
diff --git a/pulsar-client-tools-test/pom.xml b/pulsar-client-tools-test/pom.xml
index 53df394654c..07881400f45 100644
--- a/pulsar-client-tools-test/pom.xml
+++ b/pulsar-client-tools-test/pom.xml
@@ -62,6 +62,13 @@
       <version>${project.version}</version>
       <scope>test</scope>
     </dependency>
+    <!-- add the dependency in order to let maven build the module before this module -->
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>pulsar-client-tools-customcommand-example</artifactId>
+      <version>${project.version}</version>
+      <scope>provided</scope>
+    </dependency>
   </dependencies>
   <build>
     <plugins>
@@ -107,6 +114,25 @@
           </execution>
         </executions>
       </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-antrun-plugin</artifactId>
+        <executions>
+          <execution>
+            <phase>process-test-resources</phase>
+            <goals>
+              <goal>run</goal>
+            </goals>
+            <configuration>
+              <target>
+                <echo>copy filters</echo>
+                <mkdir dir="${project.build.outputDirectory}/cliExtensions" />
+                <copy verbose="true" file="${basedir}/../pulsar-client-tools-customcommand-example/target/customCommands-nar.nar" tofile="${project.build.outputDirectory}/cliExtensions/customCommands-nar.nar" />
+              </target>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
     </plugins>
   </build>
 </project>
diff --git a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
index e3e9157233c..4bbb443278d 100644
--- a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
+++ b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/admin/cli/PulsarAdminToolTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pulsar.admin.cli;
 
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.longThat;
 import static org.mockito.Mockito.doReturn;
@@ -27,14 +28,19 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
 import static org.testng.Assert.fail;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+
+import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.PrintStream;
 import java.lang.reflect.Field;
 import java.net.URL;
 import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -44,6 +50,8 @@ import java.util.Optional;
 import java.util.Properties;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
+
+import lombok.extern.slf4j.Slf4j;
 import org.apache.pulsar.admin.cli.utils.SchemaExtractor;
 import org.apache.pulsar.client.admin.Bookies;
 import org.apache.pulsar.client.admin.BrokerStats;
@@ -57,6 +65,7 @@ import org.apache.pulsar.client.admin.Namespaces;
 import org.apache.pulsar.client.admin.NonPersistentTopics;
 import org.apache.pulsar.client.admin.ProxyStats;
 import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.admin.PulsarAdminBuilder;
 import org.apache.pulsar.client.admin.ResourceQuotas;
 import org.apache.pulsar.client.admin.Schemas;
 import org.apache.pulsar.client.admin.Tenants;
@@ -100,6 +109,7 @@ import org.apache.pulsar.common.policies.data.ResourceQuota;
 import org.apache.pulsar.common.policies.data.RetentionPolicies;
 import org.apache.pulsar.common.policies.data.SubscribeRate;
 import org.apache.pulsar.common.policies.data.TenantInfoImpl;
+import org.apache.pulsar.common.policies.data.TopicStats;
 import org.apache.pulsar.common.policies.data.TopicType;
 import org.apache.pulsar.common.protocol.schema.PostSchemaPayload;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
@@ -107,6 +117,7 @@ import org.mockito.ArgumentMatcher;
 import org.mockito.Mockito;
 import org.testng.annotations.Test;
 
+@Slf4j
 public class PulsarAdminToolTest {
 
     @Test
@@ -2238,6 +2249,122 @@ public class PulsarAdminToolTest {
         verify(schemas).createSchema("persistent://tn1/ns1/tp1", postSchemaPayload);
     }
 
+    @Test
+    public void customCommands() throws Exception {
+
+        // see the custom command help in the main help
+        String logs = runCustomCommand(new String[]{"-h"});
+        assertTrue(logs.contains("customgroup"));
+        assertTrue(logs.contains("Custom group 1 description"));
+
+        logs = runCustomCommand(new String[]{"customgroup"});
+        assertTrue(logs.contains("command1"));
+        assertTrue(logs.contains("Command 1 description"));
+        assertTrue(logs.contains("command2"));
+        assertTrue(logs.contains("Command 2 description"));
+
+        logs = runCustomCommand(new String[]{"customgroup", "command1"});
+        assertTrue(logs.contains("Command 1 description"));
+        assertTrue(logs.contains("Usage: command1 [options] Topic"));
+
+        // missing required parameter
+        logs = runCustomCommand(new String[]{"customgroup", "command1", "mytopic"});
+        assertTrue(logs.contains("Command 1 description"));
+        assertTrue(logs.contains("Usage: command1 [options] Topic"));
+        assertTrue(logs.contains("The following option is required"));
+
+        // run a comand that uses PulsarAdmin API
+        logs = runCustomCommand(new String[]{"customgroup", "command1", "--type", "stats", "mytopic"});
+        assertTrue(logs.contains("Execute:"));
+        // parameters
+        assertTrue(logs.contains("--type=stats"));
+        // configuration
+        assertTrue(logs.contains("webServiceUrl=http://localhost:2181"));
+        // execution of the PulsarAdmin command
+        assertTrue(logs.contains("Topic stats: MOCK-TOPIC-STATS"));
+
+
+        // run a command that uses all parameter types
+        logs = runCustomCommand(new String[]{"customgroup", "command2",
+                "-s", "mystring",
+                "-i", "123",
+                "-b", "true", // boolean variable, true|false
+                "-bf", // boolean flag, no arguments
+                "mainParameterValue"});
+        assertTrue(logs.contains("Execute:"));
+        // parameters
+        assertTrue(logs.contains("-s=mystring"));
+        assertTrue(logs.contains("-i=123"));
+        assertTrue(logs.contains("-b=true"));
+        assertTrue(logs.contains("-bf=true")); // boolean flag, passed = true
+        assertTrue(logs.contains("main=mainParameterValue"));
+
+
+        // run a command that uses all parameter types, see the default value
+        logs = runCustomCommand(new String[]{"customgroup", "command2"});
+        assertTrue(logs.contains("Execute:"));
+        // parameters
+        assertTrue(logs.contains("-s=null"));
+        assertTrue(logs.contains("-i=0"));
+        assertTrue(logs.contains("-b=null"));
+        assertTrue(logs.contains("-bf=false")); // boolean flag, not passed = false
+        assertTrue(logs.contains("main=null"));
+
+    }
+
+    private static String runCustomCommand(String[] args) throws Exception {
+        File narFile = new File(PulsarAdminTool.class.getClassLoader()
+                .getResource("cliExtensions/customCommands-nar.nar").getFile());
+        log.info("NAR FILE is {}", narFile);
+
+        PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class);
+        PulsarAdmin admin = mock(PulsarAdmin.class);
+        when(builder.build()).thenReturn(admin);
+        Topics topics = mock(Topics.class);
+        when(admin.topics()).thenReturn(topics);
+        TopicStats topicStats = mock(TopicStats.class);
+        when(topics.getStats(anyString())).thenReturn(topicStats);
+        when(topicStats.toString()).thenReturn("MOCK-TOPIC-STATS");
+
+        Properties properties = new Properties();
+        properties.put("webServiceUrl", "http://localhost:2181");
+        properties.put("cliExtensionsDirectory", narFile.getParentFile().getAbsolutePath());
+        properties.put("customCommandFactories", "dummy");
+        PulsarAdminTool tool = new PulsarAdminTool(properties) {
+            @Override
+            protected PulsarAdminBuilder createAdminBuilder(Properties properties) {
+                return builder;
+            }
+        };
+
+        // see the custom command help in the main help
+        StringBuilder logs = new StringBuilder();
+        try (CaptureStdOut capture = new CaptureStdOut(logs)){
+            tool.run(args);
+        }
+        log.info("Captured out: {}", logs);
+        return logs.toString();
+    }
+
+    private static class CaptureStdOut implements AutoCloseable {
+        final PrintStream currentOut = System.out;
+        final PrintStream currentErr = System.err;
+        final ByteArrayOutputStream logs = new ByteArrayOutputStream();
+        final PrintStream capturedOut = new PrintStream(logs, true);
+        final StringBuilder receiver;
+        public CaptureStdOut(StringBuilder receiver) {
+            this.receiver = receiver;
+            System.setOut(capturedOut);
+            System.setErr(capturedOut);
+        }
+        public void close() {
+            capturedOut.flush();
+            System.setOut(currentOut);
+            System.setErr(currentErr);
+            receiver.append(logs.toString(StandardCharsets.UTF_8));
+        }
+    }
+
     public static class SchemaDemo {
         public SchemaDemo() {
         }
diff --git a/pulsar-client-tools/pom.xml b/pulsar-client-tools/pom.xml
index 78e727b0c8f..de15c117e39 100644
--- a/pulsar-client-tools/pom.xml
+++ b/pulsar-client-tools/pom.xml
@@ -43,6 +43,11 @@
       <artifactId>pulsar-client-admin-api</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>pulsar-client-tools-api</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>${project.groupId}</groupId>
       <artifactId>pulsar-client-admin-original</artifactId>
@@ -74,6 +79,11 @@
       <groupId>org.conscrypt</groupId>
       <artifactId>conscrypt-openjdk-uber</artifactId>
     </dependency>
+    <dependency>
+      <!-- custom commands -->
+      <groupId>org.javassist</groupId>
+      <artifactId>javassist</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.commons</groupId>
       <artifactId>commons-lang3</artifactId>
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CliCommand.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CliCommand.java
index a609025faa5..1a2136ee967 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CliCommand.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CliCommand.java
@@ -36,7 +36,7 @@ import org.apache.pulsar.common.naming.TopicName;
 import org.apache.pulsar.common.policies.data.AuthAction;
 import org.apache.pulsar.common.util.ObjectMapperFactory;
 
-abstract class CliCommand {
+public abstract class CliCommand {
 
     static String[] validatePropertyCluster(List<String> params) {
         return splitParameter(params, 2);
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdGenerateDocument.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdGenerateDocument.java
index 70878f8ef53..c8cc58a83ee 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdGenerateDocument.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CmdGenerateDocument.java
@@ -58,7 +58,7 @@ public class CmdGenerateDocument extends CmdBase {
         }
         for (Map.Entry<String, Class<?>> c : tool.commandMap.entrySet()) {
             try {
-                if (!c.getKey().equals("documents")) {
+                if (!c.getKey().equals("documents") && c.getValue() != null) {
                     baseJcommander.addCommand(
                             c.getKey(), c.getValue().getConstructor(Supplier.class).newInstance(admin));
                 }
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CustomCommandsUtils.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CustomCommandsUtils.java
new file mode 100644
index 00000000000..9063d0d1d0c
--- /dev/null
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/CustomCommandsUtils.java
@@ -0,0 +1,223 @@
+/**
+ * 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.pulsar.admin.cli;
+
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import javassist.ClassPool;
+import javassist.CtClass;
+import javassist.CtConstructor;
+import javassist.CtField;
+import javassist.CtNewConstructor;
+import javassist.Modifier;
+import javassist.bytecode.AnnotationsAttribute;
+import javassist.bytecode.ClassFile;
+import javassist.bytecode.ConstPool;
+import javassist.bytecode.annotation.Annotation;
+import javassist.bytecode.annotation.ArrayMemberValue;
+import javassist.bytecode.annotation.BooleanMemberValue;
+import javassist.bytecode.annotation.IntegerMemberValue;
+import javassist.bytecode.annotation.MemberValue;
+import javassist.bytecode.annotation.StringMemberValue;
+import lombok.Setter;
+import org.apache.pulsar.admin.cli.extensions.CommandExecutionContext;
+import org.apache.pulsar.admin.cli.extensions.CustomCommand;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandGroup;
+import org.apache.pulsar.admin.cli.extensions.ParameterDescriptor;
+import org.apache.pulsar.admin.cli.extensions.ParameterType;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+
+public final class CustomCommandsUtils {
+    private CustomCommandsUtils() {
+    }
+
+    public static Object generateCliCommand(CustomCommandGroup group, CommandExecutionContext context,
+                                            Supplier<PulsarAdmin> pulsarAdmin){
+        List<CustomCommand> commands = group.commands(context);
+        String description = group.description();
+
+        try {
+            ClassPool pool = ClassPool.getDefault();
+            CtClass ctClass = pool.makeClass("CustomCommandGroup" + group
+                    + "_" + System.nanoTime());
+            ctClass.setSuperclass(pool.get(CmdBaseAdapter.class.getName()));
+
+            // add class annotation
+            ClassFile classFile = ctClass.getClassFile();
+            ConstPool constpool = classFile.getConstPool();
+            AnnotationsAttribute annotationsAttribute = new AnnotationsAttribute(constpool,
+                    AnnotationsAttribute.visibleTag);
+            Annotation annotation = new Annotation(Parameters.class.getName(), constpool);
+            annotation.addMemberValue("commandDescription", new StringMemberValue(description,
+                    classFile.getConstPool()));
+            annotationsAttribute.setAnnotation(annotation);
+            ctClass.getClassFile().addAttribute(annotationsAttribute);
+
+            // Add a constructor which calls super( ... );
+            CtClass[] params = new CtClass[]{
+                    pool.get(String.class.getName()),
+                    pool.get(Supplier.class.getName()),
+                    pool.get(List.class.getName()),
+                    pool.get(CommandExecutionContext.class.getName())
+            };
+            final CtConstructor ctor = CtNewConstructor.make(params, null, CtNewConstructor.PASS_PARAMS,
+                    null, null, ctClass);
+            ctClass.addConstructor(ctor);
+
+            return ctClass.toClass().getConstructor(String.class, Supplier.class, List.class,
+                            CommandExecutionContext.class)
+                    .newInstance(group.name(), pulsarAdmin, commands, context);
+        } catch (Throwable t) {
+            throw new RuntimeException(t);
+        }
+    }
+
+    public static class CmdBaseAdapter extends CmdBase {
+        public CmdBaseAdapter(String cmdName, Supplier<PulsarAdmin> adminSupplier,
+                              List<CustomCommand> customCommands, CommandExecutionContext context) {
+            super(cmdName, adminSupplier);
+            for (CustomCommand command : customCommands) {
+                String name = command.name();
+                DecoratedCommand commandImpl = generateCustomCommand(cmdName, name, command);
+                commandImpl.setCommand(command);
+                commandImpl.setContext(context);
+                jcommander.addCommand(name, commandImpl);
+            }
+        }
+    }
+
+
+    @Setter
+    public static class DecoratedCommand extends CliCommand {
+
+        private CustomCommand command;
+        private CommandExecutionContext context;
+
+        public DecoratedCommand() {
+        }
+
+        @Override
+        public void run() throws Exception {
+            Map<String, Object> parameters = new HashMap<>();
+            for (Field f : this.getClass().getFields()) {
+                parameters.put(f.getName(), f.get(this));
+            }
+            command.execute(parameters, context);
+        }
+    }
+
+    private static DecoratedCommand generateCustomCommand(String group, String name, CustomCommand command) {
+        try {
+            String description = command.description();
+            ClassPool pool = ClassPool.getDefault();
+            CtClass ctClass = pool.makeClass("CustomCommand" + group
+                    + "_" + name + "_" + System.nanoTime());
+            ctClass.setSuperclass(pool.get(DecoratedCommand.class.getName()));
+
+            // add class annotation
+
+            ClassFile classFile = ctClass.getClassFile();
+            ConstPool constpool = classFile.getConstPool();
+
+            AnnotationsAttribute annotationsAttribute = new AnnotationsAttribute(constpool,
+                    AnnotationsAttribute.visibleTag);
+            Annotation annotation = new Annotation(Parameters.class.getName(), constpool);
+            annotation.addMemberValue("commandDescription",
+                    new StringMemberValue(description, classFile.getConstPool()));
+            annotationsAttribute.setAnnotation(annotation);
+            ctClass.getClassFile().addAttribute(annotationsAttribute);
+
+
+            // add fields
+            List<ParameterDescriptor> parameters = command.parameters();
+            for (ParameterDescriptor parameterDescriptor : parameters) {
+                CtClass fieldType;
+                switch (parameterDescriptor.getType()) {
+                    case BOOLEAN_FLAG:
+                        //  command -parameter
+                        fieldType = CtClass.booleanType;
+                        break;
+                    case BOOLEAN:
+                        // command -parameter true|false
+                        fieldType = pool.get(Boolean.class.getName());
+                        break;
+                    case INTEGER:
+                        // command -parameter 123
+                        fieldType = CtClass.intType;
+                        break;
+                    case STRING:
+                        // command -parameter foo
+                        fieldType = pool.get(String.class.getName());
+                        break;
+                    default:
+                        throw new IllegalStateException();
+                }
+                List<String> parameterNames = parameterDescriptor.getNames();
+                if (parameterNames == null || parameterNames.isEmpty()) {
+                    // ignore
+                    continue;
+                }
+                String fieldName = parameterNames.get(0);
+                CtField field = new CtField(fieldType, fieldName, ctClass);
+
+                AnnotationsAttribute fieldAnnotationsAttribute = new AnnotationsAttribute(constpool,
+                        AnnotationsAttribute.visibleTag);
+                Annotation fieldAnnotation = new Annotation(Parameter.class.getName(), constpool);
+
+                // in JCommander if you don't set the "names" property then you want to get all the other
+                // parameters
+                if (!parameterDescriptor.isMainParameter()) {
+                    MemberValue[] memberValues = new MemberValue[parameterNames.size()];
+                    int i = 0;
+                    for (String parameterName : parameterNames) {
+                        memberValues[i++] = new StringMemberValue(parameterName, classFile.getConstPool());
+                    }
+                    ArrayMemberValue arrayMemberValue = new ArrayMemberValue(classFile.getConstPool());
+                    arrayMemberValue.setValue(memberValues);
+                    fieldAnnotation.addMemberValue("names", arrayMemberValue);
+                }
+
+                fieldAnnotation.addMemberValue("description",
+                        new StringMemberValue(parameterDescriptor.getDescription(), classFile.getConstPool()));
+                fieldAnnotation.addMemberValue("required",
+                        new BooleanMemberValue(parameterDescriptor.isRequired(), classFile.getConstPool()));
+                if (parameterDescriptor.getType() == ParameterType.BOOLEAN) {
+                    fieldAnnotation.addMemberValue("arity",
+                            new IntegerMemberValue(classFile.getConstPool(), 1));
+                }
+                fieldAnnotationsAttribute.setAnnotation(fieldAnnotation);
+                field.getFieldInfo().addAttribute(fieldAnnotationsAttribute);
+                field.setModifiers(Modifier.PUBLIC);
+
+                ctClass.addField(field);
+            }
+
+
+            return (DecoratedCommand) ctClass.toClass().getConstructor().newInstance();
+        } catch (Throwable t) {
+            t.printStackTrace(System.out);
+            throw new RuntimeException(t);
+        }
+    }
+}
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/PulsarAdminTool.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/PulsarAdminTool.java
index b4a0e04439f..16c9d58efe1 100644
--- a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/PulsarAdminTool.java
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/PulsarAdminTool.java
@@ -25,8 +25,10 @@ import com.beust.jcommander.Parameter;
 import com.google.common.annotations.VisibleForTesting;
 import java.io.FileInputStream;
 import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.concurrent.TimeUnit;
@@ -34,6 +36,10 @@ import java.util.function.Function;
 import java.util.function.Supplier;
 import lombok.Getter;
 import org.apache.pulsar.PulsarVersion;
+import org.apache.pulsar.admin.cli.extensions.CommandExecutionContext;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandFactory;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandGroup;
+import org.apache.pulsar.admin.cli.utils.CustomCommandFactoryProvider;
 import org.apache.pulsar.client.admin.PulsarAdmin;
 import org.apache.pulsar.client.admin.PulsarAdminBuilder;
 import org.apache.pulsar.client.admin.internal.PulsarAdminImpl;
@@ -44,6 +50,7 @@ public class PulsarAdminTool {
 
     private static int lastExitCode = Integer.MIN_VALUE;
 
+    protected List<CustomCommandFactory> customCommandFactories = new ArrayList();
     protected Map<String, Class<?>> commandMap;
     protected JCommander jcommander;
     protected final PulsarAdminBuilder adminBuilder;
@@ -167,21 +174,34 @@ public class PulsarAdminTool {
         }
     }
 
-    protected void setupCommands(Function<PulsarAdminBuilder, ? extends PulsarAdmin> adminFactory) {
+    public void setupCommands(Function<PulsarAdminBuilder, ? extends PulsarAdmin> adminFactory) {
         try {
-            adminBuilder.serviceHttpUrl(rootParams.serviceUrl);
-            adminBuilder.authentication(rootParams.authPluginClassName, rootParams.authParams);
-            adminBuilder.requestTimeout(rootParams.requestTimeout, TimeUnit.SECONDS);
-            if (isBlank(rootParams.tlsProvider)) {
-                rootParams.tlsProvider = properties.getProperty("webserviceTlsProvider");
-            }
-            if (isNotBlank(rootParams.tlsProvider)) {
-                adminBuilder.sslProvider(rootParams.tlsProvider);
-            }
             Supplier<PulsarAdmin> admin = new PulsarAdminSupplier(adminBuilder, adminFactory);
             for (Map.Entry<String, Class<?>> c : commandMap.entrySet()) {
                 addCommand(c, admin);
             }
+
+            CommandExecutionContext context = new CommandExecutionContext() {
+                @Override
+                public PulsarAdmin getPulsarAdmin() {
+                    return admin.get();
+                }
+
+                @Override
+                public Properties getConfiguration() {
+                    return properties;
+                }
+            };
+            loadCustomCommandFactories();
+
+            for (CustomCommandFactory factory : customCommandFactories) {
+                List<CustomCommandGroup> customCommandGroups = factory.commandGroups(context);
+                for (CustomCommandGroup group : customCommandGroups) {
+                    Object generated = CustomCommandsUtils.generateCliCommand(group, context, admin);
+                    jcommander.addCommand(group.name(), generated);
+                    commandMap.put(group.name(), null);
+                }
+            }
         } catch (Exception e) {
             Throwable cause;
             if (e instanceof InvocationTargetException && null != e.getCause()) {
@@ -194,6 +214,11 @@ public class PulsarAdminTool {
         }
     }
 
+    private void loadCustomCommandFactories() throws Exception {
+        customCommandFactories.addAll(CustomCommandFactoryProvider.createCustomCommandFactories(properties));
+    }
+
+
     private void addCommand(Map.Entry<String, Class<?>> c, Supplier<PulsarAdmin> admin) throws Exception {
         // To remain backwards compatibility for "source" and "sink" commands
         // TODO eventually remove this
@@ -215,8 +240,8 @@ public class PulsarAdminTool {
     }
 
     boolean run(String[] args, Function<PulsarAdminBuilder, ? extends PulsarAdmin> adminFactory) {
+        setupCommands(adminFactory);
         if (args.length == 0) {
-            setupCommands(adminFactory);
             jcommander.usage();
             return false;
         }
@@ -230,10 +255,20 @@ public class PulsarAdminTool {
 
         try {
             jcommander.parse(Arrays.copyOfRange(args, 0, Math.min(cmdPos, args.length)));
+
+            //rootParams are populated by jcommander.parse
+            adminBuilder.serviceHttpUrl(rootParams.serviceUrl);
+            adminBuilder.authentication(rootParams.authPluginClassName, rootParams.authParams);
+            adminBuilder.requestTimeout(rootParams.requestTimeout, TimeUnit.SECONDS);
+            if (isBlank(rootParams.tlsProvider)) {
+                rootParams.tlsProvider = properties.getProperty("webserviceTlsProvider");
+            }
+            if (isNotBlank(rootParams.tlsProvider)) {
+                adminBuilder.sslProvider(rootParams.tlsProvider);
+            }
         } catch (Exception e) {
             System.err.println(e.getMessage());
             System.err.println();
-            setupCommands(adminFactory);
             jcommander.usage();
             return false;
         }
@@ -250,17 +285,14 @@ public class PulsarAdminTool {
         }
 
         if (rootParams.help) {
-            setupCommands(adminFactory);
             jcommander.usage();
             return true;
         }
 
         if (cmdPos == args.length) {
-            setupCommands(adminFactory);
             jcommander.usage();
             return false;
         } else {
-            setupCommands(adminFactory);
             String cmd = args[cmdPos];
 
             // To remain backwards compatibility for "source" and "sink" commands
@@ -391,7 +423,6 @@ public class PulsarAdminTool {
 
         // Automatically generate documents for pulsar-admin
         commandMap.put("documents", CmdGenerateDocument.class);
-
         // To remain backwards compatibility for "source" and "sink" commands
         // TODO eventually remove this
         commandMap.put("source", CmdSources.class);
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinition.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinition.java
new file mode 100644
index 00000000000..f91f4bb6dd4
--- /dev/null
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinition.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.pulsar.admin.cli.utils;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class CustomCommandFactoryDefinition {
+
+    /**
+     * The name of the command factory.
+     */
+    private String name;
+
+    /**
+     * The class name for factory.
+     */
+    private String factoryClass;
+}
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinitions.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinitions.java
new file mode 100644
index 00000000000..fdd73c2fc37
--- /dev/null
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryDefinitions.java
@@ -0,0 +1,28 @@
+/**
+ * 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.pulsar.admin.cli.utils;
+
+import java.util.Map;
+import java.util.TreeMap;
+import lombok.Data;
+
+@Data
+public class CustomCommandFactoryDefinitions {
+    private final Map<String, CustomCommandFactoryMetaData> factories = new TreeMap<>();
+}
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryMetaData.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryMetaData.java
new file mode 100644
index 00000000000..fce33157435
--- /dev/null
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryMetaData.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.pulsar.admin.cli.utils;
+
+import java.nio.file.Path;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class CustomCommandFactoryMetaData {
+    /**
+     * The definition of the entry filter.
+     */
+    private CustomCommandFactoryDefinition definition;
+
+    /**
+     * The path to the handler package.
+     */
+    private Path archivePath;
+}
diff --git a/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java
new file mode 100644
index 00000000000..34155efd4a5
--- /dev/null
+++ b/pulsar-client-tools/src/main/java/org/apache/pulsar/admin/cli/utils/CustomCommandFactoryProvider.java
@@ -0,0 +1,173 @@
+/**
+ * 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.pulsar.admin.cli.utils;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.pulsar.admin.cli.extensions.CustomCommandFactory;
+import org.apache.pulsar.common.nar.NarClassLoader;
+import org.apache.pulsar.common.nar.NarClassLoaderBuilder;
+import org.apache.pulsar.common.util.ObjectMapperFactory;
+
+@Slf4j
+public class CustomCommandFactoryProvider {
+
+    @VisibleForTesting
+    static final String COMMAND_FACTORY_ENTRY = "command_factory";
+
+    /**
+     * create a Command Factory.
+     */
+    public static List<CustomCommandFactory> createCustomCommandFactories(
+            Properties conf) throws IOException {
+        String names = conf.getProperty("customCommandFactories", "");
+        List<CustomCommandFactory> result = new ArrayList<>();
+        if (names.isEmpty()) {
+            // early exit
+            return result;
+        }
+
+        String directory = conf.getProperty("cliExtensionsDirectory", "cliExtensions");
+        String narExtractionDirectory = NarClassLoader.DEFAULT_NAR_EXTRACTION_DIR;
+        CustomCommandFactoryDefinitions definitions = searchForCustomCommandFactories(directory,
+                narExtractionDirectory);
+        for (String name : names.split(",")) {
+            CustomCommandFactoryMetaData metaData = definitions.getFactories().get(name);
+            if (null == metaData) {
+                throw new RuntimeException("No factory is found for name `" + name
+                        + "`. Available names are : " + definitions.getFactories());
+            }
+            CustomCommandFactory factory = load(metaData, narExtractionDirectory);
+            if (factory != null) {
+                result.add(factory);
+            }
+            log.debug("Successfully loaded command factory for name `{}`", name);
+        }
+        return result;
+    }
+
+    private static CustomCommandFactoryDefinitions searchForCustomCommandFactories(String directory,
+                                                                       String narExtractionDirectory)
+            throws IOException {
+        Path path = Paths.get(directory).toAbsolutePath();
+        log.debug("Searching for command factories  in {}", path);
+
+        CustomCommandFactoryDefinitions customCommandFactoryDefinitions = new CustomCommandFactoryDefinitions();
+        if (!path.toFile().exists()) {
+            log.error("Pulsar command factories directory not found");
+            return customCommandFactoryDefinitions;
+        }
+
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(path, "*.nar")) {
+            for (Path archive : stream) {
+                try {
+                    CustomCommandFactoryDefinition def =
+                            getCustomCommandFactoryDefinition(archive.toString(), narExtractionDirectory);
+                    log.debug("Found command factory from {} : {}", archive, def);
+
+                    checkArgument(StringUtils.isNotBlank(def.getName()));
+                    checkArgument(StringUtils.isNotBlank(def.getFactoryClass()));
+
+                    CustomCommandFactoryMetaData metadata = new CustomCommandFactoryMetaData();
+                    metadata.setDefinition(def);
+                    metadata.setArchivePath(archive);
+
+                    customCommandFactoryDefinitions.getFactories().put(def.getName(), metadata);
+                } catch (Throwable t) {
+                    log.warn("Failed to load command factories from {}."
+                            + " It is OK however if you want to use this command factory,"
+                            + " please make sure you put the correct NAR"
+                            + " package in the directory.", archive, t);
+                }
+            }
+        }
+
+        return customCommandFactoryDefinitions;
+    }
+
+    private static CustomCommandFactoryDefinition getCustomCommandFactoryDefinition(String narPath,
+                                                                                    String narExtractionDirectory)
+            throws IOException {
+        try (NarClassLoader ncl = NarClassLoaderBuilder.builder()
+                .narFile(new File(narPath))
+                .extractionDirectory(narExtractionDirectory)
+                .build()) {
+            return getCustomCommandFactoryDefinition(ncl);
+        }
+    }
+
+    @VisibleForTesting
+    static CustomCommandFactoryDefinition getCustomCommandFactoryDefinition(NarClassLoader ncl) throws IOException {
+        String configStr;
+
+        try {
+            configStr = ncl.getServiceDefinition(COMMAND_FACTORY_ENTRY + ".yaml");
+        } catch (NoSuchFileException e) {
+            configStr = ncl.getServiceDefinition(COMMAND_FACTORY_ENTRY + ".yml");
+        }
+
+        return ObjectMapperFactory.getThreadLocalYaml().readValue(
+                configStr, CustomCommandFactoryDefinition.class
+        );
+    }
+
+    private static CustomCommandFactory load(CustomCommandFactoryMetaData metadata,
+                                                   String narExtractionDirectory)
+            throws IOException {
+        final File narFile = metadata.getArchivePath().toAbsolutePath().toFile();
+        NarClassLoader ncl = NarClassLoaderBuilder.builder()
+                .narFile(narFile)
+                .parentClassLoader(CustomCommandFactory.class.getClassLoader())
+                .extractionDirectory(narExtractionDirectory)
+                .build();
+        CustomCommandFactoryDefinition def = getCustomCommandFactoryDefinition(ncl);
+        if (StringUtils.isBlank(def.getFactoryClass())) {
+            throw new IOException("Command Factory `" + def.getName() + "` does NOT provide a Command Factory"
+                    + " implementation");
+        }
+
+        try {
+            Class commandFactoryClass = ncl.loadClass(def.getFactoryClass());
+            Object factory = commandFactoryClass.getDeclaredConstructor().newInstance();
+            if (!(factory instanceof CustomCommandFactory)) {
+                throw new IOException("Class " + def.getFactoryClass()
+                        + " does not implement CustomCommandFactory interface");
+            }
+           return (CustomCommandFactory) factory;
+        } catch (Exception e) {
+            if (e instanceof IOException) {
+                throw (IOException) e;
+            }
+            log.error("Failed to load class {}", def.getFactoryClass(), e);
+            throw new IOException(e);
+        }
+    }
+}