You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ra...@apache.org on 2019/06/20 12:51:14 UTC

[sling-org-apache-sling-committer-cli] branch master updated: SLING-8519 - Improve CLI parsing and general look and feel

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

radu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git


The following commit(s) were added to refs/heads/master by this push:
     new 57c186e  SLING-8519 - Improve CLI parsing and general look and feel
57c186e is described below

commit 57c186ebcd06117e9676c93728afbb901939dfc5
Author: Radu Cotescu <17...@users.noreply.github.com>
AuthorDate: Thu Jun 20 14:51:09 2019 +0200

    SLING-8519 - Improve CLI parsing and general look and feel
    
    * refactored commands to rely on picocli
---
 README.md                                          |  21 +--
 bnd.bnd                                            |   1 +
 pom.xml                                            |   6 +
 src/main/features/app.json                         |   3 +
 .../java/org/apache/sling/cli/impl/CLIGroup.java   |  24 ++++
 .../java/org/apache/sling/cli/impl/Command.java    |  14 +-
 .../apache/sling/cli/impl/CommandProcessor.java    | 157 ++++++++++++---------
 .../apache/sling/cli/impl/ExecutionContext.java    |  89 ------------
 .../org/apache/sling/cli/impl/ExecutionMode.java   |  36 +++++
 .../cli/impl/release/CreateJiraVersionCommand.java |  47 ++++--
 .../apache/sling/cli/impl/release/ListCommand.java |  23 +--
 .../cli/impl/release/PrepareVoteEmailCommand.java  | 133 +++++++++--------
 .../sling/cli/impl/release/ReleaseCLIGroup.java    |  39 +++++
 .../sling/cli/impl/release/ReusableCLIOptions.java |  34 +++++
 .../sling/cli/impl/release/TallyVotesCommand.java  |  28 ++--
 .../cli/impl/release/UpdateLocalSiteCommand.java   |  31 ++--
 .../cli/impl/release/UpdateReporterCommand.java    |  35 +++--
 .../impl/release/PrepareVoteEmailCommandTest.java  |  20 ++-
 .../cli/impl/release/TallyVotesCommandTest.java    |  29 +++-
 .../impl/release/UpdateReporterCommandTest.java    |  34 +++--
 20 files changed, 506 insertions(+), 298 deletions(-)

diff --git a/README.md b/README.md
index 5288c99..6e1da2c 100644
--- a/README.md
+++ b/README.md
@@ -16,21 +16,24 @@ The image is built using `mvn package`. Afterwards it may be run with
 
     docker run --env-file=./docker-env apache/sling-cli
     
-This invocation produces a list of available subcommands.
+This invocation produces a list of available commands.
 
 ## Commands
 
 The commands can be executed in 3 different modes:
 
-  * `dry-run` (default mode) - commands only list their output without performing any actions on the user's behalf
-  * `interactive` - commands list their output but ask for user confirmation when it comes to performing an action on the user's behalf
-  * `auto` - comands list their output and assume that all questions are provided the default answers when it comes to performing an action on the user's behalf
+  * `DRY_RUN` (default mode) - commands only list their output without performing any actions on the user's behalf
+  * `INTERACTIVE` - commands list their output but ask for user confirmation when it comes to performing an action on the user's behalf
+  * `AUTO` - commands list their output and assume that all questions are provided the default answers when it comes to performing an 
+  action on the user's behalf
 
 To select a non-default execution mode provide the mode as an argument to the command:
 
-    docker run -it --env-file=./docker-env apache/sling-cli release prepare-email $STAGING_REPOSITORY_ID --interactive
+    docker run -it --env-file=./docker-env apache/sling-cli release prepare-email --repository=$STAGING_REPOSITORY 
+    --execution-mode=INTERACTIVE
 
-Note that for running commands in the `interactive` mode you need to run the Docker container in interactive mode with a pseudo-tty attached (e.g. `docker run -it ...`).
+Note that for running commands in the `INTERACTIVE` mode you need to run the Docker container in interactive mode with a pseudo-tty 
+attached (e.g. `docker run -it ...`).
 
 Listing active releases
 
@@ -38,15 +41,15 @@ Listing active releases
 
 Generating a release vote email
 
-    docker run --env-file=./docker-env apache/sling-cli release prepare-email $STAGING_REPOSITORY_ID
+    docker run --env-file=./docker-env apache/sling-cli release prepare-email --repository=$STAGING_REPOSITORY_ID
     
 Generating a release vote result email
 
-    docker run --env-file=./docker-env apache/sling-cli release tally-votes $STAGING_REPOSITORY_ID
+    docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID
     
 Generating the website update (only diff for now)
 
-	docker run --env-file=docker-env apache/sling-cli release update-local-site $STAGING_REPOSITORY_ID
+	docker run --env-file=docker-env apache/sling-cli release update-local-site --repository=$STAGING_REPOSITORY_ID
 
 ## Assumptions
 
diff --git a/bnd.bnd b/bnd.bnd
index e69de29..78e45e3 100644
--- a/bnd.bnd
+++ b/bnd.bnd
@@ -0,0 +1 @@
+Import-Package: !org.fusesource.jansi, *
diff --git a/pom.xml b/pom.xml
index 09766e2..0e21a19 100644
--- a/pom.xml
+++ b/pom.xml
@@ -224,6 +224,12 @@
           <scope>provided</scope>
         </dependency>
         <dependency>
+            <groupId>info.picocli</groupId>
+            <artifactId>picocli</artifactId>
+            <version>4.0.0-beta-2</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
             <groupId>javax.mail</groupId>
             <artifactId>mail</artifactId>
             <version>1.5.0-b01</version>
diff --git a/src/main/features/app.json b/src/main/features/app.json
index d8a2bb2..739d561 100644
--- a/src/main/features/app.json
+++ b/src/main/features/app.json
@@ -70,6 +70,9 @@
         },
         {
             "id": "org.apache.sling:org.apache.sling.javax.activation:0.1.0"
+        },
+        {
+            "id": "info.picocli:picocli:4.0.0-beta-2"
         }
     ]
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/CLIGroup.java b/src/main/java/org/apache/sling/cli/impl/CLIGroup.java
new file mode 100644
index 0000000..ead6ba5
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/CLIGroup.java
@@ -0,0 +1,24 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.cli.impl;
+
+/**
+ * Marker interface for CLI sub-command groups.
+ */
+public interface CLIGroup extends Runnable {}
diff --git a/src/main/java/org/apache/sling/cli/impl/Command.java b/src/main/java/org/apache/sling/cli/impl/Command.java
index 70a6c45..57b9e50 100644
--- a/src/main/java/org/apache/sling/cli/impl/Command.java
+++ b/src/main/java/org/apache/sling/cli/impl/Command.java
@@ -16,14 +16,12 @@
  */
 package org.apache.sling.cli.impl;
 
-
-import org.jetbrains.annotations.NotNull;
-
-public interface Command {
+/**
+ * Marker interface for {@code Commands} supported by the Apache Sling Committer CLI.
+ */
+public interface Command extends Runnable {
     
-    String PROPERTY_NAME_COMMAND = "command";
-    String PROPERTY_NAME_SUBCOMMAND = "subcommand";
-    String PROPERTY_NAME_SUMMARY = "summary";
+    String PROPERTY_NAME_COMMAND_GROUP = "command.group";
+    String PROPERTY_NAME_COMMAND_NAME = "command.name";
 
-    void execute(@NotNull ExecutionContext context) throws Exception;
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java b/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java
index 9029d4b..20f083a 100644
--- a/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java
+++ b/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java
@@ -16,10 +16,15 @@
  */
 package org.apache.sling.cli.impl;
 
+import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 
+import org.apache.sling.cli.impl.release.ReleaseCLIGroup;
+import org.jetbrains.annotations.NotNull;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.BundleException;
 import org.osgi.framework.Constants;
@@ -30,9 +35,15 @@ import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import picocli.CommandLine;
+
 import static org.osgi.service.component.annotations.ReferenceCardinality.MULTIPLE;
 import static org.osgi.service.component.annotations.ReferencePolicy.DYNAMIC;
 
+@CommandLine.Command(
+        name = "docker run -it --env-file=./docker-env apache/sling-cli",
+        description = "Apache Sling Committers CLI"
+)
 @Component(service = CommandProcessor.class)
 public class CommandProcessor {
 
@@ -40,7 +51,14 @@ public class CommandProcessor {
     private static final String EXEC_ARGS = "exec.args";
     private BundleContext ctx;
 
-    private Map<CommandKey, CommandWithProps> commands = new ConcurrentHashMap<>();
+    private static final Map<String, Class> CLI_GROUPS;
+
+    static {
+        CLI_GROUPS = new HashMap<>();
+        CLI_GROUPS.put("release", ReleaseCLIGroup.class);
+    }
+
+    private Map<String, TreeSet<CommandWithProps>> commands = new ConcurrentHashMap<>();
 
     @Activate
     private void activate(BundleContext ctx) {
@@ -49,26 +67,56 @@ public class CommandProcessor {
 
     @Reference(service = Command.class, cardinality = MULTIPLE, policy = DYNAMIC)
     protected void bindCommand(Command cmd, Map<String, ?> props) {
-        commands.put(CommandKey.of(props), CommandWithProps.of(cmd, props));
+        CommandWithProps commandWithProps = CommandWithProps.of(cmd, props);
+        Set<CommandWithProps> bucket = commands.computeIfAbsent(commandWithProps.group, key -> new TreeSet<>());
+        bucket.add(commandWithProps);
     }
 
-    protected void unbindCommand(Map<String, ?> props) {
-        commands.remove(CommandKey.of(props));
+    protected void unbindCommand(Command cmd, Map<String, ?> props) {
+        CommandWithProps commandWithProps = CommandWithProps.of(cmd, props);
+        Set<CommandWithProps> bucket = commands.get(commandWithProps.group);
+        if (bucket != null) {
+            bucket.remove(commandWithProps);
+            if (bucket.isEmpty()) {
+                commands.remove(commandWithProps.group);
+            }
+        }
+
     }
 
     void runCommand() {
-        String[] arguments = arguments(ctx.getProperty(EXEC_ARGS));
-        CommandKey key = CommandKey.of(arguments);
-        ExecutionContext context = defineContext(arguments);
+        System.setProperty("picocli.usage.width", "140");
+        CommandLine commandLine = new CommandLine(this);
+        commandLine.addSubcommand(CommandLine.HelpCommand.class);
+        for (Map.Entry<String, TreeSet<CommandWithProps>> entry : commands.entrySet()) {
+            String group = entry.getKey();
+            Class<?> groupClass = CLI_GROUPS.get(group);
+            if (groupClass != null) {
+                CommandLine secondary = new CommandLine(groupClass);
+                for (CommandWithProps command : entry.getValue()) {
+                    secondary.addSubcommand(command.name, command.cmd);
+                }
+                secondary.addSubcommand(CommandLine.HelpCommand.class);
+                commandLine.addSubcommand(group, secondary);
+            } else {
+                for (CommandWithProps command : entry.getValue()) {
+                    commandLine.addSubcommand(command.group, command.cmd);
+                }
+            }
+        }
+        int commandExitCode;
         try {
-            commands.getOrDefault(key, new CommandWithProps(ignored -> {
-                logger.info("Usage: sling command sub-command [target]");
-                logger.info("");
-                logger.info("Available commands:");
-                commands.forEach((k, c) -> logger.info("{} {}: {}", k.command, k.subCommand, c.summary));
-            }, "")).cmd.execute(context);
+            String[] arguments = arguments(ctx.getProperty(EXEC_ARGS));
+            commandExitCode = commandLine.execute(arguments);
+        } catch (CommandLine.ParameterException e) {
+            commandLine.getErr().println(e.getMessage());
+            if (!CommandLine.UnmatchedArgumentException.printSuggestions(e, commandLine.getErr())) {
+                e.getCommandLine().usage(commandLine.getErr());
+            }
+            commandExitCode = commandLine.getCommandSpec().exitCodeOnInvalidInput();
         } catch (Exception e) {
-            logger.warn("Failed running command", e);
+            logger.warn("Failed running command.", e);
+            commandExitCode = 1;
         } finally {
             try {
                 ctx.getBundle(Constants.SYSTEM_BUNDLE_LOCATION).adapt(Framework.class).stop();
@@ -77,6 +125,7 @@ public class CommandProcessor {
                 System.exit(1);
             }
         }
+        System.exit(commandExitCode);
     }
 
     private String[] arguments(String cliSpec) {
@@ -86,70 +135,52 @@ public class CommandProcessor {
         return cliSpec.split(" ");
     }
 
-    private ExecutionContext defineContext(String[] arguments) {
-        if (arguments.length < 3)
-            return ExecutionContext.DEFAULT;
-        String target = arguments[2];
-        if (arguments.length > 3) {
-            return new ExecutionContext(ExecutionContext.Mode.fromString(arguments[3]), target);
-        } else {
-            return new ExecutionContext(ExecutionContext.Mode.DRY_RUN, target);
-        }
-    }
-    
-
-    static class CommandKey {
-
-        private static final CommandKey EMPTY = new CommandKey("", "");
-
-        private final String command;
-        private final String subCommand;
-
-        static CommandKey of(String[] arguments) {
-            if (arguments.length < 2)
-                return EMPTY;
+    static class CommandWithProps implements Comparable<CommandWithProps> {
+        private final String group;
+        private final String name;
+        private final Command cmd;
 
-            return new CommandKey(arguments[0], arguments[1]);
+        static CommandWithProps of(Command cmd, Map<String, ?> props) {
+            return new CommandWithProps(
+                    cmd,
+                    (String) props.get(Command.PROPERTY_NAME_COMMAND_GROUP),
+                    (String) props.get(Command.PROPERTY_NAME_COMMAND_NAME)
+            );
         }
 
-        static CommandKey of(Map<String, ?> serviceProps) {
-            return new CommandKey((String) serviceProps.get(Command.PROPERTY_NAME_COMMAND), (String) serviceProps.get(Command.PROPERTY_NAME_SUBCOMMAND));
+        CommandWithProps(Command cmd, String group, String name) {
+            this.cmd = cmd;
+            this.group = group;
+            this.name = name;
         }
 
-        CommandKey(String command, String subCommand) {
-            this.command = command;
-            this.subCommand = subCommand;
+        @Override
+        public int compareTo(@NotNull CommandProcessor.CommandWithProps o) {
+            if (!group.equals(o.group)) {
+                return group.compareTo(o.group);
+            } else {
+                return name.compareTo(o.name);
+            }
         }
 
         @Override
         public int hashCode() {
-            return Objects.hash(command, subCommand);
+            return Objects.hash(group, name);
         }
 
         @Override
         public boolean equals(Object obj) {
-            if (this == obj)
+            if (this == obj) {
                 return true;
-            if (obj == null)
-                return false;
-            if (getClass() != obj.getClass())
+            }
+            if (obj == null) {
                 return false;
-            CommandKey other = (CommandKey) obj;
-            return Objects.equals(command, other.command) && Objects.equals(subCommand, other.subCommand);
-        }
-    }
-    
-    static class CommandWithProps {
-        private final Command cmd;
-        private final String summary;
-
-        static CommandWithProps of(Command cmd, Map<String, ?> props) {
-            return new CommandWithProps(cmd, (String) props.get(Command.PROPERTY_NAME_SUMMARY));
-        }
-        
-        CommandWithProps(Command cmd, String summary) {
-            this.cmd = cmd;
-            this.summary = summary;
+            }
+            if (obj instanceof CommandWithProps) {
+                CommandWithProps other = (CommandWithProps) obj;
+                return Objects.equals(group, other.group) && Objects.equals(name, other.name);
+            }
+            return false;
         }
     }
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/ExecutionContext.java b/src/main/java/org/apache/sling/cli/impl/ExecutionContext.java
deleted file mode 100644
index 6505508..0000000
--- a/src/main/java/org/apache/sling/cli/impl/ExecutionContext.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- ~ Licensed to the Apache Software Foundation (ASF) under one
- ~ or more contributor license agreements.  See the NOTICE file
- ~ distributed with this work for additional information
- ~ regarding copyright ownership.  The ASF licenses this file
- ~ to you under the Apache License, Version 2.0 (the
- ~ "License"); you may not use this file except in compliance
- ~ with the License.  You may obtain a copy of the License at
- ~
- ~   http://www.apache.org/licenses/LICENSE-2.0
- ~
- ~ Unless required by applicable law or agreed to in writing,
- ~ software distributed under the License is distributed on an
- ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- ~ KIND, either express or implied.  See the License for the
- ~ specific language governing permissions and limitations
- ~ under the License.
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-package org.apache.sling.cli.impl;
-
-import java.util.Objects;
-
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Defines the way a {@link Command} will be executed, together with the {@code command}'s execution target.
- */
-public class ExecutionContext {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(ExecutionContext.class);
-
-    private final Mode mode;
-    private final String target;
-    public static final ExecutionContext DEFAULT = new ExecutionContext(Mode.DRY_RUN, null);
-
-    /**
-     * Creates an {@code ExecutionContext}.
-     *
-     * @param target the command's target
-     * @param mode   the execution mode
-     */
-    public ExecutionContext(@NotNull Mode mode, @Nullable String target) {
-        this.mode = mode;
-        this.target = Objects.requireNonNullElse(target, "");
-    }
-
-    /**
-     * Returns the execution target for a command.
-     *
-     * @return the execution target
-     */
-    @NotNull
-    public String getTarget() {
-        return target;
-    }
-
-    /**
-     * Returns the execution mode for a command.
-     *
-     * @return the execution mode
-     */
-    @NotNull
-    public Mode getMode() {
-        return mode;
-    }
-
-    public enum Mode {
-        DRY_RUN("--dry-run"), INTERACTIVE("--interactive"), AUTO("--auto");
-
-        private final String string;
-
-        Mode(String string) {
-            this.string = string;
-        }
-
-        static Mode fromString(String value) {
-            for (Mode m : values()) {
-                if (m.string.equals(value)) {
-                    return m;
-                }
-            }
-            LOGGER.warn("Unknown command execution mode {}. Switching to default mode {}.", value, DRY_RUN.string);
-            return DRY_RUN;
-        }
-    }
-}
diff --git a/src/main/java/org/apache/sling/cli/impl/ExecutionMode.java b/src/main/java/org/apache/sling/cli/impl/ExecutionMode.java
new file mode 100644
index 0000000..bf879af
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/ExecutionMode.java
@@ -0,0 +1,36 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.cli.impl;
+
+public enum ExecutionMode {
+    /**
+     * In {@code DRY_RUN} mode {@link Command}s should only output what actions they would execute.
+     */
+    DRY_RUN,
+
+    /**
+     * In {@code INTERACTIVE} mode {@link Command}s should always ask for user confirmation for each action they would execute.
+     */
+    INTERACTIVE,
+
+    /**
+     * In {@code AUTO} mode {@link Command}s should execute their normal actions assuming the user has provided all the default answers.
+     */
+    AUTO;
+}
diff --git a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
index 6605268..c0bd5d0 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
@@ -20,7 +20,7 @@ import java.io.IOException;
 import java.util.List;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
+import org.apache.sling.cli.impl.ExecutionMode;
 import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.jira.Issue;
@@ -28,31 +28,48 @@ import org.apache.sling.cli.impl.jira.Version;
 import org.apache.sling.cli.impl.jira.VersionClient;
 import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-@Component(service = Command.class, property = {
-        Command.PROPERTY_NAME_COMMAND+"=release",
-        Command.PROPERTY_NAME_SUBCOMMAND+"=create-jira-new-version",
-        Command.PROPERTY_NAME_SUMMARY+"=Creates a new Jira version, if needed, and transitions any unresolved issues from the version being released to the next one."
-    })
+import picocli.CommandLine;
+
+@Component(service = Command.class,
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + CreateJiraVersionCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + CreateJiraVersionCommand.NAME
+           }
+)
+@CommandLine.Command(
+        name = CreateJiraVersionCommand.NAME,
+        description = "Creates a new Jira version, if needed, and transitions any unresolved issues from the version being released to " +
+                "the next one",
+        subcommands = CommandLine.HelpCommand.class
+)
 public class CreateJiraVersionCommand implements Command {
 
+    static final String GROUP = "release";
+    static final String NAME = "create-new-jira-version";
+
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
     @Reference
     private StagingRepositoryFinder repoFinder;
     
     @Reference
     private VersionClient versionClient;
 
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
     private final Logger logger = LoggerFactory.getLogger(getClass());
 
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
-            StagingRepository repo = repoFinder.find(Integer.parseInt(context.getTarget()));
+            StagingRepository repo = repoFinder.find(repositoryId);
             for (Release release : Release.fromString(repo.getDescription()) ) {
                 Version version = versionClient.find(release);
                 logger.info("Found {}.", version);
@@ -60,13 +77,13 @@ public class CreateJiraVersionCommand implements Command {
                 boolean createNextRelease = false;
                 if ( successorVersion == null ) {
                     Release next = release.next();
-                    if (context.getMode() == ExecutionContext.Mode.DRY_RUN) {
+                    if (reusableCLIOptions.executionMode == ExecutionMode.DRY_RUN) {
                         logger.info("Version {} would be created.", next.getName());
-                    } else if (context.getMode() == ExecutionContext.Mode.INTERACTIVE) {
+                    } else if (reusableCLIOptions.executionMode == ExecutionMode.INTERACTIVE) {
                         InputOption answer = UserInput.yesNo(String.format("Should version %s be created?", next.getName()),
                                 InputOption.YES);
                         createNextRelease = (answer == InputOption.YES);
-                    } else if (context.getMode() == ExecutionContext.Mode.AUTO) {
+                    } else if (reusableCLIOptions.executionMode == ExecutionMode.AUTO) {
                         createNextRelease = true;
                     }
                     if (createNextRelease) {
@@ -81,16 +98,16 @@ public class CreateJiraVersionCommand implements Command {
                     List<Issue> unresolvedIssues = versionClient.findUnresolvedIssues(release);
                     if (!unresolvedIssues.isEmpty()) {
                         boolean moveIssues = false;
-                        if (context.getMode() == ExecutionContext.Mode.DRY_RUN) {
+                        if (reusableCLIOptions.executionMode == ExecutionMode.DRY_RUN) {
                             logger.info("{} unresolved issues would be moved from version {} to version {} :",
                                     unresolvedIssues.size(), version.getName(), successorVersion.getName());
-                        } else if (context.getMode() == ExecutionContext.Mode.INTERACTIVE) {
+                        } else if (reusableCLIOptions.executionMode == ExecutionMode.INTERACTIVE) {
                             InputOption answer = UserInput.yesNo(String.format("Should the %s unresolved issue(s) from version %s be " +
                                             "moved " +
                                     "to version %s?", unresolvedIssues.size(), version.getName(), successorVersion.getName()),
                                     InputOption.YES);
                             moveIssues = (answer == InputOption.YES);
-                        } else if (context.getMode() == ExecutionContext.Mode.AUTO) {
+                        } else if (reusableCLIOptions.executionMode == ExecutionMode.AUTO) {
                             moveIssues = true;
                         }
                         if (moveIssues) {
diff --git a/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java b/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
index 4a59b7f..e4115ef 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/ListCommand.java
@@ -19,30 +19,35 @@ package org.apache.sling.cli.impl.release;
 import java.io.IOException;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-@Component(service = Command.class, property = {
-        Command.PROPERTY_NAME_COMMAND + "=release",
-        Command.PROPERTY_NAME_SUBCOMMAND + "=list",
-        Command.PROPERTY_NAME_SUMMARY + "=Lists all open releases" })
+import picocli.CommandLine;
+
+@Component(service = Command.class,
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + ListCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + ListCommand.NAME,
+           }
+)
+@CommandLine.Command(name = ListCommand.NAME, description = "Lists all open releases", subcommands = CommandLine.HelpCommand.class)
 public class ListCommand implements Command {
 
+    static final String GROUP = "release";
+    static final String NAME = "list";
+
     private final Logger logger = LoggerFactory.getLogger(getClass());
 
     @Reference
     private StagingRepositoryFinder repoFinder;
 
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
-            repoFinder.list().stream()
-                .forEach( r -> logger.info("{}\t{}", r.getRepositoryId(), r.getDescription()));
+            repoFinder.list().forEach( r -> logger.info("{}\t{}", r.getRepositoryId(), r.getDescription()));
         } catch (IOException e) {
             logger.warn("Failed executing command", e);
         }
diff --git a/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java b/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java
index aff45f0..99dbb96 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java
@@ -23,7 +23,6 @@ import java.util.stream.Collectors;
 import javax.mail.internet.InternetAddress;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
 import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.jira.Version;
@@ -33,18 +32,29 @@ import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
 import org.apache.sling.cli.impl.people.Member;
 import org.apache.sling.cli.impl.people.MembersFinder;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-@Component(service = Command.class, property = {
-    Command.PROPERTY_NAME_COMMAND + "=release",
-    Command.PROPERTY_NAME_SUBCOMMAND + "=prepare-email",
-    Command.PROPERTY_NAME_SUMMARY + "=Prepares an email vote for the specified release." })
+import picocli.CommandLine;
+
+@Component(service = Command.class,
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + PrepareVoteEmailCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + PrepareVoteEmailCommand.NAME
+           }
+)
+@CommandLine.Command(
+        name = PrepareVoteEmailCommand.NAME,
+        description = "Prepares an email vote for the releases found in the Nexus staged repository",
+        subcommands = CommandLine.HelpCommand.class
+)
 public class PrepareVoteEmailCommand implements Command {
 
+    static final String GROUP = "release";
+    static final String NAME = "prepare-email";
+
     private static final Logger LOGGER = LoggerFactory.getLogger(PrepareVoteEmailCommand.class);
 
     @Reference
@@ -59,6 +69,15 @@ public class PrepareVoteEmailCommand implements Command {
     @Reference
     private Mailer mailer;
 
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
+    @CommandLine.Spec
+    CommandLine.Model.CommandSpec spec;
+
     // TODO - replace with file template
     private static final String EMAIL_TEMPLATE =
             "From: ##FROM##\n" +
@@ -95,59 +114,63 @@ public class PrepareVoteEmailCommand implements Command {
             "https://issues.apache.org/jira/browse/SLING/fixforversion/##VERSION_ID##";
 
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
-            int repoId = Integer.parseInt(context.getTarget());
-            StagingRepository repo = repoFinder.find(repoId);
-            List<Release> releases = Release.fromString(repo.getDescription());
-            List<Version> versions = releases.stream()
-                    .map( r -> versionClient.find(r))
-                    .collect(Collectors.toList());
-
-            String releaseName = releases.stream()
-                    .map( Release::getFullName )
-                    .collect(Collectors.joining(", "));
-
-            int fixedIssueCounts = versions.stream().mapToInt( Version::getIssuesFixedCount).sum();
-            String releaseOrReleases = versions.size() > 1 ?
-                    "these releases" : "this release";
-
-            String releaseJiraLinks = versions.stream()
-                .map( v -> RELEASE_TEMPLATE.replace("##VERSION_ID##", String.valueOf(v.getId())))
-                .collect(Collectors.joining("\n"));
-
-            Member currentMember = membersFinder.getCurrentMember();
-            String emailContents = EMAIL_TEMPLATE
-                    .replace("##FROM##", new InternetAddress(currentMember.getEmail(), currentMember.getName()).toString())
-                    .replace("##RELEASE_NAME##", releaseName)
-                    .replace("##RELEASE_ID##", String.valueOf(repoId))
-                    .replace("##RELEASE_OR_RELEASES##", releaseOrReleases)
-                    .replace("##RELEASE_JIRA_LINKS##", releaseJiraLinks)
-                    .replace("##FIXED_ISSUES_COUNT##", String.valueOf(fixedIssueCounts))
-                    .replace("##USER_NAME##", currentMember.getName());
-            switch (context.getMode()) {
-                case DRY_RUN:
-                    LOGGER.info("The following email would be sent from your @apache.org address (see the \"From:\" header):\n");
-                    LOGGER.info(emailContents);
-                    break;
-                case INTERACTIVE:
-                    String question ="Should the following email be sent from your @apache.org address (see the" +
-                            " \"From:\" header)?\n\n" + emailContents;
-                    InputOption answer = UserInput.yesNo(question, InputOption.YES);
-                    if (InputOption.YES.equals(answer)) {
+            CommandLine commandLine = spec.commandLine();
+            if (commandLine.isUsageHelpRequested()) {
+                commandLine.usage(commandLine.getOut());
+            } else {
+                StagingRepository repo = repoFinder.find(repositoryId);
+                List<Release> releases = Release.fromString(repo.getDescription());
+                List<Version> versions = releases.stream()
+                        .map(r -> versionClient.find(r))
+                        .collect(Collectors.toList());
+
+                String releaseName = releases.stream()
+                        .map(Release::getFullName)
+                        .collect(Collectors.joining(", "));
+
+                int fixedIssueCounts = versions.stream().mapToInt(Version::getIssuesFixedCount).sum();
+                String releaseOrReleases = versions.size() > 1 ?
+                        "these releases" : "this release";
+
+                String releaseJiraLinks = versions.stream()
+                        .map(v -> RELEASE_TEMPLATE.replace("##VERSION_ID##", String.valueOf(v.getId())))
+                        .collect(Collectors.joining("\n"));
+
+                Member currentMember = membersFinder.getCurrentMember();
+                String emailContents = EMAIL_TEMPLATE
+                        .replace("##FROM##", new InternetAddress(currentMember.getEmail(), currentMember.getName()).toString())
+                        .replace("##RELEASE_NAME##", releaseName)
+                        .replace("##RELEASE_ID##", String.valueOf(repositoryId))
+                        .replace("##RELEASE_OR_RELEASES##", releaseOrReleases)
+                        .replace("##RELEASE_JIRA_LINKS##", releaseJiraLinks)
+                        .replace("##FIXED_ISSUES_COUNT##", String.valueOf(fixedIssueCounts))
+                        .replace("##USER_NAME##", currentMember.getName());
+                switch (reusableCLIOptions.executionMode) {
+                    case DRY_RUN:
+                        LOGGER.info("The following email would be sent from your @apache.org address (see the \"From:\" header):\n");
+                        LOGGER.info(emailContents);
+                        break;
+                    case INTERACTIVE:
+                        String question = "Should the following email be sent from your @apache.org address (see the" +
+                                " \"From:\" header)?\n\n" + emailContents;
+                        InputOption answer = UserInput.yesNo(question, InputOption.YES);
+                        if (InputOption.YES.equals(answer)) {
+                            LOGGER.info("Sending email...");
+                            mailer.send(emailContents);
+                            LOGGER.info("Done!");
+                        } else if (InputOption.NO.equals(answer)) {
+                            LOGGER.info("Aborted.");
+                        }
+                        break;
+                    case AUTO:
+                        LOGGER.info(emailContents);
                         LOGGER.info("Sending email...");
                         mailer.send(emailContents);
                         LOGGER.info("Done!");
-                    } else if (InputOption.NO.equals(answer)) {
-                        LOGGER.info("Aborted.");
-                    }
-                    break;
-                case AUTO:
-                    LOGGER.info(emailContents);
-                    LOGGER.info("Sending email...");
-                    mailer.send(emailContents);
-                    LOGGER.info("Done!");
-                    break;
+                        break;
+                }
             }
         } catch (IOException e) {
             LOGGER.warn("Failed executing command", e);
diff --git a/src/main/java/org/apache/sling/cli/impl/release/ReleaseCLIGroup.java b/src/main/java/org/apache/sling/cli/impl/release/ReleaseCLIGroup.java
new file mode 100644
index 0000000..1700765
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/ReleaseCLIGroup.java
@@ -0,0 +1,39 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.cli.impl.release;
+
+import org.apache.sling.cli.impl.CLIGroup;
+
+import picocli.CommandLine;
+
+@CommandLine.Command(
+        description = "Performs release-related operations"
+)
+public class ReleaseCLIGroup implements CLIGroup {
+
+    private ReleaseCLIGroup(){}
+
+    @CommandLine.Spec
+    private CommandLine.Model.CommandSpec commandSpec;
+
+    @Override
+    public void run() {
+        commandSpec.commandLine().usage(System.console().writer());
+    }
+}
diff --git a/src/main/java/org/apache/sling/cli/impl/release/ReusableCLIOptions.java b/src/main/java/org/apache/sling/cli/impl/release/ReusableCLIOptions.java
new file mode 100644
index 0000000..afa4ef4
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/ReusableCLIOptions.java
@@ -0,0 +1,34 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.cli.impl.release;
+
+import org.apache.sling.cli.impl.ExecutionMode;
+
+import picocli.CommandLine;
+
+class ReusableCLIOptions {
+
+    @CommandLine.Option(
+            names = {"-x", "--execution-mode"},
+            defaultValue = "DRY_RUN",
+            description = "execution mode: ${COMPLETION-CANDIDATES}; default: ${DEFAULT-VALUE}")
+    ExecutionMode executionMode;
+
+
+}
diff --git a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
index 8cf26f2..8235819 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
@@ -28,7 +28,6 @@ import java.util.stream.Collectors;
 import javax.mail.internet.InternetAddress;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
 import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.mail.Email;
@@ -38,19 +37,25 @@ import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
 import org.apache.sling.cli.impl.people.Member;
 import org.apache.sling.cli.impl.people.MembersFinder;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import picocli.CommandLine;
+
 @Component(service = Command.class, property = {
-    Command.PROPERTY_NAME_COMMAND+"=release",
-    Command.PROPERTY_NAME_SUBCOMMAND+"=tally-votes",
-    Command.PROPERTY_NAME_SUMMARY+"=Counts votes cast for a release and generates the result email"
+        Command.PROPERTY_NAME_COMMAND_GROUP + "=" + TallyVotesCommand.GROUP,
+        Command.PROPERTY_NAME_COMMAND_NAME + "=" + TallyVotesCommand.NAME
 })
+@CommandLine.Command(name = TallyVotesCommand.NAME,
+                     description = "Counts votes cast for a release and generates the result email",
+                     subcommands = CommandLine.HelpCommand.class)
 public class TallyVotesCommand implements Command {
 
+    static final String GROUP = "release";
+    static final String NAME = "tally-votes";
+
     private static final Logger LOGGER = LoggerFactory.getLogger(TallyVotesCommand.class);
 
     @Reference
@@ -65,6 +70,12 @@ public class TallyVotesCommand implements Command {
     @Reference
     private Mailer mailer;
 
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
     // TODO - move to file
     private static final String EMAIL_TEMPLATE =
             "From: ##FROM## \n" +
@@ -86,10 +97,9 @@ public class TallyVotesCommand implements Command {
             "\n";
 
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
-            
-            StagingRepository repository = repoFinder.find(Integer.parseInt(context.getTarget()));
+            StagingRepository repository = repoFinder.find(repositoryId);
             List<Release> releases = Release.fromString(repository.getDescription());
             String releaseName = releases.stream().map(Release::getName).collect(Collectors.joining(", "));
             String releaseFullName = releases.stream().map(Release::getFullName).collect(Collectors.joining(", "));
@@ -130,7 +140,7 @@ public class TallyVotesCommand implements Command {
                 }
 
                 if (bindingVoters.size() >= 3) {
-                    switch (context.getMode()) {
+                    switch (reusableCLIOptions.executionMode) {
                         case DRY_RUN:
                             LOGGER.info("The following email would be sent from your @apache.org address (see the \"From:\" header):\n");
                             LOGGER.info(email);
diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
index d9ad68b..14cad7e 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
@@ -24,7 +24,6 @@ import java.time.LocalDateTime;
 import java.util.List;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
 import org.apache.sling.cli.impl.jbake.JBakeContentUpdater;
 import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
@@ -32,33 +31,43 @@ import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.ResetCommand.ResetType;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-@Component(service = Command.class, property = {
-    Command.PROPERTY_NAME_COMMAND+"=release",
-    Command.PROPERTY_NAME_SUBCOMMAND+"=update-local-site",
-    Command.PROPERTY_NAME_SUMMARY+"=Updates the Sling website with the new release information, based on a local checkout"
-})
+import picocli.CommandLine;
+
+@Component(service = Command.class,
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateLocalSiteCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateLocalSiteCommand.NAME
+           }
+)
+@CommandLine.Command(name = UpdateLocalSiteCommand.NAME, description = "Updates the Sling website with the new release information, " +
+        "based on a local checkout", subcommands = CommandLine.HelpCommand.class)
 public class UpdateLocalSiteCommand implements Command {
-    
+
+    static final String GROUP = "release";
+    static final String NAME = "update-local-site";
+
     private static final String GIT_CHECKOUT = "/tmp/sling-site";
 
     @Reference
     private StagingRepositoryFinder repoFinder;
     
     private final Logger logger = LoggerFactory.getLogger(getClass());
-    
+
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
             ensureRepo();
             try ( Git git = Git.open(new File(GIT_CHECKOUT)) ) {
                 
-                StagingRepository repository = repoFinder.find(Integer.parseInt(context.getTarget()));
+                StagingRepository repository = repoFinder.find(repositoryId);
                 List<Release> releases = Release.fromString(repository.getDescription());
                 
                 JBakeContentUpdater updater = new JBakeContentUpdater();
diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
index d613487..a78ff22 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
@@ -32,27 +32,34 @@ import org.apache.http.client.methods.HttpPost;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
 import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
 import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
-import org.jetbrains.annotations.NotNull;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import picocli.CommandLine;
+
 @Component(service = Command.class,
-    property = {
-        Command.PROPERTY_NAME_COMMAND + "=release",
-        Command.PROPERTY_NAME_SUBCOMMAND + "=update-reporter",
-        Command.PROPERTY_NAME_SUMMARY + "=Updates the Apache Reporter System with the new release information"
-    }
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateReporterCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateReporterCommand.NAME,
+           }
+)
+@CommandLine.Command(
+        name = UpdateReporterCommand.NAME,
+        description = "Updates the Apache Reporter System with the new release information",
+        subcommands = CommandLine.HelpCommand.class
 )
 public class UpdateReporterCommand implements Command {
 
+    static final String GROUP = "release";
+    static final String NAME = "update-reporter";
+
     private static final Logger LOGGER = LoggerFactory.getLogger(UpdateReporterCommand.class);
 
     @Reference
@@ -61,13 +68,19 @@ public class UpdateReporterCommand implements Command {
     @Reference
     private HttpClientFactory httpClientFactory;
 
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
     @Override
-    public void execute(@NotNull ExecutionContext context) {
+    public void run() {
         try {
-            StagingRepository repository = repoFinder.find(Integer.parseInt(context.getTarget()));
+            StagingRepository repository = repoFinder.find(repositoryId);
             List<Release> releases = Release.fromString(repository.getDescription());
             String releaseReleases = releases.size() > 1 ? "releases" : "release";
-            switch (context.getMode()) {
+            switch (reusableCLIOptions.executionMode) {
                 case DRY_RUN:
                     LOGGER.info("The following {} would be added to the Apache Reporter System:", releaseReleases);
                     releases.forEach(release -> LOGGER.info("  - {}", release.getFullName()));
@@ -93,7 +106,7 @@ public class UpdateReporterCommand implements Command {
             }
 
         } catch (IOException e) {
-            LOGGER.error(String.format("Unable to update reporter service; passed command: %s.", context.getTarget()), e);
+            LOGGER.error(String.format("Unable to update reporter service; passed command: %s.", repositoryId), e);
         }
 
     }
diff --git a/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java
index d459306..e408509 100644
--- a/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java
@@ -21,7 +21,7 @@ package org.apache.sling.cli.impl.release;
 import java.io.IOException;
 
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
+import org.apache.sling.cli.impl.ExecutionMode;
 import org.apache.sling.cli.impl.jira.Version;
 import org.apache.sling.cli.impl.jira.VersionClient;
 import org.apache.sling.cli.impl.mail.Mailer;
@@ -33,8 +33,12 @@ import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
 import org.junit.Rule;
 import org.junit.Test;
 import org.osgi.framework.ServiceReference;
+import org.powermock.reflect.Whitebox;
+
+import picocli.CommandLine;
 
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -47,12 +51,22 @@ public class PrepareVoteEmailCommandTest {
     public void testPrepareEmailGeneration() throws Exception {
         Mailer mailer = mock(Mailer.class);
         prepareExecution(mailer);
-        osgiContext.registerInjectActivateService(new PrepareVoteEmailCommand());
+        PrepareVoteEmailCommand prepareVoteEmailCommand = spy(new PrepareVoteEmailCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        CommandLine.Model.CommandSpec commandSpec = mock(CommandLine.Model.CommandSpec.class);
+        CommandLine commandLine = mock(CommandLine.class);
+        when(commandSpec.commandLine()).thenReturn(commandLine);
+        when(commandLine.isUsageHelpRequested()).thenReturn(false);
+        Whitebox.setInternalState(prepareVoteEmailCommand, "spec", commandSpec);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.AUTO);
+        Whitebox.setInternalState(prepareVoteEmailCommand, "reusableCLIOptions", reusableCLIOptions);
+        Whitebox.setInternalState(prepareVoteEmailCommand, "repositoryId", 123);
+        osgiContext.registerInjectActivateService(prepareVoteEmailCommand);
 
         ServiceReference<?> reference =
                 osgiContext.bundleContext().getServiceReference(Command.class.getName());
         Command command = (Command) osgiContext.bundleContext().getService(reference);
-        command.execute(new ExecutionContext(ExecutionContext.Mode.AUTO, "123"));
+        command.run();
         verify(mailer).send(
                 "From: John Doe <jo...@apache.org>\n" +
                         "To: \"Sling Developers List\" <de...@sling.apache.org>\n" +
diff --git a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
index 6b8bece..c61cc7f 100644
--- a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java
@@ -29,7 +29,7 @@ import javax.mail.internet.InternetAddress;
 import org.apache.sling.cli.impl.Command;
 import org.apache.sling.cli.impl.Credentials;
 import org.apache.sling.cli.impl.CredentialsService;
-import org.apache.sling.cli.impl.ExecutionContext;
+import org.apache.sling.cli.impl.ExecutionMode;
 import org.apache.sling.cli.impl.mail.Email;
 import org.apache.sling.cli.impl.mail.Mailer;
 import org.apache.sling.cli.impl.mail.VoteThreadFinder;
@@ -81,11 +81,16 @@ public class TallyVotesCommandTest {
             add(mockEmail("johndoe@apache.org", "John Doe"));
         }};
         prepareExecution(mock(Mailer.class), thread);
-        osgiContext.registerInjectActivateService(new TallyVotesCommand());
+        TallyVotesCommand tallyVotesCommand = spy(new TallyVotesCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.DRY_RUN);
+        Whitebox.setInternalState(tallyVotesCommand, "repositoryId", 123);
+        Whitebox.setInternalState(tallyVotesCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(tallyVotesCommand);
         ServiceReference<?> reference =
                 osgiContext.bundleContext().getServiceReference(Command.class.getName());
         Command command = (Command) osgiContext.bundleContext().getService(reference);
-        command.execute(new ExecutionContext(ExecutionContext.Mode.DRY_RUN, "123"));
+        command.run();
         verify(logger).info(
                 "From: John Doe <jo...@apache.org> \n" +
                 "To: \"Sling Developers List\" <de...@sling.apache.org>\n" +
@@ -120,11 +125,16 @@ public class TallyVotesCommandTest {
             add(mockEmail("daniel@apache.org", "Daniel"));
         }};
         prepareExecution(mock(Mailer.class), thread);
-        osgiContext.registerInjectActivateService(new TallyVotesCommand());
+        TallyVotesCommand tallyVotesCommand = spy(new TallyVotesCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.DRY_RUN);
+        Whitebox.setInternalState(tallyVotesCommand, "repositoryId", 123);
+        Whitebox.setInternalState(tallyVotesCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(tallyVotesCommand);
         ServiceReference<?> reference =
                 osgiContext.bundleContext().getServiceReference(Command.class.getName());
         Command command = (Command) osgiContext.bundleContext().getService(reference);
-        command.execute(new ExecutionContext(ExecutionContext.Mode.DRY_RUN, "123"));
+        command.run();
         verify(logger).info(
                 "Release {} does not have at least 3 binding votes.",
                 "Apache Sling CLI Test 1.0.0"
@@ -144,11 +154,16 @@ public class TallyVotesCommandTest {
         }};
         Mailer mailer = mock(Mailer.class);
         prepareExecution(mailer, thread);
-        osgiContext.registerInjectActivateService(new TallyVotesCommand());
+        TallyVotesCommand tallyVotesCommand = spy(new TallyVotesCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.AUTO);
+        Whitebox.setInternalState(tallyVotesCommand, "repositoryId", 123);
+        Whitebox.setInternalState(tallyVotesCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(tallyVotesCommand);
         ServiceReference<?> reference =
                 osgiContext.bundleContext().getServiceReference(Command.class.getName());
         Command command = (Command) osgiContext.bundleContext().getService(reference);
-        command.execute(new ExecutionContext(ExecutionContext.Mode.AUTO, "123"));
+        command.run();
         verify(mailer).send(
                 "From: John Doe <jo...@apache.org> \n" +
                         "To: \"Sling Developers List\" <de...@sling.apache.org>\n" +
diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
index ac5f758..ece4d18 100644
--- a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
@@ -24,7 +24,7 @@ import org.apache.http.StatusLine;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.sling.cli.impl.Command;
-import org.apache.sling.cli.impl.ExecutionContext;
+import org.apache.sling.cli.impl.ExecutionMode;
 import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
@@ -38,12 +38,14 @@ import org.junit.runner.RunWith;
 import org.powermock.core.classloader.annotations.PowerMockIgnore;
 import org.powermock.core.classloader.annotations.PrepareForTest;
 import org.powermock.modules.junit4.PowerMockRunner;
+import org.powermock.reflect.Whitebox;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -81,16 +83,20 @@ public class UpdateReporterCommandTest {
 
     @Test
     @PrepareForTest({LoggerFactory.class})
-    public void testDryRun() throws Exception {
+    public void testDryRun() {
         mockStatic(LoggerFactory.class);
         Logger logger = mock(Logger.class);
         when(LoggerFactory.getLogger(UpdateReporterCommand.class)).thenReturn(logger);
-
-        osgiContext.registerInjectActivateService(new UpdateReporterCommand());
+        UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand());
+        Whitebox.setInternalState(updateReporterCommand, "repositoryId", 42);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.DRY_RUN);
+        Whitebox.setInternalState(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(updateReporterCommand);
         Command updateReporter = osgiContext.getService(Command.class);
         assertTrue("Expected to retrieve the UpdateReporterCommand from the mocked OSGi environment.",
                 updateReporter instanceof UpdateReporterCommand);
-        updateReporter.execute(new ExecutionContext(ExecutionContext.Mode.DRY_RUN, "42"));
+        updateReporter.run();
         verify(logger).info("The following {} would be added to the Apache Reporter System:", "releases");
         verify(logger).info("  - {}", "Apache Sling CLI 1");
         verify(logger).info("  - {}", "Apache Sling CLI 2");
@@ -100,7 +106,12 @@ public class UpdateReporterCommandTest {
     @Test
     @PrepareForTest({UserInput.class})
     public void testInteractive() throws Exception {
-        osgiContext.registerInjectActivateService(new UpdateReporterCommand());
+        UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand());
+        Whitebox.setInternalState(updateReporterCommand, "repositoryId", 42);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.INTERACTIVE);
+        Whitebox.setInternalState(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(updateReporterCommand);
         Command updateReporter = osgiContext.getService(Command.class);
         assertTrue("Expected to retrieve the UpdateReporterCommand from the mocked OSGi environment.",
                 updateReporter instanceof UpdateReporterCommand);
@@ -113,13 +124,18 @@ public class UpdateReporterCommandTest {
         when(response.getStatusLine()).thenReturn(statusLine);
         when(statusLine.getStatusCode()).thenReturn(200);
         when(client.execute(any())).thenReturn(response);
-        updateReporter.execute(new ExecutionContext(ExecutionContext.Mode.INTERACTIVE, "42"));
+        updateReporter.run();
         verify(client, times(2)).execute(any());
     }
 
     @Test
     public void testAuto() throws Exception {
-        osgiContext.registerInjectActivateService(new UpdateReporterCommand());
+        UpdateReporterCommand updateReporterCommand = spy(new UpdateReporterCommand());
+        Whitebox.setInternalState(updateReporterCommand, "repositoryId", 42);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        Whitebox.setInternalState(reusableCLIOptions, "executionMode", ExecutionMode.AUTO);
+        Whitebox.setInternalState(updateReporterCommand, "reusableCLIOptions", reusableCLIOptions);
+        osgiContext.registerInjectActivateService(updateReporterCommand);
         Command updateReporter = osgiContext.getService(Command.class);
         assertTrue("Expected to retrieve the UpdateReporterCommand from the mocked OSGi environment.",
                 updateReporter instanceof UpdateReporterCommand);
@@ -128,7 +144,7 @@ public class UpdateReporterCommandTest {
         when(response.getStatusLine()).thenReturn(statusLine);
         when(statusLine.getStatusCode()).thenReturn(200);
         when(client.execute(any())).thenReturn(response);
-        updateReporter.execute(new ExecutionContext(ExecutionContext.Mode.AUTO, "42"));
+        updateReporter.run();
         verify(client, times(2)).execute(any());
     }